Three libraries, one job. They all turn sys.argv into typed values and dispatch to a function,
and they all produce the same usage errors and the same exit code 2. What differs is how much you
write yourself, and how much arrives for free.
TL;DR
- argparse — stdlib, no dependency, everything beyond parsing is yours to build.
- Click — decorators, nested groups, completion, a test runner, explicit parameter objects.
- Typer — Click underneath, declared through type hints instead of decorators.
- The deciding question is whether a dependency is acceptable. Everything else is style.
- Typer and Click share a runtime, so moving between them is a local refactor, not a rewrite.
Prerequisites
Familiarity with writing a small CLI in any one of the three. Everything here targets current versions on Python 3.11 or newer.
The same command, three times
# argparse
parser = argparse.ArgumentParser(prog="mytool")
parser.add_argument("source", type=Path)
parser.add_argument("--retries", type=int, default=3, help="Attempts per file.")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
sync(args.source, retries=args.retries, dry_run=args.dry_run)
# Click
@click.command()
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option("--retries", type=int, default=3, show_default=True, help="Attempts per file.")
@click.option("--dry-run", is_flag=True)
def sync(source: Path, retries: int, dry_run: bool) -> None:
"""Sync SOURCE to the bucket."""
# Typer
@app.command()
def sync(
source: Annotated[Path, typer.Argument(exists=True)],
retries: Annotated[int, typer.Option(help="Attempts per file.")] = 3,
dry_run: bool = False,
) -> None:
"""Sync SOURCE to the bucket."""
Three dialects, identical behaviour. The trend is visible: each step moves information out of function calls and into the place Python already keeps it — the signature.
Feature by feature
Four differences matter in practice.
Nested commands. argparse has subparsers, and they work, but every level needs its own
dest, dispatch is a set_defaults(func=…) convention you implement, and there is no equivalent
of a group callback that runs before each subcommand. Click and Typer treat groups as first-class.
Shell completion. Free in Click and one command away in Typer. In argparse it needs
argcomplete — a third-party dependency, which undercuts the main reason to be on argparse in
the first place.
Testing. Click's CliRunner invokes commands in-process with captured streams; Typer
re-exports it. Testing argparse means calling parse_args on a list and testing your dispatch
separately, which works but exercises less of the real path.
Shared state. Click's context carries an object down the tree, so global options load
configuration once. In argparse shared state travels in the Namespace or in variables you
thread through by hand.
Where each one is the right answer
Choose argparse when a dependency is genuinely unacceptable: an installer, a bootstrap script, a tool that runs inside a locked-down image, or something that ships in an environment where you cannot add packages. It is stable, documented and present in every Python.
Choose Typer for most new tools with more than one command. The type hints you would write anyway become the interface, completion and groups are free, and the runtime is Click's.
Choose Click when your commands are built rather than declared — a plugin system that
constructs commands in a loop, options derived from a schema at run time, or a custom Command
subclass. Click's object model is designed for that; Typer's signature-driven model assumes the
parameters are known at import time.
What should not drive the choice is performance. Typer's construction cost is single-digit milliseconds against a 20 ms interpreter floor — an order of magnitude less than one heavy import.
What "no dependencies" is really worth
The argument for argparse is a real one, and it is worth being precise about its size.
A wheel with typer and rich adds roughly two megabytes and about 50 ms of import time. In
exchange you stop maintaining: subcommand dispatch, completion scripts, parameter type conversion
with consistent error messages, a testing runner, and context propagation. That is a few hundred
lines you own forever, and they are lines that get subtly wrong edge cases — the kind that produce
a confusing message six months later.
Where the trade genuinely favours argparse:
- The tool is the bootstrap step, so it cannot depend on anything installable.
- It ships into an audited environment where every dependency is a review.
- It is one command with three flags and will stay that way.
UX considerations
The frameworks differ in what a user sees by default, which is worth knowing before you pick.
Help rendering. Typer renders through Rich — colour, panels, aligned columns. Click and
argparse produce plain text. Most people prefer the Rich output; if you do not,
rich_markup_mode=None turns it off.
Error messages. All three exit 2 on a usage error. Click and Typer name the offending option
and suggest --help; argparse prints the usage line and a shorter message. The difference is
small and consistently in Click's favour.
Defaults in help. Typer shows them automatically, Click needs show_default=True, and
argparse needs ArgumentDefaultsHelpFormatter. Turn it on in all three — undocumented defaults
are the most common gap in CLI help.
Testing the behaviour
If you are evaluating rather than reading, the fastest comparison is to write the same two-command tool three times and test it each way. The tests tell you more than the implementations:
# Click / Typer
result = CliRunner().invoke(app, ["sync", "./data", "--dry-run"])
assert result.exit_code == 0
# argparse
args = build_parser().parse_args(["sync", "./data", "--dry-run"])
assert args.dry_run is True
assert args.func is do_sync # dispatch tested separately
The framework version tests the whole path — parsing, dispatch, output, exit code — in one call.
The argparse version tests parsing, and leaves dispatch and output to separate tests you write
yourself. For a small tool that is fine; at twenty commands it is a meaningful amount of test
infrastructure.
Conclusion
If you cannot take a dependency, use argparse and accept the extra wiring. If you can, use Typer
unless your commands are constructed dynamically, in which case use Click. Because Typer and Click
share a runtime, that last decision is reversible in an afternoon — which is why it deserves far
less agonising than it usually gets.
Frequently asked questions
Is Click still maintained now that Typer exists?
Yes — Typer is built on it, so Click's maintenance is a prerequisite for Typer's. They are complementary rather than competing, and a project using Typer is using Click whether it says so or not.
Which is fastest to start?
argparse, marginally, because it imports less. The gap is a few milliseconds and irrelevant next
to a single heavy import in your own code. Measure with -X importtime before treating framework
choice as a performance decision.
Can I mix them in one tool?
Yes. typer.main.get_command(app) produces a Click command you can attach to a Click group, and
Typer can host Click commands. That interop is what makes an incremental migration possible, and it
is occasionally useful permanently — a dynamically built group in Click alongside declared commands
in Typer.
Do they all handle Unicode and Windows paths the same?
Yes. Arguments arrive as str already decoded by Python, and Path behaves correctly everywhere.
Cross-platform problems in CLIs are nearly always in your own code — a hard-coded separator, an
assumption about bin versus Scripts — not in the parser.
What about other libraries — docopt, Fire, argh?
docopt is effectively unmaintained; Fire is convenient for exploration and produces an
interface that is hard to keep stable; argh is a thin argparse wrapper. For a tool other people
depend on, the three in this comparison are the ones with the ecosystem and the maintenance to
match.
How much code does the framework actually save?
For a tool with eight commands, typically two to three hundred lines: dispatch, shared option declarations, parameter conversion with consistent errors, completion, and the test harness around them. That is not a huge amount of code, but it is code that has edge cases — and every line of it is something you maintain instead of the library's authors.
Which has the best documentation?
Click's is the most complete reference; Typer's is the friendliest introduction and assumes you
will read Click's for the details; argparse's is thorough and dense, in the style of the standard
library. All three are good enough that documentation should not decide the choice.
Does the choice affect how testable my logic is?
Not directly — that depends on whether the logic lives outside the command functions, which is a
decision independent of the framework. What the framework affects is how cheaply you can test the
interface: Click and Typer give you an in-process runner, while argparse leaves you asserting
on a parsed namespace and testing dispatch separately.
Related
- Up: Command-line parsing with argparse
- Sideways: Typer vs Click: when to use each
- Sideways: Migrating from argparse to Typer
- Related: Structuring multi-command Python CLIs
- Related: Shell completion for Python CLIs