Lifetimes in Practice
Lifetimes are Rust's way of tracking how long references are valid. The compiler infers them silently in the vast majority of code — you write functions, return references, and never think about it. But when the compiler cannot figure it out on its own, it asks you to be explicit.
That moment is where most developers get stuck. This tutorial skips the theory and focuses on the real situations where lifetime annotations appear, what they mean, and the patterns that resolve them.
What a lifetime annotation actually says
A lifetime annotation like 'a is not a duration. It does not say "this reference lives for X seconds." It is a constraint: it says that two or more references must be valid for the same region of code.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
This says: the returned reference is valid for as long as both x and y are valid. The caller must keep both alive as long as they use the return value. The compiler enforces this — it will reject code that drops one of them too early.
Scenario 1 - Returning a reference to one of two inputs
This is the most common place lifetime annotations appear. When a function takes multiple references and returns one of them, the compiler needs to know which input the output borrows from.
Why annotation is required here
// ERROR: missing lifetime specifier
fn pick(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}
The compiler cannot tell at compile time whether the returned reference comes from a or b. Both are possible. Without knowing which one, it cannot verify the caller's usage is safe.
The fix
fn pick<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
Now the compiler knows: the return value borrows from whichever of a or b is returned, and both must outlive the return value. The caller gets a useful constraint rather than an unpredictable one.
let result;
let s1 = String::from("long string");
{
let s2 = String::from("xy");
result = pick(&s1, &s2); // result borrows from whichever is longer
println!("{}", result); // OK — both s1 and s2 are alive here
}
// result cannot be used here — s2 has been dropped
Scenario 2 - Structs that hold references
When a struct holds a reference, the struct cannot outlive the data it points to. Lifetimes on struct fields express this constraint.
struct Config<'a> {
host: &'a str,
port: u16,
}
This says: a Config cannot outlive the string it borrows host from. The compiler enforces this wherever Config is used.
fn build_config<'a>(host: &'a str) -> Config<'a> {
Config { host, port: 8080 }
}
If the string host points to is dropped while a Config is still alive, the compiler rejects it.
When to use this vs. owning the data
Structs with lifetime parameters are more flexible but more constraining to use. They cannot be easily moved across thread boundaries or stored in places that require 'static.
A simpler alternative is often to let the struct own its data:
struct Config {
host: String, // owned, no lifetime needed
port: u16,
}
Use a lifetime parameter on a struct when the struct is short-lived and the caller clearly owns the underlying data — parsing results, views into a buffer, temporary configuration windows. For long-lived structs, owning the data is usually cleaner.
Scenario 3 - The lifetime elision rules
Most functions do not need explicit lifetime annotations because the compiler applies three elision rules automatically.
Rule 1: Each reference parameter gets its own lifetime.
fn foo(x: &str, y: &str) -> ...
// becomes: fn foo<'a, 'b>(x: &'a str, y: &'b str) -> ...
Rule 2: If there is exactly one input reference, its lifetime is used for any output references.
fn first_word(s: &str) -> &str
// becomes: fn first_word<'a>(s: &'a str) -> &'a str
// no annotation needed — the compiler fills it in
Rule 3: If one of the inputs is &self or &mut self, its lifetime is used for any output references.
impl Config {
fn host(&self) -> &str { &self.host }
// becomes: fn host<'a>(&'a self) -> &'a str
}
These three rules cover the overwhelming majority of real code. You only need to write annotations when the compiler cannot apply them — which is usually when there are multiple input references and an output reference, as in Scenario 1.
Scenario 4 - Lifetime annotations on impl blocks
When a struct has a lifetime parameter, method blocks must declare it too.
struct Parser<'a> {
input: &'a str,
pos: usize,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self {
Parser { input, pos: 0 }
}
fn remaining(&self) -> &str {
&self.input[self.pos..]
}
}
The impl<'a> declares the lifetime and Parser<'a> uses it. Methods can return references that borrow from self (covered by elision rule 3) without extra annotation.
If a method returns a reference that borrows from the struct's inner data rather than from self itself, you may need to be explicit:
impl<'a> Parser<'a> {
fn current_token(&self) -> &'a str {
// borrows from self.input (lifetime 'a), not from self
&self.input[self.pos..self.pos + 5]
}
}
The distinction matters when the struct is dropped before the returned reference is finished being used. Returning &'a str instead of &str tells the caller this reference borrows from the original input, not from the Parser itself.
Scenario 5 - The 'static lifetime
'static means the reference is valid for the entire program. String literals are 'static because they are baked into the binary.
let s: &'static str = "hello";
You will see 'static in two contexts:
As a bound on generic types:
fn spawn_task<T: Send + 'static>(task: T) {
std::thread::spawn(move || { /* use task */ });
}
The 'static bound here means T must not contain any non-static references. A thread can outlive the scope it was spawned in, so anything it captures must be safe to keep alive indefinitely. This is not saying T lives forever — it is saying T does not borrow from something that might disappear.
As a return type constraint:
fn get_name() -> &'static str {
"Alice" // string literal, always valid
}
Do not use 'static as a shortcut to silence lifetime errors. If a function returns &'static str but the actual string is not a literal, the program will not compile — and if you force it to compile with unsafe, you will have a bug. Use it only when the data genuinely lives for the duration of the program.
Scenario 6 - When to stop fighting and own the data
Lifetime annotations solve a real problem, but they can also be a signal that ownership should be restructured.
If you find yourself with more than one or two lifetime parameters, or if lifetimes are propagating through three or four layers of struct definitions, consider whether the code can be simplified by owning the data instead.
// Getting complex — two lifetime parameters
struct Context<'a, 'b> {
config: &'a Config,
request: &'b Request,
}
// Often simpler — own what you need, or use Arc for shared data
struct Context {
config: Arc<Config>,
request: Request,
}
Arc<T> adds shared ownership without lifetime parameters. The trade-off is a small amount of heap allocation and reference counting overhead. For most application code, that trade-off is worth the simplicity.
Key Takeaways
- Lifetime annotations are constraints on how long references must be valid, not durations.
- You only need to write them when the compiler has multiple input references and cannot determine which one the output borrows from.
- The three elision rules cover most functions automatically: one input reference implies the output borrows from it;
&selfimplies the output borrows from self. - Structs that hold references need a lifetime parameter to prevent the struct from outliving its data. Prefer owning data in long-lived structs.
'staticmeans "valid for the entire program." As a bound (T: 'static), it means the type contains no borrowed references, not that it lives forever.- More than two lifetime parameters is usually a sign to restructure ownership, often with
Arc<T>instead.