Article

argparse vs Click vs Typer for GIS Tooling

argparse is genuinely sufficient for a one-command script with no dependencies to spare β€” and it stops being sufficient at the second subcommand, the first custom type, and the first time someone asks for shell completion. Knowing exactly where those lines fall saves both an unnecessary dependency and an unnecessary rewrite. It builds on the Click vs Typer for Geospatial Workflows guide, part of the broader CLI Architecture & Design Patterns reference.

Prerequisites

  • Python 3.10 or later; argparse is in the standard library
  • pip install click typer for the comparisons
  • A sense of how many commands the tool will have in a year, which is the deciding variable

What Each Step Up Buys

The four capabilities that decide the choice Nested subcommands are possible in argparse but manual. Reusable custom parameter types exist in Click and Typer and must be hand-rolled in argparse. Shell completion needs a third-party package for argparse. A context object shared between a group and its commands has no argparse equivalent. nested subcommands argparse: manual subparsers per level Click and Typer: built in reusable custom types argparse: a callable per argument, no shared errors Click: ParamType shell completion argparse: a third-party package Typer: built in shared context object argparse: pass it yourself, everywhere Click: ctx.obj

For a geospatial tool the second row bites first. CRS validation, bounding boxes and file-format choices are all custom types used by several commands, and in argparse each one is a function whose error message you write again at every call site.

The Same Command, Three Ways

# argparse β€” no dependencies, and this is about its comfortable limit
import argparse
from pathlib import Path


def epsg(value: str) -> int:
    from pyproj import CRS
    try:
        return CRS.from_user_input(value).to_epsg()
    except Exception:
        raise argparse.ArgumentTypeError(f"not a usable CRS: {value}")


parser = argparse.ArgumentParser(prog="gis-warp")
parser.add_argument("src", type=Path)
parser.add_argument("dst", type=Path)
parser.add_argument("--dst-crs", type=epsg, required=True)
args = parser.parse_args()
# Click β€” the same, plus a reusable type and a group to hang more commands on
import click
from pathlib import Path


class EpsgParam(click.ParamType):
    name = "epsg"

    def convert(self, value, param, ctx):
        from pyproj import CRS
        try:
            return CRS.from_user_input(value).to_epsg()
        except Exception:
            self.fail(f"not a usable CRS: {value}", param, ctx)


@click.group()
def cli() -> None:
    """Geospatial tools."""


@cli.command()
@click.argument("src", type=click.Path(exists=True, path_type=Path))
@click.argument("dst", type=click.Path(path_type=Path))
@click.option("--dst-crs", type=EpsgParam(), required=True)
def warp(src: Path, dst: Path, dst_crs: int) -> None:
    ...
# Typer β€” the same again, with completion and the signature as the declaration
from pathlib import Path

import typer

app = typer.Typer()


def parse_epsg(value: str) -> int:
    from pyproj import CRS
    try:
        return CRS.from_user_input(value).to_epsg()
    except Exception as exc:
        raise typer.BadParameter(f"not a usable CRS: {value}") from exc


@app.command()
def warp(
    src: Path = typer.Argument(..., exists=True, dir_okay=False),
    dst: Path = typer.Argument(...),
    dst_crs: int = typer.Option(..., "--dst-crs", parser=parse_epsg),
) -> None:
    ...

The three are close in length at one command. The gap opens at five, because the argparse version grows a subparser block per command while the other two grow one decorator.

Where the Line Falls

Choosing by what the tool will be in a year A single-purpose script with no dependency budget is well served by argparse. A tool with a handful of commands shared across a team benefits from Click or Typer. A platform accumulating commands and plugins needs the group and context machinery from the start. a single-purpose script one command, no plugins installed by copying the file no dependency budget argparse a small team tool three to ten commands shared options, custom types, completion installed with pip Typer a growing platform commands discovered from entry points context, plugins, and dynamic registration Click

The right-hand column is the one people mis-assign. Dynamic command registration β€” building the command set at run time from installed plugins β€” is straightforward in Click and awkward in Typer, because Typer derives its parameters from a function signature that does not exist yet.

One Named Gotcha: argparse Type Errors Lose the Argument Name

An ArgumentTypeError raised from a type= callable reports the value and the option, which reads fine β€” until the same callable is used for three options and the message says not a usable CRS with no indication of which one. In argparse the callable receives only the string, so there is nothing to include.

# The workaround: a factory that closes over the name.
def epsg_for(option: str):
    def convert(value: str) -> int:
        from pyproj import CRS
        try:
            return CRS.from_user_input(value).to_epsg()
        except Exception:
            raise argparse.ArgumentTypeError(f"{option}: not a usable CRS: {value}")
    return convert


