Fs

#273 Jul 2026

273. remove_file — Deleting Something That Might Already Be Gone

create_dir_all (bite 272) is idempotent by design. Its deletion twins are not: remove_file and remove_dir_all fail with NotFound — and the exists() guard is the same race all over again.

Cleanup code usually doesn’t care whether the thing was there:

1
2
// Error: NotFound — first run, nothing to clean.
fs::remove_file("cache/state.bin")?;

So people reach for the same guard the whole filesystem run has been warning about:

1
2
3
if path.exists() {
    fs::remove_file(path)?; // another process can win the race
}

Check-then-act again (bite 270): if anything else deletes the file between exists() and remove_file, you’re back to the NotFound error the guard was supposed to prevent.

The fix is to call the operation unconditionally and treat “already gone” as success:

1
2
3
4
5
6
7
8
use std::io::{self, ErrorKind};

fn remove_if_exists(path: &Path) -> io::Result<()> {
    match fs::remove_file(path) {
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
        other => other,
    }
}

Now it’s safe to call on every shutdown, every retry, every run — the outcome you wanted (“the file is not there”) is the same whether the call deleted it or found nothing.

Three details worth knowing:

  • Same pattern for directories. fs::remove_dir_all also errors on a missing path; wrap it the same way. (Since Rust 1.62 its internals are TOCTOU-hardened on most platforms, so the recursive walk doesn’t race — but the top-level NotFound is still yours to handle.)
  • Only swallow NotFound. A PermissionDenied or “directory not empty” is a real failure — the match above still propagates everything else, which a bare let _ = fs::remove_file(...) would silently eat.
  • Deletion may not be immediate. On Windows, a file open by another process is marked for deletion and disappears when the last handle closes — so don’t follow a successful remove_file with code that assumes the name is instantly reusable.

The asymmetry is worth remembering: create_dir_all bakes idempotence in, the remove_* family makes you opt in with one ErrorKind match.

#272 Jul 2026

272. fs::create_dir_all — mkdir -p Without the exists() Check

fs::create_dir fails if the parent is missing and fails if the directory already exists. create_dir_all handles both — recursive and idempotent in one call.

The single-level version trips over both ends of the problem:

1
2
3
4
5
// Error: NotFound — `cache/images` doesn't exist yet.
fs::create_dir("cache/images/thumbs")?;

// Error: AlreadyExists — on the second run.
fs::create_dir("cache")?;

So people reach for the guard:

1
2
3
if !dir.exists() {
    fs::create_dir(dir)?; // still one level at a time
}

That’s a check-then-act race (bite 270 again): another process can create the directory between the check and the call, and you still can’t build a nested path in one go.

create_dir_all is the whole fix:

1
2
fs::create_dir_all("cache/images/thumbs")?; // creates all three
fs::create_dir_all("cache/images/thumbs")?; // Ok — already there

It creates every missing component along the path, and it returns Ok when the directory already exists — no exists() probe, no AlreadyExists special-casing, safe to call on every startup.

Three details worth knowing:

  • It’s concurrency-tolerant. If another thread or process creates one of the components mid-walk, create_dir_all shrugs and keeps going. The exists() guard can’t promise that.
  • A file in the way is still an error. If cache/images exists but is a regular file, you get an error instead of silent breakage — exactly what you want.
  • It won’t touch what’s already there. Permissions of existing directories are left alone; only newly created components get the default (or DirBuilder-configured) mode.

Same moral as the rest of this filesystem run: don’t interrogate the filesystem and then act on the answer — call the operation that already handles every case.

#271 Jul 2026

271. fs::rename — Replace a File Atomically, Never Expose a Half-Write

fs::write truncates the file before writing. Crash halfway — or get read halfway — and the world sees a torn file. fs::rename is the atomic swap that fixes it.

The obvious way to save state overwrites in place:

1
2
// Truncates state.json to 0 bytes, then writes.
fs::write("state.json", &json)?;

Between the truncate and the final byte, state.json is incomplete. A concurrent reader gets garbage; a crash or power loss leaves it that way permanently. For a config file, a cache, or anything another process watches, that window is a real bug.

The fix is the classic write-then-rename dance:

1
2
fs::write("state.json.tmp", &json)?;   // build the full file aside
fs::rename("state.json.tmp", "state.json")?; // atomic swap

On every major platform, rename over an existing file is atomic: any observer sees either the old complete file or the new complete file, never a mix and never a missing file. If the process dies before the rename, the old file is untouched and only a .tmp straggler is left behind.

Three details worth knowing:

  • Keep the temp file in the same directory. rename can’t cross filesystems — a temp file in /tmp and a target on another mount fails with an error (ErrorKind::CrossesDevices). Same directory guarantees same filesystem, and keeps the swap atomic.
  • Durability needs one more step. Atomic ≠ flushed. If you need the data to survive power loss, open the temp file yourself and call sync_all() before renaming, instead of using fs::write.
  • Unique temp names matter under concurrency. Two processes writing state.json.tmp will trample each other. Give each writer its own name (PID, random suffix) — and create it with File::create_new (bite 169) so collisions fail loudly.

Same moral as try_exists in bite 270: don’t check-then-act on the filesystem — make the filesystem do the transition in one atomic step.

#270 Jul 2026

270. Path::try_exists — exists() Can't Tell "Missing" From "Couldn't Check"

path.exists() returns false for a file that’s missing — and for a file that’s right there behind a directory you can’t read. try_exists() finally separates the two.

Path::exists() is implemented as “did fs::metadata succeed?”. Any failure — permission denied on a parent directory, an I/O error, an interrupted syscall — collapses into false:

1
2
3
4
5
6
let cfg = Path::new("/etc/app/config.toml");

if !cfg.exists() {
    // Missing? Or unreadable? No way to know.
    write_default_config(cfg)?;
}

If the app lacks permission to look inside /etc/app, this code concludes the config is absent and happily tries to overwrite it — or silently skips loading settings that exist. The error didn’t go away; it was converted into wrong control flow.

Path::try_exists() (stable since 1.63) returns io::Result<bool>, so “no” and “don’t know” are different answers:

1
2
3
4
5
match cfg.try_exists() {
    Ok(true)  => load_config(cfg)?,
    Ok(false) => write_default_config(cfg)?,
    Err(e)    => return Err(e.into()), // surface it
}

Ok(false) now means the path is definitely absent — the lookup succeeded and found nothing. Everything else is an Err you can log, retry, or propagate with ?.

Two details worth knowing:

  • Broken symlinks report Ok(false)try_exists() follows symlinks, so a link pointing at nothing counts as “the target doesn’t exist”. Use symlink_metadata if the link itself is what you care about.
  • TOCTOU still applies — any exists-then-act sequence can race with other processes. For “create only if absent”, skip the check entirely and use OpenOptions::new().create_new(true) (bite 169), which makes the filesystem decide atomically.

Same theme as file_type() in bite 269: the std methods returning plain bool are quietly eating errors, and each has a Result sibling that doesn’t.

#269 Jul 2026

269. DirEntry::file_type — Stop Paying a stat() Per Entry

Filtering read_dir entries with path.is_file() costs a full metadata lookup per entry — and quietly reports false when the check fails. DirEntry::file_type() is free on most platforms and tells you when it couldn’t answer.

This morning’s bite 268 collected read_dir entries into paths. The next thing most code does is filter them, and the obvious way has two hidden problems:

1
2
3
4
5
6
for entry in fs::read_dir("logs")? {
    let path = entry?.path();
    if path.is_file() {          // stat() per entry
        process(&path);
    }
}

First, Path::is_file() asks the filesystem for full metadata — an extra syscall for every entry, on top of the directory read that already happened. Second, it returns plain bool: if the metadata lookup fails (permissions, a file deleted mid-loop), you get false, indistinguishable from “this is a directory”. Errors vanish.

The entry you already have knows its own type:

1
2
3
4
5
6
for entry in fs::read_dir("logs")? {
    let entry = entry?;
    if entry.file_type()?.is_file() {
        process(&entry.path());
    }
}

Two things improved:

  • no extra syscall — on Linux, readdir returns each entry’s type alongside its name (d_type), and Windows directory entries carry file attributes. file_type() just hands you what the directory read already produced. (A few filesystems don’t fill d_type in; std falls back to a metadata call only then.)
  • errors surfacefile_type() returns io::Result<FileType>, so a failed lookup is a ?-able error instead of a silent false

