Intermediate

Your Python script takes a filename, a mode, and an optional verbose flag. You wire it up with argparse and two hours later you have 80 lines of parser setup just to read three arguments. Then the requirements grow: now you need subcommands — process, validate, export — each with its own flags and help text. At that point, argparse stops being a tool and starts being a project in itself. There is a better way.

Click and Typer solve this cleanly. Click is a mature, decorator-based library that turns Python functions into CLI commands with a single line. Typer builds on top of Click, using Python type annotations to generate argument parsers automatically — you write normal typed function signatures and Typer does the rest. Both ship via pip, both integrate with the same testing toolchain, and both produce CLIs that behave the way users expect: proper help text, error messages with context, tab completion support.

This article covers everything you need to build complex, production-ready CLIs in Python. We start with a quick working example, then walk through Typer’s command system, Click’s group and subcommand architecture, type validation, shared context, and callback hooks. The article closes with a multi-command project manager CLI that demonstrates all the patterns together.

Building a CLI with Typer: Quick Example

The fastest path to a working CLI is Typer. Install it with pip install typer, then write a typed function decorated with @app.command(). Typer reads the type annotations and builds the argument parser for you.

# greet_cli.py
import typer

app = typer.Typer()

@app.command()
def greet(
    name: str,
    count: int = 1,
    loud: bool = typer.Option(False, "--loud", "-l", help="Print in uppercase"),
):
    """Greet a user by name, optionally multiple times."""
    message = f"Hello, {name}!"
    if loud:
        message = message.upper()
    for _ in range(count):
        typer.echo(message)

if __name__ == "__main__":
    app()
$ python greet_cli.py Alice
Hello, Alice!

$ python greet_cli.py Alice --count 3
Hello, Alice!
Hello, Alice!
Hello, Alice!

$ python greet_cli.py Alice --loud
HELLO, ALICE!

$ python greet_cli.py --help
Usage: greet_cli.py [OPTIONS] NAME

  Greet a user by name, optionally multiple times.

Arguments:
  NAME  [required]

Options:
  --count      INTEGER  [default: 1]
  --loud   -l           Print in uppercase
  --help                Show this message and exit.

Three things to notice: name: str becomes a required positional argument, count: int = 1 becomes an optional argument with a default, and typer.Option(False, "--loud", "-l") creates a boolean flag with a short alias. The --help text is generated from the function’s docstring. No parser setup, no add_argument calls, no parse_args(). The deeper sections below show how to extend this into a multi-command CLI with subcommands, shared state, and validation.

What Is Typer and How Does It Relate to Click?

Typer is a thin wrapper around Click that replaces Click’s decorator-based argument definitions with Python type annotations. Where Click requires you to explicitly declare each option’s type and metadata, Typer infers them from your function signature. The underlying parser, error handling, and shell integration are all Click — Typer just removes the boilerplate.

Here is the same two-argument command written in both libraries so you can see the difference:

# comparison.py
# --- Click version ---
import click

@click.command()
@click.argument("filename")
@click.option("--lines", default=10, help="Number of lines to show")
def tail_click(filename, lines):
    """Show the last N lines of a file."""
    with open(filename) as f:
        content = f.readlines()
    for line in content[-lines:]:
        click.echo(line, nl=False)

# --- Typer version ---
import typer

app = typer.Typer()

@app.command()
def tail_typer(
    filename: str,
    lines: int = typer.Option(10, help="Number of lines to show"),
):
    """Show the last N lines of a file."""
    with open(filename) as f:
        content = f.readlines()
    for line in content[-lines:]:
        typer.echo(line, nl=False)

Both produce identical CLIs. The Typer version is slightly more concise and the type annotations double as IDE hints — hover over filename anywhere in your code and your editor knows it is a str. Click is the better choice when you need low-level control over parser behavior or are extending an existing Click-based CLI. Typer is the better choice for new projects where speed of development matters.

FeatureClickTyper
Argument definitionDecorators (@click.argument)Type annotations + defaults
Type validationExplicit type=click.INTInferred from annotation
Subcommands@cli.group() + @cli.command()typer.Typer() + app.add_typer()
Underlying engineClick itselfClick
IDE autocompletionLimited (decorator metadata)Full (native type annotations)
Best forExisting Click projects, fine controlNew projects, typed codebases

Building Multi-Command Apps with Typer

A single-command CLI works for simple scripts, but real tools have subcommands. The pattern in Typer is to create a main Typer() app and add sub-apps to it using app.add_typer(). Each sub-app is its own Typer() instance with its own commands. This mirrors how tools like git, docker, and aws are organized.

