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
Contextto any command decorated with@click.pass_context; itsctx.objattribute 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)soctx.objexists even when a subcommand is invoked directly in a test. - Prefer a dataclass on
ctx.objover a bare dict, and inject it with@click.pass_objor a custompass_configdecorator built frommake_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_objectvs 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. Useensure_objectfor dicts, direct assignment (ormake_pass_decorator(..., ensure=True)) for dataclasses.- Don't smuggle globals. The temptation is a module-level
CONFIGsingleton. It makes tests order-dependent and breaks under Click'sCliRunner, 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.objand 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_closefor teardown. Resources you open in the group (files, connections) should be released withctx.call_on_close(handle.close), which fires when the context exits even on error — cleaner than atry/finallyaround dispatch.- Pin Click ≥8.1 for the current
make_pass_decoratorandContexttyping behaviour.