How should a Python CLI report failure — sys.exit codes, and sys.stderr versus sys.stdout?
answer
- One small integer is the whole contract
- It raises rather than stopping immediately
- Below Exception in the hierarchy? No, beside it
- Results are pipeable; diagnostics are not
- Redirected output can sit in a buffer
basics
~20 sExit 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 linesimport 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
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.
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.
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.
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