# multi_app.py
import typer

app = typer.Typer(help="A simple multi-command file tool.")
convert_app = typer.Typer(help="File conversion commands.")
info_app = typer.Typer(help="File information commands.")

app.add_typer(convert_app, name="convert")
app.add_typer(info_app, name="info")

@convert_app.command("to-upper")
def convert_upper(filename: str, output: str = typer.Option(None, help="Output file")):
    """Convert a text file to uppercase."""
    with open(filename) as f:
        content = f.read().upper()
    dest = output or filename + ".upper"
    with open(dest, "w") as f:
        f.write(content)
    typer.echo(f"Written to {dest}")

@convert_app.command("count-words")
def convert_count(filename: str):
    """Count words in a file."""
    with open(filename) as f:
        words = len(f.read().split())
    typer.echo(f"{filename}: {words} words")

@info_app.command()
def size(filename: str):
    """Show the size of a file in bytes."""
    import os
    bytes_ = os.path.getsize(filename)
    typer.echo(f"{filename}: {bytes_} bytes")

@info_app.command()
def lines(filename: str):
    """Count lines in a file."""
    with open(filename) as f:
        n = sum(1 for _ in f)
    typer.echo(f"{filename}: {n} lines")

if __name__ == "__main__":
    app()
$ python multi_app.py --help
Usage: multi_app.py [OPTIONS] COMMAND [ARGS]...

  A simple multi-command file tool.

Commands:
  convert  File conversion commands.
  info     File information commands.

$ python multi_app.py convert --help
Commands:
  to-upper     Convert a text file to uppercase.
  count-words  Count words in a file.

$ echo "hello world foo bar" > test.txt
$ python multi_app.py info lines test.txt
test.txt: 1 lines

$ python multi_app.py convert count-words test.txt
test.txt: 4 words

The app.add_typer(sub_app, name="convert") call registers the sub-app as a command group. Each @convert_app.command() becomes a sub-subcommand under convert. The help text at each level is auto-generated from the Typer(help="...")) argument and each function’s docstring. Adding a new subcommand is just adding a new decorated function — no manual registration needed.

Typer CLI subcommands architecture diagram
add_typer() — because –help with twenty flags stopped being help.

Click Command Groups and Subcommands

Click uses a @click.group() decorator to create command groups. A group is itself a command that dispatches to subcommands. You attach subcommands with @group_name.command(). This is the Click-native way to build the same structure that Typer’s add_typer creates under the hood.

# click_groups.py
import click
import os

@click.group()
def cli():
    """Developer toolkit: db and cache management."""
    pass

@cli.group()
def db():
    """Database operations."""
    pass

@cli.group()
def cache():
    """Cache operations."""
    pass

@db.command()
@click.argument("table")
@click.option("--limit", default=100, show_default=True, help="Row limit")
def query(table, limit):
    """Run a SELECT query on TABLE."""
    click.echo(f"SELECT * FROM {table} LIMIT {limit}")
    # In a real tool, execute the query here

@db.command()
@click.argument("table")
@click.confirmation_option(prompt="This will delete all rows. Are you sure?")
def truncate(table):
    """Truncate TABLE (requires confirmation)."""
    click.echo(f"TRUNCATE TABLE {table}")

@cache.command()
def flush():
    """Flush the entire cache."""
    click.echo("Cache flushed.")

@cache.command()
@click.argument("key")
def delete(key):
    """Delete a single cache key."""
    click.echo(f"Deleted key: {key}")

if __name__ == "__main__":
    cli()
$ python click_groups.py --help
Usage: click_groups.py [OPTIONS] COMMAND [ARGS]...

  Developer toolkit: db and cache management.

Commands:
  cache  Cache operations.
  db     Database operations.

$ python click_groups.py db query users --limit 50
SELECT * FROM users LIMIT 50

$ python click_groups.py db truncate orders
This will delete all rows. Are you sure? [y/N]: y
TRUNCATE TABLE orders

$ python click_groups.py cache delete session:abc123
Deleted key: session:abc123

The @click.confirmation_option decorator on truncate shows one of Click’s built-in validation helpers — it prompts the user to confirm before running the command, and aborts cleanly if they decline. Click provides similar helpers for passwords (@click.password_option), version information (@click.version_option), and verbose mode. These cover the most common patterns so you do not have to implement them yourself.

Passing Context Between Commands

Real CLIs often need to share state between a group command and its subcommands — a database connection, a config file path, or a verbosity flag set at the top level. Both Click and Typer support this through a context object that flows down the command chain.

