skip to content

How should a Python CLI report failure — sys.exit codes, and sys.stderr versus sys.stdout?

level: middleimportance: must knowfreq 50%

answer

  1. One small integer is the whole contract
  2. It raises rather than stopping immediately
  3. Below Exception in the hierarchy? No, beside it
  4. Results are pipeable; diagnostics are not
  5. Redirected output can sit in a buffer

basics

~20 s

Exit 0 for success and a small non-zero code for failure via sys.exit, which raises SystemExit. Send results a caller might pipe to sys.stdout, and diagnostics, warnings and errors to sys.stderr so they survive redirection.

solid answer

~40 s

`sys.exit(n)` raises `SystemExit`; the interpreter unwinds, runs cleanup, and exits with status `n`. `0` or `None` means success, a small non-zero integer means failure, and passing anything else — typically a string — prints it to `sys.stderr` and exits with status `1`, which makes `sys.exit("config error: INGEST_URL unset")` a compact way to die well. `SystemExit` inherits from `BaseException`, not `Exception`, precisely so that a broad `except Exception` around your main loop does not swallow a requested exit; a bare `except:` still will. Stream discipline is the other half: machine-readable output goes to `sys.stdout` so a caller can pipe it, everything else — progress, warnings, errors, the traceback of an unhandled exception — goes to `sys.stderr`. Conventionally the cleanest shape is a `main()` that *returns* an integer and one `sys.exit(main())` at the entry point.

code

python · 12 lines
python
import sys


def main(argv: list[str]) -> int:
    if len(argv) < 2:
        print(f"usage: {argv[0]} SPOOL_DIR", file=sys.stderr)
        return 2
    print(argv[1])
    return 0


sys.exit(main(["collector", "/var/spool/telemetry"]))

go deeper

for a junior

Recall that 0 means success and non-zero means failure, that sys.exit(2) sets the status, and that error messages belong on sys.stderr while results belong on sys.stdout.

for a middle

Explain that sys.exit raises SystemExit, that SystemExit sits under BaseException so except Exception does not catch it, and that a non-integer argument is printed to the error stream with status 1. Know that only the low 8 bits reach the shell on Unix.

for a senior

Demonstrate production judgment: a documented set of exit codes, results on the output stream so the tool composes in pipelines, buffering understood well enough that container logs are not empty, and a main() returning an int so failure paths are testable.

for a principal

Own the convention across a fleet of tools — what each code means, how failures are distinguished for an orchestrator's retry logic, and where structured machine-readable output belongs relative to human diagnostics.

