Architecture

argparse vs Click vs Typer Compared

A practical comparison of argparse, Click and Typer - dependencies, nested commands, completion, testing and the one question that decides the choice.

Updated

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

One command, three declarations The same command expressed as imperative argparse calls, Click decorators and a Typer type-annotated signature, all producing identical behaviour. One command, three declarations add_argument(...) imperative calls @click.option(...) decorators def sync(n: int = 3) type hints same parse same parse All three produce the same usage errors and the same exit code 2 — the difference is authoring style.
# 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

The three parsers, feature by feature A comparison of argparse, Click and Typer across dependencies, nested commands, shell completion, parameter types, testing support and context passing. The three parsers, feature by feature Capability argparse Click Typer Dependencies none one two Nested groups manual subparsers built in built in Shell completion third-party built in one command Parameter types type= callables ParamType classes type hints Test runner parse_args() CliRunner CliRunner Shared state the Namespace Context.obj Context.obj Everything argparse lacks is buildable; the question is whether you want to own that code.

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

Which parser for this project? A decision diagram for choosing a command line parser based on whether dependencies are acceptable and how many commands the tool will have. Which parser for this project? Can this tool take a dependency? No — a bootstrap script or a locked image argparse always available Yes, and it will grow past one command Typer least code per command Choose Click over Typer when your commands are built dynamically rather than declared.

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.