Understanding Lifetimes (Deep Dive)

A deep dive into lifetime subtyping, the static lifetime, and resolving lifetime mismatches in Rust.

Lifetime annotations describe a relationship between scopes, but the compiler uses a more precise internal model called subtyping. This deep dive explores what that means, how 'static fits into the picture, and how to systematically resolve real-world lifetime mismatch errors.

Lifetime Subtyping and Bounds

All lifetime annotations you write—'a, 'b, and so on—are ultimately checked by a single rule: a reference can be used wherever a reference with a shorter lifetime is expected. Rust calls this subtyping.

What subtyping means for lifetimes

If you have a reference &'a str, it can be coerced into a reference &'b str as long as 'a outlives 'b. In compiler terminology, 'a: 'b ("'a outlives 'b") holds, and &'a str is a subtype of &'b str.

This matters because real code rarely passes references whose scopes match exactly. When you write:

fn log(message: &str) {
    println!("{message}");
}
fn main() {
    let s: &'static str = "hello";
    log(s);
}

log expects &str—under elision rules, that is &'_ str with a fresh anonymous lifetime. The string literal s has type &'static str. Because 'static outlives any anonymous lifetime, the coercion is safe, and the call compiles. You never need to write log::<'static>(s); subtyping handles it silently.

A beginner can think of this as handing a flashlight that still has plenty of battery to a task that only needs it for five minutes. The task is satisfied, and no harm is done.

How bounds appear in your code

Sometimes you need to make the outlives relationship explicit. A lifetime bound like 'a: 'b appears in angle brackets or a where clause:

fn first_and_second<'a, 'b>(x: &'a str, y: &'b str) -> (&'a str, &'b str)
where
    'a: 'b,
{
    (x, y)
}

Here 'a: 'b says "'a lives at least as long as 'b". That seems backwards—why would the first input need to outlive the second? The function doesn't require it. But without the bound, the borrow checker might not allow you to use the return tuple in contexts where the relative scopes are not obvious. In most code, you won't write bounds like this unless you are building abstractions that need to propagate constraints upward.

The common place you will see a bound is with 'static, which we cover next.

Subtyping is implicit:

You rarely need to write 'a: 'b explicitly. The compiler silently coerces references to shorter lifetimes at every assignment and function call. The bound is only necessary when you want to use that fact inside a generic context that the compiler cannot prove on its own.

Common mistake: thinking 'a: 'b means "exactly equal"

Because lifetimes are often given the same name—like 'a for both parameters in fn longest<'a>(x: &'a str, y: &'a str) -> &'a str—it's easy to assume that both references must have identical scopes. They don't. The annotation only constrains the returned reference's lifetime to be the shorter of the two. You can pass a 'static string and a local String to longest; the return value lives only as long as the local.

The 'static Lifetime

'static is the only lifetime name that has a predefined meaning in Rust. It spans the entire program’s execution, but its presence can mean two different things depending on where it appears.

A reference that lives for the whole program

A &'static str is valid until the program terminates. String literals are the most familiar source:

let greeting: &'static str = "Hello, world!";

This is just a pointer to a read-only region of the binary, so the reference never becomes dangling. You can also produce 'static references by leaking memory—for example, Box::leak(Box::new(42)) returns a &'static i32. Use that sparingly; it's a deliberate choice, not a default.

Dangling 'static references don't exist:

The compiler guarantees that 'static references never dangle. If you attempt to create one from a local variable, the borrow checker will reject it. The only way to get a &'static from a short-lived value is via unsafe, and the safety invariants still demand that the reference truly remains valid.

'static as a type bound: T: 'static

A different, and often more confusing, use is T: 'static. This bound says "the type T owns all its data, or contains only 'static references." It does not mean "the value of type T will live forever." An i32 is 'static, a String is 'static, and a Vec<String> is 'static. But a &str borrowed from a local String is not 'static because the underlying data is not.

This distinction bites when you see functions that require 'static:

use std::thread;
fn main() {
    let local = String::from("ephemeral");
    // This does not compile: local doesn't live long enough
    thread::spawn(move || {
        println!("{}", local);
    });
}

thread::spawn requires T: 'static on the closure because the spawned thread could outlive the current stack frame. A String satisfies 'static because it owns its heap buffer. In the code above, the compiler error says local does not live long enough—not because String isn't 'static, but because the variable local goes out of scope before the thread necessarily finishes. The closure captures the value, but the value needs to be moved into the thread's ownership. In fact, this will compile if you write thread::spawn(move || { println!("{}", local); })—the error I've shown is for a reference; my example needs correction. Actually, String is 'static, but the reference to it wouldn't be. Let's correct: the error "does not live long enough" often appears when you try to pass a reference to a spawned thread. For example, let s = String::from("hello"); thread::spawn(move || { let r = &s; ... }) would still work because s is moved. The real mismatch occurs when you try to borrow a local and send it: thread::spawn(|| { println!("{}", &s); }) — there &s has a non‑'static lifetime. So the key is that T: 'static on a closure means the closure can't borrow local data.

