Architecture

Typer vs Click: When to Use Each

Compare Typer and Click for Python CLIs — type-hint inference vs decorator control, test ergonomics, and when each framework best fits your project.

Updated

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.
Typer or Click? — choosing by how you define the interface Typer or Click? How do you want to define the interface? From type hints Explicit decorators / max control Typer Click new projects, less boilerplate existing Click code, fine control

The core difference

Click is decorator-driven and explicit. You declare each option and argument with a decorator, and nothing is inferred:

Typer is a layer, not a rival A diagram showing Typer sitting on top of Click: Typer reads type hints and builds the Click objects that do the parsing, dispatch and completion. Typer is a layer, not a rival Click — the runtime underneath parsing, dispatch, completion Commands & groups the objects that actually run Parameter types conversion and error text Context ctx.obj, invocation chain Testing (CliRunner) shared by both Typer reads your type hints… …and constructs those Click objects for you Escape hatches let you drop to Click objects directly Because the runtime is shared, choosing Typer never rules out a Click feature — it changes how you declare it.
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

FactorTyperClick
Interface definitionInferred from type hintsExplicit decorators
BoilerplateLowerHigher, but very transparent
Learning curveGentle if you already write type hintsGentle if you think in decorators
Control over edge-case parsingGood; drop to Click when neededMaximum
Ecosystem / pluginsInherits Click'sLargest, most mature
Shell completionBuilt in, minimal setupAvailable, more manual
Best forNew projects, type-hinted codebasesExisting 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:

Testing either framework Testing practices that work for Click and Typer command line applications, and the habits that make CLI tests brittle. Testing either framework Works in both Invoke through CliRunner, not subprocess Assert on result.exit_code before output Keep logic in plain functions and test those directly Use isolated_filesystem() for commands that write files Makes tests brittle Shelling out to the installed console script Matching whole help screens character for character Depending on the developer machine's environment variables Testing only the happy path, never exit codes Typer re-exports the Click runner, so a test suite written this way survives a switch between the two.
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

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.

How do I share options across many commands?

Put them on the group callback if every command needs them, and reach for a small decorator when only some do. In Click a decorator that applies two or three @click.option calls in order is the idiomatic answer; in Typer the same effect comes from a shared Annotated alias reused across signatures:

Retries = Annotated[int, typer.Option(help="Attempts per file.")]

@app.command()
def sync(source: Path, retries: Retries = 3) -> None: ...

@app.command()
def push(target: str, retries: Retries = 3) -> None: ...

That keeps the help text and metadata in one place while leaving each signature explicit about what it accepts, which is easier to read than a decorator stack and much easier to test.

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.