Skip to content

Escribe un fichero de forma atómica (tmp + fsync + rename) snippet

Escribe un fichero de modo que los lectores jamás vean una versión a medias: escribe a un nombre temporal en el MISMO directorio, haz fsync y luego renombra sobre el destino — renombrar dentro de un mismo sistema de ficheros es atómico, y todo lector ve o el fichero viejo o el nuevo, nunca una mezcla.

Escribe un fichero de modo que los lectores jamás vean una versión a medias: escribe a un nombre temporal en el MISMO directorio, haz fsync y luego renombra sobre el destino — renombrar dentro de un mismo sistema de ficheros es atómico, y todo lector ve o el fichero viejo o el nuevo, nunca una mezcla. Saltarte el fsync deja el rename durable-pero-vacío tras un cuelgue (los datos siguen en la page cache); poner el fichero temporal en otro sistema de ficheros convierte el rename en una copia — no atómica y lenta. Todo lenguaje esconde este baile tras un flag WriteFile-atomic o te deja los tres pasos a ti.

Receta ejecutable · 12 lenguajes
Files & Streamsfilesatomic-writefsyncrenamedurabilitycrash-safetyio

Every language

12 lenguajes, 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.