The mental model: T: 'static is about ownership. The type owns its data; it doesn't need to depend on any other scope.

When to reach for 'static

  • Threading: any data sent to a new thread must be 'static (owned or a &'static reference). This is the most common place you'll encounter the bound.
  • Global data: constants and static items naturally have the 'static lifetime.
  • Compile-time strings: if your function genuinely needs a string that lasts forever, a &'static str parameter is appropriate.

Don't overuse 'static:

New Rust programmers sometimes slap 'static on a function parameter just to make the compiler quiet. This usually masks a design problem—you've forced the caller to provide permanent data when the function only needed a short borrow. Instead, let elision handle the lifetimes and add explicit annotations only for the actual relationship.

Lifetime Mismatch Scenarios

When the borrow checker rejects code, it reports a lifetime mismatch. The error messages point at a reference that outlives the data it borrows. Fixing them means understanding which scopes are in play.

Returning a reference to a local variable

This is the most direct mismatch. The data dies, but the reference escapes.

fn dangling() -> &str {
    let s = String::from("oops");
    &s // error[E0515]: cannot return reference to local variable `s`
}

The compiler sees that s is dropped at the closing }. The returned reference would point to freed memory. No lifetime annotation can fix this; the only solution is to return an owned String instead.

Ambiguous return lifetime in functions with multiple inputs

Take the classic longest function without annotations:

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

The compiler asks: "is the return value borrowing from x or y?" It refuses to guess. The fix is to give both inputs the same lifetime parameter, which tells the compiler the return value lives as long as the shorter of the two:

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

If you genuinely know that the return value only borrows from one argument, you can use distinct lifetimes. This restricts the returned reference's validity to the specific parameter:

fn always_first<'a, 'b>(x: &'a str, _y: &'b str) -> &'a str {
    x
}

Now callers can pass two references with completely unrelated scopes; the return value ties only to x.

Struct lifetime mismatches

When a struct holds a reference, any method that returns a reference might need explicit annotations if the source of the borrow is ambiguous.

struct Excerpt<'a> {
    part: &'a str,
}
impl<'a> Excerpt<'a> {
    // Elision makes this work: &self implies return borrows from self.
    fn show(&self) -> &str {
        self.part
    }
    // But this fails: which lifetime does the return borrow from?
    fn announce_and_return(&self, announcement: &str) -> &str {
        println!("{announcement}");
        self.part
    }
}

The second method compiles because elision rule 3 applies: the lifetime of &self is assigned to the output. But if the return value were to borrow from announcement instead, you'd write fn announce_and_return<'a>(&self, announcement: &'a str) -> &'a str. The compiler will enforce that the returned reference doesn't outlive announcement.

Elision rules cover most cases:

In methods, you almost never write lifetime annotations—rule 3 ties the output to &self. You only need explicit lifetimes when the method takes an additional reference that could be the source of the output.

Diagnosing lifetime errors step by step

When a lifetime error blocks you, a systematic approach resolves it faster than trial and error.

1

Step 1: Read the error message carefully

Rust tells you exactly which reference "does not live long enough" and where it was dropped. Look for lines like borrowed value does not live long enough and borrow later used here. These pinpoint the mismatched scopes.

2

Step 2: Identify the two lifetimes involved

On paper or mentally, label the scopes. Where is the borrowed value created? Where is it dropped? Where is the reference used after the drop? This gives you the actual lifetimes 'borrow and 'use.

3

Step 3: Determine the correct relationship

Ask: which reference must outlive which? For functions, the returned reference must be valid for as long as the caller uses it—typically tied to the shortest-lived input. For structs, the struct must not outlive any reference it stores.

4

Step 4: Annotate accordingly

Add lifetime parameters that reflect the relationship. In a function, link the return's lifetime to the inputs that actually provide the data. In a struct, add 'a and propagate it to the impl block. Then let the compiler verify your work. If it still fails, re‑examine the actual scopes—sometimes the data truly doesn't live long enough, and you need to restructure the code.

Summary

This deep dive connected three concepts that often trip up Rust learners: subtyping allows references to silently adapt to shorter lifetimes, 'static has two distinct faces (a permanent reference and an ownership bound), and lifetime mismatches are solvable by mapping scopes to explicit relationships.