Skip to content

Eine Datei atomar schreiben (tmp + fsync + rename) snippet

Eine Datei so schreiben, dass Leser nie eine halbfertige Version sehen: unter einem temporären Namen im GLEICHEN Verzeichnis schreiben, fsync, dann über das Ziel renamen — ein Rename innerhalb eines Dateisystems ist atomar, und jeder Leser sieht entweder die alte oder die neue Datei, nie ein Gemisch.

Eine Datei so schreiben, dass Leser nie eine halbfertige Version sehen: unter einem temporären Namen im GLEICHEN Verzeichnis schreiben, fsync, dann über das Ziel renamen — ein Rename innerhalb eines Dateisystems ist atomar, und jeder Leser sieht entweder die alte oder die neue Datei, nie ein Gemisch. Fehlendes fsync macht das Rename nach einem Crash dauerhaft-aber-leer (die Daten stecken noch im Page Cache); liegt die Temp-Datei auf einem anderen Dateisystem, wird das Rename zur Kopie — nicht atomar und langsam. Jede Sprache versteckt diesen Tanz hinter einem WriteFile-atomic-Flag oder überlässt die drei Schritte dem Entwickler.

Runnable recipe · 12 languages
Files & Streamsfilesatomic-writefsyncrenamedurabilitycrash-safetyio

Every language

12 languages, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
import fs from 'node:fs';

function writeFileAtomic(path, data) {
  const tmp = `${path}.${process.pid}.tmp`; // same directory → same filesystem
  const fd = fs.openSync(tmp, 'w');
  try {
    fs.writeFileSync(fd, data);
    fs.fsyncSync(fd);              // data hits the disk BEFORE the swap
  } finally {
    fs.closeSync(fd);
  }
  try {
    fs.renameSync(tmp, path);      // atomic within one filesystem
  } catch (err) {
    fs.unlinkSync(tmp);            // never leave the temp file behind
    throw err;
  }
}

The tmp name must live in the TARGET's directory (suffix trick here, mkdtemp/tmpfile for many files in parallel) — /tmp is often another filesystem, and rename across filesystems degrades to a non-atomic copy. graceful-fs / the atomic-write npm wrappers package this whole dance; hand-rolled, the unlink-on-error is the part everyone forgets.

TSTypeScript
import fs from 'node:fs';
import path from 'node:path';

export function atomicWrite(file: string, data: string): void {
  const tmp = path.join(
    path.dirname(file),
    `.${path.basename(file)}.tmp`,
  ); // provably the same directory
  const fd = fs.openSync(tmp, 'w');
  try {
    fs.writeFileSync(fd, data, 'utf8');
    fs.fsyncSync(fd);
  } finally {
    fs.closeSync(fd);
  }
  try {
    fs.renameSync(tmp, file);
  } catch (err) {
    try { fs.unlinkSync(tmp); } catch { /* already gone */ }
    throw err; // try/catch keeps the rename error AND drops the tmp
  }
}

path.join(dirname, ...) is the guarantee that keeps the temp file on the target's filesystem. The empty catch around unlinkSync is deliberate — cleanup must not mask the rename failure it follows; the original error is what rethrows.

GoGo
import (
	"os"
	"path/filepath"
)

func writeFileAtomic(path string, data []byte) error {
	tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name() // CreateTemp put it in dir → same filesystem
	defer os.Remove(tmpName) // no-op once the rename succeeds
	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil { // flush to disk before the swap
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpName, path) // atomic within one filesystem
}

os.CreateTemp(dir, pattern) takes the TARGET directory — the same-filesystem guarantee comes free, and the pattern's trailing * becomes the random part. It creates with 0600, so the renamed file keeps those perms; Chmod before the rename when the file needs 0644.

RsRust
use std::fs::{self, File};
use std::io::Write;
use std::path::Path;

fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
    let tmp = path.with_extension("tmp"); // sits beside the target
    let mut f = File::create(&tmp)?;
    f.write_all(data)?;
    f.sync_all()?; // data AND metadata — content is on disk
    drop(f); // close before the rename
    fs::rename(&tmp, path) // POSIX: atomic within one filesystem
}

sync_all flushes data plus metadata; sync_data may skip the metadata, which after a crash can leave the renamed file with a stale size. The tempfile crate is the packaged form — NamedTempFile::new_in(dir) then .persist(path) runs this exact sequence for you.

PHPPHP
function writeFileAtomic(string $path, string $data): void
{
    $tmp = $path . '.tmp'; // same directory → rename stays atomic
    if (file_put_contents($tmp, $data, LOCK_EX) === false) {
        throw new \RuntimeException("write failed: {$tmp}");
    }
    // fflush() only drains PHP's userspace buffer into the OS page cache —
    // PHP core has no fsync (see notes).
    if (!rename($tmp, $path)) {
        @unlink($tmp);
        throw new \RuntimeException("rename failed: {$tmp} → {$path}");
    }
}

rename() within the same directory is the atomic step and always available. The honest gap: PHP core exposes no fsync — fflush is userspace-only, so a crash right after the rename can still surface an empty file; ext-dio's dio_fsync() on the handle, or OS-level sync, is the durability answer.

PyPython
import os
import tempfile

def write_file_atomic(path: str, data: str) -> None:
    d = os.path.dirname(path) or '.'
    fd, tmp = tempfile.mkstemp(dir=d, prefix='.tmp-')  # same directory
    try:
        with os.fdopen(fd, 'w', encoding='utf-8') as f:
            f.write(data)
            f.flush()
            os.fsync(f.fileno())     # to disk BEFORE the swap
        os.replace(tmp, path)        # atomic; overwrites; Windows-safe
    except BaseException:
        os.unlink(tmp)               # cleanup on ANY failure
        raise