One behavioral difference to know: file_type() does not follow symlinks — a symlink to a file reports is_symlink(), not is_file(), while Path::is_file() traverses the link and reports on the target. If following symlinks is what you actually want, that’s the one case for fs::metadata(entry.path()) — deliberately, not by accident.

#268 Jul 2026

268. fs::read_dir — The Filesystem Doesn't Sort for You

fs::read_dir looks like ls, but ls sorts its output and read_dir doesn’t. You get entries in whatever order the filesystem feels like — and that order changes between machines.

The docs say it plainly: “The order in which this iterator returns entries is platform and filesystem dependent.” On your dev box the entries may happen to come back alphabetical; on ext4 with hashed directories, or in CI, they won’t. Code like this works until it doesn’t:

1
2
3
4
// Process migrations in order... on SOME machines
for entry in fs::read_dir("migrations")? {
    apply(&entry?.path()); // 002 may run before 001
}

The fix is to collect and sort — PathBuf is Ord, so it’s two lines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::fs;

let mut paths: Vec<_> = fs::read_dir("migrations")?
    .map(|res| res.map(|e| e.path()))
    .collect::<Result<Vec<_>, _>>()?;
paths.sort();

for p in &paths {
    apply(p); // 001, then 002, on every machine
}

Two details worth noticing along the way:

  • each item the iterator yields is a Result — reading an individual entry can fail even after opening the directory succeeded. Collecting into Result<Vec<_>, _> surfaces the first error instead of hiding it in the loop
  • DirEntry::path() hands back the entry joined to the base you passed in (migrations/001.sql, not 001.sql) — ready to open, no join needed

Sorting PathBufs compares component-wise, which is what you want for nested listings. Just remember it’s lexicographic: 10.sql sorts before 2.sql — zero-pad your file names or sort by a parsed key.

#169 May 2026

169. File::create_new — Atomic 'Create Only If It Doesn't Exist'

You want to write a config file, a lockfile, or a “did we run yet” sentinel — but only if it isn’t already there. The if path.exists() { … } else { File::create(path) } pattern looks fine until two processes hit it at the same time. There’s a one-line fix sitting in std::fs.

The naive guard is a textbook TOCTOU race: between the moment you check existence and the moment you call create, another process can slip in and put a file there. You’ll then happily truncate their work.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::fs::{self, File};
use std::path::Path;

fn write_once_racy(path: &Path) -> std::io::Result<File> {
    if path.exists() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::AlreadyExists,
            "already there",
        ));
    }
    // Window of vulnerability: another process can create the file here.
    File::create(path) // truncates if it now exists
}

File::create_new (stable since 1.77) collapses both steps into a single syscall — O_CREAT | O_EXCL on Unix, CREATE_NEW on Windows — so the kernel decides the winner:

1
2
3
4
5
6
use std::fs::File;
use std::path::Path;

fn write_once(path: &Path) -> std::io::Result<File> {
    File::create_new(path)
}

If the file already exists, you get back an io::Error with ErrorKind::AlreadyExists and nothing on disk is touched. That’s the whole behaviour — and it’s the same whether one process or fifty are racing for the same path.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::fs::{self, File};
use std::io::{ErrorKind, Write};

let path = std::env::temp_dir().join("rustbites-169.lock");
let _ = fs::remove_file(&path); // start clean

// First call wins and gets a writable handle.
let mut first = File::create_new(&path).expect("first create");
first.write_all(b"owner=me").unwrap();

// Second call fails — no truncation, no clobber.
let err = File::create_new(&path).unwrap_err();
assert_eq!(err.kind(), ErrorKind::AlreadyExists);

fs::remove_file(&path).unwrap();

For the equivalent guarantee on an existing handle you’d previously reach for OpenOptions::new().write(true).create_new(true).open(path) — that still works, and File::create_new is just the shorthand when you want the default “write, create-new, truncate-off” combo.

Use it for lockfiles, idempotent setup steps, “did we already write the manifest” checks, and anywhere the existence test and the create were a single logical step pretending to be two.