Lifetimes Explained

A lifetime in Rust is not a duration of time you measure with a clock — it’s a name the compiler gives to a region of code where a reference is guaranteed to be valid. Lifetimes exist so the borrow checker can prove, entirely at compile time, that no reference ever outlives the data it points to. This is what lets Rust hand you raw memory access without a garbage collector and without the use-after-free bugs that plague languages like C. If you’ve ever wondered why Rust code sometimes has odd <'a> markings sprinkled through function signatures, this lesson explains exactly what they mean and why the compiler insists on them.

Overview: What Problem Do Lifetimes Solve?

Every reference in Rust — every &T or &mut T — borrows a value rather than owning it. The value being borrowed lives somewhere: in a variable, in a struct field, in a Vec. That value has an owner, and when the owner goes out of scope, the value is dropped and its memory is reclaimed. A reference that outlives the value it points to would be a dangling reference — it would point at memory that no longer holds what the reference thinks it holds. In C or C++, this compiles fine and quietly corrupts memory or crashes at runtime. In Rust, it simply does not compile.

To catch this at compile time, the compiler needs to reason about how long every reference is valid for. Most of the time it can figure this out silently, without you writing anything special — this is called lifetime elision, covered below. But sometimes a function’s signature is ambiguous enough that the compiler cannot infer the relationship between the lifetimes of different references on its own. In those cases, you write an explicit lifetime annotation, using a tick mark and a lowercase name like 'a, to describe that relationship yourself.

Here’s the key mental model: a lifetime annotation does not change how long anything lives. It doesn’t extend or shrink any value’s actual lifespan. It’s purely a label you attach to a reference so the compiler can check your claim about how long it stays valid, and reject your code if the claim can’t be proven. Think of it as annotating an existing constraint, not creating a new one.

Consider a function that takes two string slices and returns the longer one. The return value is a reference — but a reference into which input? The compiler cannot know from the body alone (an if could return either branch), so it cannot know how long the returned reference stays valid. Lifetime annotations let you say: “the reference I return lives exactly as long as the shorter of the two inputs’ overlapping valid regions.” That’s precisely what the 'a in fn longest<'a>(x: &'a str, y: &'a str) -> &'a str communicates.

Syntax

Lifetime parameters look like generic type parameters, but their names start with an apostrophe and are conventionally short, lowercase letters: 'a, 'b, 'static. They are declared inside angle brackets after the function or type name, alongside any generic type parameters.

fn some_function<'a>(x: &'a str, y: &'a str) -> &'a str {
    // body
}
  • <'a> — declares a lifetime parameter named 'a, scoped to this function.
  • x: &'a str — the parameter x is a reference that must be valid for at least the region 'a.
  • y: &'a str — same constraint applied to y; both are tied to the same lifetime, so the compiler treats 'a as the overlap of however long each is actually valid.
  • -> &'a str — the returned reference is only guaranteed valid for that same region 'a, so callers cannot use it beyond that point.

A single lifetime name means “these are related”; it does not mean the two references must literally have identical lifetimes. The compiler picks the smaller (more restrictive) of the two actual regions to stand in for 'a.

The special 'static lifetime

One lifetime has a reserved meaning: 'static denotes a reference valid for the entire remainder of the program. String literals are &'static str because they’re baked directly into the compiled binary and never get deallocated.

let s: &'static str = "I have a static lifetime.";
println!("{}", s);

Examples

Example 1: A function that needs an explicit lifetime

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

fn main() {
    let string1 = String::from("long string is long");
    let string2 = String::from("short");

    let result = longest(string1.as_str(), string2.as_str());
    println!("The longest string is {}", result);
}

Output:

The longest string is long string is long

Both string1 and string2 are alive for the whole call, so the compiler can verify that result, whichever branch it comes from, stays valid at least until the println!. Without the 'a annotation, the compiler would refuse to compile this function at all, because it cannot otherwise know whether the return value’s validity is tied to x, to y, or to something else entirely.

Example 2: A struct that holds a reference

Structs can hold references too, but whenever they do, every reference field needs a lifetime parameter on the struct itself — this says “an instance of this struct cannot outlive the data its fields point to.”

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

impl<'a> Excerpt<'a> {
    fn announce(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let excerpt = Excerpt { part: first_sentence };

    println!("Excerpt: {}", excerpt.part);
    let result = excerpt.announce("New chapter");
    println!("Returned part: {}", result);
}

Output:

Excerpt: Call me Ishmael
Attention please: New chapter
Returned part: Call me Ishmael

Excerpt<'a> cannot exist for longer than the novel string it borrows from — the compiler enforces this at every call site. Notice that announce doesn’t need an explicit lifetime on its return type; that’s thanks to elision, explained next.

Example 3: When you don’t need annotations at all

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    &s[..]
}

