Architecture

Migrating from argparse to Typer

Move a Python CLI from argparse to Typer step by step: map arguments and subparsers to Typer commands, keep behaviour, and cut boilerplate.

Updated

You have an argparse CLI that works, but adding features means more boilerplate, nested subcommands are painful, and you want shell completion for free. Typer gives you all of that by reading your function's type hints instead of making you declare every argument. This guide moves a real CLI across, one construct at a time, so behaviour stays identical while the code shrinks.

TL;DR

  • A Typer app replaces ArgumentParser; each @app.command() function's parameters replace add_argument() calls.
  • type= becomes a type annotation, choices= becomes an Enum, action="store_true" becomes a bool, and add_subparsers() becomes multiple @app.command()s.
  • Positional parameters become Typer arguments; parameters with defaults become options.
  • Migrate incrementally: Typer is built on Click, so you can wrap an existing argparse parser behind a single Typer command and peel it apart command by command.
  • Lock behaviour with CliRunner tests written before you change anything.

The starting point: an argparse CLI

Here is a small but representative tool — it greets people, takes a count, a log level with fixed choices, and a verbose flag.

# greet_argparse.py
import argparse

def main() -> None:
    parser = argparse.ArgumentParser(
        prog="greet", description="Greet someone a number of times.",
    )
    parser.add_argument("name", help="Who to greet.")
    parser.add_argument(
        "--count", type=int, default=1, help="How many times (default: 1).",
    )
    parser.add_argument(
        "--level", choices=["quiet", "normal", "loud"], default="normal",
        help="Greeting volume.",
    )
    parser.add_argument("--verbose", action="store_true", help="Explain what is happening.")

    args = parser.parse_args()
    if args.verbose:
        print(f"[verbose] level={args.level}")
    greeting = "hi" if args.level == "quiet" else "HELLO" if args.level == "loud" else "Hello"
    for _ in range(args.count):
        print(f"{greeting}, {args.name}!")

if __name__ == "__main__":
    main()

The destination: the same CLI in Typer

The equivalent Typer program produces the same --help, the same defaults, and the same output, with the parser gone:

# greet_typer.py
from enum import Enum
from typing import Annotated
import typer

class Level(str, Enum):
    quiet = "quiet"
    normal = "normal"
    loud = "loud"

app = typer.Typer(help="Greet someone a number of times.")

@app.command()
def greet(
    name: Annotated[str, typer.Argument(help="Who to greet.")],
    count: Annotated[int, typer.Option(help="How many times.")] = 1,
    level: Annotated[Level, typer.Option(help="Greeting volume.")] = Level.normal,
    verbose: Annotated[bool, typer.Option(help="Explain what is happening.")] = False,
) -> None:
    if verbose:
        typer.echo(f"[verbose] level={level.value}")
    greeting = {"quiet": "hi", "normal": "Hello", "loud": "HELLO"}[level.value]
    for _ in range(count):
        typer.echo(f"{greeting}, {name}!")

if __name__ == "__main__":
    app()
$ python greet_typer.py World --count 2 --level loud
HELLO, World!
HELLO, World!

The annotation is the type=. The default value is the default=. The Enum is the choices=. Typer even validates the enum and lists the options in --help, exactly like argparse's choices. Roughly a third of the lines are gone, and everything left describes intent rather than plumbing.

The mapping, construct by construct

Keep this table next to you while you translate. Almost every argparse pattern has a direct Typer equivalent.

