Architecture

Sharing State with Click Context Objects

Pass configuration and shared services between Click commands with ctx.obj and pass_context, set defaults in a group callback, and keep commands testable.

Updated

A grouped Click CLI needs a way to hand shared state — resolved config, an open database handle, an API client — from the top-level group down to whichever subcommand runs. Click's answer is the Context: an object threaded through every invocation, with a free-form ctx.obj slot you own. Done well, it gives every command a typed handle on shared services while keeping each one testable in isolation. Done carelessly, ctx.obj becomes a stringly-typed grab bag. This guide shows the disciplined version.

TL;DR

  • Click passes a Context to any command decorated with @click.pass_context; its ctx.obj attribute is yours to fill with shared state.
  • Load config once in the group callback, store it on ctx.obj, and every subcommand can read it.
  • Use ctx.ensure_object(dict) so ctx.obj exists even when a subcommand is invoked directly in a test.
  • Prefer a dataclass on ctx.obj over a bare dict, and inject it with @click.pass_obj or a custom pass_config decorator built from make_pass_decorator.
  • Test commands with CliRunner(...).invoke(cli, [...], obj=...) to inject state directly.

The Context and ctx.obj

Every time Click runs a command it builds a Context. Groups create a context, and each subcommand gets a child context whose obj is inherited from the parent by default. That inheritance is the whole mechanism: set ctx.obj in the group, read it in the child.

Grab the context with @click.pass_context, which injects it as the first parameter:

import click

@click.group()
@click.pass_context
def cli(ctx: click.Context) -> None:
    ctx.obj = {"user": "martin"}          # available to every subcommand

@cli.command()
@click.pass_context
def whoami(ctx: click.Context) -> None:
    click.echo(ctx.obj["user"])
$ python app.py whoami
martin

The group body runs before the chosen subcommand, so it is the correct place to populate shared state. The subcommand reads it back off the inherited context.

A group callback that loads config

The realistic version: the group parses global options, loads a config file, layers environment variables and flags on top, and stashes the result so no subcommand re-reads the config. ctx.ensure_object(dict) creates ctx.obj as a dict if nothing set it yet — which is what makes a subcommand safe to invoke on its own.

# app/cli.py
import os
import click

@click.group()
@click.option("--config", type=click.Path(dir_okay=False), default="config.toml")
@click.option("-v", "--verbose", is_flag=True)
@click.pass_context
def cli(ctx: click.Context, config: str, verbose: bool) -> None:
    """app — resolves config once, shares it with every command."""
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose
    # Precedence: explicit flag > env var > config file default.
    ctx.obj["region"] = os.environ.get("APP_REGION", "us-east-1")
    ctx.obj["config_path"] = config

@cli.command()
@click.pass_context
def deploy(ctx: click.Context) -> None:
    """Deploy using the shared, resolved config."""
    if ctx.obj["verbose"]:
        click.echo(f"[verbose] region={ctx.obj['region']}")
    click.echo(f"Deploying to {ctx.obj['region']}")

For the full precedence story — flags over environment over files over defaults — see config precedence: flags, env, files, defaults. The point here is where it happens: once, in the callback, not scattered across commands.

pass_obj: skip the ceremony

When a command only needs ctx.obj and not the whole context, @click.pass_obj injects the object directly, so you drop the ctx. prefix everywhere:

@cli.command()
@click.pass_obj
def status(obj: dict) -> None:
    click.echo(f"region={obj['region']} verbose={obj['verbose']}")

This is the same ctx.obj you set in the group — pass_obj is just pass_context that hands you ctx.obj instead of ctx. Reach for it in the common case; keep pass_context for commands that also need ctx.exit(), ctx.call_on_close(), or sub-context work.

The dataclass pattern

A bare dict on ctx.obj invites bugs: a mistyped ctx.obj["reigon"] fails at runtime, and your editor can't help. For anything past a flag or two, store a dataclass. You get attribute access, autocompletion, and a mypy-checked shape.

# app/state.py
from dataclasses import dataclass, field

@dataclass
class AppConfig:
    region: str
    verbose: bool = False
    tags: list[str] = field(default_factory=list)
# app/cli.py
import click
from app.state import AppConfig

@click.group()
@click.option("--region", default="us-east-1")
@click.option("-v", "--verbose", is_flag=True)
@click.pass_context
def cli(ctx: click.Context, region: str, verbose: bool) -> None:
    ctx.obj = AppConfig(region=region, verbose=verbose)

@cli.command()
@click.pass_obj
def status(cfg: AppConfig) -> None:          # typed, autocompleted
    click.echo(f"region={cfg.region} verbose={cfg.verbose}")

cfg.region is now checked at author time; a typo is a type error, not a 2 a.m. traceback. This mirrors the typed-state approach in building a CLI with subcommands in Click, scaled up to a real config object.

A custom pass_config decorator

