CLI Design

Good command-line tools have discoverable subcommands, clear help text, meaningful exit codes, and error messages that tell the user what to do next.

A command-line interface (CLI) is the contract between a program and the humans (and scripts) that invoke it. Python's standard library gives you the pieces to build a well-behaved CLI — argparse for parsing, sys.exit() for exit codes, logging for diagnostics — but good CLI design is more than parsing flags: it's about predictability, discoverability, and playing well with the rest of the shell ecosystem.

What Makes a CLI Well-Designed

A well-designed CLI is predictable (similar operations use similar flag names across the tool), discoverable (--help explains itself without external docs), scriptable (machine-readable output and exit codes so it can be composed in pipelines), and fails loudly with actionable messages rather than a raw traceback. These qualities matter more as a tool grows from a personal script into something teammates or CI systems depend on.

argparse is the standard-library tool for building CLIs and is what this topic uses throughout. Third-party libraries like Typer and Click (not part of the standard library) offer a more declarative, type-hint-driven style and are popular for larger CLIs — mentioned here for context, not covered in depth.

Subcommands

Subcommands (git commit, pip install) let one program expose multiple related operations, each with its own arguments and help text, instead of overloading a single flat argument list. argparse supports this via add_subparsers(), which creates a dispatch point: each subcommand gets its own parser, and you typically store the invoked subcommand's handler in a dedicated argument (commonly via set_defaults(func=...)) so dispatch is a single function call rather than a chain of if statements.

Each subcommand is an independent parser with its own arguments and help text; set_defaults(func=...) turns dispatch into a single call instead of a long if command == ... chain.

import argparse

def cmd_add(args: argparse.Namespace) -> int:
    print(f"adding {args.item!r} to {args.list_name!r}")
    return 0

def cmd_remove(args: argparse.Namespace) -> int:
    print(f"removing {args.item!r} from {args.list_name!r}")
    return 0

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="todo", description="A tiny todo-list CLI.")
    subparsers = parser.add_subparsers(dest="command", required=True)

    add_p = subparsers.add_parser("add", help="add an item")
    add_p.add_argument("list_name")
    add_p.add_argument("item")
    add_p.set_defaults(func=cmd_add)

    rm_p = subparsers.add_parser("remove", help="remove an item")
    rm_p.add_argument("list_name")
    rm_p.add_argument("item")
    rm_p.set_defaults(func=cmd_remove)

    return parser

if __name__ == "__main__":
    parser = build_parser()
    args = parser.parse_args()
    raise SystemExit(args.func(args))

Help Text

argparse generates -h/--help automatically from the description you give the parser, the help string on each argument, and metavar/type information. Investing a sentence per argument pays off constantly — help text is the documentation most users actually read, right before they read nothing else. Good help text says what a flag does and, for non-obvious defaults, what happens if it's omitted.

%(default)s in a help string interpolates the argument's actual default, keeping the help text accurate if the default ever changes. action="count" lets -v/-vv/-vvv scale verbosity.

import argparse

parser = argparse.ArgumentParser(
    prog="fetcher",
    description="Download and cache remote resources.",
)
parser.add_argument(
    "url",
    help="the URL to fetch",
)
parser.add_argument(
    "--timeout",
    type=float,
    default=10.0,
    metavar="SECONDS",
    help="request timeout in seconds (default: %(default)s)",
)
parser.add_argument(
    "-v", "--verbose",
    action="count",
    default=0,
    help="increase output verbosity (repeatable: -vv for more detail)",
)

Exit Codes

By POSIX convention, a process exits with 0 on success and a nonzero code on failure; shells, CI systems, and &&/|| chaining all rely on this. In Python, sys.exit(code) (or the equivalent raise SystemExit(code)) sets the process exit code; calling sys.exit() with no argument, or letting main() fall off the end, exits with 0. Reserve specific nonzero codes for specific failure categories only if your tool's users benefit from distinguishing them (e.g. 2 for usage errors, matching argparse's own convention) — otherwise a plain 1 for 'something failed' plus a clear stderr message is perfectly fine.

