rust · level 4

Lifetimes

When and why they appear, elision rules, 'static, struct lifetimes.

175 XP

Lifetimes

Every reference in Rust has a lifetime — a region of code over which the reference is valid. The compiler tracks lifetimes implicitly almost everywhere; you only have to write them down when the compiler can't infer the relationship between input and output references.

The mental model: lifetimes are not durations. They're scopes. 'a doesn't mean "5 milliseconds" — it means "the region of code where this reference is guaranteed to point at live data".

Why they exist

Without lifetimes, this would be possible:

fn dangle() -> &String {           // ❌
    let s = String::from("hi");
    &s                             // returning a reference to a local
}

s is dropped when dangle returns. The reference would be dangling. The compiler refuses to compile it: "missing lifetime specifier — this function's return type contains a borrowed value, but there is no value for it to be borrowed from".

The function's return type needs a lifetime to express what it borrows from — and there's nothing valid to choose.

Elision — the lifetimes you don't write

Most function signatures don't need explicit lifetimes. Three elision rules quietly fill them in:

  1. Each input reference gets its own lifetime.

    fn one(x: &str) { /* ... */ }
    // becomes: fn one<'a>(x: &'a str)
    
  2. If there's exactly one input lifetime, all output lifetimes equal it.

    fn first(s: &str) -> &str { &s[0..1] }
    // becomes: fn first<'a>(s: &'a str) -> &'a str
    
  3. If there's &self, all output lifetimes equal self's lifetime.

    impl Parser {
        fn next_token(&self) -> &str { /* ... */ }
        // becomes: fn next_token<'a>(&'a self) -> &'a str
    }
    

If none of these rules give the compiler an answer, you must annotate.

Where elision fails

Multiple input references and a returned reference, no &self:

fn longer(a: &str, b: &str) -> &str {     // ❌
    if a.len() > b.len() { a } else { b }
}

Which input does the output borrow from? a or b? The compiler doesn't know — both are possible at runtime. You answer:

fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

Both inputs get the same lifetime 'a. The output is &'a str. The compiler now understands: the returned reference is valid for the intersection of the input lifetimes — i.e. as long as both a and b are still alive.

Lifetime annotations are constraints, not durations

'a doesn't pick a length. It says "these references all share some common lifetime". The compiler picks the actual length to make the constraints solvable. You're describing relationships between scopes, not measuring time.

Try to read <'a> as "for some lifetime 'a chosen by the caller…". The function works for any lifetime that satisfies its constraints.

Struct lifetimes

When a struct holds a reference, the struct itself needs a lifetime parameter:

struct Excerpt<'a> {
    part: &'a str,
}

let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let e = Excerpt { part: first_sentence };
// e cannot outlive `novel`, because e.part borrows from it

The struct's lifetime parameter makes the dependency explicit: an Excerpt<'a> is "an excerpt of something that lives for 'a". When novel goes out of scope, any Excerpt<'a> borrowing from it must already be dead.

This is why most idiomatic Rust returns owned Strings rather than &strs — the moment you return a reference, every caller has to plumb the lifetime through. Owned values just work.

'static — the all-program lifetime

let s: &'static str = "hello, world";

'static means "valid for the entire program". String literals get &'static str because their bytes live in the binary itself.

You'll also see 'static in trait bounds:

fn spawn<F>(f: F)
where
    F: FnOnce() + Send + 'static,
{
    std::thread::spawn(f);
}

Reading: "F can be sent to another thread, doesn't borrow anything that could go out of scope before the thread finishes." 'static here doesn't mean the closure runs forever — it means it doesn't capture short-lived borrows.

Lifetime bounds — 'a: 'b

Rare, but you'll see this on advanced APIs:

fn relate<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

'b: 'a reads "'b outlives 'a". The constraint says: y's lifetime must contain x's. With that promise, returning a &'a str that came from y is safe — y is at least as long as x, so a reference valid for y is certainly valid for x.

Multiple input lifetimes

When references are independent, give them distinct lifetimes:

fn print_pair<'a, 'b>(a: &'a str, b: &'b str) {
    println!("{} {}", a, b);
}

This is what elision rule 1 produces. Each input gets its own. Use distinct names when the input lifetimes don't need to relate to each other or to outputs.

NLL — non-lexical lifetimes

The borrow checker uses non-lexical lifetimes (NLL): a borrow ends at its last use, not at the end of the lexical scope. This is what makes a lot of "obviously fine" code compile:

let mut v = vec![1, 2, 3];
let r = &v[0];          // borrow starts
println!("{}", r);      // last use of r — borrow ENDS here
v.push(4);              // ✅ OK — no live borrow at this point

Without NLL the borrow would extend to the closing } and the push would fail. NLL has been on by default since 2018.

When you genuinely need to write lifetimes

In practice, three places:

  1. Functions that return references derived from multiple inputs. Elision can't pick which input.
  2. Structs that hold borrowed data. The struct must declare the lifetime of what it borrows.
  3. Trait methods returning references when default elision picks the wrong source. Rare.

Beyond these, write owned types where you can. Lifetime-laden APIs are powerful but viral — once a &'a str enters your type, every type that holds one needs 'a too.

Reading lifetime errors

The compiler emits unusually good errors here. Read them carefully:

error[E0106]: missing lifetime specifier
 --> src/main.rs:3:31
  |
3 | fn longer(a: &str, b: &str) -> &str {
  |              ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the
          signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
  |
3 | fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
  |          ++++      ++          ++          ++

The hint at the bottom is usually the answer. Apply it, run cargo check again, iterate.

Lifetimes are the single feature that takes longest to feel natural. Once they click — usually around the time you write your second struct that borrows — they become invisible again.

Tools in the wild

4 tools
  • rustcfree tier

    The compiler error messages for lifetime issues are unusually good — read them word-by-word.

    cli
  • cargo checkfree tier

    Type-check + borrow-check without code generation. The lifetime feedback loop.

    cli
  • Poloniusfree tier

    Next-generation borrow checker — accepts more lifetime patterns; opt-in via nightly.

    cli
  • Paste annotated lifetimes, hit Build, get the borrow checker's verdict in milliseconds.

    service