A script becomes a tool the moment other people depend on it. This track is about the decisions that make that transition survivable: which framework to commit to, how to lay out a command tree that stays testable as it grows, and how to let features ship independently through plugins instead of accreting into one unmaintainable file.
These guides assume your project foundation is already in place. Here we focus on the shape of the code itself.
The three decisions that shape a CLI codebase
Almost every difficulty people hit with a growing command-line tool traces back to three early decisions. They are worth naming, because they have very different reversal costs.
Which parser you commit to determines how you declare an interface — and how much machinery you get for free. It is the decision people agonise over and the cheapest one to change: a hundred-line CLI moves between frameworks in an afternoon, because the code that matters is the part underneath.
How the command tree is laid out determines whether your logic can be tested without a runner, whether two people can add commands in the same week without conflicts, and whether a twentieth command is a five-minute job or a refactor. This is the decision that quietly compounds.
What runs at import time determines how the tool feels. A CLI pays its startup cost on
every single invocation, including --help and every keystroke of tab completion. A tool
that imports pandas at module level is a tool with a 300 ms tax on everything.
Get the second and third right and the first stops mattering very much. That is the order this track treats them in.
What this track covers
Picking a framework
- Command-line parsing with argparse — the zero-dependency standard-library baseline: arguments, options, types, and subparsers for subcommands. When you outgrow it, follow the path for migrating from argparse to Typer.
- Typer vs Click: when to use each — type-hint inference versus explicit decorator control, test ergonomics, and the project traits that point to one framework over the other. Go deeper with building a CLI with subcommands in Click and Typer callback functions explained.
Structuring the command tree
- Structuring multi-command Python CLIs
— separate parsing, business logic, and output so commands stay testable and the tree
stays navigable. Then scale up with
how to structure a large Python CLI project
using the
src/layout, and lock in best practices for Python CLI entry points via PEP 621. Keep commands decoupled by sharing state with Click context objects instead of globals.
Startup performance
- CLI startup performance and lazy loading
— keep
--helpand shell completion instant as the command set grows. Learn to profile Python CLI startup time and then apply lazy loading of subcommands to defer heavy imports.
Extensibility
- Plugin architectures for extensible CLIs
— design plugin systems with entry points,
importlib.metadata, and protocol interfaces so teams can ship features against a stable API without touching the core.
The framework landscape in one page
All three of the mainstream options parse the same command lines. What differs is how much you write yourself, and what you get without asking.
argparse ships with Python. Nothing to install, nothing to pin, available in the most
locked-down environment your tool will ever run in. You describe the interface with a series
of imperative calls:
import argparse
from pathlib import Path
parser = argparse.ArgumentParser(prog="mytool", description="Sync files to a bucket.")
parser.add_argument("source", type=Path, help="Directory to sync.")
parser.add_argument("--retries", type=int, default=3, help="Attempts per file.")
parser.add_argument("--dry-run", action="store_true", help="Show what would happen.")
args = parser.parse_args()
Click replaces those calls with decorators, and adds the things people build by hand on top
of argparse: nested command groups, shell completion, parameter types with good error
messages, and a testing runner.
import click
from pathlib import Path
@click.command()
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option("--retries", type=int, default=3, show_default=True, help="Attempts per file.")
@click.option("--dry-run", is_flag=True, help="Show what would happen.")
def sync(source: Path, retries: int, dry_run: bool) -> None:
"""Sync files to a bucket."""
Typer sits on Click and reads the same information from your type hints, so the signature is the interface:
import typer
from pathlib import Path
from typing import Annotated
app = typer.Typer(help="Sync files to a bucket.")
@app.command()
def sync(
source: Annotated[Path, typer.Argument(exists=True)],
retries: Annotated[int, typer.Option(help="Attempts per file.")] = 3,
dry_run: Annotated[bool, typer.Option(help="Show what would happen.")] = False,
) -> None:
...
Three dialects, one behaviour. The practical rule: if a third-party dependency is
unacceptable — you ship into a locked image, or your tool is the bootstrap step — use
argparse and accept the extra wiring. Otherwise pick by how you prefer to declare things,
knowing that Typer and Click share a runtime and you can drop from one to the other at any
point. The full comparison
goes through the trade-offs case by case.
Command architecture that survives growth
Tools rarely stay at one command. The shape that scales is a root group that owns the global options, subject-area groups that own their verbs, and one module per command.
# src/mytool/cli.py — registration only, no logic
import typer
from mytool.commands import db, remote, sync
app = typer.Typer(help="Manage deployments.")
app.command()(sync.sync)
app.add_typer(db.app, name="db", help="Database maintenance.")
app.add_typer(remote.app, name="remote", help="Manage remotes.")
Two rules keep that file from becoming the problem it was meant to solve. First, cli.py
registers and nothing else — no argument parsing logic, no branching, no I/O. Second, a
command becomes a group only when it grows verbs of its own; mytool db migrate earns its
group because seed and dump live beside it, while mytool doctor stays flat forever.
Global options belong on the root callback, where they are declared once and reach every command through the context rather than through a module-level global:
@app.callback()
def main(
ctx: typer.Context,
config: Annotated[Path | None, typer.Option(help="Config file to load.")] = None,
verbose: Annotated[int, typer.Option("--verbose", "-v", count=True)] = 0,
) -> None:
configure_logging(verbose)
ctx.obj = load_settings(config)
The details — how deep to nest, how to share state without globals, where the callback should stop doing work — are in structuring multi-command Python CLIs and sharing state with Click context objects.
Keep the command layer thin
The single most valuable habit in this whole track: a command function reads input, calls a plain function, and formats the result. Nothing else.
# src/mytool/commands/sync.py — the CLI layer
def sync(source: Path, retries: int = 3, dry_run: bool = False) -> None:
"""Sync SOURCE to the configured bucket."""
result = core.sync_directory(source, retries=retries, dry_run=dry_run)
typer.echo(f"{result.uploaded} uploaded, {result.skipped} skipped")
# src/mytool/core/sync.py — no CLI imports anywhere in this file
def sync_directory(source: Path, *, retries: int = 3, dry_run: bool = False) -> SyncResult:
...
The test for whether the split is real is mechanical: core/ must never import click or
typer. When it does not, the interesting assertions live in fast unit tests that call
sync_directory directly, and the command tests only need to prove that flags map to
arguments and that failures produce the right exit code. When it does, every test needs a
runner, and the runner becomes the slowest part of your suite.
The same separation is what makes a tool reusable later — as a library, an API handler, or a scheduled job — without rewriting the logic that matters.
Startup time is an architecture problem
A CLI is not a server. It pays its import cost on every invocation, and the invocations that
matter most are the cheap ones: --help, --version, and the tab-completion callback that
runs while the user is still typing.
python -X importtime -m mytool --help 2>&1 | sort -k2 -rn | head -5
The output is almost always the same story: one or two heavy libraries, imported at module level in a command that the user did not run. The fix is structural rather than clever — move the import inside the function that needs it, and make the command tree resolve modules on demand:
def report(period: str) -> None:
import pandas as pd # 250 ms, and only the report command needs it
...
Profiling CLI startup time
covers the measurement, and
lazy loading subcommands
covers the registry pattern that keeps --help instant no matter how many commands exist.
The number to aim for is under 100 ms; past roughly 250 ms, interactive use starts to feel
sluggish and people stop pressing Tab.
Extending a CLI without forking it
Once a tool crosses team boundaries, the bottleneck stops being code and becomes review
throughput: every new command needs someone who owns the core repository to merge it. Entry
points solve that. A plugin declares itself in its own pyproject.toml, and installing it is
the registration step:
# in the plugin's pyproject.toml
[project.entry-points."mytool.plugins"]
deploy = "mytool_deploy.cli:app"
# in the host CLI
from importlib.metadata import entry_points
for ep in entry_points(group="mytool.plugins"):
try:
app.add_typer(ep.load(), name=ep.name)
except Exception as exc: # one broken plugin must not kill start-up
log.warning("plugin %s failed to load: %s", ep.name, exc)
The try is not optional. A plugin is code you did not write running inside your process,
and load failures are an expected condition rather than an exceptional one. The
plugin architecture guide
covers versioned contracts, name collisions with built-in commands, and the --no-plugins
escape hatch that makes support conversations tractable.
Arguments, options and validation at the boundary
Every framework here gives you the same lever: a parameter type that converts a string into a real object and raises when it cannot. Using it is what keeps validation out of your command bodies.
@app.command()
def deploy(
manifest: Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True)],
replicas: Annotated[int, typer.Option(min=1, max=50)] = 3,
env: Annotated[Environment, typer.Option(case_sensitive=False)] = Environment.staging,
) -> None:
...
Three things happen before your function is entered. The path is checked for existence and
readability, so the body never writes if not manifest.exists(). The replica count is
bounded, and an out-of-range value produces a usage error and exit code 2 rather than a
confusing failure three steps later. And Environment, an ordinary enum.Enum, restricts the
value to a known set and feeds shell completion for free.
What parameter types cannot express is a rule that spans two values — a --start that must
precede an --end, or an option that is only meaningful with another. Those belong in one
validation step at the top of the command body, or in the model you parse into. The
validation strategies guide
works through the layering; the rule of thumb is that a check belongs as close to the boundary
as it can be expressed.
Errors deserve the same discipline. A command should raise a domain exception; one top-level
boundary turns that into a message on stderr and a documented exit code. That is what stops
sys.exit calls from spreading through the codebase and what makes
exit codes
a contract scripts can rely on rather than an accident of where a failure happened.
Testing a command tree
Because Typer re-exports Click's testing runner, one approach covers both frameworks — and it does not involve spawning a subprocess:
from typer.testing import CliRunner
from mytool.cli import app
runner = CliRunner()
def test_sync_dry_run_reports_but_writes_nothing(tmp_path):
result = runner.invoke(app, ["sync", str(tmp_path), "--dry-run"])
assert result.exit_code == 0
assert "0 uploaded" in result.stdout
Assert on exit_code first and output second. A test that only checks output passes happily
when the command failed for an unrelated reason, and exit codes are the part scripts actually
depend on. For commands that touch the filesystem, runner.isolated_filesystem() gives each
test its own working directory so nothing leaks between them.
Keep runner tests for the things only the CLI layer can get wrong — flag names, defaults,
mutually exclusive combinations, exit codes — and test the behaviour itself against the plain
functions in core/. A suite built that way runs in milliseconds and keeps failing for the
right reasons.
Packaging the result
The architecture only reaches a user through an entry point. One table in pyproject.toml
turns a module into a command on their PATH:
[project.scripts]
mytool = "mytool.cli:app"
The installer reads that once and writes a small launcher into the environment; nothing is
consulted at run time, which is why editing the table has no effect until you re-install.
Ship a three-line __main__.py alongside it so python -m mytool works too — it costs
nothing and rescues anyone whose PATH is not set up.
From there,
packaging Python CLIs for distribution
covers building the wheel and publishing it, and
installing CLIs with pipx
covers the isolated install that end users should be given as the recommended path. The
smoke test worth having in CI is the smallest one imaginable: install the built wheel into an
empty environment and run mytool --version. It catches every packaging mistake that a test
suite run against the source tree cannot see.
Five patterns that age badly
Most CLI codebases that become unpleasant did not take a wrong turn — they took five small conveniences, each defensible on its own.
A module-level settings dict. It starts as CONFIG = {} populated in the callback, and it
works perfectly until the first test, which now has to remember to reset it between cases. A
frozen dataclass built once and handed through the context costs the same three lines and
never has to be reset.
Business logic in the command body. Every line of logic inside a function decorated with
@app.command() is a line that can only be reached through a runner. It is not that runner
tests are bad; it is that they are slow, awkward to parametrise, and they fail for reasons
that have nothing to do with the logic — a renamed flag, a changed default.
Importing everything to build --help. Registering thirty commands means importing thirty
modules and everything they pull in, on every invocation, so that Python can read thirty
docstrings. A registry of strings resolved on demand costs one small Group subclass and
removes the entire class of problem.
sys.exit sprinkled through the code. Each call is a separate decision about what a
failure means, made in the place least equipped to decide. Raising a domain exception and
mapping it once in a boundary keeps the meanings in one file and makes the mapping testable.
One commands.py. It is the natural place to put the second command, and the third. By
the tenth it is the file with the most merge conflicts in the repository, and nobody can
remember which of the four helper functions near the top is still used.
None of these is expensive to avoid on day one. All of them are expensive to unpick at command twenty, which is precisely when the tool has become important enough that nobody has time.
A recommended path
- Start with argparse for a tiny tool; decide between Typer and Click before you write the second command.
- Establish multi-command structure with clean layer separation early.
- Standardize entry points so installs behave the same everywhere.
- Watch startup performance — lazy-load subcommands before launch feels sluggish.
- Reach for a plugin architecture only once the command set outgrows one team.
Key takeaways
- The parser you choose is the cheapest decision to reverse; the layout of the code is not.
cli.pyregisters commands. Logic lives incore/, which never imports a CLI framework.- Global options belong on the root callback and travel through the context, not a global.
- Startup cost is paid on every invocation — measure it with
-X importtimebefore guessing. - Entry points let other teams ship commands without touching your repository, provided every load is wrapped.
Frequently asked questions
Should I start a new CLI with argparse or go straight to Typer?
Go straight to Typer unless a dependency is genuinely unacceptable. The moment a tool has two
subcommands, the things Typer gives you for free — nested groups, shell completion,
conversion and validation with readable errors, a test runner — are exactly the code you
would otherwise write and maintain by hand. argparse remains the right answer for a
bootstrap script, an installer, or anything that ships into an environment where you cannot
add packages.
How many commands before I need to restructure?
The trigger is not a count, it is a symptom: the first time two people conflict in the same file, or the first time you scroll to find a command, split into one module per command. Done early it is a mechanical change; done at command fifteen it is an afternoon of untangling shared state that grew between them.
Is Typer slower than Click at startup?
Marginally, and it almost never matters. Typer builds Click objects from your annotations at import time, which costs single-digit milliseconds for a typical command tree — against the 20 ms interpreter floor and the hundreds of milliseconds a single heavy library import costs. Measure before attributing sluggishness to the framework; it is nearly always an import.
Where should configuration loading happen?
In the root callback, once, before any command runs — and the result should be a typed object on the context rather than a module-level global. That keeps precedence rules in one testable place and stops two commands disagreeing about what the configuration was. The configuration section covers the layering itself.
Do plugins have to be Python packages?
For the entry-points mechanism, yes — that is what makes installation the registration step and gives you version metadata for free. Directory-scanning plugin systems avoid the packaging requirement but give up dependency resolution, versioning and uninstallation, which is a poor trade for anything that outlives a prototype.
How do I keep help output readable as the tree grows?
Write a one-line summary for every command (Click and Typer both take it from the first line
of the docstring), group related commands under a subject-area group so the root help lists
five entries rather than thirty, and keep the root help focused on navigation rather than
detail. If the root --help no longer fits on a screen, that is the signal to add a level.
Can one codebase expose the same functionality as a CLI and a library?
Yes, and it is the natural consequence of keeping core/ free of framework imports. The
library is core/; the CLI is a thin adapter over it. What breaks the arrangement is letting
CLI concerns leak downward — a core function that prints, prompts, or calls sys.exit is one
that cannot be imported by anything else. Return values and raise exceptions; let the adapter
decide how they are rendered.
What belongs in --version output?
The version, and enough to make a bug report actionable: the Python version and, if it is not
obvious, where the tool was installed from. Read the version from the installed metadata with
importlib.metadata.version("mytool") rather than a hard-coded string, so the number can
never disagree with what was packaged. Make the flag eager so it prints even when the rest of
the command line is incomplete.
Should commands be able to call each other?
Rarely, and never through the CLI layer. Invoking one command from inside another couples them
through the parser — the caller now depends on flag names and on whatever the callee prints —
and makes both harder to change. If two commands need the same work done, that work belongs in
core/ where both can call it as a function, with the arguments each one actually has.
Related tracks
Build the foundation first in Project Setup & Dependency Management, then make the resulting commands a pleasure to use in Advanced Input Parsing & User Experience.