skip to content

A Python video-metadata extractor's piped log stops short when the process is killed. Why, and how do you fix it?

level: seniorimportance: should knowfreq 45%

answer

  1. The log is shorter than the work done
  2. Ask what the stream is attached to
  3. Compare the two standard streams' tails
  4. Killed processes skip interpreter finalisation
  5. Roughly one block of lines vanishes

basics

~20 s

Piped stdout is block buffered, so a whole block of already-printed lines, up to 128 KiB on Python 3.14, dies in the process's own buffer when a signal kills it. Make stdout line buffered instead.

solid answer

~40 s

The log is not a record of what the program did, it is a record of what escaped its buffer. Piped or captured stdout is block buffered, so the tail, up to a full block of `io.DEFAULT_BUFFER_SIZE` bytes, is still in userspace when a `signal.SIGKILL` or an unhandled `signal.SIGTERM` ends the process without running interpreter finalisation, and it is lost. The classic tell is a log that ends one record short of the truth, or mid-line, while `sys.stderr` output from the same moment is present, because stderr is line buffered even when redirected. Fix it at the producer: launch with `-u` or `PYTHONUNBUFFERED=1`, or call `sys.stdout.reconfigure(line_buffering=True)` once at startup so every completed line leaves immediately. Line buffering is usually the right cost.

code

python · 11 lines
python
import signal
import sys

sys.stdout.reconfigure(line_buffering=True)

def _stop(signum, frame):
    print("stopping on", signal.Signals(signum).name)
    sys.exit(0)

signal.signal(signal.SIGTERM, _stop)
print("worker ready", sys.stdout.line_buffering)

go deeper

for a junior

Recall the core fact: output written to a pipe or file sits in a buffer, and a killed process loses it. print(..., flush=True) is the fix you can apply immediately.

for a middle

Explain why a clean exit loses nothing while a signal loses the tail, and name the three producer-side fixes: line buffering via reconfigure, -u or PYTHONUNBUFFERED, and per-call flush.

for a senior

Demonstrate the diagnosis under pressure: compare stdout and stderr tails, confirm the stream mode at startup, and reason about how many lines the buffer size implies you lost before touching any code.

for a principal

Take a position on observability guarantees: which logs are audit trails that must be line buffered, which are chatter, and where progress state should live in a store rather than in a stream that can be truncated.