main() returns an int; sys.exit() at the very top propagates it as the process exit code. Keeping main() a plain function (rather than calling sys.exit() deep inside it) keeps it testable.

argparse itself calls sys.exit(2) automatically when parsing fails (missing required argument, unknown flag) and prints a usage message to stderr — you get this for free without writing any error-handling code.

import sys

def main() -> int:
    try:
        risky_operation()
    except FileNotFoundError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1
    except PermissionError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 13  # arbitrary but documented: permission failure
    return 0

if __name__ == "__main__":
    sys.exit(main())

Error Messages

A CLI's error messages are read under time pressure, often by someone who is not the tool's author. Good error messages: go to stderr (not stdout, which may be piped/parsed), state what went wrong in plain language, and — where possible — suggest the fix. A raw Python traceback leaking to an end user is rarely actionable; catch expected failure modes at the top level and translate them.

import sys
from pathlib import Path

def main(argv: list[str]) -> int:
    if len(argv) < 2:
        print("error: missing required argument <path>", file=sys.stderr)
        print("usage: mytool <path>", file=sys.stderr)
        return 2

    path = Path(argv[1])
    if not path.exists():
        print(f"error: '{path}' does not exist", file=sys.stderr)
        return 1

    return 0

if __name__ == "__main__":
    sys.exit(main(sys.argv))

Composability

Unix-style CLIs are designed to be piped and scripted, not just typed interactively by a human. Composability means: read input from stdin when no file argument is given (so cat data.txt | mytool works), write results to stdout, keep diagnostic/progress noise on stderr so it doesn't pollute piped output, and support a machine-readable output mode (--json, --porcelain) alongside a human-friendly default when other tools might consume your output.

Accepting - (or no argument) as 'read from stdin' is a long-standing Unix convention that lets your tool slot into a pipeline: generate.py | mytool | consume.py.

import sys

def read_input(path: str | None) -> str:
    if path is None or path == "-":
        return sys.stdin.read()
    with open(path, encoding="utf-8") as f:
        return f.read()

def main() -> int:
    path = sys.argv[1] if len(sys.argv) > 1 else None
    text = read_input(path)
    print(text.upper(), end="")  # result -> stdout
    print(f"processed {len(text)} chars", file=sys.stderr)  # diagnostics -> stderr
    return 0

Logging in a CLI

For anything beyond a trivial script, prefer the standard-library logging module over scattered print() calls for diagnostics: it gives you severity levels, consistent formatting, and a single place to control verbosity (often wired to -v/--quiet flags). Reserve print() for the tool's actual output — the thing meant to be piped or read as the result — and use logging (directed at stderr) for progress, warnings, and debug detail.

import argparse
import logging

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser()
    parser.add_argument("-v", "--verbose", action="store_true")
    return parser

def main() -> int:
    args = build_parser().parse_args()
    level = logging.DEBUG if args.verbose else logging.WARNING
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
    log = logging.getLogger(__name__)

    log.debug("starting work")
    log.info("this only shows with -v at INFO or lower")
    print("actual result here")  # stdout: the real output
    return 0

Practical Usage

A typical well-structured CLI script separates three layers: argument parsing (build_parser()), a testable main(args) that contains the actual logic and returns an int, and a thin if __name__ == "__main__": sys.exit(main()) entry point. This separation lets you unit-test main() directly by constructing a Namespace in tests, without shelling out to the actual process.

Edge Cases

Handle KeyboardInterrupt (Ctrl+C) gracefully — an uncaught one prints an ugly traceback; catching it at the top level and exiting with a conventional code (commonly 130, i.e. 128 + SIGINT) is more polite. Be careful mixing print() buffering with sys.stdout/sys.stderr interleaving when a tool is piped rather than run in a terminal — output that looks synchronized on a terminal can arrive out of order once redirected, since terminals and pipes buffer differently.

import sys

