Typer and Click are the two frameworks most Python teams choose between in 2026, and the decision matters less than newcomers fear: Typer is built on top of Click, so picking one doesn't lock you out of the other's concepts. The real question is how much you want the framework to infer from your type hints versus how much you want to spell out by hand.
TL;DR
- Choose Typer when you want type hints to drive the interface, you're starting fresh, and you value minimal boilerplate and automatic help from function signatures.
- Choose Click when you need fine-grained control over parsing, you're maintaining an existing Click codebase, or you depend on the broad ecosystem of Click extensions.
- You can always drop down to Click primitives from inside a Typer app, because a Typer command is a Click command underneath.
The core difference
Click is decorator-driven and explicit. You declare each option and argument with a decorator, and nothing is inferred:
import click
@click.command()
@click.option("--count", default=1, type=int, help="Number of greetings.")
@click.argument("name")
def hello(count: int, name: str) -> None:
"""Greet NAME COUNT times."""
for _ in range(count):
click.echo(f"Hello {name}")
Typer is type-hint-driven. The same command infers types, defaults, and help text
from the function signature using Annotated:
from typing import Annotated
import typer
def hello(
name: str,
count: Annotated[int, typer.Option(help="Number of greetings.")] = 1,
) -> None:
"""Greet NAME COUNT times."""
for _ in range(count):
typer.echo(f"Hello {name}")
if __name__ == "__main__":
typer.run(hello)
Both produce equivalent --help output and the same int coercion. Typer reads the
parameter kind (positional → argument, keyword with default → option) and the annotation;
Click wants you to say it outright.
Decision factors
| Factor | Typer | Click |
|---|---|---|
| Interface definition | Inferred from type hints | Explicit decorators |
| Boilerplate | Lower | Higher, but very transparent |
| Learning curve | Gentle if you already write type hints | Gentle if you think in decorators |
| Control over edge-case parsing | Good; drop to Click when needed | Maximum |
| Ecosystem / plugins | Inherits Click's | Largest, most mature |
| Shell completion | Built in, minimal setup | Available, more manual |
| Best for | New projects, type-hinted codebases | Existing Click code, fine control |
When the choice is already made for you
- Inheriting a Click codebase? Stay on Click. Mixing frameworks for the sake of it adds cognitive load without payoff.
- Heavy use of a Click plugin (e.g.
click-plugins, framework-specific groups)? Click keeps that path frictionless. - Greenfield tool with type hints everywhere? Typer removes boilerplate and keeps the signature as the single source of truth.
Test ergonomics
Both frameworks ship a CliRunner that invokes commands in-process and captures output and
exit codes — no subprocess required. The APIs are nearly identical because Typer reuses
Click's testing machinery, so your testing strategy doesn't change with the framework:
from click.testing import CliRunner # Typer: from typer.testing import CliRunner
def test_hello() -> None:
runner = CliRunner()
result = runner.invoke(hello, ["World", "--count", "2"])
assert result.exit_code == 0
assert result.output.count("Hello World") == 2
Go deeper
- Building a CLI with subcommands in Click
—
group(), nested commands, shared context, and middleware-style behavior. - Typer callback functions explained
— shared options,
--versionflags, and pre-command hooks withinvoke_without_command.
What Typer actually does for you
The clearest way to see the difference is to write the same command twice. Here it is in Click, where every parameter is declared explicitly:
import click
from pathlib import Path
@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("--tag", "tags", multiple=True, help="Attach a tag; repeat for several.")
@click.option("--dry-run", is_flag=True, help="Show what would happen.")
def sync(source: Path, retries: int, tags: tuple[str, ...], dry_run: bool) -> None:
"""Sync SOURCE to the configured bucket."""
And in Typer, where the same information is already in the signature:
import typer
from pathlib import Path
from typing import Annotated
app = typer.Typer()
@app.command()
def sync(
source: Annotated[Path, typer.Argument(exists=True)],
retries: Annotated[int, typer.Option(help="Attempts per file.")] = 3,
tags: Annotated[list[str], typer.Option("--tag", help="Attach a tag.")] = [],
dry_run: Annotated[bool, typer.Option(help="Show what would happen.")] = False,
) -> None:
"""Sync SOURCE to the configured bucket."""
Notice what disappeared. The type of every parameter is stated once instead of twice, so the
annotation and the parser can never disagree. multiple=True becomes list[str]. is_flag=True
becomes bool, and Typer generates --dry-run/--no-dry-run from it. The default value is where
Python already puts defaults.
Notice also what did not change. Both produce the same parsing behaviour, the same
--help structure, the same exit code 2 on a bad value, and both are tested with the same
runner — because Typer builds Click objects and hands them to Click to execute. That is the
single most useful fact about this comparison: it is not two engines, it is one engine with two
front ends.
The practical consequence is that "Typer cannot do X" is almost never true. Where Typer has no sugar for something, you drop to the Click object underneath and carry on:
# a Click-style custom parameter type, used from a Typer command
class BucketName(click.ParamType):
name = "bucket"
def convert(self, value, param, ctx):
if not re.fullmatch(r"[a-z0-9-]{3,63}", value):
self.fail(f"{value!r} is not a valid bucket name", param, ctx)
return value
@app.command()
def push(bucket: Annotated[str, typer.Option(click_type=BucketName())]) -> None:
...
Where the two genuinely differ
Four differences are real rather than stylistic, and they are worth knowing before you commit.
Enums and choices. Typer reads an enum.Enum and produces both the restricted set of values
and the completion candidates. In Click you pass click.Choice([...]), which is a list of
strings — fine, but the values are not a type your code can use, so the command body usually
re-derives an enum member anyway.
Dynamic parameters. If the options a command accepts are computed at run time — from a plugin, a config file, a remote schema — Click's object model is the natural fit, because commands are objects you can build in a loop. Typer's signature-driven model assumes the parameters are known when the module is imported. You can mix the two, but if most of your interface is dynamic, Click is the better starting point.
Help rendering. Typer renders help through Rich by default: colours, panels, aligned
columns. Most people prefer it; some CI logs and terminals do not, and the answer there is
typer.Typer(rich_markup_mode=None) rather than switching framework.
Argument style for repeated values. Typer's list[str] defaults are a known Python trap —
a mutable default in a signature — and while Typer handles it correctly, linters will complain.
Click's multiple=True avoids the argument entirely.
None of these is a reason to migrate an existing codebase. All of them are worth five minutes of thought before the first command of a new one.
Committing to one, and living with it
Whichever you pick, the decision should show up in exactly one place in your project: the command modules. Everything below them — the functions that do the work, the settings object, the error types — should be framework-free, which is what makes the choice reversible in the first place.
# commands/sync.py — the only file that knows which framework this is
@app.command()
def sync(source: Path, retries: int = 3) -> None:
result = core.sync_directory(source, retries=retries)
typer.echo(f"{result.uploaded} uploaded")
Two habits keep that boundary honest. Never import typer (or click) outside a command
module — if a core function needs to signal a problem, it raises a domain exception and the
boundary translates it. And never accept a framework object as a parameter of a core function;
pass the plain values it needs.
Teams that do this report the same thing: the framework question stops being interesting. A migration becomes a day of mechanical edits in one directory rather than a rewrite, which is why it is worth deciding quickly and moving on to the decisions that actually compound.
Moving between them, in either direction
Because the runtime is shared, a migration is a series of local edits rather than a rewrite — and it can be done one command at a time with the tool working throughout.
Start by mounting the new app inside the old tree. Going from Click to Typer:
import click
import typer
from mytool.commands import legacy # existing @click.command functions
from mytool.commands import migrated # new Typer app
cli = click.Group(help="Manage deployments.")
cli.add_command(legacy.sync) # untouched
cli.add_command(typer.main.get_command(migrated.app)) # the new half
Going the other way, a Typer app hosts Click commands directly:
app = typer.Typer()
app.add_click_command(legacy.sync, name="sync")
Then move commands in order of increasing risk: the smallest and least-used first, so the mechanics are familiar before you touch the command everyone runs daily. Each move is one commit, each commit ships, and at no point is the tool half-broken.
The conversions themselves are mechanical. @click.argument("src") becomes a positional
parameter with a type hint. @click.option("--n", type=int, default=3) becomes n: int = 3.
is_flag=True becomes bool. multiple=True becomes list[str]. click.Choice becomes an
Enum. What needs judgement is anything that reaches into Click's object model — a custom
Command subclass, a dynamically built group, a parameter whose callback mutates the context.
Those keep working unchanged, so the pragmatic answer is usually to leave them in Click.
Protect the whole exercise with a parity test written before the first move:
import pytest
from typer.testing import CliRunner
CASES = [
([], 0),
(["--help"], 0),
(["sync", "./data"], 0),
(["sync"], 2), # missing argument is a usage error
(["sync", "./data", "--retries", "x"], 2),
]
@pytest.mark.parametrize("argv,expected", CASES)
def test_exit_codes_unchanged(argv, expected):
assert CliRunner().invoke(cli, argv).exit_code == expected
Exit codes are the part scripts depend on, so pinning them first turns "did the migration change anything" from an opinion into a test run. Add an assertion on a distinctive fragment of stdout for the commands that produce data, and leave help text out of it — the rendering differs between the two by design.
Test ergonomics in practice
Both frameworks are tested the same way, and it is worth being explicit about the shape because it is what makes CLI tests fast enough to keep.
from typer.testing import CliRunner # or: from click.testing import CliRunner
runner = CliRunner()
def test_dry_run_writes_nothing(tmp_path):
(tmp_path / "a.txt").write_text("x")
result = runner.invoke(app, ["sync", str(tmp_path), "--dry-run"])
assert result.exit_code == 0
assert "would upload 1 file" in result.stdout
assert not (tmp_path / ".uploaded").exists()
The runner invokes in-process. There is no subprocess, no installed console script and no PATH
lookup, so a test costs milliseconds and a failure gives you a real traceback rather than a
captured stderr blob. runner.isolated_filesystem() gives a test its own working directory when
the command writes relative paths.
Two habits matter more than the framework choice. Assert on exit_code before output — a test
that only checks text passes cheerfully when the command failed for an unrelated reason. And
keep the interesting assertions in unit tests against the plain functions the command calls;
runner tests should be proving that flags map to arguments, that defaults are what the help says,
and that failures produce the documented code.
One difference worth knowing: by default Click's runner mixes stderr into result.output, while
recent versions expose result.stderr separately when constructed with
CliRunner(mix_stderr=False). If you are asserting that an error message went to the right
stream — and you should be — construct the runner that way and assert against result.stderr
explicitly.
A note on the third option
Neither framework is the only reasonable answer. argparse remains the right choice when a
dependency is genuinely unacceptable, and it is more capable than its reputation suggests —
subparsers, type callables, parent parsers and mutually exclusive groups cover a lot of ground.
What you give up is the machinery you would otherwise build by hand: shell completion, a testing
runner, nested groups with per-level callbacks, parameter types with consistent error messages,
and context propagation. Every one of those is writable in argparse, and every one of them is
code you then own. For a tool with one or two commands that is a fine trade. For a growing
command tree it is a slow leak of effort into infrastructure that Click already solved.
The migration path in the other direction is well worn: migrating from argparse to Typer maps every construct, and the incremental strategy is the same one described above — mount both, move one command at a time, and pin the behaviour with a parity test.
Frequently asked questions
Is Typer just a wrapper, or does it add capability?
It is a front end that adds ergonomics, not capability. Everything it produces is Click objects executing under Click's runtime, so the parsing, dispatch, context and completion behaviour are identical. What it adds is that your type annotations become the source of truth, which removes the class of bug where the declared type and the annotation drift apart.
Can I use both in the same project?
Yes. typer.main.get_command(app) turns a Typer app into a Click command you can attach to an
existing Click group, and Typer apps can host Click commands with app.add_click_command. That
is what makes an incremental migration possible: the tree stays whole while individual commands
move.
Which has better shell completion?
Both use the same machinery; Typer makes it easier to turn on. --install-completion writes the
script for the user's shell and adds the line that loads it, where Click expects you to document
an eval or a file to source. For dynamic completion the APIs differ in spelling —
autocompletion= versus shell_complete= — and behave the same way.
Does Typer require Python 3.10 or newer?
No, but the modern annotation style reads much better on 3.10+, where X | None and
list[str] are available without typing imports. Annotated, which is now the recommended
way to attach option metadata, is available from 3.9 and via typing_extensions before that.
If I am already on Click, is there a reason to move?
Only if you are writing a lot of new commands and the boilerplate is bothering you. A working Click codebase gains nothing from a migration by itself — the runtime is the same — so the justification has to be authoring speed on the commands you have not written yet, not a capability you are missing.
Which one handles errors better?
Neither has an advantage, because both give you the same two hooks. A parameter type that raises
becomes a usage error and exit code 2 automatically, and an exception raised in a command body
propagates to whatever boundary you installed around the app. What differs is spelling:
click.BadParameter and click.ClickException versus typer.BadParameter and typer.Exit.
Choose one vocabulary, wrap the app once in main(), and the error behaviour of your tool stops
depending on which framework is underneath it at all.