skip to content

Why can open(path, 'w') expose an empty or half-written file to readers?

level: juniorimportance: should knowfreq 30%

answer

  1. The write is not one step
  2. Something already happened at open time
  3. The file shrinks before any write call
  4. Readers open the very same file
  5. Mode 'w' truncates to zero bytes first

basics

~20 s

Mode 'w' truncates the file the instant it is opened, before a single byte is written, and later writes reach it piecemeal as buffers flush. A reader that opens the path meanwhile sees an empty or half-written file.

solid answer

~40 s

`open(path, "w")` is not one operation. Opening truncates the existing file to zero bytes; the writes that follow land later, in chunks, whenever the buffer flushes. Between those moments the path still resolves to the same file, so any process that opens it - a config loader, a sidecar, a backup job - reads whatever happens to be there: nothing, or a prefix that stops mid-line. A crash inside that window leaves the truncated file on disk permanently, which is worse than not having written at all. `pathlib.Path.write_text` has exactly the same shape and the same exposure. The fix is to stop modifying the destination in place: build the new contents in a separate file in the same directory and swap it into position with `os.replace`, which repoints the name in a single step.

code

python · 10 lines
python
import os, tempfile

path = os.path.join(tempfile.mkdtemp(), "routes.json")
with open(path, "w", encoding="utf-8") as f:
    f.write('{"services": 17}')

writer = open(path, "w", encoding="utf-8")   # nothing written yet
with open(path, encoding="utf-8") as reader:
    print(repr(reader.read()))               # '' - already truncated
writer.close()

go deeper

for a junior

Be ready to say what mode 'w' does at the moment of opening, not just what it does overall. Knowing that the file is already empty before your first write is the whole point of the question.

for a middle

Explain the two-layer timing: truncation at open, then buffered writes reaching the file later. Name the observable states a concurrent reader can catch, and say why no locking prevents them.

for a senior

Show that you treat this as a durability problem, not just a race: a kill inside the window leaves corruption on disk. Be ready to name the write-elsewhere-then-rename fix and say what it costs.

for a principal

An interviewer expects you to say where the rule belongs - a shared helper every service uses for config and state files - and where in-place writes remain acceptable, such as append-only logs and disposable caches.

## Truncation happens at open, not at write When Python evaluates `open(path, "w")` it asks the operating system to open that name for writing **and to truncate whatever is already there to zero bytes**. That truncation happens as part of the open call - before your first `write()`, before you have serialized anything, and even if the program raises immediately afterwards and never writes at all. From that instant the old contents are gone. There is no shadow copy, no undo and no transaction to roll back. ## The new bytes arrive afterwards, in pieces What follows is not one step either. A text file object accumulates your writes in a userspace buffer and hands them to the operating system when that buffer fills, when you call `flush()`, or when the file is closed. So the sequence of states an outside observer can catch is: the full old contents, then an empty file, then a growing prefix of the new contents, then finally the complete new file. For a small file those states pass in microseconds; for a few megabytes on a busy volume the window is long enough to hit reliably. ## Why anyone can observe those states at all A path is a directory entry pointing at a file. Writing in place mutates that same file, so every process that opens the path is looking at the bytes you are currently rewriting. Unix applies no implicit locking: an open for writing does not exclude readers, and Python adds none of its own. Nothing anywhere in the stack promises that a reader sees either the old version or the new version and nothing between. ## What that looks like in production A route-optimisation job recomputes a 17-service dependency graph every few minutes and rewrites `routes.json` in place, while a supervisor process reloads the file whenever its modification time changes. Most of the time the reload lands between rewrites and everything is fine. Occasionally it lands inside the window and decodes an empty file, or an object cut off mid-key, and the loader raises. The failure is timing-dependent, so it reproduces on a loaded machine roughly once a week and never in a test. A crash makes the same window permanent. If the process is killed between the truncation and the final flush - an out-of-memory kill, a deploy, a power loss - what remains on disk after the restart is the truncated file. Overwriting in place converts a transient race into durable corruption, and the old good copy is not recoverable because it was destroyed at open time. ## Things that do not fix it * **Writing everything in one `write()` call.** The buffer still flushes on its own schedule, and the operating system is free to split the transfer. * **Writing faster, or writing a smaller file.** That shrinks the window; it does not close it. * **Using `pathlib.Path.write_text`.** It is the same truncate-then-write sequence with a shorter spelling. * **Calling `flush()` more often.** That makes partial states visible sooner, not less often. * **Advisory locking.** It works only if every reader - including tools you did not write - cooperates, which they generally do not. ## What does fix it Never modify the destination in place. Write the new contents into a different file in the same directory, close it, and put it into position with `os.replace(tmp, path)`, which overwrites the destination by repointing the directory entry in a single step. A reader either opens the old file or the new one, and a crash leaves one whole version or the other. ## The neighbouring modes Mode `'a'` avoids the truncation - it keeps existing bytes and appends - but it does not make an update atomic; a reader can still catch a half-appended record. Mode `'x'` refuses to open a path that already exists, which is useful for create-once files but says nothing about replacing one. Only the write-elsewhere-then-rename approach gives readers an all-or-nothing view of a file you are replacing.

  • Does opening with mode 'a' instead of 'w' remove the problem?
    It removes the truncation: `'a'` keeps the existing bytes and positions every write at the end, so a reader never sees an empty file. It does not make the update atomic - a reader can still catch a half-appended record - and it only makes sense when the format is a log of independent records rather than a document you are replacing wholesale. To replace a document, you still want the temp-file-plus-`os.replace` swap.
  • Why is pathlib.Path.write_text no safer here?
    `Path.write_text` opens the path in text write mode, writes, and closes: the same truncate-then-write sequence in one line. It is convenient for files nobody else reads concurrently, and it is exactly as unsafe for anything a second process, a supervisor, or a restart may read while the write is in flight.
  • How would you demonstrate the window deliberately?
    Open the path for writing and, before writing anything, open it again for reading from the same program: the reader gets an empty string. That is the whole race, made deterministic without threads or timing tricks, and it is a convincing thing to show in a review when someone argues the write is 'basically atomic'.

saying these in an interview costs you the question

  • Claims the file is only touched once write() is called
  • Thinks closing the file makes the whole update atomic
  • Believes the OS locks the file against readers automatically
  • Says writing in a single call removes the window
  • Assumes a crash mid-write leaves the old contents intact

context