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.

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:

  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:

# 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.