parser.add_argument("--src-crs", type=epsg_for("--src-crs"))
parser.add_argument("--dst-crs", type=epsg_for("--dst-crs"))

Repeating the option name is exactly the kind of duplication that drifts. Click’s ParamType receives the parameter object and formats the name itself, which is a small thing that shows up on every error message a user ever sees.

Verification

# Whatever you chose, the interface contract is the same. Check it directly.
gis warp --help
gis warp in.tif out.tif --dst-crs EPSG:4362; echo "exit: $?"   # expect 2
gis warp in.tif out.tif --dst-crs EPSG:3857; echo "exit: $?"   # expect 0

Those three commands are the whole externally-visible difference between the implementations, which is the practical argument for not agonising over the choice: a migration later changes the declarations and leaves the contract, as Migrating a Click Geospatial CLI to Typer describes.

The migration cost between the three is asymmetric, which is worth knowing before committing.

Which migrations are cheap and which are not argparse to Click is a rewrite of the declarations with the same domain code. Click to Typer is mechanical because Typer produces Click objects. Typer to Click is easy for the same reason. Anything back to argparse means giving up features and is rarely worth it. argparse to Click declarations rewritten, domain code untouched an afternoon Click to Typer mechanical β€” Typer produces Click objects incremental, per command Typer to Click easy for the same reason, in reverse incremental anything to argparse custom types and completion have to be rebuilt rarely worth it

The two cheap directions are the ones inside the Click family, which is another way of saying that the consequential decision is whether to leave the standard library at all.

What the Standard Library Genuinely Gives You

It is worth being fair to argparse, because the case for it is stronger than its reputation suggests. It has no dependencies, which matters for a script that will be copied into a repository rather than installed. It is present on every Python installation, so a colleague can run the file without a virtual environment. Its behaviour is stable across versions in a way third-party libraries are not. And for a single command with fewer than about eight options, the code is no longer than the alternatives.

For a geospatial script that reprojects one file, validates one CRS and writes one output, that combination is hard to beat. The failure is not in choosing argparse for that script; it is in staying with it when the script becomes a tool.

The Signals That You Have Outgrown It

Four things reliably indicate the transition has already happened, usually some months before anyone notices.

The first is a subparser block longer than the commands it dispatches to. When the plumbing outweighs the work, a framework that generates the plumbing is paying for itself.

The second is a validation function copied between arguments. argparse has no shared parameter type, so a CRS check used by three options exists three times or is factored into a closure that passes the option name in β€” which is duplication either way.

The third is a request for shell completion. It is possible with argparse through a third-party package, and once that package is a dependency the dependency-free argument has gone, which was the main reason to stay.

The fourth is a global option that every command needs. Passing a verbosity flag or a config path down through hand-written dispatch is exactly the problem a context object solves, and doing it by hand is where inconsistencies creep in between commands.

None of the four is a crisis on its own. Together they mean the tool has grown a shape the standard library does not model, and the code will keep accumulating workarounds until it moves.

Migrating Without a Rewrite

The migration is smaller than it looks because the domain code never moves. If the parser is thin β€” as the layering in the section recommends β€” the change is confined to one module: the declarations become decorators or annotations, and the call into the orchestration layer stays exactly as it was.

Doing it one command at a time is possible even from argparse, by keeping the existing parser as the entry point for the commands not yet ported and adding a Click group alongside it. The dispatch becomes a two-line branch on the first argument, which is ugly and temporary and lets the port land in reviewable pieces rather than one large commit nobody wants to read.

The one thing worth pinning before starting is the external contract: the flag names, the exit codes, and the shape of any machine-readable output. Capturing those as tests first means the migration is provably behaviour-preserving, and it turns a change that feels risky into one that is verified.

Testing Is Identical Either Way

One thing that does not vary across the three is how the tool should be tested. Whichever parser declares the options, the assertions are about exit codes, stderr and the artefacts produced β€” none of which knows or cares which library parsed the arguments.

For argparse, the harness is subprocess or a direct call to parser.parse_args with a list. For Click and Typer it is the shared CliRunner, which is faster because it stays in-process. The difference is a matter of milliseconds per test rather than of what can be asserted, and a suite written against the contract rather than the framework survives a migration untouched β€” which is itself an argument for writing it that way from the start.

One further consideration applies to any tool that will be installed by people outside the team: the help output is the documentation most users will ever read. Click and Typer both format it from the declarations, so it stays accurate for free; an argparse help string is a literal that drifts from the behaviour it describes as soon as someone changes a default without editing the text beside it. That drift is invisible in review and obvious to a user, which is a poor combination.