rust lifetimes: a visual guide to the borrow checker
why lifetimes exist
rust's borrow checker ensures that references never outlive the data they point to. lifetimes are the mechanism that makes this work — they are annotations that tell the compiler how long a reference is valid.
most of the time, the compiler infers lifetimes automatically through lifetime elision rules. you only need to write them explicitly when the compiler can't figure it out on its own.
the problem lifetimes solve
consider this C code — a classic use-after-free:
char *get_name() {
char name[] = "hello"; // lives on the stack
return name; // stack frame is gone — dangling pointer!
}
rust makes this a compile error:
fn get_name() -> &str {
let name = String::from("hello");
&name // error: `name` does not live long enough
} // `name` is dropped here, but we returned a reference to it
basic lifetime syntax
lifetime parameters look like generic type parameters but start with an apostrophe:
// 'a is a lifetime parameter
// this signature says: the returned reference lives as long as the input reference
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xy");
result = longest(s1.as_str(), s2.as_str());
println!("longest: {}", result); // ok — both live here
}
// println!("{}", result); // error — s2 is dropped
}
lifetime elision rules
rust has three elision rules that let you omit lifetime annotations in common cases:
rule 1 — each reference parameter gets its own lifetime:
// you write:
fn first_word(s: &str) -> &str
// compiler sees:
fn first_word<'a>(s: &'a str) -> &'a str
rule 2 — if there is exactly one input lifetime, it is assigned to all output lifetimes:
fn first_word(s: &str) -> &str {
&s[..s.find(' ').unwrap_or(s.len())]
}
// no explicit lifetimes needed — rule 2 applies
rule 3 — if one input is &self or &mut self, its lifetime is assigned to all outputs:
impl Parser {
fn current_token(&self) -> &Token {
&self.tokens[self.pos] // lifetime of &self implicitly applied
}
}
lifetimes in structs
if a struct holds a reference, it needs a lifetime annotation:
// the struct cannot outlive the reference it holds
struct Tokenizer<'a> {
input: &'a str,
pos: usize,
}
impl<'a> Tokenizer<'a> {
fn new(input: &'a str) -> Self {
Tokenizer { input, pos: 0 }
}
fn peek(&self) -> Option<char> {
self.input[self.pos..].chars().next()
}
}
the 'static lifetime
'static means the reference lives for the entire duration of the program:
// string literals are 'static — they live in the binary
let s: &'static str = "i live forever";
// this function requires a 'static reference
fn spawn_thread(f: impl Fn() + Send + 'static) {
std::thread::spawn(f);
}
higher-ranked trait bounds
for advanced cases, for<'a> lets you express "for any lifetime":
// this closure must work for any lifetime, not just a specific one
fn apply<F>(f: F, s: &str) -> &str
where
F: for<'a> Fn(&'a str) -> &'a str,
{
f(s)
}
the mental model
stop thinking of lifetimes as something you write. think of them as constraints you declare so the compiler can verify your invariants. when the borrow checker rejects your code, it has found a real bug — not a false positive. trust it.