Rust drop order: locals, fields, temporaries, and why let _ drops right away

Rust drops locals in reverse order and struct fields in declaration order. Temporaries usually die at the end of the statement, and let _ binds nothing. Plus Rust 2024's scope changes.

rustdropraiirust-2024language-spec

In Rust, values that clean up when a scope ends, such as mutex guards, file handles and transactions, behave according to when Drop runs. Most of the time that matches intuition. Then a line like let _ = mutex.lock().unwrap(); turns out to release the lock on the very line that was supposed to take it, or an if let deadlocks because its else block tries to take the same lock again.

This post works through the drop order rules using the destructors chapter of The Rust Reference and the Rust 2024 edition guide. The level is intermediate: it assumes you know ownership and the Drop trait.

The basic rules

Summarized from the Reference:

  • Every variable and temporary belongs to a drop scope. When control flow leaves that scope, its variables are dropped in reverse order of declaration and its temporaries in reverse order of creation.
  • A local declared with let belongs to the scope of the block containing the let.
  • Function parameters belong to the scope of the whole function body, so they are dropped last when the function finishes.
  • Dropping one value first calls Drop::drop if the type implements it, then recursively drops its fields. Struct fields drop in declaration order, tuple elements in order, and elements of an array or owned slice from the first to the last.
  • Variables bound inside one pattern drop in reverse order of their declaration within the pattern.

Locals go in reverse, fields go forward. Once you remember that the two run in opposite directions, most of what follows comes from it.

let _ binds nothing

The patterns chapter of the Reference says of the wildcard pattern _: unlike identifier patterns, it does not copy, move, or borrow the value it matches. A name that starts with an underscore, like _c, is an ordinary identifier pattern that creates a real binding: it copies a Copy value and moves a non-Copy value. The Noisy value in the example below is moved, because Noisy is not Copy. They look alike but behave differently.

The temporary rules add to this. The initializer of a let statement is a place expression context, and when a value expression such as a function call appears there, an unnamed temporary is created. That temporary’s drop scope is usually the end of the statement. Binding by reference, as in let x = &temp();, extends the temporary to the end of the block, but _ binds nothing, so there is no extension.

Here is an example.

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

struct Pair {
    first: Noisy,
    second: Noisy,
}

fn main() {
    let a = Noisy("a");
    let _ = Noisy("b");
    let _c = Noisy("c");
    let pair = Pair { first: Noisy("pair.first"), second: Noisy("pair.second") };
    let _ = a;
    println!("end of main");
}

The output below is derived from the Reference rules. It was not produced by running the program in the environment this post was written in.

drop b
end of main
drop pair.first
drop pair.second
drop c
drop a
  • In let _ = Noisy("b");, Noisy("b") is a temporary that _ does not bind, so it is dropped as soon as that let statement ends. That is why it prints before end of main.
  • let _ = a; does not move a. Nothing happens on that line; a stays where it is and is dropped when main ends.
  • At the end of main, variables drop in reverse declaration order: pair, _c, a.
  • Dropping pair drops its fields in declaration order: first, then second.

Where this bites: locks

The rustc lint documentation gives this example:

let _ = data.lock().unwrap();
println!("doing some work");

The MutexGuard returned by data.lock().unwrap() is a temporary that _ does not bind, so it is dropped as soon as the statement ends. The lock is released immediately and the next line runs without it. rustc catches this with the let_underscore_lock lint, which was deny by default in the lint listing checked on 2026-10-14, so it is a compile error.

That lint only covers synchronization locks. For other values that implement Drop, such as a guard type you wrote yourself, the relevant lint is let_underscore_drop, which is allow by default, so the code passes silently unless you turn it on. The lint documentation suggests two fixes:

  • To keep the value until the end of the scope, bind it to a name that starts with an underscore, such as let _guard = ....
  • If dropping it immediately is really what you want, call drop(...) to make that intent visible.

std::mem::drop is not special. It is an ordinary function defined as pub fn drop<T>(_x: T) {}: it takes the value by move, and the value is dropped when the function returns. For a Copy type, a copy is passed in, so the original is unaffected.

Temporary scopes

