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