os.replace is atomic, but a shared .tmp name one step earlier still corrupts the file

I used the textbook atomic-write recipe — dump JSON to state.json.tmp, then os.replace() it onto state.json — and readers still crashed on JSONDecodeError, on a file that was supposedly replaced atomically. Two concurrent writer processes were enough to corrupt 92 of 200 reads.

TL;DRos.replace() is atomic, but a temp name shared between writer processes is not: a second writer’s open(tmp, "w") truncates the first writer’s in-flight file just before the rename. Create the temp file with tempfile.mkstemp(dir=<target's directory>) so every writer renames its own private file. In my test that took corrupt reads from 121–162 out of 200 down to 0.

The setup

The program is a log analyzer that checkpoints its state: after each batch it dumps its counters — about 24 KB of JSON — to a single state.json, and the next run loads it back. Several instances can run at once (overlapping scheduled runs sharing one state directory), and other processes read the file whenever they want a snapshot. The write used the recipe every tutorial gives: write the new content to state.json.tmp next to the target, then os.replace("state.json.tmp", "state.json"), so a reader sees either the old complete file or the new complete file, never a half-written one. (That checkpoint also stores reservoir samples, which have their own trap — merging two of them makes percentiles order-dependent.)

What I expected

os.replace() is rename(2), and the docs promise that « if successful, the renaming will be an atomic operation ». I read that as: the whole recipe is concurrency-safe. It survives a crash mid-write, so surely it survives a second writer. Every snippet I had ever copied built the temp path as path + ".tmp", and none of them said a word about other processes.

What actually happens

I ran a self-contained test: six writer processes each looping this 400 times, plus one reader calling json.load() on the target 200 times, 2 ms apart.

tmp = target + ".tmp"          # every writer computes the SAME path
for i in range(400):
    with open(tmp, "w") as fh:
        json.dump(payload, fh) # ~24 KB
    os.replace(tmp, target)    # atomic — but of which bytes?

Three runs on Linux (Python 3.14.4, tmpfs):

fixed: 162/200 corrupt reads
fixed: 153/200 corrupt reads
fixed: 121/200 corrupt reads

Every corrupt read raised json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0). In a separate run that polled every millisecond and logged the target’s size at the moment of failure, 237 of 238 corrupt reads found it 0 bytes long (the last one was back to full length by the time the size check ran — the file was being rewritten in place under the reader). The writers were casualties too: in one 6-writer run, 54 of 2400 os.replace calls raised FileNotFoundError because the shared temp file had vanished under them (the loop above needs a try/except just to finish). With only 2 writers instead of 6, I still got 92 and 94 corrupt reads out of 200.

Two writers sharing the name state.json.tmp truncate each other's file and publish 0 bytes, while mkstemp gives each writer a private name and every rename publishes a complete file.

The fix

Same loop, same rename — only the temp file now comes from tempfile.mkstemp, created in the target’s own directory:

import json, os, tempfile

def save_atomic(target, payload):
    dirpath = os.path.dirname(os.path.abspath(target))
    fd, tmp = tempfile.mkstemp(prefix=".state-", dir=dirpath)
    try:
        with os.fdopen(fd, "w") as fh:
            json.dump(payload, fh)
        os.replace(tmp, target)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise

Three runs of the same 6-writer, 200-read test: 0/200 corrupt reads, every time. Two details matter. dir=dirpath keeps the temp file on the same filesystem as the target — the os.replace docs warn the operation « may fail if src and dst are on different filesystems », and the default temp directory often is one. And mkstemp creates the file with mode 0600, which the rename carries over to the target (my state.json came out -rw-------); add an os.chmod before the rename if other users read the file.

Why it works

The corruption never involved os.replace misbehaving. open(tmp, "w") on a shared path means O_TRUNC on whatever inode currently holds that name. So: writer A creates state.json.tmp and writes its 24 KB. Writer B opens the same path, truncating A’s file to 0 bytes. A then renames — atomically publishing B’s empty file as state.json. Worse, B’s file descriptor still points at that inode, so B goes on writing its JSON directly into the live target, with no rename ever shielding readers. When B finally calls os.replace, the temp name is gone (FileNotFoundError) — or, with more writers, it renames a third writer’s half-written file. Every rename was atomic; the bytes being renamed were already torn one step earlier.

mkstemp repairs exactly that link: it creates the file with O_EXCL under a name no other process holds, so each inode has one writer for its whole life, and every rename publishes a complete document. Writers still race, but the race collapses to « which complete version wins » — the one fight os.replace was built to referee.

What I did not test

  • Durability. Neither variant calls fsync; this is about concurrent readers and writers, not surviving power loss.
  • Other filesystems. My test directory sat on tmpfs; the race lives in path semantics, not storage, but I did not rerun it on ext4 or NFS.
  • Windows or macOS. All numbers are from one Linux machine and are load-dependent — rerun the demo on yours.

Facts

context: multiple processes checkpoint one ~24 KB JSON state file using write-to-.tmp then os.replace
problem: a fixed shared .tmp name lets one writer truncate another's in-flight file; readers got 121-162 corrupt (mostly 0-byte) reads out of 200
solution: create the temp file with tempfile.mkstemp(dir=<target's directory>) so each writer renames a private file; 0/200 corrupt reads
verified_on: 2026-08-17
applies_to: [Python 3.14, POSIX rename semantics, any number of writer processes >= 2]
does_not_apply_to: [crash durability (needs fsync), Windows/macOS (untested), a single sequential writer (fixed name is safe there)]

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Retour en haut