def main() -> int:
    try:
        do_long_running_work()
    except KeyboardInterrupt:
        print("\ninterrupted", file=sys.stderr)
        return 130
    return 0

Complete Example

import argparse
import logging
import sys
from pathlib import Path

log = logging.getLogger("wordcount")

def cmd_count(args: argparse.Namespace) -> int:
    path = Path(args.file)
    if not path.exists():
        print(f"error: '{path}' does not exist", file=sys.stderr)
        return 1
    text = path.read_text(encoding="utf-8")
    words = len(text.split())
    log.debug("read %d characters", len(text))
    if args.json:
        print(f'{{"file": "{path}", "words": {words}}}')
    else:
        print(f"{path}: {words} words")
    return 0

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="wordcount", description="Count words in a file.")
    parser.add_argument("-v", "--verbose", action="store_true", help="show debug logging")
    sub = parser.add_subparsers(dest="command", required=True)

    count_p = sub.add_parser("count", help="count words in a file")
    count_p.add_argument("file", help="path to the file")
    count_p.add_argument("--json", action="store_true", help="output as JSON")
    count_p.set_defaults(func=cmd_count)
    return parser

def main() -> int:
    args = build_parser().parse_args()
    logging.basicConfig(
       
…

Mental Model

Think of a CLI as a small API whose consumers include both a human typing at a prompt and a shell script running unattended at 3 AM. Every design choice — help text, exit codes, stdout/stderr separation — is really answering: 'if a script depended on this behavior, would it work reliably?'

Quick Reference

add_subparsers(dest=..., required=True)  - multi-command CLIs
parser.add_argument(..., help="...")     - self-documenting flags
sys.exit(code) / return int from main()  - 0 = success, nonzero = failure
print(..., file=sys.stderr)              - diagnostics, not results
logging.basicConfig(level=...)           - leveled diagnostics
"-" as filename argument                 - conventional "read stdin"
KeyboardInterrupt -> exit 130            - graceful Ctrl+C handling

Key Takeaways

Subcommands (argparse.add_subparsers) let one CLI cleanly expose multiple operations, each with independent arguments and help text.

Help text, generated from description/help strings, is the documentation most users actually read — write it for a stranger in a hurry.

Exit 0 for success and nonzero for failure; argparse already exits 2 on its own usage errors.

Send actual output to stdout and diagnostics/errors to stderr so the tool composes correctly in pipelines.

Separate parsing, a testable main(args) -> int, and a thin sys.exit(main()) entry point.

Use logging for leveled diagnostics instead of scattering print() calls once a script grows past trivial size.

import sys def main() -> int: if len(sys.argv) < 2: print("error: missing <path> argument", file=sys.stderr) return 2 try: with open(sys.argv[1], encoding="utf-8") as f: print(f.read()) except OSError as exc: print(f"error: {exc}", file=sys.stderr) return 1 return 0 sys.exit(main())

An unhandled exception prints an intimidating traceback and exits with code 1 regardless of the failure type; catching expected errors and reporting them on stderr with a clear message (and a deliberate exit code) is far more usable.

import sys print("processing...", file=sys.stderr) print(result) # the actual result: stdout only print("done", file=sys.stderr)

If someone pipes this tool's stdout into another program (mytool | jq .), progress chatter mixed into stdout corrupts the data the next program expects to parse.

import sys def main() -> int: try: do_work() except Exception as exc: print(f"error: {exc}", file=sys.stderr) return 1 return 0 sys.exit(main())

A script that always exits 0, even on failure, breaks && chaining and CI pipelines that check the exit code to decide whether the step succeeded.

Why should error messages be printed to stderr rather than stdout?

stdout carries a program's actual output, which may be piped into another program or redirected to a file; mixing error text into stdout corrupts that data. stderr is the conventional channel for diagnostics, so redirecting stdout alone (prog > out.txt) still lets errors reach the terminal.

What exit code should a Python script use to signal success, and how do you set it?

0 signals success. sys.exit(0) (or no argument, or letting main() return None/0 and passing that to sys.exit) sets it; simply falling off the end of a script with no unhandled exception also exits 0.

What does argparse.add_subparsers(dest="command", required=True) accomplish?

It creates a dispatch point for a multi-command CLI: each subcommand gets its own independent parser (with its own positional/optional arguments and help text), dest stores which subcommand name was invoked, and required=True makes omitting a subcommand a parse error instead of silently doing nothing.

A teammate's CLI script calls sys.exit() deep inside a helper function on failure. Why is that a design smell?

It couples business logic to process termination, making the function untestable without actually killing the test process, and makes control flow hard to follow. Prefer returning a status/raising an exception from inner functions and calling sys.exit() exactly once, at the top-level entry point.

Why is print(f"timeout: {SECONDS} (default: %(default)s)")-style interpolation preferred in argparse help strings over hardcoding the default value as text?

%(default)s is filled in by argparse from the argument's actual default= value at help-generation time, so the help text can never drift out of sync if the default is changed later in the code.

What is the conventional exit code for a process interrupted by Ctrl+C, and why that number?

130, by the common convention of 128 + the signal number (SIGINT is signal 2), used by many shells and tools; it lets a calling script distinguish 'user interrupted' from an ordinary application error.

Why does accepting - as a filename argument (meaning 'read from stdin') make a CLI more composable?

It lets the tool be dropped into a Unix pipeline in place of a real file — generate | mytool - — without special-casing; many standard Unix tools honor the same convention, so users expect it.

Write a main(argv) function for a script that requires exactly one command-line argument, a filename. If the argument is missing, print a usage error to stderr and return 2. If the file doesn't exist, print an error to stderr and return 1. Otherwise print the file's line count to stdout and return 0.

import sys from pathlib import Path def main(argv: list[str]) -> int: if len(argv) != 2: print(f"usage: {argv[0]} <file>", file=sys.stderr) return 2 path = Path(argv[1]) if not path.exists(): print(f"error: '{path}' does not exist", file=sys.stderr) return 1 line_count = len(path.read_text(encoding="utf-8").splitlines()) print(line_count) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))

