Skip to content

Building Command Line Interfaces#

Command-line interfaces (CLIs) are the fastest way to expose scripts, data tools, and automation to users and CI pipelines. Python ships argparse in the stdlib; libraries like click and typer add composability, validation, and better UX. A well-designed CLI has clear help text, sensible defaults, exit codes, and testable command functions.

How to use this page

Start with argparse for stdlib-only tools; reach for typer when you want type-hint-driven CLIs. Pair with Managing Secrets for credentials, Error Handling for exit codes, and Unit Testing for CLI tests.

At a glance
Track Python Advanced → Advanced Use Cases and Applications
Sections 13 major topics
Outline Use the right-hand TOC to jump

Topics: CLI design principles · sys.argv — minimal parsing · argparse — stdlib standard · click — composable CLI framework · typer — type hints drive the CLI · Entry points — installable commands · Exit codes and errors · Logging vs user output · … (+5 more)

  1. CLI design principles
  2. sys.argv — minimal parsing
  3. argparse — stdlib standard
  4. click — composable CLI framework
  5. typer — type hints drive the CLI
  6. Entry points — installable commands
  7. Exit codes and errors
  8. Logging vs user output
  9. Configuration and environment
  10. Progress and long-running tasks
  11. Testing CLIs
  12. Choosing a framework … and 1 more sections

CLI design principles#

Principle Practice
Helpful --help Document every flag with examples
Sensible defaults Minimize required arguments
Exit codes 0 success, non-zero on failure
Idempotent flags --verbose / --quiet don't conflict
stdin/stdout/stderr Log diagnostics to stderr, data to stdout
Path args as strings Convert to Path internally — File IO
mytool process data.csv --output result.json
echo $?  # 0 on success

sys.argv — minimal parsing#

import sys

def main(argv: list[str] | None = None) -> int:
    argv = argv or sys.argv[1:]
    if not argv:
        print("usage: greet <name>", file=sys.stderr)
        return 1
    print(f"Hello, {argv[0]}")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

Fine for one or two positional args — scales poorly without argparse.


argparse — stdlib standard#

import argparse
from pathlib import Path

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="csvmerge",
        description="Merge CSV files by a key column",
    )
    parser.add_argument("inputs", nargs="+", type=Path, help="Input CSV paths")
    parser.add_argument("-o", "--output", type=Path, required=True, help="Output path")
    parser.add_argument("-k", "--key", default="id", help="Join key column")
    parser.add_argument("-v", "--verbose", action="count", default=0)
    return parser

def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if args.verbose:
        print(f"Merging {len(args.inputs)} files on {args.key}")
    merge_csvs(args.inputs, args.output, key=args.key)
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

Common add_argument options#

Parameter Effect
nargs="+" One or more positional values
nargs="*" Zero or more
action="store_true" Boolean flag
choices=[...] Restrict to enumerated values
type=Path Convert to pathlib.Path
default= Value when flag omitted
required=True Mandatory option

Subcommands#

parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)

init_p = sub.add_parser("init", help="Initialize project")
init_p.add_argument("name")

run_p = sub.add_parser("run", help="Run pipeline")
run_p.add_argument("--dry-run", action="store_true")
mytool init myproject
mytool run --dry-run

click — composable CLI framework#

import click
from pathlib import Path

@click.group()
@click.option("--verbose", "-v", count=True)
@click.pass_context
def cli(ctx, verbose):
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose

@cli.command()
@click.argument("input", type=click.Path(exists=True, path_type=Path))
@click.option("--output", "-o", type=click.Path(path_type=Path), required=True)
def convert(input: Path, output: Path):
    """Convert INPUT file to OUTPUT format."""
    click.echo(f"Converting {input} -> {output}")

if __name__ == "__main__":
    cli()
Feature Benefit
@click.group() Nested subcommands
click.Path Validation + path_type=Path
click.echo Cross-platform output
click.prompt / confirm Interactive input
ctx.obj Shared context across commands

Password input#

password = click.prompt("Password", hide_input=True)

Pairs with Managing Secrets — never echo secrets.


typer — type hints drive the CLI#

import typer
from pathlib import Path
from typing import Optional

app = typer.Typer()

@app.command()
def greet(
    name: str,
    loud: bool = False,
    count: int = typer.Option(1, "--count", "-c", min=1),
):
    """Greet NAME count times."""
    message = f"Hello, {name}"
    if loud:
        message = message.upper()
    for _ in range(count):
        typer.echo(message)

@app.command()
def process(
    input: Path = typer.Argument(..., exists=True, readable=True),
    output: Optional[Path] = typer.Option(None, "--output", "-o"),
):
    ...

if __name__ == "__main__":
    app()

