Scope and Lifetime of Bindings
Every variable binding in Rust has two distinct but related properties: its scope (the lexical region of source code where the variable's name can be referenced) and its lifetime (the span of execution during which the value stored in that binding exists in memory before being dropped).
Scope vs Lifetime of a Binding
| Concept | What It Governs | Determined By | When It Begins and Ends |
|---|---|---|---|
| Scope | Name visibility (where the identifier x can be written in source code) |
Lexical block structure ({ ... }) and shadowing |
Starts immediately after the let statement; ends at the closing } of its block (or while shadowed) |
| Lifetime (storage duration) | Memory residency (when the value is allocated, valid, and destroyed) | Block structure, moves, and explicit early drops (drop(x)) |
Starts when the value is initialized; ends when the value is moved out or dropped at the end of its block |
In simple cases, a variable's scope and lifetime end together at the closing brace } of the block in which it was declared:
fn main() {
{
let message = String::from("scoped value"); // `message` enters scope & is allocated
println!("{message}");
} // `message` goes out of scope AND its value is dropped here
// `message` is neither in scope nor alive in memory here
}
scoped value
Attempting to reference a binding outside its lexical block scope produces E0425:
// ❌ does not compile
fn main() {
{
let inner_value = 42;
}
println!("{inner_value}");
}
error[E0425]: cannot find value `inner_value` in this scope
--> src/main.rs:6:16
|
6 | println!("{inner_value}");
| ^^^^^^^^^^^ not found in this scope
error: aborting due to 1 previous error
Lexical Scope Rules Within a Block
Unlike function items (fn) and constants (const), which are visible throughout their entire enclosing block even before the line where they are defined, let bindings are introduced sequentially. A variable binding is not in scope on lines above its let declaration:
// ❌ does not compile
fn main() {
println!("{x}");
let x = 10;
}
error[E0425]: cannot find value `x` in this scope
--> src/main.rs:2:16
|
2 | println!("{x}");
| ^ not found in this scope
error: aborting due to 1 previous error
Nested Blocks and Outer Visibility
An inner block { ... } inherits visibility of all bindings currently in scope in the outer block, while any bindings declared inside the inner block remain confined to that inner block:
fn main() {
let outer = 1; --+
| `outer` is in scope
{ |
let inner = 2; --+ |
// Both `outer` | `inner` is in scope
// and `inner` | |
// are visible | |
} --+ | (`inner` dropped at `}`)
|
// Only `outer` is visible |
} --+ (`outer` dropped at `}`)
fn main() {
let outer = 100;
let sum = {
let inner = 50;
outer + inner // Reads `outer` from enclosing scope
};
println!("outer = {outer}, sum = {sum}");
}
outer = 100, sum = 150
Deterministic Destruction and Reverse Drop Order (LIFO)
When control leaves a block (whether by reaching the closing }, executing return, break, or continue, or unwinding during a panic), Rust automatically cleans up every live variable declared in that block.
Variables within the same block are dropped in reverse declaration order (Last-In, First-Out, or LIFO). This ensures that a later variable—which might hold a reference to or depend on an earlier variable—is always destroyed before the earlier variable it depends on:
struct Tracer(&'static str);
impl Drop for Tracer {
fn drop(&mut self) {
println!("Dropping {}", self.0);
}
}
fn main() {
let _first = Tracer("1. first (outer)");
let _second = Tracer("2. second (outer)");
{
let _inner_a = Tracer("3. inner_a");
let _inner_b = Tracer("4. inner_b");
println!("--- Reaching end of inner block ---");
}
println!("--- Reaching end of main ---");
}
--- Reaching end of inner block ---
Dropping 4. inner_b
Dropping 3. inner_a
--- Reaching end of main ---
Dropping 2. second (outer)
Dropping 1. first (outer)
When Scope and Value Lifetime Diverge
A variable's lexical scope and its value's lifetime are not always identical. There are three common situations where they diverge:
1. Moving or Early Dropping a Value (Scope Continues, Value Lifetime Ends Early)
If a non-Copy value is moved to another variable or passed to std::mem::drop(x), the value's lifetime in that binding ends immediately, even though the variable's name remains in lexical scope until the end of the block:
fn main() {
let text = String::from("hello");
drop(text); // Value is destroyed and deallocated right here!
// `text` is still in lexical scope (it is NOT an "unknown identifier" E0425 error),
// but the compiler tracks that its value has already been moved/dropped (E0382).
println!("After drop");
}
After drop
Attempting to read text after drop(text) produces a move error (E0382), not a scope error (E0425):
// ❌ does not compile
fn main() {
let text = String::from("hello");
drop(text);
println!("{text}");
}
error[E0382]: borrow of moved value: `text`
--> src/main.rs:4:15
|
2 | let text = String::from("hello");
| ---- move occurs because `text` has type `String`, which does not implement the `Copy` trait
3 | drop(text);
| ---- value moved here
4 | println!("{text}");
| ^^^^^^ value borrowed here after move
error: aborting due to 1 previous error
2. Moving a Value Out of an Inner Block (Scope Ends, Value Lifetime Continues)
When an inner block returns a value via its tail expression, the inner binding's lexical scope ends at }, but the value itself moves into the outer binding and continues living:
fn main() {
let outer_holder = {
let inner_temp = String::from("created inside block");
inner_temp // Ownership moves out of the block; NOT dropped at `}`!
};
// `inner_temp` is out of scope, but its String data is alive inside `outer_holder`
println!("{outer_holder}");
}
created inside block
3. Shadowing a Binding (Name Leaves Scope, Value Remains Alive)
When let x = ... shadows an earlier x in the same block, the first x can no longer be named directly, but its value remains alive on the stack until the enclosing block ends:
struct Tracked(&'static str);
impl Drop for Tracked {
fn drop(&mut self) {
println!("Dropped {}", self.0);
}
}
fn main() {
let x = Tracked("first x");
println!("About to shadow x");
let x = Tracked("second x");
println!("Still inside main with {}", x.0);
}
About to shadow x
Still inside main with second x
Dropped second x
Dropped first x
first x is not dropped when shadowed; it stays alive until main exits and drops after second x.
Wildcard _ vs _name Bindings
The single underscore _ is a wildcard pattern, not a variable binding. Assigning a value to let _ = expr; does not bind the value to a variable; instead, the value is dropped immediately at the end of that statement rather than living until the end of the block.
By contrast, an identifier starting with an underscore—such as let _guard = expr;—is a normal variable binding (with unused-variable warnings suppressed) whose value lives until the end of the enclosing block:
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("Dropped {}", self.0);
}
}
fn main() {
let _ = Guard("wildcard `_` (dropped immediately)");
let _named = Guard("`_named` binding (lives until block end)");
println!("Reached end of main body");
}
Dropped wildcard `_` (dropped immediately)
Reached end of main body
Dropped `_named` binding (lives until block end)
Never write let _ = mutex.lock().unwrap(); when holding a lock guard or RAII scope guard, because let _ = ... drops and unlocks the guard on the very same line. Always use a named binding like let _guard = ... so the guard stays alive until the end of the scope.