fn main() {
    let sentence = String::from("hello world");
    let word = first_word(&sentence);
    println!("First word: {}", word);
}

Output:

First word: hello

This compiles with zero lifetime syntax even though it takes and returns a reference. That’s because of the elision rules below — with only one input reference, the compiler can always assume the output borrows from it.

How It Works Step by Step: The Elision Rules

The Rust compiler applies three rules to guess lifetimes before ever asking you to write one out. If, after applying all three, every reference still has a determined lifetime, no annotation is required.

Rule What it does
1 Each reference parameter gets its own distinct lifetime.
2 If there is exactly one input lifetime, it’s assigned to all output references.
3 If one of the parameters is &self or &mut self, the lifetime of self is assigned to all output references.

In Example 3, rule 2 applies: there’s one input reference (s), so the output automatically borrows its lifetime — no annotation needed. In Example 2’s announce method, rule 3 applies: because &self is a parameter, the return type’s lifetime is inferred to match self‘s, even though announcement is also a reference. Only when none of the three rules pins down every output lifetime — as in the two-parameter longest function — do you have to spell it out yourself.

Common Mistakes

Mistake 1: Returning a reference to a local variable

A very common first encounter with the borrow checker is trying to return a reference to something created inside the function. The value is dropped when the function ends, so any reference to it would dangle.

fn dangle() -> &String {
    let s = String::from("hello");
    &s
} // `s` is dropped here — the returned reference would point at freed memory

fn main() {
    let reference_to_nothing = dangle();
    println!("{}", reference_to_nothing);
}

This fails with a missing-lifetime-specifier error, and even if you added one, it would still fail borrow checking, because there is no valid lifetime long enough — s is destroyed the moment dangle returns. The fix is to return an owned String instead of a reference:

fn no_dangle() -> String {
    let s = String::from("hello");
    s
}

fn main() {
    let owned = no_dangle();
    println!("{}", owned);
}

Mistake 2: A borrowed value that doesn’t live long enough

Even with a correctly annotated function, the caller can still violate the contract by letting one of the referenced values die too early.

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

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    } // string2 is dropped here
    println!("The longest string is {}", result); // error: `string2` does not live long enough
}

The 'a annotation told the compiler that the return value is only valid as long as both inputs are valid — and string2 stops being valid at the closing brace of the inner block, before the println!. The fix is to keep string2 alive for as long as result is used, typically by moving the println! inside the same scope, or by giving string2 the same outer scope as string1.

Best Practices

  • Let the compiler try first — most functions never need an explicit lifetime thanks to elision; only add one when the compiler asks for it.
  • Prefer returning owned data (String, Vec<T>) from functions unless there’s a clear performance or API reason to return a borrowed slice.
  • When a struct must hold a reference, ask whether it would be simpler to store an owned value instead — reference-holding structs propagate lifetime parameters through every place they’re used.
  • Read <'a> as “related to,” not “identical to” — two parameters sharing 'a just means the compiler will use whichever region is smaller.
  • Don’t reach for 'static just to silence a compiler error; it’s a strong claim (“valid for the whole program”) and is rarely what you actually want for non-literal data.
  • When you hit a lifetime error, read it as the compiler telling you about a real ordering problem in your code, not an obstacle to work around with more annotations.

Practice Exercises

  • Write a function fn longest_word(sentence: &str) -> &str that splits the input on spaces and returns the longest word. Reason through why it doesn’t need an explicit lifetime.
  • Define a struct Highlight<'a> with a field text: &'a str and a method fn shout(&self) -> String that returns the text uppercased and owned. Confirm it compiles without needing a lifetime on the method’s return type.
  • Take the broken dangle example from Common Mistakes and fix it two different ways: once by returning an owned String, and once by taking the string as a parameter and returning a slice of it.

Summary

  • A lifetime is a compile-time label for how long a reference stays valid — it never changes how long a value actually lives.
  • Lifetime annotations exist so the borrow checker can verify a function or struct’s claims about reference validity when it can’t infer them alone.
  • The three elision rules mean most code never needs explicit lifetime syntax; annotations are only required when a function has multiple reference inputs (or none) and an ambiguous output.
  • Structs that store references need a lifetime parameter, tying the struct’s own validity to the data it borrows from.
  • 'static means “valid for the whole program” and applies naturally to string literals, but should be used deliberately elsewhere.
  • Lifetime errors are the compiler catching real dangling-reference bugs at compile time — the fix is almost always to restructure ownership, not to add more annotations.