The same interface, in both dialects A mapping table from argparse constructs to their Typer equivalents for positional arguments, optional flags, choices, counts and subcommands. The same interface, in both dialects You had argparse Typer Positional add_argument("src") src: str Option with default add_argument("--n", type=int, default=3) n: int = 3 Boolean flag action="store_true" force: bool = False Choices choices=[...] an Enum type Repeatable action="append" tags: list[str] Subcommand add_subparsers() @app.command() Almost every argparse construct has a signature-level equivalent; the type hint carries the information the call used to.
argparseTyper
ArgumentParser(description=...)typer.Typer(help=...)
add_argument("name") (positional)function parameter with no defaulttyper.Argument
add_argument("--opt") (optional)function parameter with a defaulttyper.Option
type=int / type=Pathannotation int / Path
default=1= 1 on the parameter
choices=[...]a str-Enum type
action="store_true"bool parameter defaulting to False
nargs="+"list[str] parameter
action="append"list[str] option (repeat the flag)
help="..."help= on typer.Argument/typer.Option
metavar="X"metavar="X" on the annotation
required=True (optional)option with no default, or ... as the default
add_subparsers()one @app.command() per subcommand
parser.error("msg")raise typer.BadParameter("msg")
parse_args() + dispatchapp()

Two rules resolve most confusion:

  1. Positional vs option is decided by the default. A parameter with no default becomes a required positional argument; a parameter with a default becomes an option. Force the issue with typer.Argument(...) or typer.Option(...) when you want to override the name, help, or make an option required with ....
  2. Hyphenation is automatic. A parameter dest_dir becomes the --dest-dir option, the same normalization argparse did in reverse.

choices → Enum

argparse choices become a str subclass of Enum. Subclassing str matters — it lets you compare level == "loud" and serialize cleanly, and Typer uses the values in help and completion.

class Level(str, Enum):
    quiet = "quiet"
    normal = "normal"
    loud = "loud"

nargs → list

A positional nargs="+" becomes a list parameter. Typer collects the trailing values into the list just as argparse did:

@app.command()
def process(paths: list[str]) -> None:
    for path in paths:
        typer.echo(path)

subparsers → commands

This is where Typer wins hardest. Every sub.add_parser("build") becomes a decorated function; the dispatch you wrote by hand with set_defaults(func=...) disappears because Typer routes to the function whose name matches the subcommand. If you are moving a subcommand-heavy CLI, read argparse subparsers for subcommands first to see exactly what you are replacing.

app = typer.Typer()

@app.command()
def build(release: bool = False) -> None:
    typer.echo(f"build release={release}")

@app.command()
def deploy(target: str) -> None:
    typer.echo(f"deploy to {target}")

app build --release and app deploy prod now work with no dispatch code at all.

Preserving help text and defaults

The most common regression in a migration is silently changed help or defaults. Guard against it:

  • Copy every help= string verbatim into the matching typer.Argument/typer.Option.
  • Keep the same default value and type. --count defaulting to 1 (an int) must stay = 1, not = "1".
  • Set the program help from typer.Typer(help=...) (or the callback docstring) to replace ArgumentParser(description=...).
  • If you relied on argparse's exit code 2 for usage errors, note that Typer also exits non-zero on bad input; align on a scheme deliberately using choosing exit codes for CLI tools.

For cross-cutting behaviour that used to live in the parser body — a --version flag, global setup that ran before every subcommand — use a Typer callback. That mechanism is covered end to end in Typer callback functions explained.

An incremental migration strategy

You do not have to rewrite everything in one commit. Because Typer sits on Click, you can adopt it at the edges and work inward:

Migrating without a big-bang rewrite A four stage migration: add Typer alongside argparse, move one command, run both against the same test suite, then delete the old parser. Migrating without a big-bang rewrite Add Typer no behaviour change yet 1 Move one command the smallest one first 2 Run parity tests same argv, same output 3 Delete argparse once nothing imports it 4 each stage ships on its own; the tool is never half-migrated in a release Keeping both parsers alive for one release is what makes the rollback cheap.
  1. Freeze behaviour with tests first (next section). Do not touch the argparse code until the tests pass against it.
  2. Wrap, then split. Add Typer as a dependency and expose the new app as the entry point, with your existing main() behind a single passthrough command. Ship that, then peel one subcommand at a time into a real @app.command().
  3. Move shared setup into a callback once more than one command needs it.
  4. Delete the parser only when the last add_argument is gone.
  5. Add completion last. Typer gives you --install-completion for free; wire it up once the command tree is stable.