@click.pass_obj is untyped — every command annotates the parameter itself and trusts that ctx.obj really is an AppConfig. click.make_pass_decorator builds a decorator bound to a specific type, giving you one named, self-documenting injector:

# app/state.py (continued)
import click

pass_config = click.make_pass_decorator(AppConfig, ensure=True)
# app/cli.py
from app.state import AppConfig, pass_config

@cli.command()
@pass_config
def deploy(cfg: AppConfig) -> None:
    click.echo(f"Deploying to {cfg.region}")

make_pass_decorator(AppConfig) walks up the context chain and finds the nearest AppConfig instance, so it works even with nested groups. ensure=True tells it to create an AppConfig() if none exists yet — handy when a subcommand can run standalone, though it requires your dataclass to be constructible with no arguments (give fields defaults, or drop ensure and set ctx.obj in the group). The payoff is readability: @pass_config says exactly what it injects, and there is one place to change if the state type evolves.

Nested groups and context walking

ctx.obj inheritance shines with nested groups. A child group gets a child context whose obj points at the parent's, so state set at the root reaches an arbitrarily deep leaf without re-passing it. make_pass_decorator walks up the chain to find the nearest instance of the requested type, which means an inner group can layer its own state on top:

@cli.group()
@click.option("--namespace", default="default")
@click.pass_obj
def db(cfg: AppConfig, namespace: str) -> None:
    """Database subcommands, scoped to a namespace."""
    cfg.tags.append(f"ns:{namespace}")     # mutate the inherited config

@db.command()
@pass_config
def migrate(cfg: AppConfig) -> None:
    click.echo(f"Migrating region={cfg.region} tags={cfg.tags}")

app --region eu-west-1 db --namespace staging migrate flows the root's region and the db group's namespace into one migrate call. Because both groups share the same AppConfig instance on ctx.obj, the child sees the parent's fields and its own additions. Keep the mutation deliberate — sharing a mutable object across levels is powerful but means an inner group can surprise a sibling if it rewrites shared fields.

Supplying defaults through the context

Beyond ctx.obj, the context carries a default_map that lets a group feed default values into its subcommands' options — useful when a config file should override an option's built-in default without the command knowing where the value came from:

@click.group()
@click.option("--config", type=click.Path(dir_okay=False), default="config.toml")
@click.pass_context
def cli(ctx: click.Context, config: str) -> None:
    ctx.ensure_object(dict)
    # e.g. {"deploy": {"region": "eu-west-1"}} loaded from the config file
    ctx.default_map = load_defaults(config)

With that in place, deploy's --region option falls back to the config-provided value instead of its hardcoded default, while an explicit --region on the command line still wins. This keeps the precedence rules — flag beats config beats default — enforced by Click rather than by hand in every command body.

Testing with CliRunner and obj injection

The reason to keep state on the context rather than in module globals is testability. Click's CliRunner lets you inject ctx.obj directly, so you can exercise a subcommand with a known config without running the group's config-loading at all.

# tests/test_cli.py
from click.testing import CliRunner
from app.cli import cli, deploy
from app.state import AppConfig

def test_deploy_uses_injected_config() -> None:
    runner = CliRunner()
    # Invoke the subcommand directly, injecting the shared object.
    result = runner.invoke(deploy, obj=AppConfig(region="eu-west-1"), standalone_mode=False)
    assert result.exit_code == 0
    assert "eu-west-1" in result.output

def test_group_resolves_config_end_to_end() -> None:
    runner = CliRunner()
    result = runner.invoke(cli, ["--region", "ap-south-1", "deploy"])
    assert result.exit_code == 0
    assert "ap-south-1" in result.output

Two complementary tests: one injects obj= to test the command in isolation (fast, no config plumbing), the other runs the group end to end to confirm the callback wires state correctly. Passing obj= to invoke is what decouples the two — each command stays independently testable, which is the entire argument for this pattern over global state. This layering is a concrete case of the discipline in structuring multi-command Python CLIs.

Production notes

  • ensure_object vs direct assignment. ctx.ensure_object(dict) is idempotent and safe when a child might run before the parent set anything; ctx.obj = AppConfig(...) overwrites unconditionally. Use ensure_object for dicts, direct assignment (or make_pass_decorator(..., ensure=True)) for dataclasses.
  • Don't smuggle globals. The temptation is a module-level CONFIG singleton. It makes tests order-dependent and breaks under Click's CliRunner, which reuses the process. Keep state on the context.
  • Context objects and lazy loading. If the group callback opens an expensive resource (DB, network client), consider deferring it — build a factory on ctx.obj and connect on first use, so commands that don't need it stay fast. This dovetails with lazy subcommand loading covered under CLI startup performance.
  • call_on_close for teardown. Resources you open in the group (files, connections) should be released with ctx.call_on_close(handle.close), which fires when the context exits even on error — cleaner than a try/finally around dispatch.
  • Pin Click ≥8.1 for the current make_pass_decorator and Context typing behaviour.