# context_demo.py
import click

@click.group()
@click.option("--config", default="config.json", help="Config file path")
@click.option("--verbose", is_flag=True, help="Enable verbose output")
@click.pass_context
def cli(ctx, config, verbose):
    """App with shared config context."""
    # Store shared state in ctx.obj
    ctx.ensure_object(dict)
    ctx.obj["config"] = config
    ctx.obj["verbose"] = verbose
    if verbose:
        click.echo(f"[verbose] Using config: {config}")

@cli.command()
@click.argument("username")
@click.pass_context
def create_user(ctx, username):
    """Create a new user."""
    config = ctx.obj["config"]
    verbose = ctx.obj["verbose"]
    if verbose:
        click.echo(f"[verbose] Reading from {config}")
    click.echo(f"Created user: {username}")

@cli.command()
@click.pass_context
def list_users(ctx):
    """List all users."""
    config = ctx.obj["config"]
    click.echo(f"Listing users from {config}")

if __name__ == "__main__":
    cli()
$ python context_demo.py --verbose create-user alice
[verbose] Using config: config.json
[verbose] Reading from config.json
Created user: alice

$ python context_demo.py --config /etc/myapp.json list-users
Listing users from /etc/myapp.json

The @click.pass_context decorator injects the Click Context object as the first argument to the function. The group command populates ctx.obj (a plain dict by convention) with whatever shared state the subcommands need. Each subcommand retrieves that state via its own ctx.obj reference. This pattern cleanly separates top-level configuration (parsed once) from per-command logic (parsed independently).

Click context passing between commands
ctx.obj — global state that travels with the command, not bolted onto a global variable.

Type Validation, Choices, and Custom Types

Both Click and Typer validate argument types before your function runs. If a user passes a string where an integer is expected, they get a clear error message and the command exits — no exception traceback, no partially executed logic. You can extend this with choices, path validation, and custom types.

# validation_demo.py
import typer
from enum import Enum
from pathlib import Path

class OutputFormat(str, Enum):
    json = "json"
    csv = "csv"
    table = "table"

app = typer.Typer()

@app.command()
def export(
    source: Path = typer.Argument(..., exists=True, help="Input file (must exist)"),
    output: Path = typer.Option(Path("output.txt"), help="Output file"),
    fmt: OutputFormat = typer.Option(OutputFormat.table, help="Output format"),
    limit: int = typer.Option(100, min=1, max=10000, help="Row limit (1-10000)"),
):
    """Export data from SOURCE in the given format."""
    typer.echo(f"Reading: {source}")
    typer.echo(f"Format:  {fmt.value}")
    typer.echo(f"Limit:   {limit}")
    typer.echo(f"Writing: {output}")

if __name__ == "__main__":
    app()
$ echo "id,name" > data.csv

$ python validation_demo.py data.csv --fmt json --limit 50
Reading: data.csv
Format:  json
Limit:   50
Writing: output.txt

$ python validation_demo.py missing.csv
Error: Invalid value for 'SOURCE': Path 'missing.csv' does not exist.

$ python validation_demo.py data.csv --limit 99999
Error: Invalid value for '--limit': 99999 is not in the range 1<=x<=10000.

$ python validation_demo.py data.csv --fmt xml
Error: Invalid value for '--fmt': 'xml' is not one of 'json', 'csv', 'table'.

The typer.Argument(..., exists=True) parameter tells Typer to validate that the path exists before running the function. The min=1, max=10000 parameters on the integer option enforce a numeric range. The Enum subclass restricts fmt to a fixed set of values. All of this validation happens before your function body runs -- and crucially, the error messages tell the user exactly what went wrong and what the valid values are. Compare this to catching a ValueError inside your function and printing a custom error: the built-in validators produce more consistent, user-friendly output.

Callbacks, Version Flags, and Eager Options

Sometimes you need an option that exits immediately after running -- a --version flag or a --list-formats option that prints information and stops. Both Click and Typer support this through "eager" options and callbacks.

# callbacks_demo.py
import typer
from typing import Optional

__version__ = "1.4.2"

def version_callback(value: bool):
    if value:
        typer.echo(f"mytool version {__version__}")
        raise typer.Exit()

app = typer.Typer()

@app.callback()
def main(
    version: Optional[bool] = typer.Option(
        None, "--version", "-v",
        callback=version_callback,
        is_eager=True,
        help="Show version and exit.",
    )
):
    """mytool -- a demonstration CLI."""

