Every listing command in your CLI takes --format, --limit and --sort. Every command that talks to the API takes --timeout and --retries. The first few commands got these options by copy-and-paste, and now there are fourteen copies, three different help texts for --limit, one command where --format still defaults to text instead of table, and a new contributor who added --output instead. Shared options are the most common kind of duplication in a multi-command CLI, and the fix is to define each group of options once and apply it everywhere. This guide shows how with Click — a decorator that adds several options and hands the command a single typed object — and with Typer, using reusable Annotated aliases, then adds a test that keeps them consistent. It belongs to the structuring multi-command Python CLIs topic.
Prerequisites
- A Click or Typer CLI with several commands that share options.
- If the shared options are global — verbosity, profile, config file — read global options vs per-command options first; this guide is about options that belong on each command but repeat across many.
The approaches
Copy-paste is where everyone starts and where drift comes from. Moving options to the group avoids duplication but changes their meaning and position — they must then appear before the subcommand name, and they apply to commands that do not want them. The two approaches that scale are a stacked decorator in Click and a type alias in Typer; both keep each option on the commands that use it, visible in each command's --help, while defining it only once.
The recipe: a Click decorator that bundles options
A decorator can add several options to a command and, crucially, replace their separate keyword arguments with one object. Commands then take a single parameter, and adding a fourth shared option later does not touch any command signature:
# src/mytool/shared.py
from __future__ import annotations
import functools
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import click
@dataclass(frozen=True)
class ListOptions:
format: str
limit: int
sort: str | None
def list_options(default_limit: int = 20, sort_fields: tuple[str, ...] = ("name",)) -> Callable:
"""Add --format/--limit/--sort to a command and pass them as one ListOptions."""
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
@click.option("--format", "-o", "fmt", type=click.Choice(["table", "json", "csv"]),
default="table", show_default=True, help="Output format.")
@click.option("--limit", "-n", type=click.IntRange(min=1), default=default_limit,
show_default=True, help="Show at most N items.")
@click.option("--sort", type=click.Choice(sort_fields), default=None,
help="Sort by this field.")
@functools.wraps(f)
def wrapper(*args: Any, fmt: str, limit: int, sort: str | None, **kwargs: Any) -> Any:
return f(*args, opts=ListOptions(fmt, limit, sort), **kwargs)
return wrapper
return decorator
# src/mytool/cli.py
import json
import click
from mytool.shared import ListOptions, list_options
USERS = [{"name": "ben", "team": "ops"}, {"name": "ana", "team": "platform"}]
TEAMS = [{"name": "platform", "size": 6}, {"name": "ops", "size": 4}]
def render(rows: list[dict], opts: ListOptions) -> None:
if opts.sort:
rows = sorted(rows, key=lambda r: r[opts.sort])
rows = rows[: opts.limit]
if opts.format == "json":
click.echo(json.dumps(rows))
elif opts.format == "csv":
click.echo(",".join(rows[0]) if rows else "")
for r in rows:
click.echo(",".join(str(v) for v in r.values()))
else:
for r in rows:
click.echo(" ".join(f"{v}" for v in r.values()))
@click.group()
def cli() -> None:
"""Directory tool."""
@cli.group()
def users() -> None:
"""Manage users."""
@users.command("list")
@list_options(sort_fields=("name", "team"))
def users_list(opts: ListOptions) -> None:
"""List users."""
render(USERS, opts)
@cli.group()
def teams() -> None:
"""Manage teams."""
@teams.command("list")
@list_options(default_limit=50, sort_fields=("name", "size"))
def teams_list(opts: ListOptions) -> None:
"""List teams."""
render(TEAMS, opts)
if __name__ == "__main__":
cli()
Why this shape
One object, not three parameters. The wrapper consumes fmt, limit and sort and passes opts=ListOptions(...). When you add --reverse next month, you edit the decorator and the dataclass; no command signature changes, and every list command gains the flag at once.
Parameterised where it must differ. default_limit and sort_fields vary per command; the option names, types, short flags and help text do not. The decorator factory expresses exactly that split.
Still visible in each command's help. Unlike group-level options, these appear in mytool users list --help, with the right defaults for that command, because they are real options on that command.
functools.wraps preserves the docstring — which Click uses as help text — and the function name.
The recipe: Typer aliases
Typer builds options from annotations, so the natural unit of reuse is an annotated type alias:
# src/mytool/typer_options.py
from enum import Enum
from typing import Annotated
import typer
class Format(str, Enum):
table = "table"
json = "json"
csv = "csv"
FormatOpt = Annotated[Format, typer.Option("--format", "-o", help="Output format.")]
LimitOpt = Annotated[int, typer.Option("--limit", "-n", min=1, help="Show at most N items.")]
SortOpt = Annotated[str | None, typer.Option("--sort", help="Sort by this field.")]
@users_app.command("list")
def users_list(fmt: FormatOpt = Format.table, limit: LimitOpt = 20, sort: SortOpt = None) -> None:
"""List users."""
Each command still lists three parameters, but their names, flags, help and validation come from one place, and defaults can differ per command. To bundle them into an object as the Click decorator does, build the dataclass on the first line of the command, or use a small wrapper; the Annotated technique itself is covered in using Annotated options in Typer.
UX considerations
- Identical flags everywhere. The whole point is that
-o jsonand-n 5work the same on every list command; users learn them once. This is the practical enforcement of naming commands and flags consistently. - Per-command defaults where they make sense. A command listing thousands of audit events may default to
--limit 50; one listing a dozen teams may show all. The shared definition should allow that without forking. - Validate per command.
--sortchoices depend on the fields a command returns, so the decorator takes them as a parameter and Click rejects--sort sizeonusers listwith a clear error. - Keep bundles small and cohesive. A decorator for "list options" and another for "network options" are easy to reason about; one decorator that adds twelve unrelated options becomes the new source of confusion.
Testing the behaviour
Test the decorator's behaviour through commands, and add one test that asserts every command using the bundle exposes identical options — the regression that shared definitions are meant to prevent:
# tests/test_shared.py
import json
import click
from click.testing import CliRunner
from mytool.cli import cli
runner = CliRunner()
def test_same_options_on_every_list_command():
for path in (["users", "list"], ["teams", "list"]):
result = runner.invoke(cli, [*path, "--format", "json", "--limit", "1", "--sort", "name"])
assert result.exit_code == 0, result.output
assert len(json.loads(result.output)) == 1
def test_per_command_sort_choices():
result = runner.invoke(cli, ["users", "list", "--sort", "size"])
assert result.exit_code == 2 and "Invalid value for '--sort'" in result.output
def test_shared_option_definitions_are_identical():
def opts(group: str) -> dict[str, tuple]:
cmd = cli.commands[group].commands["list"]
return {p.name: (tuple(p.opts), p.help) for p in cmd.params if isinstance(p, click.Option)}
users, teams = opts("users"), opts("teams")
for name in ("fmt", "limit", "sort"):
assert users[name] == teams[name], name
The last test compares flag spellings and help text between commands, so nobody can quietly change one copy — because there are no copies. The broader patterns for CLI tests are in testing Click commands with CliRunner.
Conclusion
Options that repeat across commands should be defined once. In Click, a decorator factory adds a cohesive bundle of options and passes the command one typed object, with per-command parameters for the parts that legitimately differ; in Typer, Annotated aliases carry flag names, help and validation into every command that uses them. Both keep options visible in each command's help, make interface consistency the default rather than a convention, and turn adding a new shared option into a one-place change.
Frequently asked questions
Why not use ctx.obj for these options?
ctx.obj suits values set once per invocation at the group level. Per-command options belong to the command's own parameters; routing them through ctx.obj hides them from the command's signature and help.
Does the decorator break shell completion?
No. The options are ordinary Click options on the command, so completion, --help and validation work exactly as if they were written inline.