At every step the CLI stays shippable, which matters when other people depend on it.

Testing parity

The safety net for the whole migration is a test suite that pins observable behaviour, run against both implementations. Typer reuses Click's runner, so the tests barely change:

Proving the migration changed nothing A parity test drives the same argument list through the old argparse entry point and the new Typer app and compares the captured output and exit code. Proving the migration changed nothing One argv case from a table of examples Old CLI argparse main() New CLI Typer CliRunner assert equal stdout + exit code run run compare Parametrise the table over every documented invocation and the migration stops being a leap of faith.
# test_greet.py
from typer.testing import CliRunner
from greet_typer import app

runner = CliRunner()

def test_default_greeting() -> None:
    result = runner.invoke(app, ["World"])
    assert result.exit_code == 0
    assert result.output == "Hello, World!\n"

def test_count_and_level() -> None:
    result = runner.invoke(app, ["World", "--count", "2", "--level", "loud"])
    assert result.exit_code == 0
    assert result.output.count("HELLO, World!") == 2

def test_invalid_choice_rejected() -> None:
    result = runner.invoke(app, ["World", "--level", "screaming"])
    assert result.exit_code != 0
    assert "screaming" in result.output

Run the same assertions against the argparse version first (invoke it as a subprocess or call parse_args directly), confirm they pass, then point the suite at the Typer app. Green on both means the migration preserved behaviour — which is the entire goal.

Production notes

  • Annotated is the modern style. Older Typer code put typer.Option() in the default slot (count: int = typer.Option(1)). That still works, but Annotated[int, typer.Option()] = 1 keeps the real default in the default position and plays nicely with type checkers and non-Typer callers. Use it in new code.
  • Enums serialize by value, not name. With a str-Enum, level.value is "loud". Compare and format against .value to match your old string logic exactly.
  • Required options use .... To reproduce argparse's required=True on an optional flag, give it no default or use typer.Option(...).
  • Keep parsing thin during the move. If your argparse main() mixed parsing with logic, extract the logic into plain functions first — see structuring multi-command Python CLIs. A thin command body makes the framework swap almost mechanical.
  • Pin Typer ≥0.12 for the Annotated-first API and current completion support.

Frequently asked questions

Can I run argparse and Typer side by side during a migration?

Yes, and it is the safest way to do it. Keep the argparse entry point exactly as it is, add a Typer app next to it, and move one command at a time by having the old dispatcher delegate to the new app. Each step ships on its own and each step is revertible, which is not true of a weekend rewrite that lands as one commit.

What does not translate cleanly from argparse?

Three things. Custom actions have no direct equivalent — they usually become a parameter callback or a small custom type. Mutually exclusive groups are not a first-class Typer concept, so the rule moves into a validation step in the command body. And argparse's parse_known_args, used to forward unknown flags to another program, needs Click's ignore_unknown_options context setting instead.

How do I prove the migration did not change behaviour?

Write a parity test before you touch anything: a table of documented invocations, each run through both the old entry point and the new app, asserting the same exit code and the same stdout. Once that table is green on the old CLI, it becomes the acceptance criterion for every migrated command, and the migration stops being a matter of opinion.

Will help output look the same afterwards?

No, and you should not try to make it identical. Typer renders help through Rich, so the layout, colours and wrapping differ from argparse's. What matters is that the same options are documented with the same text, which is why parity tests should assert on option names and descriptions rather than on whole help screens.

Is Typer worth the dependency for a small tool?

For a single-command script that reads two arguments, argparse is fine and adds nothing to install. The trade tips as soon as you have subcommands, want tab completion, or find yourself writing conversion and validation code by hand — that is exactly the work Typer removes, and it is the work that grows fastest as a tool gets more commands.