Skip to content
← All articles

Temporary scopes: E0716 and what edition 2024 changed

The last piece needed to fully predict when any value dies — and because this site compiles with `--edition 2024`, the two changed rules are live here while most tutorials on the internet are on the other side of them.

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, while or loop;
  • the else block of an if;
  • 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

  1. A temporary dies at the end of its statement, unless an extending pattern applies.
  2. let x = &temp(); extends. let x = f(&temp()); does not.
  3. When E0716 confuses you, give the temporary a name. It costs one line and turns an unfamiliar error into a familiar one.
  4. On edition 2024: if let drops scrutinee temporaries before the else; match does not drop them until the whole match is over.
  5. Never hold a lock or a RefCell guard 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.