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;
argparseis in the standard library pip install click typerfor 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
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
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.
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.
Related
- Click vs Typer for Geospatial Workflows β the comparison once the standard library is behind you.
- CLI Subcommand Organization β the structure that makes the second column worth it.