perl.gg / snippets

<!-- category: snippets -->

Atomic File Write

2026-03-18

Your program is writing a config file. Power blip. OOM killer. Stray SIGKILL. The process dies mid-write.

Now you have half a config file. Your service reads it on restart, chokes, and does something quietly, subtly wrong. The kind of wrong that takes hours to notice and days to fix.

The solution is four lines of Perl and about forty years of filesystem wisdom:

open my $fh, '>', "$file.tmp.$$" or die "Can't write $file.tmp.$$: $!"; print $fh $data; close $fh or die "Can't close $file.tmp.$$: $!"; rename("$file.tmp.$$", $file) or die "Can't rename to $file: $!";
Write to a temp file. Rename it into place. That's the whole trick.

Part 1: WHY DIRECT WRITES ARE DANGEROUS

The obvious approach:
open my $fh, '>', $file or die "Can't write $file: $!"; print $fh $data; close $fh;
That > truncates the file immediately on open. Before you've written a single byte, the old content is gone. If your process dies between open and close, you have either an empty file or a partial one.
Timeline of disaster: open '>' $file --> old content: GONE. 0 bytes. print chunk 1 --> partial data print chunk 2 --> still partial *** CRASH *** --> chunks 1-2 of N (service restarts, reads garbage, bad day)
Even without crashes, there's a race condition. Another process reads the file while you're writing it. No crash needed. Just bad timing.

Part 2: THE TEMP FILE PATTERN

Split the operation into two phases. Create the new content in a temp file. Then atomically swap it into place.
my $tmp = "$file.tmp.$$"; open my $fh, '>', $tmp or die "Can't write $tmp: $!"; print $fh $data; close $fh or die "Can't close $tmp: $!"; rename($tmp, $file) or die "Can't rename $tmp -> $file: $!";
The $$ variable is the current process ID. It makes the temp filename unique per process. Two instances writing the same file won't clobber each other's temp files.
Process 1 writes to: config.yml.tmp.4821 Process 2 writes to: config.yml.tmp.4822 Both rename to: config.yml Last rename wins, but both files are always complete.
No partial files. No truncation of the original until the replacement is fully written and ready.

Part 3: WHY RENAME() IS ATOMIC

Perl's rename() calls the C library's rename(2), which on POSIX systems is an atomic operation within the same filesystem. It either fully succeeds or fully fails. There is no intermediate state.
Before rename(): config.yml <-- old, complete content config.yml.tmp.$$ <-- new, complete content After rename(): config.yml <-- new, complete content (temp file is gone)
At no point does config.yml contain partial data. Any process that opens the file gets either the complete old version or the complete new version. Never a mix. Never empty.

This guarantee comes from the filesystem, not from Perl. Perl just gives you clean access to it.

Part 4: THE CROSS-FILESYSTEM GOTCHA

One critical requirement: the temp file and the target must be on the same filesystem. rename() across filesystem boundaries fails.
# FAILS: /tmp and /etc are different filesystems rename("/tmp/config.tmp.$$", "/etc/myapp/config.yml"); # $! will be "Invalid cross-device link"
The fix is simple. Put the temp file next to the target:
# WORKS: same directory, same filesystem my $tmp = "/etc/myapp/config.yml.tmp.$$";
This is why the pattern uses "$file.tmp.$$" instead of File::Spec->tmpdir(). Same directory guarantees same filesystem.

Part 5: CHECKING CLOSE() FOR ERRORS

Notice the or die on close. This is not paranoia. This is essential.
close $fh or die "Can't close $tmp: $!";
Buffered I/O means print might succeed even if the disk is full. The data sits in a buffer in memory. The actual write to disk happens at close when the buffer flushes. If the flush fails, close returns false and sets $!.

If you skip this check, you might rename a file that wasn't fully written. You've atomically replaced your good file with a bad one. The pattern saved you from crashes but you defeated it by ignoring the write error.

Always check close(). It's the most commonly skipped error check in file I/O and the one that matters most here.

Part 6: PRESERVING PERMISSIONS

rename() doesn't handle permissions. The temp file has your umask's defaults. If the original had specific permissions, they're gone.

Fix it before the rename:

my $tmp = "$file.tmp.$$"; open my $fh, '>', $tmp or die "Can't write $tmp: $!"; print $fh $data; close $fh or die "Can't close $tmp: $!"; # Copy permissions from the original if (-e $file) { my @stat = stat($file); chmod $stat[2] & 07777, $tmp; } rename($tmp, $file) or die "Can't rename $tmp -> $file: $!";
The & 07777 masks off the file type bits, keeping only permissions. If you're running as root and need ownership preserved too:
chown $stat[4], $stat[5], $tmp; # uid, gid

