Architecture

Click Option Callbacks and Eager Options Explained

Use parameter callbacks to validate and transform values, eager options for --version, and an eager --config that fills ctx.default_map — in Click and in Typer, with tests.

Updated

Most options are passive: Click parses a value and hands it to your function. Two features make options active. A callback runs as soon as an option's value is processed — before your command starts — and can validate it, transform it, or act on it. An eager option is processed before all the others, regardless of where it appears on the command line, which is how --version and --help can print and exit even when the rest of the command line is invalid. Combined, they enable one of the most useful patterns in Click: a --config option that loads a file and supplies defaults for every other option, while explicit flags still win. This guide explains both features, builds --version, a validating callback and the eager --config pattern, and shows the Typer equivalents. It belongs to the Typer vs Click topic.

Prerequisites

How Click processes parameters

How Click processes parameters Click parses the whole command line, processes eager parameters first, then the rest in declaration order, running each type conversion, fallback and callback, before calling the command. How Click processes parameters Parse argv all tokens Eager params --version, --config Other params declaration order Command final values first then call Each parameter: convert, fall back to env and default_map, then run its callback.

Click parses the whole command line first, then processes parameters one by one: converting the raw string with the parameter's type, falling back to environment variables and defaults, and finally calling the callback with (ctx, param, value). Eager parameters are processed before non-eager ones, in the order they appeared; everything else follows in declaration order. Whatever a callback returns becomes the value passed to the command (unless expose_value=False), and raising click.BadParameter turns into a usage error naming the option.

The recipe

# src/mytool/cli.py
from __future__ import annotations

import tomllib
from pathlib import Path

import click


def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> None:
    if not value or ctx.resilient_parsing:
        return
    click.echo("mytool 1.6.0")
    ctx.exit()


def load_config(ctx: click.Context, param: click.Parameter, value: str | None) -> str | None:
    if value is None or ctx.resilient_parsing:
        return value
    try:
        data = tomllib.loads(Path(value).read_text(encoding="utf-8"))
    except FileNotFoundError:
        raise click.BadParameter(f"{value} does not exist") from None
    except tomllib.TOMLDecodeError as exc:
        raise click.BadParameter(f"{value} is not valid TOML: {exc}") from None
    ctx.default_map = {**(ctx.default_map or {}), **data.get("sync", {})}
    return value


def positive_seconds(ctx: click.Context, param: click.Parameter, value: float | None):
    if value is not None and value <= 0:
        raise click.BadParameter("must be greater than zero")
    return value


@click.command()
@click.option("--version", is_flag=True, expose_value=False, is_eager=True,
              callback=print_version, help="Show the version and exit.")
@click.option("--config", type=click.Path(dir_okay=False), is_eager=True,
              callback=load_config, expose_value=False, help="Read defaults from a TOML file.")
@click.option("--target", default="local", show_default=True)
@click.option("--timeout", type=float, default=30.0, callback=positive_seconds,
              show_default=True)
def sync(target: str, timeout: float) -> None:
    """Sync files."""
    click.echo(f"target={target} timeout={timeout}")

--version: eager, exits, not exposed

print_version is the classic eager callback. Because it is eager, mytool sync --timeout -1 --version prints the version and exits before --timeout is validated — users asking for the version should never be told their other arguments are wrong. expose_value=False keeps the flag out of the command's signature, since the command never needs it. ctx.resilient_parsing is true during shell completion, when Click parses the line without executing anything; callbacks with side effects must return early in that case, or pressing Tab would print the version. Click also ships @click.version_option(), which does all of this for you and reads the version from package metadata — use it unless you need custom output.

A validating callback

positive_seconds shows the other common use: a check that a type alone cannot express. Raising click.BadParameter produces a standard usage error, Error: Invalid value for '--timeout': must be greater than zero, and exit code 2. For reusable validation, a custom parameter type is often cleaner — see writing custom Click parameter types — but a callback is ideal for one-off rules and for checks that need the context.

--config: eager, fills default_map

ctx.default_map is Click's mechanism for supplying defaults from somewhere other than the decorators: when an option has no value from the command line or environment, Click looks it up in default_map before falling back to the declared default. An eager --config callback runs before the other options are processed, so it can populate default_map in time for them to use it. The precedence that results is exactly what users expect:

Where a value comes from Precedence of value sources for a Click option when an eager config callback fills default_map. Where a value comes from Command line highest --target x — wins wherever it appears Environment variable env envvar= or auto_envvar_prefix ctx.default_map config file filled by the eager --config callback Declared default lowest default= in the decorator Eagerness is what lets the config load before the options it supplies.
$ cat c.toml
[sync]
target = "s3"
timeout = 5

$ mytool sync --config c.toml
target=s3 timeout=5.0
$ mytool sync --target x --config c.toml          # flag position does not matter
target=x timeout=5.0

The explicit --target x wins even though it appears before --config, because the eager option is processed first and default_map only supplies values that were not given. This is a compact implementation of the flags-over-file rule described in config precedence: flags, env, files, defaults. For a group with subcommands, nest the map by command name ({"sync": {...}, "push": {...}}) and set it on the group's context; Click passes the right sub-dictionary to each subcommand.

