skip to content

Why does a Python filter that catches BrokenPipeError still print an error at exit?

level: seniorimportance: should knowfreq 32%

answer

  1. Your handler is not the last writer
  2. Something still has to happen at exit
  3. Buffered bytes are flushed during shutdown
  4. No frame left to catch it, exit 120
  5. Point descriptor 1 at the null device

basics

~20 s

Because sys.stdout still holds buffered bytes. As the interpreter shuts down it flushes the standard streams, that flush hits the dead pipe, and the failure is reported outside your try block as an ignored exception. The process then exits with status 120.

solid answer

~40 s

Catching the exception around your loop only handles the write that happened to raise. Whatever was still sitting in the stdout buffer is flushed later, during interpreter finalization, and that second failure cannot be caught by any `except` in your code: it is reported through the unraisable-exception path, which on 3.14 prints `Exception ignored while flushing sys.stdout:` followed by the exception. Worse, it overrides your exit status — even after `sys.exit(1)` the process exits **120**, the code CPython uses when flushing standard streams fails. The documented fix is to make the final flush harmless: in the handler, open the null device and duplicate it over file descriptor 1, then exit. `os._exit` also works because it skips finalization entirely, but it skips your cleanup too.

code

python · 11 lines
python
import os
import sys

try:
    for i in range(10_000_000):
        print(i)
except BrokenPipeError:
    # descriptor 1 is stdout: send the shutdown flush to the null
    # device so finalization cannot fail and override the exit code
    os.dup2(os.open(os.devnull, os.O_WRONLY), 1)
    sys.exit(1)

go deeper

for a junior

Know that catching the exception once may not be the end of it: output is buffered, and whatever is left over gets written when the program exits, which can fail again.

for a middle

Explain that the shutdown flush happens outside any frame you control, is reported as an ignored exception rather than a traceback, and that the fix is to leave stdout in a state the final flush can survive.

for a senior

Recognise the 120 exit status on sight and know it overrides your own. Be able to fix a fleet of filters properly rather than muting stderr, and to explain why muting hides real write failures too.

for a principal

Decide the house rule for streaming CLI tools: which exit codes callers may depend on, whether a shared entry-point wrapper installs this handling once, and how job runners should interpret an unexpected 120.

## Two failures, not one A broken pipe produces two distinct write failures in a typical script, and they need different treatment. The first is the write inside your loop. Stdout to a pipe is block-buffered, so most `print` calls only fill an in-process buffer; eventually one of them spills the buffer to the pipe, the write fails with `EPIPE`, and `BrokenPipeError` propagates out of that `print`. An `except BrokenPipeError` around the loop catches this one cleanly. The second happens after your code has finished. At that point the buffer usually still holds bytes — the exception interrupted the flush partway, and anything printed since remains queued. During finalization CPython flushes the standard streams so that output is not lost. That flush writes to the same dead pipe and fails again, and this time there is no user frame anywhere on the stack to catch it. ## Why no except clause can help The finalization flush is not part of your program's control flow. There is no statement to wrap: the interpreter is tearing itself down. CPython treats the failure as an *unraisable* exception, the same category as an error inside a `__del__` method, and hands it to the unraisable hook, which prints a short report to stderr rather than a traceback. On 3.14 that report reads `Exception ignored while flushing sys.stdout:` followed by `BrokenPipeError: [Errno 32] Broken pipe`. The exact wording has changed across releases, so match on behaviour rather than on that string. The hook is a real extension point, and replacing it does suppress the message — but suppressing the report does not undo the failure, which brings us to the exit status. ## The 120 that overwrites your exit code When the interpreter cannot flush the standard streams during finalization, it reports the process failure with exit status **120**. This overrides whatever status your code asked for: a script that catches `BrokenPipeError` and calls `sys.exit(1)` still exits 120 when the buffer could not be drained. That is a genuine operational trap. A supervisor or job runner keyed on specific exit codes sees an unfamiliar 120 rather than the 1 you intended, and 120 is easily mistaken for something the program itself defined. ## The fix that actually works Make the final flush succeed by giving it somewhere harmless to write. In the handler, open the null device and duplicate that descriptor over file descriptor 1, the descriptor stdout writes through. The buffered bytes are then flushed into nothing, finalization succeeds, no ignored-exception message is printed, and your chosen exit status survives: ```python except BrokenPipeError: os.dup2(os.open(os.devnull, os.O_WRONLY), 1) sys.exit(1) ``` This is the shape the standard library's own documentation recommends for the broken-pipe case, and it is worth understanding rather than copying: you are not hiding the error, you are removing the reason the shutdown flush would fail. A second option is `os._exit`, which terminates immediately without running finalization at all. It certainly avoids the message, and it lets you choose the exit status, but it also skips `atexit` handlers and the flushing of every *other* open file. Use it only when you know nothing else needs to be written. What does not work is catching the exception and returning normally, or replacing the unraisable hook and calling the problem solved. The first leaves the buffer full and gets you the 120; the second silences the report while still getting you the 120. ## Diagnosing it in the wild The signature is unmistakable once you know it: a program that appears to handle broken pipes correctly, produces the right output, and yet exits 120 with a one- or two-line stderr complaint that is not a traceback. The absence of a traceback is the clue — a traceback means the exception escaped your code; an `Exception ignored` line means it escaped the *interpreter's* code, after yours had finished. Check whether stdout is a pipe, whether the writer buffers, and whether the handler leaves the stream in a state the final flush can survive.

  • Your script catches the exception and calls sys.exit(1), but the job runner records 120. Why?
    Because the interpreter could not flush the standard streams while shutting down. CPython reports that failure with exit status 120, and it replaces the status your code requested. The buffer was still holding output for a pipe with no reader. Draining it somewhere harmless before exiting — duplicating the null device over descriptor 1 — lets your own status survive.
  • Would replacing sys.unraisablehook be a legitimate fix for the message?
    It suppresses the report, not the failure. The flush still fails, so the process still exits 120 and any buffered output is still lost. It is also a process-wide hook that would then swallow unrelated ignored exceptions, such as errors raised inside a `__del__`. Fix the cause: make the final flush succeed, or skip finalization deliberately.
  • When is os._exit an acceptable way out of this?
    When the program has nothing left to persist. `os._exit` terminates immediately without finalization, so there is no flush to fail and you keep your exit status, but `atexit` handlers do not run and no other open file is flushed. In a tiny filter that is fine; in anything holding a log, a temporary file or a database handle it silently loses work.

You can apologise for the letter you failed to deliver, but the sorting office still has a sack of yours to deliver after you have gone home, and it will file its own complaint.

saying these in an interview costs you the question

  • Thinks a try/except around the loop covers the shutdown flush
  • Cannot explain where exit status 120 comes from
  • Treats the ignored-exception line as an ordinary traceback
  • Suppresses the message with a hook and calls it fixed
  • Reaches for os._exit without noticing atexit and other files

context