A command-line tool is judged as much by how it fails as by how it succeeds. When something goes wrong, a script piping into your CLI, a CI job gating a deploy, or a human at 2 a.m. all need the same three things: a truthful exit code, a message that says what to do next, and no wall of Python internals. This overview shows how to design failure on purpose — classifying errors, keeping stdout and stderr honest, and installing one error boundary so every command exits cleanly.
TL;DR
- Exit code
0means success; anything non-zero means failure. That number is the only part of your output a shell reads, so treat it as your CLI's real API. - Sort every failure into three buckets: usage errors (bad invocation, exit
2), expected runtime errors (file missing, network down — exit1or a specific code), and unexpected bugs (exit1, print a traceback only under--debug). - Send results to stdout, send diagnostics and errors to stderr, so
mytool | jqnever chokes on a warning. - Signal failure with the right tool:
raise SystemExit(code),raise typer.Exit(code=...), orraise click.ClickException(msg)— notprint()plussys.exit. - Wrap
main()in one top-level error boundary that maps exceptions to messages and codes, so no command leaks a raw traceback.
Exit codes are the CLI's API for scripts
Humans read your error text. Machines read your exit code, and nothing else. The moment your tool is used in a pipeline — mytool build && mytool deploy, a Makefile target, a GitHub Actions step — the surrounding shell decides what happens next purely from $?, the status of the last command.
$ mytool deploy --env prod
$ echo $?
0
That && chain only advances when the left side exits 0. If your tool prints Error: could not connect to the screen but still exits 0, the deploy proceeds on a lie. Getting the number right is not a nicety; it is the contract every automation depends on.
A minimal, honest program looks like this:
import sys
def main() -> int:
if not config_exists():
print("error: no config found; run 'mytool init' first", file=sys.stderr)
return 1
run()
return 0
if __name__ == "__main__":
sys.exit(main())
Returning an int from main() and handing it to sys.exit() keeps the exit logic in one place and makes the function trivially testable. Which specific numbers to use — and when the richer sysexits.h codes earn their keep — is its own topic; see choosing exit codes for CLI tools.
A failure taxonomy: usage, expected, unexpected
Every failure your CLI can hit falls into one of three categories, and each wants different handling.
Usage errors are the caller's fault at the invocation level: an unknown flag, a missing required argument, a value that fails validation. The user needs to fix the command line and retry. Both argparse and Click already exit 2 for these and print a short usage hint, so match that convention. Argument-level validation belongs here — see advanced argument validation strategies for turning a ValidationError into a clean exit-2 message.
Expected runtime errors are conditions your code anticipates but cannot prevent: a file that isn't there, a network timeout, a permission denied, an API returning 409. These are not bugs — they are the world being the world. Catch them, print a one-line explanation, and exit non-zero (commonly 1, or a specific code so scripts can branch).
Unexpected bugs are the case you did not foresee: an AttributeError, a KeyError deep in your own logic. Here a traceback is genuinely useful — but only to whoever can fix the code. For everyone else it is noise that hides the real message. The answer is to keep the traceback available behind a flag and show a calm one-liner by default.
class ExpectedError(Exception):
"""A failure we anticipated; message is safe to show the user."""
def load_project(path: str) -> dict:
try:
with open(path, encoding="utf-8") as fh:
return parse(fh.read())
except FileNotFoundError:
raise ExpectedError(f"project file not found: {path}")
except PermissionError:
raise ExpectedError(f"cannot read {path}: permission denied")
The discipline is to raise your own ExpectedError for the anticipated cases and let everything else bubble up as a genuine bug. The friendly error messages and tracebacks guide builds this into a full boundary.
Keep stdout and stderr honest
The single most common CLI hygiene bug is writing errors to stdout. Stdout is for your tool's output — the JSON, the table, the value another program will consume. Stderr is for everything else: errors, warnings, progress, prompts.
import sys
print(json.dumps(result)) # data → stdout
print("warning: cache stale, refetching", file=sys.stderr) # noise → stderr
Why it matters: users pipe your data into other tools. If a warning lands on stdout, mytool export | jq . fails to parse because a human sentence is now sitting in the middle of the JSON stream. Keeping diagnostics on stderr means the pipe stays clean while the human still sees the message on their terminal. This separation also lets someone run mytool export > out.json and still watch progress and errors scroll past live. Structured diagnostics deserve the same care; the structured logging for CLI apps section covers routing logs to stderr so they never pollute your data channel.
Signalling failure: SystemExit, Exit, and ClickException
Python gives you several ways to end a program, and the difference matters.
sys.exit(code) raises SystemExit, which unwinds the stack (running finally blocks and context managers) before the interpreter exits with code. Because it is an exception, a stray except Exception: can swallow it — so catch Exception, never bare except:, if you want exits to work.
In Click, prefer raise click.ClickException(message). Click catches it, prints Error: message to stderr, and exits 1 automatically — no manual sys.exit needed:
import click
@click.command()
@click.argument("name")
def greet(name: str) -> None:
if not name.isascii():
raise click.ClickException("name must be ASCII")
click.echo(f"hello {name}")
Subclass ClickException and override exit_code to change the number, or raise click.UsageError to get exit 2 with a usage hint.
In Typer, raise typer.Exit(code=1) ends the command with that code, and typer.BadParameter gives you the usage-error path. Typer and Click share the same underlying machinery, so the mental model carries across both. If you are choosing between the frameworks, Typer vs Click compares them head to head.
One top-level error boundary
Tie it together with a single boundary around your entry point. Every command flows through it, so no individual command has to remember to handle failure:
import sys
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
run(args)
return 0
except ExpectedError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("aborted", file=sys.stderr)
return 130 # 128 + SIGINT(2)
except Exception as exc: # a real bug
if args.debug:
raise # full traceback for developers
print(f"internal error: {exc} (run with --debug for details)", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
Three things earn their place here: ExpectedError becomes a tidy one-liner, KeyboardInterrupt exits 130 (the shell convention for a Ctrl-C'd process) instead of dumping a traceback, and genuine bugs stay quiet unless --debug is set. Click and Typer give you most of this for free — but a hand-rolled argparse CLI needs the boundary written out, and even framework apps benefit from wrapping unexpected exceptions.
Production notes
- Test the number, not just the text. Assert exit codes in CI. Click's
CliRunnerexposesresult.exit_code; for a subprocess, checkcompleted.returncode. A tool that prints the right error but exits0will silently break pipelines. finallystill runs onSystemExit. Temp-file cleanup and lock release in afinallyblock or context manager execute during a normalsys.exit, but are skipped onos._exit()— never use the latter to bail out.- Broken pipes. When a downstream consumer closes early (
mytool | head), Python may raiseBrokenPipeError. Catch it near your boundary and exit quietly rather than printing a traceback. - Windows. Exit codes are 32-bit there and signal-based codes like
130are a Unix convention; keep your meaningful codes in the0–125range for portability. - Never exceed 255. Exit codes wrap modulo 256, so
sys.exit(256)becomes0— a silent success. Keep custom codes small and reserved values clear.
An exception hierarchy worth having
Exit codes work best when commands do not choose them. One small module gives every layer a vocabulary and gives the boundary something to map:
# src/mytool/errors.py
class MytoolError(Exception):
"""Base class for every expected failure. Never raised directly."""
class UsageError(MytoolError):
"""The command line was wrong in a way the parser could not catch."""
class ConfigError(MytoolError):
"""Configuration is missing, unreadable or invalid."""
class InputDataError(MytoolError):
"""A file or payload the user supplied is malformed."""
class RemoteUnavailable(MytoolError):
"""A service the tool depends on could not be reached."""
Four properties make this worth the file. Core functions can raise without importing a CLI
framework. Tests can assert on a type rather than on a message. Callers that embed your package
can catch MytoolError and handle everything expected in one clause. And the mapping to exit
codes lives in one dictionary:
EXIT_CODES = {
UsageError: 2,
InputDataError: 65,
RemoteUnavailable: 69,
ConfigError: 78,
MytoolError: 1, # anything else expected
}
Order matters when you look codes up, because subclasses must be checked before their parents:
def exit_code_for(exc: MytoolError) -> int:
for cls, code in EXIT_CODES.items():
if isinstance(exc, cls):
return code
return 1
The boundary, in full
One function owns termination for the whole program. Everything else raises.
import sys
import typer
from mytool.cli import app
from mytool.errors import MytoolError
def main() -> None:
try:
app()
except MytoolError as exc:
typer.secho(str(exc), fg=typer.colors.RED, err=True)
sys.exit(exit_code_for(exc))
except KeyboardInterrupt:
typer.secho("cancelled", err=True)
sys.exit(130) # 128 + SIGINT, what the shell expects
except BrokenPipeError:
sys.stderr.close() # `mytool list | head` is not an error
sys.exit(0)
except Exception:
if "--debug" in sys.argv:
raise
typer.secho("internal error — re-run with --debug for the traceback", err=True)
sys.exit(70)
Four clauses, four categories, and each is a decision you make once. Note the two that most tools
miss. KeyboardInterrupt should exit 130, not 0 and not with a traceback — a cancelled run
that reports success is how a wrapper script decides the work is done. BrokenPipeError
happens whenever someone pipes your output into head, and printing a traceback for it makes a
perfectly normal shell idiom look like a crash.
Because app() is the only call inside the try, there is exactly one place in the codebase that
calls sys.exit. A quick test enforces it:
def test_no_command_calls_sys_exit():
for path in Path("src/mytool/commands").rglob("*.py"):
assert "sys.exit" not in path.read_text()
Documenting the codes
An exit code nobody knows about is worth no more than exit 1. Two places to write them down, and both are cheap.
In --help, as an epilog:
exit codes:
0 success
1 the operation failed
2 the command line was invalid
65 input data was malformed
69 a required service was unavailable
78 configuration was invalid
130 cancelled by the user
And in the changelog, whenever one changes. Exit codes are as much a public interface as flag names: a script somewhere is testing for 2, and altering what that means silently changes a conditional in someone's pipeline. Add a new code rather than repurposing an old one, and treat a change as a major version bump.
Testing them is straightforward, and worth doing for every documented code:
@pytest.mark.parametrize("argv,expected", [
(["sync", "./data"], 0),
(["sync"], 2), # missing argument
(["sync", "./nope"], 66), # input file missing
(["--config", "bad.toml", "sync", "."], 78),
])
def test_documented_exit_codes(argv, expected):
assert CliRunner().invoke(app, argv).exit_code == expected
Cleaning up on the way out
A tool that exits mid-operation should leave the machine in a state someone can reason about. Two mechanisms cover almost all of it, and both belong near the boundary rather than inside commands.
Context managers for anything acquired. A temporary directory, a lock file, an open
connection, a modified terminal state — all of it should be released by the with block that
created it, so an exception unwinds cleanly without an explicit cleanup path:
from contextlib import contextmanager
@contextmanager
def exclusive_lock(path: Path):
lock = path.with_suffix(".lock")
try:
lock.touch(exist_ok=False)
except FileExistsError:
raise MytoolError(f"another run holds {lock}; remove it if that is stale") from None
try:
yield
finally:
lock.unlink(missing_ok=True)
Atomic writes for anything produced. Writing directly to the destination means an interrupted run leaves a half-written file that looks complete. Write to a temporary file beside it and rename, which is atomic on every platform that matters:
def write_atomic(path: Path, data: str) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(data, encoding="utf-8")
tmp.replace(path)
What not to do is register cleanup with atexit or trap signals globally. Both run at
unpredictable times relative to your own error handling, and both make the program harder to
reason about than a finally in the place that owns the resource. The one exception is restoring
a terminal you deliberately altered — hiding the cursor, entering an alternate screen — where a
finally around the whole run is the right home.
Frequently asked questions
Should a command ever call sys.exit directly?
No. It couples the command to a decision that belongs to the program as a whole, and it makes the command untestable as a function. Raise a domain exception and let the boundary translate it — the exception carries the message, the boundary carries the policy.
What is the right code for "nothing to do"?
Usually 0. A sync with no changed files did what it was asked to do. Reserve a distinct code for
the "no results" case only if scripts genuinely need to branch on it — grep uses exit 1 for no
matches, and if your tool is a filter in that mould, following the convention is reasonable.
Document whichever you pick.
How do I signal partial failure?
With a distinct code, and only if it is genuinely useful: 3 for "some items failed" lets a wrapper retry just those. Do not overload 1, which callers read as total failure, and always print a summary saying how many succeeded — a code without the detail tells the user nothing actionable.
Should warnings affect the exit code?
No. A warning means the work completed in a way worth mentioning; if it should stop the pipeline, it is an error. A tool that exits non-zero for deprecations breaks every CI job that uses it, which is why deprecation notices belong on stderr with an exit code of 0.
Where do exceptions raised by libraries fit?
Catch them at the boundary of your own code and re-raise as a domain error with context.
A bare httpx.ConnectError reaching the user tells them about your dependencies; a
RemoteUnavailable("could not reach the deploy API at …") tells them what to check. Keep the
original with raise ... from exc so --debug still shows the chain.
Does an error boundary make debugging harder?
Only if it swallows the traceback unconditionally, which is why --debug exists. With the flag,
the boundary re-raises and Python prints the chain exactly as it would without any handling at all;
without it, users get a sentence. You lose nothing and they gain a great deal, provided the hint
telling them the flag exists is in the message.