#273 Jul 21, 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.

← Previous 272. fs::create_dir_all — mkdir -p Without the exists() Check Next → 274. fetch_update — The CAS Loop You Keep Writing by Hand