Rust's Drop Order Isn't What You Think: Why RAII Cleanup Can Fire in the Wrong Sequence
Here's a struct that looks entirely reasonable:
use std::fs::{self, File};
use std::path::PathBuf;
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
struct Session {
dir: TempDir,
file: File,
}
A Session owns a scratch directory and a file inside it. When the session goes out of scope, both need to disappear: the file handle closes, the directory gets deleted. Nothing about the code above tells you which happens first, and it turns out that matters, because on Windows you cannot delete a directory while something inside it still has an open handle. If dir is cleaned up before file, remove_dir_all fails with a sharing violation and leaves half a temp directory behind. On Linux this same code works fine, because Unix lets you unlink things that are still open, which is exactly the kind of platform difference that turns a struct field order into a real bug rather than a theoretical one.
The fix, once you know where to look, is to swap the two fields. That only makes sense once you understand the rule that got you here, because it isn't the rule most people assume.
Two different rules, and they point in opposite directions
Ask most Rust programmers how drop order works and they'll say "reverse order of declaration, like a stack." That's correct, but only for one specific case: local variables (and function parameters) in a scope. It is not true for the fields of a struct, tuple, or enum variant. Those drop in declaration order, forwards, the same order you wrote them in the type definition. This is documented explicitly in the Rust reference's section on destructors, and it is easy to demonstrate:
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
fn main() {
let _first = Guard("first");
let _second = Guard("second");
let _third = Guard("third");
}
This prints third, then second, then first. Locals unwind in reverse, last-declared-first-dropped, which matches the mental model of a stack and is why most people never question it. Now take exactly the same type and put it inside a struct instead:
struct Bundle {
first: Guard,
second: Guard,
third: Guard,
}
fn main() {
let _bundle = Bundle {
first: Guard("first"),
second: Guard("second"),
third: Guard("third"),
};
}
This prints first, then second, then third, in the order the fields were declared. Same Drop impl, same construction order, opposite result. Nothing in the syntax warns you that moving a value from "local variable" to "struct field" flips the order it gets cleaned up in.
The rule generalises the same way for anything that owns multiple values: tuples drop index 0 first, arrays and slices drop element 0 first, and a Vec<T> drops its elements from the front. The useful mental split is: bindings in a scope unwind like a stack (last in, first out); the contents of a single value drop in the order they're written (first in, first out). Two rules, and they're mirror images of each other.
Back to the temp directory
With that in mind, the Session struct's bug is obvious: dir is declared before file, so dir is dropped first, which means the temp directory removal runs while the file handle is still open. Swapping the declaration order fixes it:
struct Session {
file: File,
dir: TempDir,
}
Now file drops first, closing the handle, then dir removes the (now unheld) directory. That's the whole fix, and it's a one-line diff once you know the rule. The reason this is worth dwelling on isn't the fix, it's that the bug is invisible in review unless you already know to check field order against resource dependencies. A MutexGuard next to a type whose Drop impl expects the lock already released, a database connection next to a transaction handle, a file descriptor next to an epoll registration that references it: all the same shape of problem, and all silent until they run on the one platform or the one timing window where the ordering actually bites.
When you can't just reorder the fields
Reordering works as long as there's a single fixed order that's always correct. Sometimes there isn't, for instance when the right order depends on a runtime condition, or when you need finer control than "before" and "after" for exactly two fields. In that case, wrap the fields in Option and drop them explicitly in your own Drop impl:
struct Session {
file: Option,
dir: Option,
}
impl Drop for Session {
fn drop(&mut self) {
// Explicit order, independent of field declaration order.
self.file.take();
self.dir.take();
}
}
Option::take replaces the field with None and returns the old value, which is then dropped immediately as a temporary. This costs you an Option wrapper and a couple of .take() calls, but it means the order is spelled out in the code instead of inferred from field declaration position, which is a much better trade when the ordering constraint is actually load-bearing. There's also ManuallyDrop<T>, which gets you the same explicit control without the Option tag, at the cost of an unsafe block to call ManuallyDrop::drop yourself, since the compiler can no longer prove it happens exactly once. Reach for Option first; ManuallyDrop is for when the tag byte or the branch actually shows up in a profile.
For local variables, the equivalent tool is just calling drop(x) directly, which moves x and drops it immediately rather than waiting for the end of scope. This is the standard fix for the classic "why did this deadlock" bug where a MutexGuard is still alive, and therefore still holding the lock, later in the same function than you intended:
let guard = mutex.lock().unwrap();
do_something_with(&guard);
drop(guard); // release the lock now, not at the end of the function
do_something_else_that_also_wants_the_lock();
One more wrinkle worth flagging rather than exploring in depth: if a Drop impl panics while the thread is already unwinding from an earlier panic, Rust doesn't try to keep going, it aborts the process. Ordering bugs and panicking destructors compound each other, so a `Drop` impl that can fail is worth writing defensively (log and continue, don't propagate) regardless of what order it runs in.
None of this makes Rust's RAII model unreliable, deterministic destruction is still exactly what it promises, and it delivers on that promise. The catch is that "deterministic" and "the order you'd guess" are different claims, and the reference spells out which one you actually get. Worth a five-minute check the next time a struct's fields include anything with a side-effecting Drop impl and a real dependency between them.