Architecture

Typer callback functions explained

Understand Typer callback functions for shared options, version flags, and pre-command hooks in Python CLIs using invoke_without_command patterns.

Updated

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 on ctx.obj.
  • For a flag that should run and exit immediately (like --version), use an eager option callback with is_eager=True that raises typer.Exit().
  • Use a parameter callback (typer.Option(callback=...)) for per-option validation.
  • Set invoke_without_command=True when the app should do something even with no subcommand.
Typer callback order of operations Typer callback order of operations 1 Eager param callbacks e.g. --version (is_eager=True) exit early 2 App callback @app.callback() 3 Param callbacks of the chosen command 4 Command body your subcommand runs

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():

Why --version needs is_eager An eager parameter callback runs before other parameters are processed, prints the version and raises Exit so no command ever runs. Why --version needs is_eager --version seen during parsing Eager callback runs first, out of order print + raise Exit exit code 0 is_eager=True nothing else runs Without is_eager the flag is processed in declaration order, so a missing required argument errors first.
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:

  1. Eager parameter callbacks (e.g. --version), which may exit early.
  2. The app callback (@app.callback()), including its own parameter callbacks.
  3. Non-eager parameter callbacks for the chosen subcommand.
  4. 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:

Callbacks fire top-down through sub-apps The callback chain: the root app callback runs, then the sub-app callback, then the command body of the invoked command. Callbacks fire top-down through sub-apps app callback global options, shared setup db callback opens the connection db migrate the command body db seed the command body invoking "mytool db migrate" runs the app callback, then the db callback, then migrate Each level may add to ctx.obj; none of them should do work the level below might not need.
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.obj over module-level globals. Globals make commands order-dependent and hard to test; ctx.obj scopes state to a single invocation.
  • For async code, reach for contextvars.ContextVar rather 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 --help and --version stay 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

Frequently asked questions

When does the app callback run?

On every invocation, before the chosen subcommand, and even when no subcommand was given if you allow that. That makes it the right home for shared setup — configuring logging, loading settings, opening a client — and the wrong home for anything expensive that only one command needs.

Why does --version need is_eager?

Because without it the flag is processed in declaration order, so a missing required argument raises a usage error before your callback ever runs. is_eager=True moves that parameter to the front of the queue, which is why a version flag can print and exit cleanly even when the rest of the command line is incomplete.

What is the right way to exit from a callback?

Raise typer.Exit(). It unwinds through Typer's own machinery, which means the exit code is respected and the runner in your tests sees it as a normal termination rather than an escaped exception. Calling sys.exit works but bypasses that path, and returning a value from a callback does nothing at all.

Can a sub-app have its own callback?

Yes, and it runs after the root callback and before the command. That is the natural place for setup scoped to one subject area — opening a database connection for the db sub-app, say — while the root callback keeps the setup that everything needs.

How do I test what a callback did?

Invoke the app through CliRunner with the flags you care about and assert on the observable effect: the log level that ended up configured, the settings object the command received, or the exit code from an eager flag. Testing the callback function in isolation misses the ordering, which is usually the part that breaks.

Can a callback change what the subcommand receives?

Not directly — it cannot rewrite another command's arguments. What it can do is put values on the context that commands read through pass_obj or ctx.obj, which covers the real use case: a --config flag on the callback producing a settings object every command below it consumes. If you find yourself wanting to mutate a subcommand's parameters, the value probably belongs in that shared object instead, where it is explicit and typed rather than hidden in the plumbing.

Does the callback run when the user asks for help?

No. Both --help and eager flags short-circuit before the app callback body runs, which is exactly what you want — printing help should never need a database connection or a config file. It also means any setup you put in the callback is not covering the help path, so a --help that crashes is telling you something is happening at import time rather than in the callback.