In Typer, a callback is the function that runs before any subcommand — the equivalent
of Click's group body. It's where you declare global options (--verbose, --config),
implement an eager --version flag, set up shared state, and enforce preconditions. This
guide explains the three kinds of callback you'll actually use and the patterns that keep
them correct.
TL;DR
- The app callback (
@app.callback()) runs before every subcommand — put global options and setup there, and store shared state onctx.obj. - For a flag that should run and exit immediately (like
--version), use an eager option callback withis_eager=Truethat raisestyper.Exit(). - Use a parameter callback (
typer.Option(callback=...)) for per-option validation. - Set
invoke_without_command=Truewhen the app should do something even with no subcommand.
The app callback: global options and setup
Add a callback to a multi-command app with @app.callback(). Its parameters become global
options that appear before the subcommand. Store anything subcommands need on ctx.obj:
# widget/main.py
from typing import Annotated
import typer
app = typer.Typer()
@app.callback()
def main(
ctx: typer.Context,
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging.")] = False,
) -> None:
"""widget — manage widgets from the command line."""
# ctx.obj is the shared bag passed down to every subcommand.
ctx.obj = {"verbose": verbose}
@app.command()
def create(ctx: typer.Context, name: str) -> None:
"""Create a widget called NAME."""
if ctx.obj["verbose"]:
typer.echo("[verbose] creating widget")
typer.echo(f"Created widget {name!r}")
if __name__ == "__main__":
app()
Run it as widget --verbose create gadget. The callback runs first, stashes verbose on
ctx.obj, and then create reads it. The global option goes before the subcommand,
exactly like Click groups.
The eager --version flag
A --version flag should print and exit before Typer validates anything else — even before
required arguments on a subcommand. That's what is_eager=True is for: eager parameters are
processed first, regardless of their position. Pair it with a callback that raises
typer.Exit():
from typing import Annotated, Optional
import typer
__version__ = "1.2.0"
app = typer.Typer()
def version_callback(value: bool) -> None:
if value:
typer.echo(f"widget {__version__}")
raise typer.Exit() # stops processing immediately, exit code 0
@app.callback()
def main(
version: Annotated[
Optional[bool],
typer.Option(
"--version",
callback=version_callback,
is_eager=True,
help="Show the version and exit.",
),
] = None,
) -> None:
"""widget — manage widgets."""
Because the option is eager, widget --version prints the version and exits cleanly even
though no subcommand was given. Without is_eager=True, Typer might complain about a
missing command first.
Running without a subcommand
By default a multi-command Typer app requires a subcommand. Set
invoke_without_command=True to let the callback run on its own — useful for showing a
default status view or custom help. Check ctx.invoked_subcommand to tell the cases apart:
import typer
app = typer.Typer(invoke_without_command=True)
@app.callback()
def main(ctx: typer.Context) -> None:
"""widget — manage widgets."""
if ctx.invoked_subcommand is None:
typer.echo("No command given. Run 'widget --help' for usage.")
Now widget with no arguments runs the callback body, while widget create ... skips the
if branch and dispatches normally.
Parameter callbacks for validation
Distinct from the app callback, a parameter callback validates a single option or
argument. Return the (optionally transformed) value, or raise typer.BadParameter to fail
with a clean error message and a non-zero exit code:
from typing import Annotated
import typer
app = typer.Typer()
def validate_name(value: str) -> str:
if not value.isidentifier():
raise typer.BadParameter("name must be a valid Python identifier")
return value.lower() # callbacks can normalize, not just validate
@app.command()
def create(
name: Annotated[str, typer.Argument(callback=validate_name)],
) -> None:
"""Create a widget."""
typer.echo(f"Created {name}")
widget create "not valid" exits non-zero with a clear message; widget create Gadget
stores the normalized gadget. Keep these callbacks pure and fast — they run during
parsing, before your command logic.
Order of operations
When a command runs, Typer processes things in this order:
- Eager parameter callbacks (e.g.
--version), which may exit early. - The app callback (
@app.callback()), including its own parameter callbacks. - Non-eager parameter callbacks for the chosen subcommand.
- The subcommand body.
Knowing this order explains why --version works without a subcommand and why validation
in a parameter callback fires before your command code.
Callbacks on sub-apps
Large CLIs split commands across multiple Typer apps and compose them with
add_typer(). Each sub-app gets its own callback, which runs after the parent's and
before that sub-app's commands — so widget remote add runs the root callback, then the
remote callback, then add. This is how you scope setup (and global options) to a branch
of the command tree:
import typer
app = typer.Typer()
remote_app = typer.Typer()
app.add_typer(remote_app, name="remote")
@app.callback()
def main(ctx: typer.Context) -> None:
"""widget — manage widgets."""
ctx.obj = {"scope": "root"}
@remote_app.callback()
def remote_main(ctx: typer.Context, registry: str = "default") -> None:
"""Manage remote registries."""
# Extend the parent's shared state instead of replacing it.
ctx.obj["registry"] = registry
@remote_app.command()
def add(ctx: typer.Context, url: str) -> None:
"""Register URL with the selected registry."""
typer.echo(f"Added {url} to {ctx.obj['registry']}")
Run widget remote --registry staging add https://example.com. The --registry option is
declared on the remote callback, so it's scoped to the remote branch and doesn't clutter
unrelated commands. This mirrors how nested Click groups carry their own options — see
building a CLI with subcommands in Click.
State management best practices
- Prefer
ctx.objover module-level globals. Globals make commands order-dependent and hard to test;ctx.objscopes state to a single invocation. - For async code, reach for
contextvars.ContextVarrather than module globals, so per-task state stays isolated across concurrent tasks. - Keep callbacks lightweight. Defer blocking I/O (network, large file reads) to the
command body so
--helpand--versionstay instant. - Validate in parameter callbacks, orchestrate in the app callback. Mixing the two makes failures harder to attribute.
Testing callbacks
Use typer.testing.CliRunner — the same in-process pattern as Click — to assert that global
flags, the version flag, and validation all behave:
from typer.testing import CliRunner
from widget.main import app
runner = CliRunner()
def test_version_flag_exits_zero() -> None:
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert "widget" in result.output
def test_verbose_reaches_command() -> None:
result = runner.invoke(app, ["--verbose", "create", "gadget"])
assert result.exit_code == 0
assert "[verbose]" in result.output