Your users type mytool deploy site --verbose and get Error: No such option: --verbose. They try mytool --verbose deploy site and it works. To them the rule looks arbitrary; to Click it is fundamental: options declared on a group are parsed before the subcommand name, and options declared on a command are parsed after it. Getting scope right is part of designing a multi-command CLI — which options affect every command and belong to the group, which belong to individual commands, and whether a handful of very common options should be accepted in either position. This guide works through those decisions, shows exactly how Click parses a command line, and builds a small decorator that lets selected options appear before or after the subcommand while resolving to a single value. It belongs to the designing CLI interfaces and conventions topic.
Prerequisites
- A multi-command CLI built with Click or Typer. Typer is built on Click (recent releases ship their own vendored copy of it), so the parsing rules below apply to both.
- Familiarity with passing state through the context, as in sharing state with Click context objects.
What belongs where
An option is global when it changes how the tool behaves regardless of which command runs: output verbosity, colour, which profile or config file to use, where logs go. It is per-command when it only makes sense for some commands: an output format for commands that return data, --dry-run and --yes for commands that change things, --limit for commands that list.
Two tests help with borderline cases. First, does it mean the same thing for every command? --verbose does; --force means something different for deploy than for delete, so it is per-command. Second, would it be surprising to see it in every command's --help? If --limit appears on mytool login, it is in the wrong place.
How Click parses the command line
Click processes the command line in stages. The group's parser consumes arguments until it reaches the subcommand name; then the subcommand's parser takes over for everything after it.
import click
@click.group()
@click.option("--verbose", "-v", is_flag=True)
@click.pass_context
def cli(ctx: click.Context, verbose: bool) -> None:
ctx.obj = {"verbose": verbose}
@cli.command()
@click.argument("site")
@click.option("--env", default="dev")
@click.pass_obj
def deploy(obj: dict, site: str, env: str) -> None:
click.echo(f"deploy {site} to {env} verbose={obj['verbose']}")
cli -v deploy web --env prod works. cli deploy web --env prod -v fails, because by the time -v appears, the deploy command's parser is in charge and knows nothing about it. This is a deliberate design — it lets a subcommand define its own -v without conflicting with the group's — but users coming from tools that accept flags anywhere find it confusing.
The recipe: shared options accepted in both positions
For the few options users genuinely expect to type anywhere — typically --verbose, --quiet and sometimes --profile — you can declare them on both the group and every command, and merge the values. The command-level value wins, because it appeared later, closer to what the user was thinking about.
# src/mytool/common.py
from __future__ import annotations
import functools
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import click
@dataclass
class Settings:
verbose: int = 0
profile: str = "default"
def _settings(ctx: click.Context) -> Settings:
return ctx.ensure_object(Settings)
def _merge_verbose(ctx: click.Context, _param: click.Parameter, value: int) -> int:
if value:
_settings(ctx).verbose = max(_settings(ctx).verbose, value)
return value
def _merge_profile(ctx: click.Context, _param: click.Parameter, value: str | None) -> str | None:
if value is not None:
_settings(ctx).profile = value
return value
def common_options(f: Callable[..., Any]) -> Callable[..., Any]:
"""Accept -v/--verbose and --profile on this group or command; merge into Settings."""
f = click.option("--profile", default=None, expose_value=False, callback=_merge_profile,
envvar="MYTOOL_PROFILE", help="Profile to use.")(f)
f = click.option("-v", "--verbose", count=True, expose_value=False, callback=_merge_verbose,
help="More output (repeatable).")(f)
return f
def pass_settings(f: Callable[..., Any]) -> Callable[..., Any]:
@click.pass_context
@functools.wraps(f)
def wrapper(ctx: click.Context, *args: Any, **kwargs: Any) -> Any:
return ctx.invoke(f, _settings(ctx), *args, **kwargs)
return wrapper
# src/mytool/cli.py
import click
from mytool.common import Settings, common_options, pass_settings
@click.group()
@common_options
@click.pass_context
def cli(ctx: click.Context) -> None:
"""Deployment tool."""
ctx.ensure_object(Settings)
@cli.command()
@common_options
@click.argument("site")
@click.option("--env", type=click.Choice(["dev", "staging", "prod"]), default="dev")
@pass_settings
def deploy(settings: Settings, site: str, env: str) -> None:
"""Deploy SITE to ENV."""
if settings.verbose:
click.echo(f"[profile={settings.profile} verbose={settings.verbose}]", err=True)
click.echo(f"deploying {site} to {env}")
if __name__ == "__main__":
cli()
How it works: expose_value=False keeps the option out of the command function's parameters, and the callback writes the value into a Settings object on the context instead. ctx.ensure_object(Settings) finds the object created by the group (Click walks up the context chain) or creates it, so the group-level and command-level options write to the same object. The command receives the merged settings through pass_settings, one parameter instead of several. envvar="MYTOOL_PROFILE" gives the profile its usual environment-variable fallback without extra code.
With this in place, all of these behave identically:
mytool -v --profile prod deploy web --env staging
mytool deploy web --env staging -v --profile prod
mytool -v deploy web --profile prod --env staging
Use this sparingly. Every option you allow in both positions appears in two --help screens and becomes something users expect from every command. Keep it to the two or three options users type constantly.
Typer
Typer's callback is the group, so the same rule applies: callback options come before the command name. The Typer version of shared options is an Annotated alias used in each command, combined with a callback that stores the group-level value on the context:
from typing import Annotated
import typer
Verbose = Annotated[int, typer.Option("--verbose", "-v", count=True, help="More output.")]
app = typer.Typer()
@app.callback()
def main(ctx: typer.Context, verbose: Verbose = 0) -> None:
ctx.obj = {"verbose": verbose}
@app.command()
def deploy(ctx: typer.Context, site: str, verbose: Verbose = 0) -> None:
level = max(verbose, ctx.obj["verbose"])
typer.echo(f"deploying {site} (verbosity {level})")
UX considerations
- Put global options in the group's help.
mytool --helpshould list every global option; each command's help lists its own. Users learn where to look. - Mention the position rule in errors. Click's "No such option: -v" can be improved with a custom error hint when the option exists on the group: "
-vis a global option; put it before the command, e.g.mytool -v deploy". A smallclick.Groupsubclass that overridesresolve_commandor catchesNoSuchOptioncan add it. - Let environment variables carry globals.
MYTOOL_PROFILEandMYTOOL_VERBOSEset once in a shell or CI job remove most of the need to type global options at all; see config precedence: flags, env, files and defaults. - Never give one name two scopes with different meanings. If
-vis verbose globally, no command may use-vfor "version" or "value".
Testing the behaviour
Test the positions explicitly, including the merge precedence and the environment variable:
# tests/test_globals.py
import pytest
from click.testing import CliRunner
from mytool.cli import cli
runner = CliRunner()
@pytest.mark.parametrize("argv", [
["-v", "--profile", "prod", "deploy", "web"],
["deploy", "web", "-v", "--profile", "prod"],
["-v", "deploy", "web", "--profile", "prod"],
])
def test_global_options_in_any_position(argv):
result = runner.invoke(cli, argv)
assert result.exit_code == 0, result.output
assert "profile=prod verbose=1" in result.output
def test_command_level_wins_for_profile():
result = runner.invoke(cli, ["--profile", "dev", "deploy", "web", "--profile", "prod", "-v"])
assert "profile=prod" in result.output
def test_env_var_fallback():
result = runner.invoke(cli, ["deploy", "web", "-v"], env={"MYTOOL_PROFILE": "staging"})
assert "profile=staging" in result.output
The parametrised test is the contract: whichever position users choose, the result is the same. More on structuring these tests is in testing Click commands with CliRunner.
Conclusion
Scope follows meaning: options that change the whole tool's behaviour belong on the group, options that make sense for particular commands belong on those commands. Click parses group options before the subcommand and command options after it, which is a feature, not a bug — but for the two or three options users type constantly, a shared decorator with callbacks that merge into one settings object lets them appear in either position. Keep global meanings consistent, document the rule in help and errors, back globals with environment variables, and pin the behaviour with position tests.
Frequently asked questions
Why not allow every option in every position?
Because every command would then have to reserve every global name, command help would repeat the same options endlessly, and conflicts between global and command-level meanings would be impossible to avoid. Allowing a small set is a convenience; allowing everything is a design smell.
Does argparse behave the same way?
With subparsers, yes: options of the main parser must come before the subcommand, and the subparser handles the rest. The same trick works — add the shared options to each subparser with parents=[common_parser] and default=argparse.SUPPRESS, so an unset command-level value does not overwrite the global one. See argparse subparsers for subcommands.
How deep can global options reach in nested groups?
As deep as the tree goes. With ctx.ensure_object(Settings), a command three levels down (mytool site domain add) finds the same Settings instance that the top-level group created, because Click searches up the parent contexts. Intermediate groups can add their own group-scoped options — mytool site --site-id X domain add — and store them on the same object or on a group-specific one found with ctx.find_object.
Where should --version go?
On the top-level group only, as an eager option that prints and exits. mytool deploy --version has no sensible meaning distinct from mytool --version.
Can a subcommand override a global option's default?
It can declare its own option with the same name, but that recreates the ambiguity this guide avoids. Prefer per-command configuration in the config file, or a differently named command option.