Validating References with Lifetimes

Learn how Rust's lifetimes ensure references never outlive the data they point to, and master lifetime annotation syntax, elision rules, struct lifetimes, and the deep mental model behind them.

Every reference in Rust has a lifetime — the span of execution during which the reference is guaranteed to stay valid. Most of the time the compiler figures lifetimes out for you, just like it infers types. But when a function receives multiple references and returns one, or when a struct holds a reference, the compiler can no longer decide by itself how those references relate to each other. That is when you must write explicit lifetime annotations.

This page moves from the concrete danger lifetimes prevent, through the annotation syntax and elision rules, into struct lifetimes and finally a deep dive into the lifetime model itself.

The Problem Lifetimes Solve

A reference that outlives the data it points to is called a dangling reference. Reading or writing through it would touch memory that may already belong to another variable or function — a classic source of crashes and security bugs in languages without a borrow checker. Rust eliminates dangling references at compile time.

fn dangling() {
    let r;
    {
        let x = 5;
        r = &x;
    } // x is dropped here
    println!("{r}"); // compile error: x does not live long enough
}

Dangling reference:

Rust rejects this code at compile time because r refers to x, which has already been deallocated when the inner block ends. If this compiled, the program would read freed memory at runtime.

The compiler does not reject the code based on a vague suspicion; it tracks the lifetime of every reference and data value.

How the Borrow Checker Uses Lifetimes

The borrow checker assigns a lifetime to every borrow and every owner, then checks that a reference never outlives its referent. You can picture those lifetimes as labels attached to scopes.

let r;                // ----+-- 'a (r lives here)
{                     //     |
    let x = 5;        // -+--+-- 'b (x lives here)
    r = &x;           //  |  |
}                     // -+  |
                      //     |
// `r` would be used here, but its referent is gone