Built on Click — annotations define types, defaults, and help automatically. Ideal when you already use Type Hints and Annotations.

pip install typer
python app.py greet World --loud --count 3

Entry points — installable commands#

pyproject.toml:

[project]
name = "csvmerge"
version = "0.1.0"
requires-python = ">=3.10"

[project.scripts]
csvmerge = "csvmerge.cli:main"

[project.entry-points."console_scripts"]
csvmerge = "csvmerge.cli:main"
# csvmerge/cli.py
def main() -> None:
    raise SystemExit(run())

After pip install -e ., users run csvmerge from any directory. See Modules and Packages.


Exit codes and errors#

import sys

class CLIError(Exception):
    def __init__(self, message: str, code: int = 1):
        super().__init__(message)
        self.code = code

def main() -> int:
    try:
        run()
        return 0
    except CLIError as exc:
        print(exc, file=sys.stderr)
        return exc.code
    except KeyboardInterrupt:
        print("Interrupted", file=sys.stderr)
        return 130  # conventional for SIGINT
Code Meaning
0 Success
1 General error
2 Misuse (argparse default)
130 Ctrl+C (128 + signal 2)

Map domain errors explicitly — patterns from Error Handling.


Logging vs user output#

import logging
import sys

logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logger = logging.getLogger(__name__)

def main():
    logger.info("starting pipeline")  # diagnostics → stderr
    print('{"status": "ok"}')        # machine-readable → stdout

Scripts piped to other tools must keep stdout clean — only structured results there.


Configuration and environment#

import os
import typer

app = typer.Typer()

@app.command()
def deploy(env: str = typer.Option(os.environ.get("APP_ENV", "dev"))):
    ...

Precedence: CLI flags > environment variables > config file > defaults. Load secrets from env — Managing Secrets.


Progress and long-running tasks#

import typer
from typer import progressbar

def process_files(paths: list[Path]) -> None:
    with progressbar(length=len(paths), label="Processing") as pb:
        for path in paths:
            handle(path)
            pb.update(1)

For minimal deps, print status to stderr with \r or use rich (Typer integrates well).


Testing CLIs#

argparse#

def test_merge_writes_output(tmp_path):
    inp = tmp_path / "a.csv"
    inp.write_text("id,name\n1,Ada\n", encoding="utf-8")
    out = tmp_path / "out.csv"
    rc = main(["str(inp)", "-o", str(out), "-k", "id"])
    assert rc == 0
    assert out.exists()

Pass argv explicitly — never shell out in unit tests.

typer / click#

from typer.testing import CliRunner

runner = CliRunner()

def test_greet_loud():
    result = runner.invoke(app, ["greet", "World", "--loud"])
    assert result.exit_code == 0
    assert "HELLO, WORLD" in result.stdout

See Unit Testing.


Choosing a framework#

Tool Best when
argparse Stdlib only, scripts, no extra deps
click Complex nested commands, custom types
typer Type-hint-first, fast development
sys.argv Prototype or single-argument tool

Complete argparse example#

#!/usr/bin/env python3
"""fetch-url — download a URL to a file."""
from __future__ import annotations

import argparse
import sys
from pathlib import Path

import httpx

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("url", help="URL to download")
    p.add_argument("-o", "--output", type=Path, default=Path("out.bin"))
    p.add_argument("--timeout", type=float, default=30.0)
    return p

def run(url: str, output: Path, timeout: float) -> None:
    with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as r:
        r.raise_for_status()
        with output.open("wb") as f:
            for chunk in r.iter_bytes():
                f.write(chunk)

def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        run(args.url, args.output, args.timeout)
    except httpx.HTTPError as exc:
        print(f"download failed: {exc}", file=sys.stderr)
        return 1
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

Combines HTTP Requests and File IO.


Interview traps (quick reference)#

Trap What goes wrong Safe approach
Print debug info to stdout Breaks piping stderr for logs
No exit code on failure CI thinks success raise SystemExit(1)
Required positional args without help Bad UX argparse with descriptions
Parsing paths as raw strings Windows/posix bugs type=Path
Secrets as CLI flags Visible in ps history Env vars or prompt
Untestable main() No injection of argv main(argv=None) pattern
action="store_true" with default=True Flag cannot disable Use store_false pair
Global state in commands Hard to test Pass context / dependency injection

Mental model checklist#

  1. What exit code should a failed CLI return?
  2. Why send logs to stderr instead of stdout?
  3. How do you test argparse without subprocess?
  4. What is the if __name__ == "__main__" guard for?
  5. When is typer preferable to raw argparse?
  6. How do entry points make a module runnable as csvmerge?

What's next#

Topic Page
Packaging Modules and Packages
Credentials Managing Secrets
HTTP downloads HTTP Requests
Output files File IO
CLI tests Unit Testing