You now know when a named value dies: at the end of its scope, in reverse
declaration order. The remaining question is when an unnamed temporary
dies — the String produced by foo() in bar(&foo()), the Ref guard
produced by cell.borrow(), the MutexGuard produced by m.lock().unwrap().
Get this wrong and you get E0716, or a deadlock, or a RefCell panic. And the
rules changed in edition 2024, which is what this site compiles with — so a
large fraction of the advice you will find online is on the other side of the
change.
What a temporary scope is
A temporary produced by an expression is dropped at the end of its temporary scope. The Reference gives the list; these are the ones that come up:
- the whole function body;
-
a statement (the common case: temporaries die at the
;); -
the body of an
if,whileorloop; -
the
elseblock of anif; -
the operands of the lazy booleans
&&and||; - a match arm’s guard, and a match arm’s body.
The default you should carry around: a temporary lives until the end of the enclosing statement. That is why this fails —
fn bar(s: &String) -> &String { s }
fn foo() -> String { String::from("hi") }
let r = bar(&foo());
// error[E0716]: temporary value dropped while borrowed
println!("{r}");
— the temporary from foo() dies at the ;, and r is read on the next line.
Temporary lifetime extension
There is one important exception, and it has a simple pair of rules of thumb:
let x = &temp(); // extended: the temporary lives as long as `x`
let y = f(&temp()); // NOT extended: E0716
A & applied directly to a temporary in a let initialiser extends that
temporary to the lifetime of the binding. Route it through a function call, a
method call, or most other expressions and extension stops applying.
The full set of “extending patterns” in the Reference is longer — it reaches
through struct and tuple constructors, and through field accesses — but the two
lines above cover the cases you will actually hit, and knowing that the
distinction exists is most of the battle. When in doubt, name the temporary
with a let and the question disappears.
💡Predict each of these. Three of the four work. click to reveal
fn len(s: &String) -> usize { s.len() }
fn keep(s: &String) -> &String { s }
fn make() -> String { String::from("abcd") }
let a = &make(); // 1
let b = len(&make()); // 2
let c = keep(&make()); // 3
let d = { &make() }.len(); // 4
1 works. Direct & of a temporary in a let initialiser. The String is extended to live as long as a.
2 works — for a different reason, and the difference is the whole point. The temporary lives until the end of the statement; len runs inside that statement and returns a usize, which borrows nothing. Nothing outlives the temporary.
3 fails with E0716. Structurally identical to 2, except the result is a &String derived from the temporary, and b‘s binding outlives the statement. So E0716 is not about whether a temporary appears — it is about whether anything outlives it.
4 fails on edition 2024, and compiled on 2021. Extension does reach through a block’s tail expression, so let d = { &make() }; on its own is fine — but appending .len() means the & result is consumed by a method call rather than bound directly, extension no longer applies, and the 2024 tail-expression rule drops the temporary first. This is one of the two edition changes below.
Change 1: if let rescoping
In editions up to 2021, the temporaries created by an if let scrutinee lived
for the whole if let construct, including the else branch. That produced
a famous deadlock:
if let Some(x) = *lock.read().unwrap() {
// ...
} else {
*lock.write().unwrap() = ...; // 2021: read guard still alive -> deadlock
}
Edition 2024 drops the scrutinee’s temporaries before the else branch
runs. The same code now works. Here it is with a RefCell, which panics
rather than deadlocking so you can actually watch it:
use std::cell::RefCell;
let d = RefCell::new(None::<i32>);
if let Some(v) = *d.borrow() {
println!("some {v}");
} else {
*d.borrow_mut() = Some(9); // fine on 2024
println!("else branch mutated ok: {:?}", d.borrow());
}
Output on this site’s toolchain: else branch mutated ok: Some(9).
The migration lint is if_let_rescope, and it is allow-by-default — it will
not fire under this site’s gate, so it is knowledge rather than enforcement. If
you need the old behaviour for some reason, rewriting as match preserves it.
The asymmetry: match was not rescoped
This is now a trap in the opposite direction. match scrutinee temporaries
still live for the entire match:
use std::cell::RefCell;
let d = RefCell::new(None::<i32>);
match *d.borrow() {
Some(v) => println!("some {v}"),
None => {
*d.borrow_mut() = Some(9);
// thread 'main' panicked: RefCell already borrowed
}
}
That panics at runtime. Same shape as the if let, opposite outcome, because
the Ref guard from d.borrow() is still alive in the arm. clippy has
significant_drop_in_scrutinee for exactly this hazard (it is a nursery lint,
so not part of this gate).
So the advice “rewrite it as a match, they are the same thing” is no longer
true on edition 2024, and the difference is a deadlock or a panic rather than a
compile error.
Change 2: tail-expression temporaries
In editions up to 2021, temporaries in a block’s tail expression were dropped after the block’s local variables. Edition 2024 reverses that: tail expression temporaries drop before the locals.
The win — this compiles on 2024 and did not on 2021:
use std::cell::RefCell;
fn f() -> usize {
let c = RefCell::new(String::from("1234"));
c.borrow().len()
}
The Ref guard from c.borrow() now drops before c does, which is the order
that makes sense.
The cost — this compiled on 2021 and does not on 2024:
let x = { &String::from("1234") }.len();
// error[E0716]: temporary value dropped while borrowed
The migration lint is tail_expr_drop_order, also allow-by-default.
💡Why is the tail-expression change a *breaking* change at all, if dropping things earlier is generally safer? click to reveal
Because “earlier” is only safer when nothing was relying on the old order — and borrowck is exactly the thing that relies on order.
The 2021 rule kept tail temporaries alive slightly longer, which meant a reference derived from one could still be valid at the point the block’s value was consumed. { &String::from("1234") } produced a reference to a temporary that (under 2021) outlived the block just long enough for .len() to run. Under 2024 the temporary is gone first, and the reference dangles, so the compiler rejects it.
There is a second, subtler class of breakage that has nothing to do with borrowck: observable Drop order. If two values’ destructors interact — a guard and the thing it guards, a logger and a span, a transaction and a connection — reordering their drops changes runtime behaviour without changing a single type. That is why the change needed an edition boundary and a migration lint, rather than being applied everywhere at once.
The general principle worth taking away: drop order is part of a program’s semantics in Rust, not an implementation detail. Editions are how the language changes it without breaking existing code.
The practical rules
- A temporary dies at the end of its statement, unless an extending pattern applies.
-
let x = &temp();extends.let x = f(&temp());does not. - When E0716 confuses you, give the temporary a name. It costs one line and turns an unfamiliar error into a familiar one.
-
On edition 2024:
if letdrops scrutinee temporaries before theelse;matchdoes not drop them until the whole match is over. -
Never hold a lock or a
RefCellguard in a scrutinee if any arm needs the same lock. Bind it to a local first, and you control the drop point.
And when reading old material: check the edition. Advice about temporaries from before 2025 may be describing a language this site does not compile.