Why must a forked child call os._exit rather than sys.exit()?
answer
- The child is a copy of everything
- One of them raises, one does not
- Watch what the parent had not flushed yet
- A handler up the stack can catch it
- The child owns its own flushing
basics
~20 sAfter os.fork() the child inherits a copy of the parent's stack and its buffered output. sys.exit() raises SystemExit, which unwinds through those inherited handlers and flushes the copied buffers, duplicating output and possibly resuming the parent's loop. os._exit() terminates immediately with none of that.
solid answer
~50 s`os.fork()` gives the child a copy of everything: the call stack with all its `try`/`except` frames, the registered cleanup, and — critically — the not-yet-flushed contents of the parent's I/O buffers. `sys.exit()` in that child raises `SystemExit`, which unwinds through the copied frames, so any broad handler up the parent's stack catches it and the child carries on as a duplicate worker inside the parent's loop. Even uncaught, normal shutdown flushes the inherited stdout buffer, so text the parent wrote once appears twice, and shared resources the parent still owns — sockets, connection pools — get closed from underneath it. `os._exit(status)` bypasses all of it: no unwinding, no cleanup, no flush, straight to the OS with that status, which the parent reads with `os.waitpid()`. The price is that the child must flush anything it genuinely wants written before calling it.
code
python · 8 linesimport os
pid = os.fork()
if pid == 0:
os._exit(3)
_, status = os.waitpid(pid, 0)
print(os.WIFEXITED(status), os.WEXITSTATUS(status))go deeper
Know the two names apart: sys.exit() raises SystemExit and lets the interpreter shut down in good order, while os._exit() ends the process immediately with no cleanup and no flushing of buffered output.
Explain what os.fork() copies — the call stack, the pending cleanup, the unflushed I/O buffers — and why an orderly exit in the child therefore duplicates output and can be caught by an inherited handler. Show the parent reading the status with a wait call.
Diagnose from symptoms: duplicated log lines with identical timestamps, worker counts that never drop, a connection the parent still needs going away. Show the child body where every path ends at os._exit after an explicit flush, and know it is the child's job to flush.
Argue the layer choice. Decide when a service may use fork-based children at all versus spawn or forkserver workers, weigh the copied-address-space speed win against a whole class of inheritance bugs, and note that 3.14 moved the default away from fork on most Unix platforms.
## What the child actually inherits `os.fork()` returns twice — once in the parent with the child's pid, once in the child with 0 — and the child starts life as a near-exact copy of the parent's memory. That copy includes three things that make exiting delicate: 1. **The call stack.** Every `try`/`except` and `with` frame the parent was inside is also live in the child. 2. **Registered cleanup.** Handlers the parent registered to run at shutdown exist in the child too, and normal interpreter shutdown will run them there. 3. **Buffered I/O.** Anything the parent wrote but has not flushed is sitting in the child's copy of the buffer as well. The child's job is to do its work and disappear without disturbing any of that. `sys.exit()` disturbs all three. ## Failure one: the exit gets caught Picture a webhook receiver that forks a child per delivery, wrapping the dispatch loop so that a slow upstream causing an intermittent timeout cannot take the service down: ```python while True: delivery = queue.get() try: pid = os.fork() if pid == 0: handle(delivery) sys.exit(0) # wrong except Exception: log("delivery failed, continuing") ``` The `SystemExit` the child raises unwinds through the child's copy of that `while` loop. Here `except Exception` does not match it, so the child does exit — but only by luck of the handler being correctly narrow. Make that handler a bare `except:` (or have any library up the stack catch `BaseException`) and the child never dies: it returns to the top of the loop and starts pulling deliveries off the queue in competition with its own parent. The bug presents as duplicate processing and a worker count that will not go down, and it is miserable to diagnose because the code that "exits" looks correct. ## Failure two: the buffer is flushed twice This one is deterministic and easy to demonstrate. When standard output is a pipe or a file rather than a terminal, it is block-buffered, so short writes sit in memory: ```python import os import sys print("buffered line", end="") if os.fork() == 0: sys.exit(0) os.wait() sys.stdout.flush() ``` Piped to another command, this prints `buffered linebuffered line`. The parent's unflushed bytes were copied into the child, and the child's orderly shutdown flushed them. Replace `sys.exit(0)` with `os._exit(0)` and the text appears exactly once, because the child dies without flushing. In a log pipeline this shows up as duplicated log lines with identical timestamps that no amount of reading the logging code will explain. ## Failure three: shared resources get torn down Orderly shutdown in the child also runs the parent's cleanup: closing file objects, running finalizers, and shutting down connection pools whose sockets are shared file descriptors. Closing a socket in the child does not close the parent's copy of the descriptor — but sending a protocol-level goodbye down it, which a connection-pool `close()` typically does, absolutely affects the connection the parent is still using. The same reasoning applies to any handle with a remote peer. ## What os._exit does instead `os._exit(status)` is a thin wrapper over the `_exit` system call. It terminates the calling process immediately: no exception is raised, no stack unwinds, no `finally` block or `__exit__` runs, no registered shutdown handler fires, no buffer is flushed. The status is passed straight to the OS and the parent reads it in the usual way: ```python import os pid = os.fork() if pid == 0: os._exit(3) _, status = os.waitpid(pid, 0) print(os.WIFEXITED(status), os.WEXITSTATUS(status)) # True 3 ``` The cost is that the child owns its own durability. If the child produced output that must reach the log, it flushes explicitly — `sys.stdout.flush()` — or writes at the descriptor level with `os.write()` before calling `os._exit()`. If it wrote a file, it flushes and syncs that file itself. "Skips cleanup" is the feature and the bill at the same time. The canonical child body therefore looks like this: ```python if os.fork() == 0: status = 0 try: do_child_work() except BaseException: status = 1 finally: sys.stdout.flush() sys.stderr.flush() os._exit(status) ``` Every path ends at `os._exit`, and the only cleanup performed is the cleanup the child explicitly chose. ## The wider judgment The deeper answer in an interview is that hand-rolled `os.fork()` is rarely the right layer to be working at. Higher-level process APIs already handle the child's exit correctly, and since **Python 3.14 the default `multiprocessing` start method on Unix other than macOS is `forkserver`** (macOS and Windows use `spawn`, and `fork` must now be requested explicitly). A `spawn` or `forkserver` child is a fresh interpreter: it inherits no stack, no buffers and no half-finished state, so the entire class of bug above cannot occur. Reach for raw `os.fork()` only when you deliberately want the copied address space — and then treat `os._exit` as mandatory in every child path. The symmetric mistake is worth naming too: `os._exit()` in the **parent**, or in ordinary application code, is a bug. It discards buffered output and every cleanup path the program has, which is exactly what you do not want when the process is meant to shut down in good order.
- How does the parent read the status a child passed to os._exit(3)?With a wait call: `pid, status = os.waitpid(child_pid, 0)`. The returned status is a packed value, not the code itself — `os.WIFEXITED(status)` says whether the child exited normally, and `os.WEXITSTATUS(status)` extracts the 3. If the child was killed instead, `os.WIFSIGNALED(status)` is true and `os.WTERMSIG(status)` gives the signal number.
- If os._exit skips flushing, how does a forked child get its output written?It flushes deliberately before exiting: `sys.stdout.flush()` and `sys.stderr.flush()`, and an explicit `flush()` plus `os.fsync()` on any file it must durably write. An alternative is to bypass the Python-level buffer entirely and write with `os.write()` to the descriptor, which the child can do safely because there is no buffered copy to duplicate.
- Why does a spawn- or forkserver-based worker avoid this problem entirely?Because the child is a fresh interpreter rather than a memory copy. It has no inherited call stack to unwind into, no copied I/O buffers to double-flush, and no cleanup registered by the parent, so an ordinary exit in the child is safe. That is part of the reason 3.14 made forkserver the default multiprocessing start method on Unix other than macOS.
- Is os._exit ever appropriate outside a forked child?Rarely, and always for the same reason: you need the process gone without running any Python-level cleanup. Legitimate cases are a crash handler that has already decided the interpreter state is untrustworthy, or a process wedged in shutdown that must not hang a supervisor. Everywhere else it is a bug, because it discards buffered output and every cleanup path the program has.
Forking is photocopying a half-written page along with the whole office; sys.exit() in the copy sends the duplicate page to the printer and files the office's paperwork, while os._exit() shreds the copy on the spot.
saying these in an interview costs you the question
- Calls sys.exit in a forked child and assumes it just exits
- Forgets the child inherits the parent's unflushed output buffers
- Thinks os._exit flushes open files before terminating
- Believes an exception in the child propagates to the parent
- Uses os._exit in ordinary parent-process code by habit
- Assumes the parent sees the raw status number from waitpid