@app.command()
def run(task: str, workers: int = 4):
    """Run TASK with N workers."""
    typer.echo(f"Running '{task}' with {workers} workers")

@app.command()
def status():
    """Check current status."""
    typer.echo("Status: OK")

if __name__ == "__main__":
    app()
$ python callbacks_demo.py --version
mytool version 1.4.2

$ python callbacks_demo.py run "build images" --workers 8
Running 'build images' with 8 workers

$ python callbacks_demo.py status
Status: OK

$ python callbacks_demo.py --help
Usage: callbacks_demo.py [OPTIONS] COMMAND [ARGS]...

  mytool -- a demonstration CLI.

Options:
  -v, --version  Show version and exit.
  --help         Show this message and exit.

Commands:
  run     Run TASK with N workers.
  status  Check current status.

The is_eager=True parameter tells Typer (and Click underneath) to process this option before any command runs -- including before validating other arguments. The callback function receives the option value and raises typer.Exit() to stop execution cleanly. The @app.callback() decorator attaches options to the root app rather than to a specific command, which is the right place for global flags like --version, --config, and --verbose.

Typer Exit callback control panel
raise typer.Exit() -- the only acceptable use of control flow as error handling.

Real-Life Example: A Task Manager CLI

This project implements a multi-command task manager CLI with Typer: add tasks, list them, mark as done, and delete them. Tasks are stored in a JSON file. The project uses sub-apps, type validation, colored output, and the callback context pattern.

# tasks_cli.py
import typer
import json
import os
from enum import Enum
from pathlib import Path
from typing import Optional

DB_PATH = Path("tasks.json")

def load_tasks():
    if not DB_PATH.exists():
        return []
    with open(DB_PATH) as f:
        return json.load(f)

def save_tasks(tasks):
    with open(DB_PATH, "w") as f:
        json.dump(tasks, f, indent=2)

class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"

app = typer.Typer(help="A simple terminal task manager.")

@app.command()
def add(
    title: str = typer.Argument(..., help="Task title"),
    priority: Priority = typer.Option(Priority.medium, help="Task priority"),
    tag: Optional[str] = typer.Option(None, help="Optional tag"),
):
    """Add a new task."""
    tasks = load_tasks()
    task_id = max((t["id"] for t in tasks), default=0) + 1
    task = {"id": task_id, "title": title, "priority": priority.value,
            "tag": tag, "done": False}
    tasks.append(task)
    save_tasks(tasks)
    typer.echo(f"[+] Added task #{task_id}: {title} [{priority.value}]")

@app.command(name="list")
def list_tasks(
    show_done: bool = typer.Option(False, "--done", help="Include completed tasks"),
    priority: Optional[Priority] = typer.Option(None, help="Filter by priority"),
):
    """List tasks."""
    tasks = load_tasks()
    if not show_done:
        tasks = [t for t in tasks if not t["done"]]
    if priority:
        tasks = [t for t in tasks if t["priority"] == priority.value]
    if not tasks:
        typer.echo("No tasks found.")
        return
    for t in tasks:
        status = "[x]" if t["done"] else "[ ]"
        tag = f" #{t['tag']}" if t.get("tag") else ""
        typer.echo(f"  {status} #{t['id']:3d}  {t['priority']:6s}  {t['title']}{tag}")

@app.command()
def done(task_id: int = typer.Argument(..., help="Task ID to mark complete")):
    """Mark a task as done."""
    tasks = load_tasks()
    for t in tasks:
        if t["id"] == task_id:
            t["done"] = True
            save_tasks(tasks)
            typer.echo(f"[ok] Task #{task_id} marked done.")
            return
    typer.echo(f"Task #{task_id} not found.", err=True)
    raise typer.Exit(1)

@app.command()
def delete(
    task_id: int = typer.Argument(..., help="Task ID to delete"),
    force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
):
    """Delete a task."""
    tasks = load_tasks()
    task = next((t for t in tasks if t["id"] == task_id), None)
    if not task:
        typer.echo(f"Task #{task_id} not found.", err=True)
        raise typer.Exit(1)
    if not force:
        typer.confirm(f"Delete task #{task_id}: '{task['title']}'?", abort=True)
    tasks = [t for t in tasks if t["id"] != task_id]
    save_tasks(tasks)
    typer.echo(f"[-] Deleted task #{task_id}.")

if __name__ == "__main__":
    app()
$ python tasks_cli.py add "Write unit tests" --priority high --tag testing
[+] Added task #1: Write unit tests [high]

$ python tasks_cli.py add "Update README"
[+] Added task #2: Update README [medium]