Without lifetime extension, a temporary’s drop scope is the smallest of the following that contains the expression. These are the cases from the Reference’s list you meet most often:

  • A statement
  • The body of an if, while or loop, and the else block of an if
  • The ordinary (non-pattern-matching) condition of an if or while
  • Each operand of && and ||
  • The body of a match arm
  • The entire tail expression of a block (2024 edition)
  • The condition and then block of an if let (2024 edition)

The scrutinee of a match is not on this list. The Reference says so explicitly, and as a result temporaries created in the scrutinee live past the match expression, usually to the end of the statement. If you write match *value.read().unwrap() { ... }, the read lock is held while any arm runs. This is the same in every edition.

Two changes in the Rust 2024 edition

According to the edition guide, the Rust 2024 edition was stabilized in Rust 1.85.0, and it adds two rules that narrow temporary scopes. The edition is a per-crate setting; with Cargo it is the edition key under [package] in Cargo.toml. The Cargo documentation says that if the key is absent, the 2015 edition is assumed.

if let temporaries drop before else

This is the edition guide’s example:

use std::sync::RwLock;

fn f(value: &RwLock<Option<bool>>) {
    if let Some(x) = *value.read().unwrap() {
        println!("value is {x}");
    } else {
        let mut v = value.write().unwrap();
        if v.is_none() {
            *v = Some(true);
        }
    }
}

Up to the 2021 edition, the read lock temporary from value.read() lived until the whole if let expression ended, meaning after the else block. Entering else and trying to take the write lock therefore deadlocks. In the 2024 edition the temporary is dropped once the then block finishes or control enters else, so this code works as intended.

Some code may instead rely on the old behaviour of keeping the temporary alive past else. The edition guide notes that rewriting if let as a match is always safe: as shown above, temporaries in a match scrutinee live past the match, which matches 2021 if let. cargo fix --edition proposes such match rewrites through the if_let_rescope lint, and the guide recommends reviewing each change afterwards to see whether you need it. In the example above, converting to match brings the deadlock back.

Tail expression temporaries drop before locals

Up to the 2021 edition, temporaries created in the last expression of a block or function body (the tail expression) were dropped after the block’s local variables. That is why this code fails in 2021 with E0597, saying c does not live long enough:

use std::cell::RefCell;

fn f() -> usize {
    let c = RefCell::new("..");
    c.borrow().len()
}

In the 2024 edition the temporary from c.borrow() is dropped first and c after it, so it compiles.

The same change makes some code stop compiling in 2024. The edition guide’s example, let x = { &String::from("1234") }.len();, worked in 2021 because the temporary String was extended to the end of the statement. In 2024 it is dropped at the end of the block, giving an error about a temporary dropped while borrowed. The guide’s fix is to bind the block’s result to a local first, as in let s = { &String::from("1234") };, so that it gets lifetime extension.

There is no semantics-preserving automatic rewrite for the tail expression change. The tail_expr_drop_order lint only points out values that may be affected and does not change your code, so you have to read the warnings and decide.

In short

  • Locals in a block drop in reverse declaration order; struct fields drop in declaration order. If you have seen similar rules elsewhere, this is the same direction as Go’s defer, which runs calls in reverse order of registration, and Java’s try-with-resources, which closes resources in reverse declaration order.
  • let _ = expr; binds nothing. If expr is a temporary it is dropped at the end of the statement; if it is an existing variable it is not moved. To keep a value until the end of the scope, use a name like _guard.
  • Temporaries in a match scrutinee outlive the match.
  • In the 2024 edition, if let temporaries drop before else, and tail expression temporaries drop before locals. When moving a crate to the new edition, check the warnings from those two lints on any code that holds locks or RefCell borrows.

Scope and sources

This post is based on The Rust Reference and the Rust Edition Guide as checked on 2026-10-14. The Reference pages showed no version label; test links on the destructors page pointed at the 1.99.0 tag of rust-lang/rust. The Reference states that the exact rules for temporary lifetime extension are subject to change and describe current behaviour only. Lint default levels (deny/allow) are as listed in the rustc documentation on the check date.

Not covered: drops during panic unwinding, the drop order of variables a closure captures by move (unspecified in the Reference), ManuallyDrop and mem::forget, and async code. Example output is derived from the rules, not from a run.

The links below were checked on 2026-10-14.

Back to the blog