The lifetime of r (annotated 'a) is the outer scope, but the data it points to only lives for the inner scope 'b. The borrow checker compares the two and rejects the program because 'b is shorter than 'a: the data does not outlive the reference.

When the data’s lifetime is longer, the code compiles:

let x = 5;            // ----+-- 'b
let r = &x;           // ----+-- 'a (ends before `x` drops)
println!("{r}");

Here 'b (the data) comfortably encloses 'a (the reference), so the reference never dangles. The borrow checker accepts it.

The same logic applies when references flow into and out of functions. A function signature that returns a reference from one of its parameters is ambiguous: the compiler cannot tell which parameter the return value borrows from, nor how long the returned reference must be valid. That brings us to explicit lifetime annotations.

Lifetime Annotation Syntax

When Annotations Are Required

Consider a function that returns the longer of two string slices. The compiler cannot look inside the function body at call sites — it only sees the signature:

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

The compiler refuses this with:

error[E0106]: missing lifetime specifier
  --> src/main.rs:1:33
   |
1 | fn longest(x: &str, y: &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 `x` or `y`

Because the return value could come from either x or y, the compiler cannot know which reference’s lifetime to trust. You must supply that information with a lifetime parameter.

What lifetime parameters do not do:

Lifetime annotations do not extend the actual scope of a variable. They only describe a relationship between the lifetimes of references. The runtime behavior does not change — the annotations are there for the borrow checker’s compile-time analysis.

Adding a Lifetime Parameter

Lifetime parameters use an apostrophe followed by a short, lowercase name (by convention 'a). They appear inside angle brackets after the function name, just like generic type parameters.

1

Step 1: Recognize that the relationship is ambiguous

The function takes two reference parameters and returns a reference. The compiler cannot infer which parameter’s lifetime governs the return value.

2

Step 2: Declare a lifetime parameter

Add <'a> between the function name and the parameter list.

3

Step 3: Attach the parameter to all references in the relationship

Annotate x, y, and the return type with 'a by writing &'a str.

4

Step 4: Understand the constraint you created

The signature now says: the return reference is valid for the overlap of the lifetimes of x and y. The shorter of the two input lifetimes limits the returned reference.

The corrected function looks like this:

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

Now the borrow checker can verify that, wherever you call longest, the returned reference is only used while both arguments are still alive. For example:

fn main() {
    let string1 = String::from("long string");
    {
        let string2 = String::from("xyz");
        let result = longest(&string1, &string2);
        println!("The longest is {result}"); // works: both are alive here
    }
    // string2 is dropped here; result cannot be used any longer
}

Attempting to use result after string2 goes out of scope would be a compile-time error, exactly because 'a is at most the shorter lifetime of the two inputs.

The annotation forces you to think about the ownership structure of your code, but after a while it becomes as natural as specifying a type parameter.

Don't assume 'a' is a concrete duration:

The lifetime 'a does not mean “the entire program” or “the block the function was called in.” It represents the intersection of the lifetimes of the arguments. At each call site, 'a shrinks to match the shortest argument lifetime. Treat it as a placeholder, not a fixed length.

Lifetime Elision Rules

Writing lifetime annotations for every function would be noisy. The Rust team defined a set of rules that allow the compiler to infer lifetimes in common patterns, a feature called lifetime elision. You only need explicit annotations when a case falls outside the rules.

The compiler applies three rules in order:

  1. Each input reference gets its own lifetime parameter.
  2. If there is exactly one input lifetime, it is assigned to all output lifetimes.
  3. If there are multiple input lifetimes, but one of them is &self or &mut self, the lifetime of self is assigned to all output lifetimes.

When the compiler reaches a point where it cannot infer an output lifetime, it stops and asks you to write one.

Rule 1 gives every reference parameter a distinct lifetime. For fn foo(x: &str, y: &str), that creates <'a, 'b>.

Rule 2 handles functions that take a single reference. The canonical first_word example compiles without an explicit lifetime because Rule 2 supplies the output lifetime automatically:

fn first_word(s: &str) -> &str {
    // ...
}

The compiler sees one input lifetime, so it sets the output lifetime to the same one.

Rule 3 makes methods ergonomic. When a method takes &self (or &mut self), the return reference defaults to the lifetime of self:

impl<'a> MyStruct<'a> {
    fn get_part(&self) -> &str {  // elided lifetime: output tied to `self`
        self.part
    }
}

This is why many methods that return references don’t require explicit annotations.

Elision keeps code clean:

Most functions don’t need explicit lifetime annotations because the elision rules cover the vast majority of everyday patterns. When you do need to write a lifetime, it signals that you’re doing something with a non-trivial relationship between references — which is exactly when the reader benefits from the extra clarity too.

When a function escapes the rules, as longest does, the compiler falls back to requiring a manual annotation. That is the boundary: explicit lifetimes appear only where the compiler can’t safely guess the relationship.

Lifetimes in Struct Definitions

A struct that contains a reference must declare a lifetime parameter so the compiler can guarantee that the struct instance never outlives the data it borrows.

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

Here 'a ties the struct instance to the borrowed string slice. The struct can be read only as long as the original string data is alive.

fn main() {
    let novel = String::from("It was a dark and stormy night...");
    let first_sentence = novel.split('.').next().unwrap();
    let excerpt = Excerpt { part: first_sentence };
    println!("{}", excerpt.part);
}

The struct Excerpt borrows from novel, so Rust ensures excerpt does not outlive novel. If you tried to move excerpt out of a scope where novel was dropped, the borrow checker would stop you.

Structs that hold references are tied to a scope:

Forgetting the lifetime annotation on a struct field produces a compiler error like “expected named lifetime parameter”. The annotation is not optional — it is the compiler’s only way to verify that the struct doesn’t become a dangling reference later.

Struct lifetimes are also necessary when a struct holds multiple references that may have different lifetimes. You can use multiple lifetime parameters to express independent scopes:

struct Pair<'a, 'b> {
    first: &'a str,
    second: &'b str,
}

This precision gives the borrow checker more flexibility, because first and second can expire at different times.

Understanding Lifetimes (Deep Dive)

Lifetimes Are Purely Compile-Time

Lifetimes exist solely in the compiler’s analysis. They have no runtime representation, no impact on binary size, and no performance cost. Once the borrow checker approves the code, the lifetime annotations are discarded. Think of them as proof obligations that the compiler checks and then forgets.

The Mental Model: Constraint Solving

The simplest way to think about lifetimes is as a set of outlives constraints that the borrow checker solves. When you write 'a: 'b, you assert that 'a lives at least as long as 'b. Function signatures and struct definitions generate such constraints automatically from the annotations you provide. The compiler then verifies that no constraint is violated.

For a function like fn foo<'a>(x: &'a str) -> &'a str, the constraint is: the return reference must be valid only within the scope where x is valid. When the checker processes a call site, it substitutes the concrete scopes of the arguments and confirms the relationship holds.

Beyond Simple Matching: Lifetime Shortening

The lifetime that actually gets used is not necessarily the longest possible one; it is the shortest that satisfies all constraints. In longest<'a>, the effective 'a is the smaller of the two argument lifetimes. The compiler can automatically shorten lifetimes to make them fit, but it can never lengthen them.

'static: The Lifetime That Lasts Forever

Rust has a special lifetime 'static that indicates a reference is valid for the entire program’s execution. String literals embedded in the binary have type &'static str:

let greeting: &'static str = "hello";

A 'static bound does not mean the value must live for the entire program — it means the value can live that long. Functions that accept T: 'static require that T contains no non-'static borrows, making it safe to hold the value indefinitely.

Common Mistakes and Misreadings

Lifetimes do not force variables to live longer:

An annotation like 'a does not extend the life of a variable. It only describes a dependency. A variable is still dropped at the end of its own scope regardless of any lifetime annotations attached to it.

Mistake: returning a reference to a local variable. The compiler will always reject this, even if you try to trick it with lifetime parameters. The data goes out of scope when the function returns, so no lifetime can be valid for the caller.

Mistake: assuming all references in a struct must share a single lifetime. You can introduce multiple lifetime parameters to allow fields to have independent scopes. This keeps the struct usable for as long as any of its borrows remain valid.

Mistake: thinking 'a always means “the whole function body.” The actual extent of 'a is determined at each call site. The same function can be called with arguments of vastly different scopes, and 'a adjusts each time.

Compilation is a proof of absence of dangling references:

When your code compiles with the necessary lifetime annotations, you have a static guarantee that no reference in that code path will ever point to deallocated memory. This is a rare property in systems programming — and it comes without garbage collection or manual pointer management.

Summary

Lifetimes are Rust’s mechanism for making all reference usage checkable at compile time. They start as implicit scopes tracked by the borrow checker, move into explicit annotations when relationships become ambiguous, and fade back into the background where elision rules cover the common cases. The annotations are a descriptive contract: they tell the compiler (and future readers) how long a reference is supposed to be valid relative to other references, without changing the actual runtime behavior.

When you internalize that lifetimes are constraints solved by the compiler, not durations you manually control, the mental model clicks.