Part 7: SIGNAL HANDLING AND CLEANUP

What if your process catches a SIGTERM while writing? The temp file sticks around like a ghost.
my $tmp = "$file.tmp.$$"; my $cleanup = sub { unlink $tmp; exit 1; }; local $SIG{INT} = $cleanup; local $SIG{TERM} = $cleanup; local $SIG{HUP} = $cleanup; open my $fh, '>', $tmp or die "Can't write $tmp: $!"; print $fh $data; close $fh or die "Can't close $tmp: $!"; rename($tmp, $file) or die "Can't rename $tmp -> $file: $!";
The local scopes the signal handlers. When the surrounding block exits, original handlers are restored. Interrupted? Temp file cleaned up, original untouched.

For belt-and-suspenders safety, wrap it in an eval:

eval { open my $fh, '>', $tmp or die "write: $!"; print $fh $data; close $fh or die "close: $!"; rename($tmp, $file) or die "rename: $!"; }; if ($@) { unlink $tmp; die "Atomic write failed for $file: $@"; }
Exception? Temp file removed. Original intact. Clean failure.

Part 8: FILE::TEMP ALTERNATIVE

Perl's core File::Temp module generates temp files with random names, slightly more collision-resistant than $$:
use File::Temp qw(tempfile); my ($fh, $tmp) = tempfile("${file}.XXXX", UNLINK => 0); print $fh $data; close $fh or die "close: $!"; rename($tmp, $file) or die "rename: $!";
The XXXX becomes random characters. UNLINK => 0 prevents auto-deletion (since we're renaming it).

The $$ pattern is simpler for most cases though, and has better debuggability. "What's config.yml.tmp.4821? Oh, PID 4821 was writing a new config." Clear provenance.

Part 9: THE PRODUCTION SUBROUTINE

Here's the whole thing wrapped in a reusable function:
sub atomic_write { my ($file, $data) = @_; my $tmp = "$file.tmp.$$"; my $cleanup = sub { unlink $tmp; exit 1; }; local $SIG{INT} = $cleanup; local $SIG{TERM} = $cleanup; eval { open my $fh, '>', $tmp or die "open: $!"; if (ref $data eq 'CODE') { $data->($fh); # callback for large writes } else { print $fh $data; } close $fh or die "close: $!"; # Preserve permissions if (-e $file) { my @st = stat($file); chmod $st[2] & 07777, $tmp; } rename($tmp, $file) or die "rename: $!"; }; if ($@) { unlink $tmp; die "atomic_write($file): $@"; } return 1; }
Usage:
# Simple string write atomic_write("/etc/myapp/config.yml", $yaml_string); # Callback for large writes (avoids building giant string in memory) atomic_write("/var/data/report.csv", sub { my $fh = shift; for my $row (@rows) { print $fh join(",", @$row), "\n"; } });

Part 10: REAL-WORLD CONFIG UPDATE

Putting it all together. Read a YAML config, modify it, write it back atomically:
#!/usr/bin/env perl use strict; use warnings; use feature 'say'; use YAML::Tiny; my $config_file = '/etc/myapp/config.yml'; # Read current config my $yaml = YAML::Tiny->read($config_file) or die "Can't read $config_file: " . YAML::Tiny->errstr; # Modify it $yaml->[0]{last_updated} = time(); $yaml->[0]{maintenance} = 0; # Write atomically my $tmp = "$config_file.tmp.$$"; eval { $yaml->write($tmp) or die "YAML write: " . YAML::Tiny->errstr; my @st = stat($config_file); chmod $st[2] & 07777, $tmp; rename($tmp, $config_file) or die "rename: $!"; }; if ($@) { unlink $tmp; die $@; } say "Config updated atomically.";
This pattern is everywhere. Vim uses it. Package managers use it. Every serious daemon that writes state to disk uses temp-plus-rename. It's not clever. It's not flashy. It's the kind of boring code that keeps you from getting paged at 3 AM.
+------------------+ +------------------+ | Write to temp | ---> | rename() into | | $file.tmp.$$ | | place | +------------------+ +------------------+ | | (crash here = (atomic = temp file left, no partial original intact) files ever) .--. |o_o | |:_/ | // \ \ (| | ) /'\_ _/`\ \___)=(___/ Write safe. Sleep well.
perl.gg