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 replaceadd_argument()calls. type=becomes a type annotation,choices=becomes anEnum,action="store_true"becomes abool, andadd_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
CliRunnertests 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.
| argparse | Typer |
|---|---|
ArgumentParser(description=...) | typer.Typer(help=...) |
add_argument("name") (positional) | function parameter with no default → typer.Argument |
add_argument("--opt") (optional) | function parameter with a default → typer.Option |
type=int / type=Path | annotation 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() + dispatch | app() |
Two rules resolve most confusion:
- 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(...)ortyper.Option(...)when you want to override the name, help, or make an option required with.... - Hyphenation is automatic. A parameter
dest_dirbecomes the--dest-diroption, 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 matchingtyper.Argument/typer.Option. - Keep the same default value and type.
--countdefaulting to1(anint) must stay= 1, not= "1". - Set the program help from
typer.Typer(help=...)(or the callback docstring) to replaceArgumentParser(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:
- Freeze behaviour with tests first (next section). Do not touch the argparse code until the tests pass against it.
- 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(). - Move shared setup into a callback once more than one command needs it.
- Delete the parser only when the last
add_argumentis gone. - Add completion last. Typer gives you
--install-completionfor 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
Annotatedis the modern style. Older Typer code puttyper.Option()in the default slot (count: int = typer.Option(1)). That still works, butAnnotated[int, typer.Option()] = 1keeps 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.valueis"loud". Compare and format against.valueto match your old string logic exactly. - Required options use
.... To reproduce argparse'srequired=Trueon an optional flag, give it no default or usetyper.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.