$ python tasks_cli.py add "Fix login bug" --priority high
[+] Added task #3: Fix login bug [high]

$ python tasks_cli.py list
  [ ]   #1  high    Write unit tests #testing
  [ ]   #2  medium  Update README
  [ ]   #3  high    Fix login bug

$ python tasks_cli.py list --priority high
  [ ]   #1  high    Write unit tests #testing
  [ ]   #3  high    Fix login bug

$ python tasks_cli.py done 1
[ok] Task #1 marked done.

$ python tasks_cli.py list --done
  [x]   #1  high    Write unit tests #testing
  [ ]   #2  medium  Update README
  [ ]   #3  high    Fix login bug

$ python tasks_cli.py delete 2
Delete task #2: 'Update README'? [y/N]: y
[-] Deleted task #2.

This project demonstrates the key Typer patterns: enum-based choices for Priority, optional filtering arguments with Optional[Priority], a command named list using name="list" (since list is a Python builtin), graceful error exits with raise typer.Exit(1) for error conditions, and typer.confirm() for destructive operations. Extend it by replacing the JSON store with SQLite via sqlite3, adding a typer.progressbar() for bulk operations, or adding shell completion with typer install-completion.

CLI task manager checklist
JSON flat-file storage: perfectly adequate until the day it isn't.

Frequently Asked Questions

When should I use Typer versus Click directly?

Use Typer for new projects, especially if you are already using type annotations elsewhere in your codebase (Pydantic models, FastAPI endpoints, mypy). Typer's annotation-based API is faster to write, works with IDE autocompletion out of the box, and produces the same underlying CLI as Click. Use Click directly when you are extending or maintaining an existing Click-based project, need low-level access to Click internals, or are building a library that other Click users will import and extend.

What does Click/Typer offer that argparse does not?

Click and Typer use a decorator pattern that is far less verbose than argparse's imperative API. More importantly, they support nested command groups natively -- building a git-style CLI with five subcommand groups in argparse requires significant manual plumbing. Click and Typer also provide built-in support for password prompts, progress bars, colored terminal output (typer.style()), shell completion generation, and confirmation prompts. These are all DIY in argparse.

How do I test Click and Typer CLIs?

Click ships a CliRunner that invokes commands without spawning a subprocess, capturing stdout and stderr for assertions. Typer exposes the same runner via its test module. Create a runner, call runner.invoke(app, ["add", "my-task"]), and check result.output and result.exit_code. This approach is fast, does not require a real terminal, and works in pytest without any special plugins. Test edge cases like missing required arguments and invalid types -- the error messages from Click/Typer validation are worth asserting on.

How do I add shell tab completion?

Typer can generate and install completion scripts automatically. Run python your_cli.py --install-completion and Typer writes a completion script for the current shell (bash, zsh, fish) to the appropriate location. Click has a similar mechanism -- set the COMP_WORDS and COMP_CWORD environment variables and add the completion script via eval "$(_CLI_COMPLETE=bash_source your_cli)". For both libraries, completion works on command names, subcommand names, and option names automatically -- you only need custom completion logic if an argument's valid values come from a dynamic source like a database query.

How do I structure a large CLI with many subcommands?

Split command groups into separate modules. Create a file per sub-app (e.g., commands/db.py, commands/cache.py), define each Typer app or Click group in its module, and import and register them in a central cli.py entry point. This keeps each module focused and testable independently. Use app.add_typer(db_app, name="db") in your entry point to stitch them together. For very large projects (50+ commands), consider using Click's lazy loading pattern -- registered commands are loaded only when invoked, so startup time stays fast regardless of how many commands are defined.

Conclusion

Click and Typer cover the full range of Python CLI complexity -- from a single-function script to a multi-level command hierarchy with shared context, type validation, and shell completion. Typer's annotation-based approach eliminates most of the setup code, while Click's decorator model gives finer-grained control when needed. The two are fully interoperable: a Typer app is a Click app, so you can mix patterns in the same project. We covered Typer multi-command apps with add_typer, Click groups with @cli.group(), context passing via ctx.obj, type validation with Enums and path constraints, eager callbacks for version flags, and a real task manager project pulling the patterns together.

The task manager in the real-life example is a good starting point -- add persistent storage with sqlite3, plug in rich for colored tabular output, or wire the done command to a webhook when tasks are completed. The full Typer documentation at typer.tiangolo.com covers advanced topics including parameter callback validation, testing with CliRunner, and generating man pages. The Click documentation remains the definitive reference for anything Typer does not expose directly.