## The exit status is your API to the shell A command-line program's contract with whatever invoked it is one small integer. Shells, CI runners, orchestrators, `make`, and `&&`/`||` chains all branch on it. `0` means success. Any non-zero value means failure. Nothing else in your output is checked by default. `sys.exit(status)` is how Python code requests one. Its argument is interpreted as follows: * `sys.exit()` or `sys.exit(None)` — status `0`. * `sys.exit(0)` — success. `sys.exit(2)` — that failure code. * `sys.exit("message")` — the object is printed to `sys.stderr` and the status is `1`. Only small integers survive: the operating system takes the low 8 bits of the value on Unix, so `sys.exit(256)` reaches the shell as `0` — a genuinely nasty bug when a program returns a count of failures as its status. Keep codes in the 1–125 range and give each one a meaning. On Unix the `os` module also exposes conventional codes such as `os.EX_OK` and `os.EX_USAGE` if you want named constants, though most tools simply document their own numbers. ## sys.exit raises; it does not stop the process on the spot `sys.exit` raises `SystemExit`. That is the single most interviewed fact about it, because everything surprising follows from it: * The exception propagates like any other, so enclosing `finally` blocks and context managers still run. That is the *good* property: buffers are flushed and resources released. * `SystemExit` derives from `BaseException`, not from `Exception`. A `try: ... except Exception:` wrapper around your main routine — the one that logs unexpected errors and keeps going — will not catch it. A bare `except:` clause, or `except BaseException`, will, and that is how a program ends up ignoring its own exit request. * Calling it inside a thread other than the main thread ends only that thread; the process keeps running. ```python try: sys.exit(3) except Exception: print("not reached") ``` If you want to end the process immediately without unwinding — the classic case being a forked child that must not run the parent's cleanup — that is `os._exit`, which takes a status and skips everything. It is the exception, not the default. ## Two streams, two audiences `sys.stdout` carries the program's **output**: the thing a caller wants to capture, pipe into another command, or parse. `sys.stderr` carries everything **about** the run: progress, warnings, errors, the traceback of an unhandled exception. The test is simple — if a user redirects output to a file, which lines must still appear on the terminal? Those are the `sys.stderr` lines. Getting this wrong has a specific and common consequence. A tool that prints "connecting…" on `sys.stdout` corrupts every pipeline that consumes its output, and the corruption is invisible until someone parses the result. In Python, `print(..., file=sys.stderr)` is the one-liner; logging handlers default to the error stream for exactly this reason. An unhandled exception is handled for you: the interpreter prints the traceback to `sys.stderr` and exits with status `1`. So "just let it crash" already produces a defensible failure signal — it is merely a noisy one, which is why deliberate error paths use a message plus an exit code instead. ## Buffering, and why output goes missing The standard streams are text wrappers over buffered binary layers, and the buffering mode depends on what they are attached to. When `sys.stdout` is a terminal it is line-buffered, so each line appears as it is written. When it is redirected to a file or a pipe it is block-buffered, and output can sit unwritten for a long time — which is why a container's logs look empty until the process ends. Remedies, in rough order of preference: pass `flush=True` to `print` for the lines that matter, set `PYTHONUNBUFFERED` in the environment for the whole process, or run the interpreter with its unbuffered flag. `sys.stderr` is line-buffered even when it is not a terminal, a change made in Python 3.9 that means diagnostics arrive promptly regardless of redirection. Because `sys.exit` unwinds normally, the interpreter flushes the streams on the way out — another reason to prefer it over an abrupt exit. ## The shape to write ```python import sys def main(argv: list[str]) -> int: if len(argv) < 2: print(f"usage: {argv[0]} SPOOL_DIR", file=sys.stderr) return 2 print(argv[1]) return 0 if __name__ == "__main__": sys.exit(main(sys.argv)) ``` `main` returns an integer and is therefore trivially testable — a test calls it and asserts on the return value instead of catching `SystemExit`. `sys.argv` is the raw argument list, with `sys.argv[0]` the program name as invoked, so `len(sys.argv)` and indexing behave as any list would. Exactly one place in the program converts a result into an exit status. And a program that ends this way exits cleanly, flushed, with a status its caller can branch on.

  • Why does a broad `except Exception` around a program's main loop not swallow sys.exit?
    `sys.exit` raises `SystemExit`, which derives from `BaseException` rather than `Exception`, specifically so that catch-all error handling does not defeat a deliberate exit. A bare `except:` clause or `except BaseException` still catches it — one more reason a bare `except` is a defect. The same hierarchy choice protects `KeyboardInterrupt`.
  • A service's container logs are empty until it exits. What is happening and how do you fix it?
    `sys.stdout` is block-buffered when it is a pipe rather than a terminal, so lines accumulate instead of appearing. Set `PYTHONUNBUFFERED` in the environment, run the interpreter unbuffered, or pass `flush=True` on the writes that matter. `sys.stderr` is unaffected: it has been line-buffered even when redirected since Python 3.9.
  • What exit status does an unhandled exception produce?
    The interpreter prints the traceback to `sys.stderr` and exits with status `1`. So crashing already signals failure correctly — it is just indiscriminate, since every kind of failure collapses to `1` and the caller gets a stack trace instead of a message. Deliberate error paths print one clear line to the error stream and exit with a code that distinguishes usage errors from runtime failures.

saying these in an interview costs you the question

  • Thinks sys.exit stops the process immediately without unwinding
  • Expects except Exception to catch SystemExit
  • Prints progress and errors on the standard output stream
  • Returns a count of failures as the exit status
  • Believes any non-zero status is reported verbatim to the shell
  • Calls sys.exit in a worker thread expecting the process to end

context