skip to content

When should a Python CLI restore SIGPIPE to signal.SIG_DFL, and when is that a mistake?

level: seniorimportance: should knowfreq 26%

answer

  1. Trading an exception for a signal
  2. Quiet like a Unix tool, but final
  3. Nothing runs after the process is killed
  4. Process-wide, so never inside a library
  5. SIG_DFL for SIGPIPE, exit status 141

basics

~20 s

Restore it only in a small standalone filter whose job is streaming to stdout, where dying silently like a Unix tool is correct. Never in a library, a service, or any program holding state it must flush, because death by signal skips all cleanup.

solid answer

~40 s

`signal.signal(signal.SIGPIPE, signal.SIG_DFL)` hands the process back to the Unix default: a write with no reader kills it immediately, printing nothing, and the shell reports 141. For a throwaway filter used inside pipelines that is exactly the desired behaviour and it removes both the traceback and the shutdown-flush complaint in one line. The cost is total: no `finally`, no context-manager exit, no `atexit`, no log line, no partial-result checkpoint. The disposition is process-wide and inherited by everything in it, so a library must never set it — it can only be a decision of the program's own entry point, made on the main thread. Anything long-running, anything writing to sockets, and anything holding expensive in-memory state should catch `BrokenPipeError` instead and exit deliberately.

code

python · 8 lines
python
import signal
import sys

if hasattr(signal, "SIGPIPE"):  # not defined on Windows
    signal.signal(signal.SIGPIPE, signal.SIG_DFL)

for line in sys.stdin:
    print(line.upper(), end="")

go deeper

for a junior

Know that a one-line signal change can make a script exit quietly in a pipeline, and that it is a deliberate choice rather than a default you should sprinkle into every program.

for a middle

Explain what changes: the kernel's default terminates the process, so the exception never appears, the exit status becomes 141, and no cleanup code runs at all.

for a senior

Judge it per program. Argue why a streaming filter can afford instant death and why anything holding state, sockets or an expensive working set must catch the exception, checkpoint and exit deliberately.

for a principal

Own the convention across your tools: which binaries are filters and may die on a broken pipe, which are services that must never, how exit codes are interpreted by the orchestrator, and where that policy is enforced.

## What the one-line change actually does `signal.signal(signal.SIGPIPE, signal.SIG_DFL)` undoes the interpreter's startup decision to ignore `SIGPIPE`. From that call onward the kernel's default applies again: a write into a pipe with no reader raises the signal, and the signal terminates the process immediately. Nothing is printed, no exception is raised, and the shell reports exit status 141 — 128 plus signal 13. For a small filter that is precisely right. The traceback disappears; so does the second complaint about the shutdown flush, because there is no shutdown. One line replaces a `try`/`except` plus descriptor juggling, and the tool now composes with the rest of the pipeline exactly like a native Unix program. ## What termination by signal costs The cost is that *nothing* runs afterwards. Not a `finally` block. Not `__exit__` on an open context manager. Not an `atexit` handler. Not the flush of any other open file, including your log. There is no last log line explaining why the process ended, because there is no opportunity to write one. Consider a stage in a genome-annotation pipeline that streams annotated records to a downstream consumer while holding a 2.4 GB working set of reference features it spent twenty minutes building. The consumer suffers an intermittent timeout and exits. With `SIG_DFL` restored, the annotation stage vanishes on the next write: no checkpoint of the records already computed, no note of which contig it had reached, no metric emitted, and an operator staring at exit status 141 with an empty log. Rebuilding that 2.4 GB working set is the entire cost of the restart, and the run has to begin again from the top. With the exception model kept, the same event is a caught `BrokenPipeError`: log that the consumer went away, write the checkpoint, release the resources, and exit with a status your orchestrator understands. The choice is not stylistic — it is about whether the process owns anything worth saving. ## Process-wide, and therefore not a library's decision A signal disposition belongs to the process, not to a module. If a library sets `SIG_DFL` on import, every future write to a departed socket peer anywhere in that process becomes fatal, including in code the library author never saw. That is a decision only the program's entry point may take, in `main`, close to the argument parsing, where a reader can see it. It also has to be taken on the main thread: `signal.signal` refuses to run anywhere else. Restoring the default in a worker is not merely bad practice, it raises. ## Where it is simply wrong Network services are the clearest case. Clients disconnect constantly, and each disconnection produces `EPIPE` on the next write to that socket. Under `SIG_DFL` the *server* dies because one client hung up, taking every other in-flight connection with it. The same logic rules it out for anything multi-tenant, any worker pulling from a queue, and any long-running daemon. It is also wrong wherever exit status carries meaning. A job runner that distinguishes "no work" from "failed" by exit code will see 141 for both the ordinary end of a pipeline and a genuine fault, since the signal status says nothing about intent. ## The middle path, and portability Most programs want neither extreme. Catch `BrokenPipeError` around the streaming loop, do whatever cleanup the program owns, neutralise the shutdown flush so the exit status you choose survives, and exit. That keeps Unix-like quiet behaviour on the outside while preserving the guarantees Python's model exists to provide. Finally, guard the call. `signal.SIGPIPE` does not exist on Windows, so a bare reference to it raises `AttributeError` there; test for the attribute before restoring it, and keep the exception handling in place for every platform, since the broken pipe itself is reported the same way everywhere.

  • Why must the restoration happen in main rather than at module import in a shared package?
    The disposition is a property of the process, so a library that sets it decides for every other component in that process, including socket writes it knows nothing about. It also only works on the main thread — `signal.signal` raises elsewhere. Making it an entry-point decision keeps it visible, reviewable, and confined to programs that genuinely want to die on a broken pipe.
  • What exit status does a caller see under each approach, and does that matter?
    Under `SIG_DFL` the process is killed by signal 13 and the shell reports 141. Under the exception approach you choose the status, provided the shutdown flush cannot fail; if it can, CPython replaces your status with 120. It matters wherever exit codes are contractual: a job runner cannot distinguish an ordinary early-exit reader from a real fault when everything reports 141.
  • A tool sometimes runs in a pipeline and sometimes writes to a file. Does that change the decision?
    It argues against the signal approach. A broken pipe is only routine in the pipeline case; when writing to a file, a failed write means something genuinely wrong, and dying without a message throws away the diagnosis. Handle the exception and decide on the merits, or restore the default only after confirming stdout is not a regular file.

It is the difference between a shop that locks up properly when the last customer leaves and one that is demolished mid-shift: both end the day, but only one still has the takings.

saying these in an interview costs you the question

  • Sets SIG_DFL inside a library or an imported module
  • Uses it in a network service, so one disconnect kills everything
  • Expects finally blocks or atexit to run after the signal
  • Assumes it works from any thread, not just the main one
  • References signal.SIGPIPE unguarded in cross-platform code

context