## The symptom, precisely An extraction worker walks a queue of media files, prints one line per file it has indexed, and its stdout is captured by whatever supervises it. The process is killed, and the log ends one file short of what the downstream store actually contains. The obvious reading, that the last write never happened, is wrong, and chasing it leads you into the wrong code: people start auditing the loop bounds for an off-by-one when the loop was correct and the *log* is the thing that is short. ## Why the tail is missing Captured stdout is not a terminal, so at interpreter startup CPython built `sys.stdout` block buffered: writes accumulate in an `io.BufferedWriter` of `io.DEFAULT_BUFFER_SIZE` bytes, 128 KiB on Python 3.14 and only reach the descriptor when the buffer fills, when something flushes, or when the interpreter flushes the standard streams during normal shutdown. That last clause is the whole story. Normal shutdown happens on a clean return, on `sys.exit()`, and even on an uncaught exception. It does **not** happen when the process is killed by a signal whose default disposition terminates it, and it does not happen through `os._exit()`. `signal.SIGKILL` cannot be handled at all. `signal.SIGTERM` can, but if you never installed a handler its default action ends the process on the spot, so the polite stop signal that an orchestrator sends before the hard one is enough to lose your buffer. Whatever was in that block window is discarded with the process's memory. The amount you lose is not fixed. It is however many lines happened to be in the buffer, which depends on line length: against Python 3.14's 128 KiB block, 90-byte lines mean well over a thousand lost, while 900-byte structured records mean about a hundred and fifty. That variability is why the same bug looks like a different bug on different days. ## The diagnostic that separates the two hypotheses One comparison settles it. `sys.stderr` is line buffered even when it is not a terminal, so anything the program wrote to stderr around the same instant is present. If stderr shows the worker still going after the last stdout line, the work happened and the log lost it. Two more confirmations are cheap: `sys.stdout.isatty()` and `sys.stdout.line_buffering` printed at startup tell you which branch the stream took, and reproducing the run interactively makes the symptom vanish entirely, which is itself the proof, because the only thing that changed is that stdout is now a terminal. ## Fixing it at the producer Buffering belongs to the writing process, so every real fix is on that side. 1. **Line buffering, set in code.** `sys.stdout.reconfigure(line_buffering=True)` early in startup. Each completed line leaves immediately, multi-write `print()` calls still coalesce, and lines never tear in half. This is usually the right default for a log-emitting worker. 2. **Unbuffered, set from outside.** `-u` on the interpreter, or `PYTHONUNBUFFERED=1` in the environment. Use it when you do not control the entry point, which in a container is common. It costs a syscall per write. 3. **Per-call flush.** `print(line, flush=True)` on the statements that matter. Right for a few latency-sensitive markers, wrong as a blanket habit. A fourth measure is complementary rather than an alternative: install a `signal.SIGTERM` handler that shuts down cleanly. That turns a graceful stop into a normal exit, which flushes, and gives you a place to finish the file in flight. It does nothing for `signal.SIGKILL`, so it is not a substitute for buffering discipline. ```python import signal import sys sys.stdout.reconfigure(line_buffering=True) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) ``` ## The judgement part The reason interviewers like this scenario is that it separates two habits. One candidate turns on unbuffered output everywhere and moves on. The other asks what the log is for. If it is the audit trail that tells you which media files were processed, buffering is a correctness problem in the observability path and line buffering is mandatory; if it is chatty progress output, the default is fine and the syscalls are not worth paying. In a pipeline made of many services this is a per-service call, not a fleet-wide reflex, though a fleet-wide default of line-buffered stdout is a defensible standard precisely because the failure mode is silent: nothing errors, the log is simply, occasionally, short. And the corollary worth saying out loud: a log with buffering in front of it is not a durable record of progress. If another stage must know exactly how far the extractor got, that state belongs in a store the worker writes to explicitly, not in lines it hoped would reach a collector.

  • How would you prove the work happened rather than just assuming the log lied?
    Compare against something outside the stream. The records the extractor actually wrote to its store, the files it moved, or a counter it exported all say how far it got independently of stdout. Alongside that, check whether stderr output from the same instant is present: stderr is line buffered even when redirected, so if it shows later activity than stdout does, the gap is buffering rather than an early stop.
  • Why is installing a SIGTERM handler not sufficient on its own?
    It converts a graceful stop into a normal exit, which flushes, so it fixes the common case. It does nothing for `signal.SIGKILL`, which cannot be caught, and nothing for a hard crash of the process, and it leaves a window where the handler is still running with a full buffer. It is a good complement to line buffering, not a replacement for it.
  • Would writing to a file instead of a pipe avoid the problem?
    No. The branch is tty versus not-a-tty, and a regular file is not a terminal, so it is block buffered exactly like a pipe. The data lost on a kill is in the process's userspace buffer, before the kernel ever sees it, so redirection target makes no difference. The stream still has to be line buffered or unbuffered.
  • Is unbuffered output the right default for a high-volume writer?
    Usually not. A worker emitting tens of thousands of lines pays one synchronous syscall per write, and `print()` can issue more than one write per call. Line buffering gives the same delivery guarantee per completed line at a fraction of the syscalls, so reserve fully unbuffered mode for the case where you cannot change the code and can only set an environment variable.

It is a shipping clerk who batches parcels until the crate is full. If the warehouse burns down, everything already crated is gone, and the shipping manifest ends exactly one crate before the truth.

saying these in an interview costs you the question

  • Blames a loop bug instead of checking the stream
  • Says the process must have crashed before that work
  • Tries to fix it in the log collector, not the writer
  • Thinks writing to a file instead of a pipe avoids it
  • Assumes a SIGTERM handler alone makes output durable
  • Reaches for unbuffered output without weighing the cost

context