Rust Lifetimes Deep Dive
Lifetimes are how Rust tracks how long references live. This deep dive covers annotations, elision rules, generic lifetimes, and the common error patterns.
What you'll learn
- ✓What lifetimes actually annotate
- ✓How elision lets you skip most annotations
- ✓Lifetimes on structs and methods
- ✓The static lifetime
- ✓Reading lifetime errors and fixing them
Prerequisites
- •Basic familiarity with the language
Lifetimes are the part of Rust people stall on after they have the basics of ownership down. They feel like a separate language layered on top of the type system. They are not. Lifetimes are just labels the compiler uses to prove that references never outlive what they point at.
What a lifetime is
A lifetime is a region of code during which a reference is guaranteed to be valid. The compiler infers it for every reference. Most of the time, you never write a lifetime annotation; the compiler figures it out.
fn first_word(s: &str) -> &str {
s.split_whitespace().next().unwrap_or("")
}
The returned &str borrows from s. The compiler knows that as long as the caller has access to the input, the output is also valid.
When the compiler cannot figure it out, you have to spell it out with named lifetimes.
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() >= y.len() { x } else { y }
}
'a is a generic lifetime parameter. It says: the output reference lives at least as long as the shorter of x and y. The lifetime is not a value at runtime; it is a constraint the compiler checks at compile time.
The mental model
Picture each variable as a horizontal bar showing when it is alive. A reference is an arrow into that bar. The borrow checker insists that the arrow tail (the reference) sits inside the bar (the referent).
'a: ----------[ x: String ]------------
^ ^
| |
&x -------ref-----+
must end before x does A lifetime annotation like 'a puts a name on one of these intervals so the compiler can talk about it in function signatures and struct definitions.
Elision rules
You rarely write lifetimes because the compiler applies a few elision rules to functions:
- Every parameter that is a reference gets its own lifetime.
- If there is exactly one input lifetime, it is assigned to all output references.
- If there are multiple inputs but one of them is
&selfor&mut self, that lifetime is assigned to all outputs.
These cover most signatures. The first_word example above works because of rule 2; the input &str is the only candidate, so the output borrows from it.
When elision cannot decide, the compiler asks you to annotate.
Lifetimes on structs
A struct that holds a reference needs a lifetime parameter.
struct Cursor<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Cursor<'a> {
fn new(data: &'a [u8]) -> Self {
Cursor { data, pos: 0 }
}
fn peek(&self) -> Option<u8> {
self.data.get(self.pos).copied()
}
}
The 'a here ties the struct’s existence to the data it borrows. A Cursor<'a> cannot outlive the &'a [u8] it was built from. The compiler enforces that across every call.
Multiple lifetimes
Sometimes two inputs have independent lifetimes and you do not want to conflate them.
struct Pair<'a, 'b> {
name: &'a str,
value: &'b str,
}
fn pair<'a, 'b>(name: &'a str, value: &'b str) -> Pair<'a, 'b> {
Pair { name, value }
}
Use multiple lifetimes when the lifetimes really are independent. If you always end up writing 'a, 'b and constraining them with where 'a: 'b, consider whether one is enough.
The static lifetime
'static is the special lifetime for data that lives for the entire program. String literals are &'static str. Anything in static memory is 'static. Heap allocations are not 'static by default, but you can leak them to become so.
let s: &'static str = "hello";
let leaked: &'static [u8] = Box::leak(vec![1, 2, 3].into_boxed_slice());
A common confusion: 'static as a trait bound (T: 'static) means the type contains no non-static references. An owned String is 'static, even though it allocates and frees normally. It says nothing about how long any particular value actually lives.
Reading lifetime errors
A typical error:
error[E0597]: `s` does not live long enough
--> src/main.rs:5:13
|
5 | let r = &s;
| ^^ borrowed value does not live long enough
6 | drop(s);
7 | println!("{}", r);
| - borrow used here, after `s` was dropped
The pattern is always: the borrow happens here, the value is destroyed there, and the borrow is used after that. Find those three lines and the fix is almost always to change ordering, restructure ownership, or copy the value.
Common pitfalls
Trying to return a reference into a value created inside the function. The function ends, the value drops, the reference would dangle. Return the owned value instead, or pass in a buffer and return a reference into that.
Storing references in long-lived structs. A struct with a borrow stops being usable once the borrowed data goes away. Often the right design is to own the data, not borrow it.
Fighting 'static bounds in async or threading code. tokio::spawn requires 'static because the runtime cannot prove your task ends before the borrow ends. The fix is usually Arc<T> or moving owned data into the task.
Adding lifetime parameters everywhere when one would do. Lifetimes are not free in cognitive cost. Use the fewest your design actually needs.
Practical tips
When you hit a lifetime puzzle, ask whether the design really needs a reference. Many borrowing knots untie themselves if you switch to String instead of &str or Arc<Foo> instead of &Foo.
Read function signatures lifetimes-first when reviewing code. The lifetimes encode the contract for what borrows from what.
If you find yourself writing complex constraints like 'a: 'b + 'c, step back and check whether the signature is doing too much. Splitting the function or copying data is often cleaner.
Wrap-up
Lifetimes are just regions, and lifetime annotations are just names for those regions. The compiler does most of the work through elision. The cases where you write annotations are the ones where the signature would otherwise be ambiguous. Read them as contracts between caller and callee, and they stop feeling like syntax noise and start reading like type-level documentation.
Related articles
- Rust Rust Lifetimes: A Gentle Intro
A friendly introduction to Rust lifetimes — what they are, when you must annotate them, common function signatures, elision rules, and struct lifetimes.
- Rust Rust Actix vs Axum Comparison
A side-by-side comparison of Actix Web and Axum, covering architecture, ergonomics, performance, ecosystem, and how to pick the right one for your project.
- Rust Rust async/await and Futures Explained
How Rust's async/await desugars into a state machine, what a Future actually is, and the runtime model that makes it efficient on real workloads.
- Rust Rust Axum Web Framework Tutorial
A practical introduction to building HTTP services in Rust using the Axum web framework, with routing, extractors, state, and JSON handling.