The crash that never happens
You write a function that parses a configuration string, extracts a temporary buffer, and tries to return a slice of that buffer. The compiler rejects the code. You move a variable declaration inside a block, take a reference, and try to use that reference after the block closes. The compiler rejects the code again.
In C or C++, this code compiles. It runs until the moment you dereference the pointer. Then you get a segmentation fault, corrupted memory, or a security vulnerability. Rust refuses to compile the code at all. The guarantee is absolute: a reference can never point to data that has been dropped. This is not a runtime check. There is no overhead. The compiler proves the data lives long enough before the binary is created.
The rental agreement analogy
Think of a reference as a rental agreement for a storage unit. The agreement gives you access to the contents, but it is only valid as long as the unit is leased. If the lease expires, the agreement becomes worthless paper. You cannot use it to open the unit.
Rust treats every reference like that agreement. The data is the storage unit. The reference is the agreement. The compiler tracks the expiration date of every unit. Before it lets you create a reference, it checks the schedule. If the unit is scheduled to be demolished before you are done with the agreement, the compiler stops you. It forces you to either extend the lease or copy the contents to a new unit that you own.
This analogy maps directly to Rust's ownership model. The owner of the data controls the lifetime. References are non-owners. They borrow access. The borrow is only valid while the owner keeps the data alive.
Minimal example: the scope trap
The most common way to trigger a dangling reference is by crossing scope boundaries. A scope is a block of code delimited by braces. Variables declared inside a scope are dropped when the scope ends.
fn main() {
// Declare a variable to hold a reference, but leave it uninitialized.
let reference_to_nothing;
// Open a new scope. Variables created here will be dropped at the closing brace.
{
let x = 10;
// Attempt to assign a reference to `x`.
// The compiler sees that `x` will be dropped when this block ends.
reference_to_nothing = &x;
}
// The inner scope has ended. `x` no longer exists.
// `reference_to_nothing` would point to deallocated stack memory.
// Rust rejects this code.
println!("reference_to_nothing: {reference_to_nothing}");
}
The compiler produces error E0597: borrowed value does not live long enough. It tells you exactly which value is dying too soon. The fix is to ensure the data outlives the reference. You can move the data declaration outside the inner scope.
fn main() {
// Declare `x` in the outer scope. It lives until `main` ends.
let x = 10;
let reference_to_nothing;
{
// `x` is still alive here. The reference is valid.
reference_to_nothing = &x;
}
// The inner scope ends, but `x` survives because it was declared outside.
// The reference remains valid.
println!("reference_to_nothing: {reference_to_nothing}");
}
The rule is simple. The borrower must always have a shorter lifetime than the lender. If the borrower tries to outlive the lender, the compiler rejects the code.
What the compiler sees: lifetimes
Rust assigns a hidden label to every reference, called a lifetime. Lifetimes are denoted by an apostrophe followed by a name, like 'a. You rarely write these labels manually. The compiler infers them through lifetime elision rules.
When you write &x, the compiler attaches a lifetime to that reference. It tracks how long x lives. If you pass a reference to a function, the compiler checks that the function does not store the reference somewhere that outlives the data.
Consider a function that returns a reference.
/// Returns a reference to a string literal.
/// String literals have a 'static lifetime, meaning they live for the entire program.
fn greeting() -> &'static str {
"Hello, world!"
}
This compiles. The string literal is embedded in the binary. It lives forever. The reference returned has a 'static lifetime, which is the longest possible lifetime. Any code can hold this reference safely.
Now consider a function that tries to return a reference to local data.
/// Attempts to return a reference to a local String.
/// This function will not compile.
fn dangerous() -> &String {
let s = String::from("hello");
// `s` is created here. It will be dropped when the function returns.
// Returning `&s` would return a reference to data that no longer exists.
&s
}
The compiler rejects this with error E0515: cannot return value referencing local variable. The local variable s is dropped at the end of the function. The reference would dangle immediately.
To fix this, you must return owned data instead of a reference.
/// Returns an owned String.
/// The caller takes ownership and is responsible for dropping the data.
fn safe_owned() -> String {
let s = String::from("hello");
// `s` is moved out of the function.
// The caller now owns the data. No reference is involved.
s
}
Or you can return a reference to data that was passed in, provided the input lives long enough.
/// Returns a reference to the first character of the input.
/// The returned reference borrows from `s`, so it cannot outlive `s`.
fn first_char(s: &str) -> &str {
// `s` is borrowed. The slice `&s[..1]` borrows from `s`.
// The compiler infers that the return lifetime is tied to the input lifetime.
&s[..1]
}
The compiler enforces that the output lifetime cannot exceed the input lifetime. If you call first_char, the returned reference dies when s dies. This chain of lifetimes prevents dangling references across function boundaries.
Realistic example: structs with references
Structs can hold references. When they do, the struct itself becomes tied to the lifetime of the data it references.
/// A struct that holds a reference to a string slice.
/// The lifetime parameter `'a` links the struct to the data it references.
struct Note<'a> {
text: &'a str,
}
fn main() {
// `content` lives in this scope.
let content = String::from("Important data");
// Create a `Note` that references `content`.
// The `Note` cannot outlive `content`.
let note = Note { text: &content };
println!("Note text: {}", note.text);
// `note` is dropped here. Then `content` is dropped.
// Everything is clean.
}
This works because content outlives note. If you try to store the Note somewhere that outlives content, the compiler stops you.
fn create_dangling_note() -> Note<'static> {
let content = String::from("Temporary");
// `content` is local. It will be dropped when the function returns.
// `Note` references `content`.
// Returning `Note` would return a struct containing a dangling reference.
Note { text: &content }
}
The compiler rejects this. The Note struct carries the lifetime of the data. You cannot return a Note that references local data. You must either return an owned String inside the struct, or ensure the data lives long enough.
/// A struct that owns its data.
/// No lifetime parameters are needed.
struct OwnedNote {
text: String,
}
fn create_owned_note() -> OwnedNote {
let content = String::from("Temporary");
// `content` is moved into the struct.
// The struct now owns the data. It can be returned safely.
OwnedNote { text: content }
}
Using owned data removes the lifetime constraints. The struct carries the data with it. This is often the right choice when the data needs to travel across scopes or be stored in collections.
Pitfalls and error codes
Dangling reference errors usually fall into two categories. Understanding the error codes helps you fix them quickly.
Error E0597 appears when a borrowed value does not live long enough. This happens when you try to use a reference after the data has been dropped. The fix is to extend the scope of the data or move the data to a longer-lived location.
Error E0515 appears when you try to return a value referencing local data. This happens in functions. The fix is to return owned data or return a reference to input data that the caller provides.
A common pitfall is assuming that mutable references change lifetime rules. They do not. A &mut T reference has the same lifetime constraints as a &T reference. The mutability affects aliasing rules, not dangling checks. If you borrow mutably, the data must still live long enough.
Another pitfall is mixing owned and borrowed data in collections. If you store references in a Vec, every reference must outlive the Vec. If the data is created inside a loop, the references will dangle as soon as the loop iteration ends.
fn main() {
let mut references = Vec::new();
for i in 0..5 {
let temp = format!("Item {}", i);
// `temp` is created here. It will be dropped at the end of the loop iteration.
// Storing `&temp` in the vector creates a dangling reference.
references.push(&temp);
}
// The vector contains references to data that no longer exists.
// The compiler rejects this code.
}
The fix is to store owned data in the vector.
fn main() {
let mut items = Vec::new();
for i in 0..5 {
let temp = format!("Item {}", i);
// `temp` is moved into the vector.
// The vector owns the data. No references are involved.
items.push(temp);
}
// The vector owns all the strings. They are safe to use.
for item in &items {
println!("{}", item);
}
}
Convention aside: when taking string arguments, prefer &str over &String. The type &str is a string slice. It accepts String, &String, and string literals. Using &String forces the caller to have a String, which is unnecessarily restrictive. The compiler can coerce &String to &str automatically. Taking &str makes your API more flexible without changing the lifetime semantics.
Decision: references vs ownership vs smart pointers
Choosing between references, owned data, and smart pointers depends on how long the data needs to live and how many owners are required.
Use references when you need to read or modify data without taking ownership and the data lives longer than the usage. References have zero cost. They are the default choice for function arguments and temporary access.
Use owned types when the data is created locally and needs to survive the current scope. Owned data carries the value with it. It removes lifetime constraints. Use String instead of &str, Vec<T> instead of &[T], and custom structs with owned fields when the data must travel across scopes or be stored in collections.
Use Rc<T> or Arc<T> when multiple owners are required and lifetimes are too complex to track statically. These smart pointers use reference counting to manage the data. The data lives as long as any owner holds a pointer. Use Rc<T> for single-threaded code and Arc<T> for concurrent code. Reference counting adds a small runtime overhead, so measure before using it.
Use Cow<str> when a function might return a reference or owned data depending on runtime conditions. Cow stands for "Clone on Write". It wraps either a borrowed reference or an owned value. This allows you to return a reference when possible and fall back to cloning only when necessary.
Use &'static str for constants and string literals that live for the entire program. These references never dangle. They are safe to store anywhere.
Trust the borrow checker. It maps the flow of data through your program. If the compiler rejects a reference, the data is dying. Move the data, extend the scope, or use a smart pointer. Dangling references are impossible in safe Rust. Your binary is safe.