os.replace, NOT os.rename — replace is atomic AND overwrites the target cross-platform (os.rename refuses to overwrite on Windows). tempfile.NamedTemporaryFile(dir=same_dir, delete=False) is the other spelling of the same-directory temp file; mkstemp just hands you the fd directly.

C#C#
using System.IO;

static void WriteFileAtomic(string path, byte[] data)
{
    string tmp = path + ".tmp"; // same directory
    using (var fs = new FileStream(tmp, FileMode.Create,
                                   FileAccess.Write, FileShare.None))
    {
        fs.Write(data, 0, data.Length);
        fs.Flush(flushToDisk: true);      // fsync, not just userspace
    }
    File.Move(tmp, path, overwrite: true); // atomic on POSIX
}

Flush(true) is the flush-to-disk overload — plain Flush() only drains .NET's buffers into the page cache, which is exactly the durable-but-empty trap. File.Move's overwrite overload (.NET Core 3.0+) is atomic on POSIX; on Windows the replace is not guaranteed atomic, so cross-platform code tolerates a reader seeing the old file a moment longer.

JvJava
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

import static java.nio.file.StandardCopyOption.ATOMIC_MOVE;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;

static void writeFileAtomic(Path path, byte[] data) throws IOException {
    Path dir = path.toAbsolutePath().getParent();
    Path tmp = Files.createTempFile(dir, path.getFileName().toString(), ".tmp");
    try {
        Files.write(tmp, data);
        try (FileChannel ch = FileChannel.open(tmp,
                StandardOpenOption.READ, StandardOpenOption.WRITE)) {
            ch.force(true); // data + metadata, before the swap
        }
        Files.move(tmp, path, ATOMIC_MOVE, REPLACE_EXISTING);
    } catch (AtomicMoveNotSupportedException e) {
        // the tmp escaped the target filesystem — surface it, never swallow
        throw new IOException("cross-filesystem move: " + tmp, e);
    } finally {
        Files.deleteIfExists(tmp); // no-op after a successful move
    }
}

ATOMIC_MOVE throws AtomicMoveNotSupportedException when source and target sit on different filesystems — that exception IS the wrong-directory detector, so catching it to 'fall back to a plain move' silently gives up atomicity. ch.force(true) covers data plus metadata; force(false) can leave the size stale.

SwSwift
import Foundation

func writeFileAtomic(at url: URL, data: Data) throws {
    let dir = url.deletingLastPathComponent()
    let tmp = dir.appendingPathComponent(".tmp-\(url.lastPathComponent)")
    try data.write(to: tmp) // same directory → same filesystem

    // Foundation exposes no public fsync; F_FULLFSYNC via raw fcntl on the fd
    // is private-API territory. The move below is the atomic step — crash
    // durability before it is the documented gap.
    if FileManager.default.fileExists(atPath: url.path) {
        _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
    } else {
        try FileManager.default.moveItem(at: tmp, to: url)
    }
}

The rename — replaceItemAt/moveItem on a same-directory URL — is the step that is atomic; POSIX guarantees it within one filesystem. fsync is the honest hole: Foundation has no public API, and Darwin's real-disk F_FULLFSYNC needs a raw fcntl call, so the pre-rename durability window is accepted or handled in C.

KtKotlin
import java.nio.channels.FileChannel
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption.ATOMIC_MOVE
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
import java.nio.file.StandardOpenOption.READ
import java.nio.file.StandardOpenOption.WRITE

fun writeFileAtomic(path: Path, data: ByteArray) {
    val dir = path.toAbsolutePath().parent
    val tmp = Files.createTempFile(dir, path.fileName.toString(), ".tmp")
    try {
        Files.write(tmp, data)
        FileChannel.open(tmp, READ, WRITE).use { it.force(true) }
        Files.move(tmp, path, ATOMIC_MOVE, REPLACE_EXISTING)
    } finally {
        Files.deleteIfExists(tmp) // no-op after the successful move
    }
}

Same NIO chain as Java, but use{} closes the channel on every path — a leaked channel pins the fd and can block the rename on Windows. The ATOMIC_MOVE option constant lives in StandardCopyOption (not StandardOpenOption); importing both statically reads cleaner than the vararg-array form.

RbRuby
require 'tempfile'

def write_file_atomic(path, data)
  dir = File.dirname(path)
  Tempfile.create('.tmp-', dir) do |tmp|  # created in the SAME directory
    tmp.write(data)
    tmp.flush                              # userspace → OS
    tmp.fsync                              # OS → disk
    File.rename(tmp.path, path)            # the atomic swap
  end
end

Tempfile.create's SECOND argument is the directory — that is the whole trick, because its default /tmp may be another filesystem. ActiveSupport's File.atomic_write is the packaged idiom; it also restores the original file's mode on the replacement.

ZigZig
const std = @import("std");

fn writeFileAtomic(path: []const u8, data: []const u8) !void {
    var buf: [std.fs.max_path_bytes]u8 = undefined;
    const tmp = try std.fmt.bufPrint(&buf, "{s}.tmp", .{path});

    var f = try std.fs.cwd().createFile(tmp, .{});
    defer f.close();
    try f.writeAll(data);
    try std.posix.fsync(f.handle); // disk BEFORE the swap — order is the contract

    try std.posix.rename(tmp, path); // rename(2): atomic within one filesystem
}

fsync BEFORE rename is the whole contract — run in the other order, the rename can beat the data to disk and a crash leaves the new name pointing at stale bytes. tmp is path + ".tmp", so it lands in the target's directory by construction; std.posix.rename exposes rename(2), whose same-filesystem atomicity is POSIX-guaranteed.