Why does CPython raise BrokenPipeError instead of dying on SIGPIPE like a C filter?
answer
- The interpreter changes a signal at startup
- The default for that signal is death
- Ignoring it turns death into an errno
- SIG_IGN installed for SIGPIPE, EPIPE returned
- So finally blocks and atexit still run
basics
~20 sCPython sets SIGPIPE to signal.SIG_IGN while the interpreter starts up. With the signal ignored, the failing write returns errno.EPIPE to the caller instead of terminating the process, and Python raises BrokenPipeError so the code can react.
solid answer
~40 sOn Unix the default disposition of `SIGPIPE` is to kill the process, which is why a C filter piped into a reader that exits just stops, silently, with the shell reporting status 141. CPython deliberately overrides that at startup by installing `signal.SIG_IGN` for `SIGPIPE`. Once the signal is ignored the kernel falls back to returning `EPIPE` from the write, and CPython converts that into `BrokenPipeError`. The motivation is that dying instantly on a signal skips every `finally` block, context-manager exit and `atexit` handler in the program, which is unacceptable for a general-purpose language. The visible cost is that a Python script in a pipeline prints a traceback where a Unix tool prints nothing. You can confirm the disposition with `signal.getsignal(signal.SIGPIPE)`, and children launched through `subprocess.Popen` get the default restored before exec.
code
pycon · 5 lines>>> import signal
>>> signal.getsignal(signal.SIGPIPE) is signal.SIG_IGN
True
>>> int(signal.SIGPIPE)
13go deeper
Recall that Python deliberately does not die when a pipe reader goes away, and that this is why you see a traceback where a Unix tool would print nothing at all.
Explain the mechanism precisely: SIG_IGN installed at startup, the kernel returning EPIPE instead of killing the process, and the exception raised from that errno. Know how to check the disposition.
Argue the tradeoff. Ignoring the signal buys you cleanup, flushing and logging on the way out; it costs you the quiet composability of Unix filters, and it means every streaming write path needs an explicit policy.
Frame it as a language-level default that your codebase either accepts or overrides. Decide where that override belongs, keep it out of libraries, and make sure spawned tools still get the normal Unix behaviour.
## The default the interpreter overrides On Unix, `SIGPIPE` (signal number 13) has a default disposition of *terminate*. When a process writes into a pipe or socket with no reader left, the kernel first raises `SIGPIPE` against the writer; if the signal is at its default, the process dies on the spot. This is exactly why classic Unix filters compose so cleanly: a producer feeding a reader that exits early is simply killed, printing nothing, and the shell reports exit status 141 — 128 plus the signal number. CPython does not want that. During interpreter startup it installs `signal.SIG_IGN` as the disposition for `SIGPIPE`. Ignoring the signal changes what the kernel does with the failed write: instead of killing the process it returns `-1` with errno set to `EPIPE`. The I/O layer sees that errno and raises `BrokenPipeError`. You can see the disposition for yourself before touching anything, with `signal.getsignal(signal.SIGPIPE)`. ## Why the interpreter makes that choice Death by signal is uncooperative. It unwinds nothing. A process killed by `SIGPIPE` runs no `finally` blocks, exits no context managers, flushes no other open file, and never reaches `atexit` handlers. For a 20-line C filter that owns no state that is fine. For a general-purpose language whose programs hold database transactions, temporary files, locks and half-written output files, silently vanishing mid-write is an unacceptable default. Turning the condition into an ordinary exception lets normal Python cleanup run and lets the program decide whether a vanished reader is fatal. There is a second reason. `EPIPE` also reaches code that has nothing to do with a shell pipeline — every socket write to a departed peer produces it. A server that died on signal every time a client disconnected would be unusable, so the exception model is the only workable one for the network case, and the interpreter applies one consistent policy. ## What it costs The cost is the one everybody meets first: a Python program in a pipeline is noisy where a Unix tool is quiet. Piping a long-running producer into a reader that takes only the first line prints a traceback ending in `BrokenPipeError: [Errno 32] Broken pipe`, and frequently a second complaint from the interpreter's own flush of stdout as it shuts down. Nothing is broken; the program is reporting an error condition that the Unix convention would have swallowed. ## The disposition is not a Python-level handler `SIG_IGN` is set at the operating-system level, not registered as a Python callable. That distinction matters. A Python signal handler installed with `signal.signal` merely sets a flag in C and runs your function later, between bytecodes, always on the main thread of the main interpreter. Ignoring is different: the kernel never delivers anything, so there is no main-thread constraint and no deferred callback. A write failing in a worker thread raises `BrokenPipeError` right there, in that thread, from the call that failed. ## Inheritance across exec Signal dispositions survive `exec`, and an *ignored* signal stays ignored in the new program image. If Python simply handed its process image to a child, every Unix tool it launched would inherit an ignored `SIGPIPE` and start misbehaving in pipelines. `subprocess.Popen` guards against this: by default it restores signals that Python set to `SIG_IGN` back to `SIG_DFL` in the child, just before exec. That default is worth knowing — turning it off is how a spawned tool ends up mysteriously surviving, and complaining about, a broken pipe it should have died on. ## Portability Windows has no `SIGPIPE`; the constant does not exist in the `signal` module there, so code that touches it must be guarded. The failure itself still surfaces: a write to a closed Windows pipe returns an error code that CPython maps onto the same `BrokenPipeError`. That is a useful property — you can write portable handling around the exception, while any code that manipulates the *signal* is Unix-only by nature.
- Which thread sees BrokenPipeError in a multithreaded program, and why is that not a signal-delivery question?The thread that issued the failing write sees it. Because `SIGPIPE` is ignored rather than handled, nothing is ever delivered to a Python-level handler, so the usual rule that signal callbacks run only on the main thread of the main interpreter never comes into play. The kernel just returns `EPIPE` to whichever thread called `write`, and the exception is raised there.
- Does a child process launched by subprocess.Popen inherit Python's ignored SIGPIPE?Not by default. `Popen` restores every signal that Python had set to `SIG_IGN` back to `SIG_DFL` in the child just before exec, controlled by its `restore_signals` parameter, which is true by default. Turn it off and a spawned Unix tool inherits an ignored `SIGPIPE`, so it survives a broken pipe and starts emitting write errors instead of exiting quietly.
- What would break if CPython left SIGPIPE at its default disposition?Any program writing to a departed peer would be killed outright: no `finally` blocks, no context-manager exits, no `atexit` handlers, no flush of other open files. Network servers would die whenever a client disconnected mid-response, and a long-running job would lose unsaved state with no log line explaining why.
saying these in an interview costs you the question
- Thinks Python installs a Python-level handler for SIGPIPE
- Says the traceback proves the pipeline is misconfigured
- Believes SIGPIPE is delivered and then converted to an exception
- Assumes children inherit the ignored disposition by default
- Claims the same signal mechanism exists on Windows