The function distinguishes three outcomes with distinct exit codes (2 for usage error, 1 for a missing file, 0 for success), keeps diagnostics on stderr and the actual result (the line count) on stdout, and takes argv as a parameter so it can be unit-tested by passing a fake argument list instead of running the process.

What exit code does this script actually produce, and why might that be surprising?

main() returns None (the return None is explicit, but even a bare return or falling off the end behaves the same), and sys.exit(None) is treated the same as sys.exit() with no argument — it exits with code 0 (success). It's surprising if the intent was 'return no explicit code' vs 'succeeded' — sys.exit(None) and sys.exit(0) produce the same observable exit status, so a main() that forgets to return 1 on a failure path will still report success to the shell.

Write an argparse-based CLI skeleton with two subcommands, greet <name> and farewell <name>, each printing an appropriate message, dispatched via set_defaults(func=...).

import argparse import sys def cmd_greet(args: argparse.Namespace) -> int: print(f"Hello, {args.name}!") return 0 def cmd_farewell(args: argparse.Namespace) -> int: print(f"Goodbye, {args.name}.") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="greeter") sub = parser.add_subparsers(dest="command", required=True) greet_p = sub.add_parser("greet", help="say hello") greet_p.add_argument("name") greet_p.set_defaults(func=cmd_greet) fw_p = sub.add_parser("farewell", help="say goodbye") fw_p.add_argument("name") fw_p.set_defaults(func=cmd_farewell) return parser if __name__ == "__main__": args = build_parser().parse_args() sys.exit(args.func(args))

Each subcommand is registered with add_parser and given its own name argument, and set_defaults(func=...) attaches the handler function directly to the parsed namespace, so dispatch is just args.func(args) rather than an if/elif chain over args.command.

import sys

def main() -> int:
    print("working")
    return None

sys.exit(main())