When your CLI fails, the user should see one clear sentence telling them what went wrong and how to fix it — not fifteen lines of Python internals ending in FileNotFoundError. A raw traceback is a debugging tool leaking into a user interface. This guide shows how to catch the errors you expect, write messages people can act on, and keep the full traceback one --debug flag away for when you actually need it.
TL;DR
- A raw traceback is bad UX: it exposes internals, buries the real cause, and reads as "this tool crashed" rather than "you gave me a path that doesn't exist."
- Install one top-level
try/exceptboundary that maps expected exceptions to a message plus an exit code, and let only genuine bugs fall through. - Write every error to stderr, not stdout, with
click.echo(msg, err=True)orprint(msg, file=sys.stderr). - Add a
--debug/--verboseflag that re-raises the full traceback so developers can still see it on demand. - Use
rich.traceback.install(show_locals=True)for gorgeous, readable tracebacks while developing — behind the same debug flag. - Style messages as what happened + how to fix it, so the next action is obvious.
Why raw tracebacks are bad UX
Here is what an uncaught exception looks like to someone who just wanted to run your tool:
$ mytool build --config app.toml
Traceback (most recent call last):
File "/usr/lib/python3.11/.../cli.py", line 88, in build
data = load_config(path)
File "/usr/lib/python3.11/.../config.py", line 22, in load_config
with open(path) as fh:
^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: 'app.toml'
Three problems. First, the actual issue — the file isn't there — is on the last line, after a stack the user can do nothing with. Second, it exposes your internal file layout and function names, which is noise at best and an information leak at worst. Third, it reads as a crash: the user assumes your tool is broken, not that they typed the wrong path. Compare:
$ mytool build --config app.toml
error: config file not found: app.toml
hint: create it with 'mytool init' or pass --config <path>
Same failure, but now the user knows exactly what to do. The traceback isn't gone — it's just moved behind --debug, where the person who can use it will look.
A top-level error boundary
The cleanest way to guarantee no command leaks a traceback is to catch everything in one place: a boundary wrapping your entry point. Individual commands raise; the boundary decides how failure is presented.
import sys
class CLIError(Exception):
"""An anticipated failure. `hint` is optional next-step guidance."""
def __init__(self, message: str, *, code: int = 1, hint: str | None = None):
super().__init__(message)
self.code = code
self.hint = hint
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
run(args)
return 0
except CLIError as exc:
print(f"error: {exc}", file=sys.stderr)
if exc.hint:
print(f" hint: {exc.hint}", file=sys.stderr)
return exc.code
except KeyboardInterrupt:
print("aborted", file=sys.stderr)
return 130
except Exception as exc: # an unanticipated bug
if args.debug:
raise # full traceback for developers
print(f"internal error: {exc}", file=sys.stderr)
print(" run again with --debug for a full traceback", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
The pattern that makes this scale: deep in your code, translate low-level exceptions into CLIError with a good message at the point where you have the most context.
def load_config(path: str) -> dict:
try:
with open(path, "rb") as fh:
return tomllib.load(fh)
except FileNotFoundError:
raise CLIError(
f"config file not found: {path}",
code=66, # EX_NOINPUT
hint="create it with 'mytool init' or pass --config <path>",
)
except tomllib.TOMLDecodeError as exc:
raise CLIError(f"config file {path} is not valid TOML: {exc}", code=65)
Everything you expected becomes a tidy CLIError; everything you didn't stays a real exception and gets the traceback (under --debug) it deserves. The exit codes here (66, 65) come from the choosing exit codes for CLI tools conventions — pair the two guides so your messages and your codes tell a consistent story.
Always write errors to stderr
Errors go to stderr, never stdout. Stdout is reserved for the tool's real output — the data a downstream program consumes. If an error lands on stdout, mytool export | jq . breaks because your error sentence is now sitting inside the JSON stream.
With plain Python, that's the file=sys.stderr argument you saw above. In Click, use err=True:
import click
click.echo("error: registry unreachable", err=True)
click.secho("error: registry unreachable", err=True, fg="red") # colored
click.secho adds color, and Click strips the color codes automatically when stderr is not a TTY (a pipe or a log file), so you never get \x1b[31m garbage in your CI logs. In Typer, the same call works via typer.echo(msg, err=True) or typer.secho(...), and raising typer.BadParameter or typer.Exit(code=...) routes through Typer's own stderr handling. If you're deciding between the two frameworks, Typer vs Click compares their error ergonomics directly.
Click also gives you ClickException as a built-in version of the boundary above: raise it anywhere and Click prints Error: <message> to stderr and exits 1 for you.
@click.command()
@click.option("--config", "config_path")
def build(config_path: str) -> None:
if not os.path.exists(config_path):
raise click.ClickException(f"config file not found: {config_path}")
A --debug flag that re-raises the traceback
Hiding the traceback for users must not mean losing it for maintainers. A single global flag toggles between the two audiences. With Click, make it an eager, expose-nothing option stored on the context:
import click
@click.group()
@click.option("--debug", is_flag=True, help="Show full tracebacks on error.")
@click.pass_context
def cli(ctx: click.Context, debug: bool) -> None:
ctx.obj = {"debug": debug}
@cli.command()
@click.pass_context
def build(ctx: click.Context) -> None:
try:
run_build()
except Exception:
if ctx.obj["debug"]:
raise # Click prints the full traceback
raise click.ClickException("build failed; re-run with --debug for details")
Sharing that debug flag across every subcommand via the context object is exactly the pattern in sharing state with Click context objects. An environment variable — MYTOOL_DEBUG=1 — makes a good second toggle for CI, where adding a flag to every invocation is awkward.
Rich tracebacks for development
When you do want the traceback, a plain one is still hard to read. rich.traceback.install() replaces Python's default handler with a syntax-highlighted, framed version that shows local variables at each frame:
from rich.traceback import install
def enable_debug() -> None:
install(show_locals=True, width=120, suppress=[click])
Call enable_debug() only when --debug is set, so users never see it. show_locals=True prints the value of every local at each frame — invaluable for "how did path end up empty?" — and suppress=[click] hides frames from framework internals so the trace stays focused on your code. Rich is also what powers the progress bars and tables in interactive terminal UI with Rich, so if you already depend on it, this is free.
One caution: show_locals=True can dump secrets (tokens, passwords) sitting in local variables. Keep it behind the debug flag and never enable it in a mode that ships logs off the machine.
Writing actionable messages
A good error message answers two questions: what happened and what do I do now. Most tracebacks answer neither; most bad hand-written messages answer only the first.
| Weak | Actionable |
|---|---|
Error: invalid input | error: --replicas must be a positive integer, got '-3' |
Error: 404 | error: image 'app:v9' not found in registry; check the tag with 'mytool images' |
Permission denied | error: cannot write /etc/mytool.conf: permission denied; try sudo or --config ~/.config/mytool.conf |
Three rules that make messages land:
- Name the thing. Include the value, the path, the flag — the specific noun that failed. "config not found" is weaker than "config not found:
app.toml". - Suggest the fix. A
hint:line with the next command turns a dead end into a step. Users copy-paste hints. - Match the user's vocabulary. Say
--config, the flag they typed — notconfig_path, your internal variable name. The message lives in their world, not your code's.
The same principle drives validation errors at the argument boundary; advanced argument validation strategies shows how to turn a Pydantic ValidationError into a field-addressed, exit-2 message in the same house style.
Production notes
- Never bare
except:. It swallowsSystemExitandKeyboardInterrupt, so your exit codes and Ctrl-C stop working. CatchExceptionat the boundary, and letKeyboardInterrupthave its own arm. - Preserve the cause when translating.
raise CLIError(...) from exckeeps the chain so--debugstill shows the original exception underneath yours. - Chained exceptions in tracebacks. With Rich installed, a
raise ... from ...renders both frames clearly; without afrom, Python appends "During handling of the above exception," which confuses users — so translate deliberately. - Broken pipes.
mytool | headcloses the pipe early and can raiseBrokenPipeErrordeep in aprint. Catch it near the boundary and exit quietly instead of printing a traceback about it. - Structured diagnostics. Human-readable stderr messages and machine-readable logs are different channels. If you emit both, keep the friendly message for the user and route structured detail through logging — see structured logging for CLI apps, which keeps logs on stderr and off your data stream.