Eager options in action Terminal session showing version printing despite an invalid option, config-file defaults, and a flag overriding the config regardless of position. Eager options in action bash $ mytool sync --timeout -1 --version mytool 1.6.0 $ mytool sync --config c.toml target=s3 timeout=5.0 $ mytool sync --target x --config c.toml target=x timeout=5.0 The flag before --config still wins, because the config only fills gaps.

The same in Typer

Typer exposes callbacks and eagerness through typer.Option, and lets callbacks declare only the parameters they need:

# src/mytool/typer_cli.py
import tomllib
from pathlib import Path
from typing import Annotated, Optional

import typer

app = typer.Typer()


def version_callback(value: bool) -> None:
    if value:
        typer.echo("mytool 1.6.0")
        raise typer.Exit()


def config_callback(ctx: typer.Context, value: Optional[Path]) -> Optional[Path]:
    if value is None or ctx.resilient_parsing:
        return value
    data = tomllib.loads(value.read_text(encoding="utf-8"))
    ctx.default_map = {**(ctx.default_map or {}), **data.get("sync", {})}
    return value


@app.command()
def sync(
    version: Annotated[Optional[bool], typer.Option(
        "--version", callback=version_callback, is_eager=True)] = None,
    config: Annotated[Optional[Path], typer.Option(
        callback=config_callback, is_eager=True, exists=True, dir_okay=False)] = None,
    target: str = "local",
    timeout: float = 30.0,
) -> None:
    """Sync files."""
    typer.echo(f"target={target} timeout={timeout}")

exists=True lets Typer check the file before the callback runs, so the callback only handles parsing. A callback that takes ctx: typer.Context gets the context; one that takes only value does not need it. The parameters still appear in the command's signature, which is the main stylistic difference from Click's expose_value=False.

UX considerations

  • Keep eager options few. --version, --help and perhaps --config. Every eager option changes processing order, which makes behaviour harder to predict.
  • Name the file in config errors. "c.toml is not valid TOML: Expected '=' after a key (at line 2, column 7)" is fixable; a traceback is not.
  • Ignore unknown config keys loudly or not at all — decide which. Silently ignoring a typo like timout = 5 is a classic source of confusion; warning on stderr is a good middle ground.
  • Respect resilient_parsing in every callback with side effects, or completion will run them on each Tab.

Testing the behaviour

# tests/test_callbacks.py
from click.testing import CliRunner

from mytool.cli import sync

runner = CliRunner()


def test_version_wins_over_invalid_options():
    result = runner.invoke(sync, ["--timeout", "-1", "--version"])
    assert result.exit_code == 0 and result.output == "mytool 1.6.0\n"


def test_callback_validation_is_a_usage_error():
    result = runner.invoke(sync, ["--timeout", "0"])
    assert result.exit_code == 2 and "must be greater than zero" in result.output


def test_config_supplies_defaults_and_flags_win(tmp_path):
    cfg = tmp_path / "c.toml"
    cfg.write_text('[sync]\ntarget = "s3"\ntimeout = 5\n')
    assert runner.invoke(sync, ["--config", str(cfg)]).output == "target=s3 timeout=5.0\n"
    result = runner.invoke(sync, ["--target", "x", "--config", str(cfg)])
    assert result.output == "target=x timeout=5.0\n"


def test_bad_config_names_the_file(tmp_path):
    cfg = tmp_path / "bad.toml"
    cfg.write_text("target s3\n")
    result = runner.invoke(sync, ["--config", str(cfg)])
    assert result.exit_code == 2 and "bad.toml is not valid TOML" in result.output

The second config test, with --target before --config, is the one that proves eagerness is doing its job; without is_eager=True the order would change the result.

Conclusion

Callbacks let options validate, transform and act on their values as they are processed; eagerness lets a few options run before everything else. Use an eager, non-exposed flag for --version (or @click.version_option), callbacks raising BadParameter for one-off validation, and an eager --config callback that fills ctx.default_map to get config-file defaults with flags-win precedence in a dozen lines. Guard side effects with ctx.resilient_parsing, and test that eager options behave the same wherever they appear on the command line.

Frequently asked questions

Can a callback read another option's value?

Only options already processed are available in ctx.params. Eager options are processed first and the rest in declaration order, so a callback can rely on options declared above it. For checks across several options, validate inside the command, or see validating dependent and conflicting options.

Why not load the config file inside the command?

You can, but then you must merge config values with flags yourself and work out which flags the user actually passed. default_map lets Click do the merge with the same rules it uses for every other source, so the command body only ever sees final values.

Does default_map work with environment variables?

Yes. The order is: command line, then environment variable (if envvar or auto_envvar_prefix is set), then default_map, then the declared default.

Is a group-level --config better than a per-command one?

For multi-command tools, yes: put the eager --config on the group and nest default_map by command name, so every subcommand gets its section of the file — the approach in global options vs per-command options.

What happens if two eager options both exit?

They run in the order they appear on the command line, and the first one to call ctx.exit() (or raise typer.Exit) wins. mytool --version --help prints the version; mytool --help --version prints help. That is rarely a problem in practice, but it is why eager options should do one quick thing and exit, not depend on each other.