Advanced Rust Series Part 3: Lifetimes Demystified – Why They Exist and How to Read Them

Advanced Rust Series Part 3: Lifetimes Demystified – Why They Exist and How to Read Them

Parts 1 and 2 of this series covered ownership and borrowing. You now know that references are temporary, that shared borrows are read-only, and that mutable borrows are exclusive. But we have been dealing with simple cases where the compiler could figure everything out on its own.

Once references cross function boundaries or live inside structs, the compiler needs more information. It needs to know: how long is this reference valid? That is what lifetimes are for.

This post demystifies lifetimes from first principles. Not the syntax first – the reasoning first. The syntax will make sense once you understand what problem lifetimes are solving.

The Problem Lifetimes Solve

Consider a function that returns a reference. The compiler needs to verify that the returned reference does not outlive the data it points to. In simple cases it can figure this out automatically. But when a function takes multiple references as input and returns one, the compiler cannot know which input the returned reference came from without help.

// Which input does the return value come from?
// The compiler cannot know without lifetime annotations
fn pick(s1: &str, s2: &str) -> &str {
    if s1.len() > s2.len() { s1 } else { s2 }
}
// ERROR: missing lifetime specifier

The compiler rejects this because it cannot verify the returned reference is valid. If s1 and s2 have different lifetimes (different scopes), and the function might return either one, the caller cannot know how long to keep the inputs alive.

Lifetime annotations solve this by letting you describe the relationship between the lifetimes of inputs and outputs. They do not change how long things live – they communicate to the compiler what you already know about those relationships.

Lifetime Annotation Syntax

A lifetime annotation is a name starting with an apostrophe: 'a, 'b, 'config. The name is arbitrary – 'a is conventional for simple cases, but descriptive names help readability in complex code.

Annotations go after the & in a reference type:

&i32        // a reference (no annotation)
&'a i32     // a reference with lifetime 'a
&'a mut i32 // a mutable reference with lifetime 'a

Fixing the earlier example:

fn pick<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() { s1 } else { s2 }
}

This tells the compiler: both s1 and s2 share the same lifetime 'a, and the returned reference also lives for 'a. In practice, 'a will be the shorter of the two input lifetimes – the region where both are valid.

What Lifetime Annotations Actually Mean

This is the key insight most explanations miss: lifetime annotations describe constraints, not durations.

When you write &'a str, you are not saying “this reference lives for exactly 'a seconds.” You are saying “this reference is valid for at least as long as 'a lasts.” The compiler uses these constraints to verify that references never outlive the data they point to.

flowchart TD
    A["fn pick<'a>(s1: &'a str, s2: &'a str) -> &'a str"] --> B[Compiler resolves 'a]
    B --> C["'a = overlap of s1's lifetime AND s2's lifetime"]
    C --> D[Return value valid only within that overlap]
    D --> E{Caller uses return value outside overlap?}
    E -->|Yes| F[Compile Error: reference does not live long enough]
    E -->|No| G[Compiles successfully]

    style F fill:#c0392b,color:#fff
    style G fill:#27ae60,color:#fff

A Concrete Example With Scopes

fn pick<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() { s1 } else { s2 }
}

fn main() {
    let s1 = String::from("long string");  // s1 lives for the rest of main
    
    let result;
    {
        let s2 = String::from("short");    // s2 lives only in this block
        result = pick(&s1, &s2);
        println!("{}", result);            // fine: result used while s2 is alive
    }
    // println!("{}", result); // ERROR: s2 dropped, result might point to s2
}

The compiler resolves 'a to the shorter lifetime – the scope of s2. So result is only valid inside that block. Using it after s2 is dropped would be a use-after-free, and the compiler catches it.

When You Need Lifetime Annotations

You do not need to annotate everything. Rust has lifetime elision rules that handle the common cases automatically. You need explicit annotations when:

  • A function takes multiple reference parameters and returns a reference
  • A struct holds a reference as a field
  • You implement methods that involve references with non-obvious relationships
  • You need to express that two references must live at least as long as each other

We cover elision rules in detail in Part 4. For now, the rule of thumb is: if the compiler asks for a lifetime annotation, add one. If it does not ask, do not add one.

Lifetimes in Function Signatures

Here are the patterns you will encounter most often:

Single Input, Single Output: No Annotation Needed

// Compiler infers: output lives as long as input
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

Multiple Inputs, Output Tied to One: Annotate the Relationship

// Output only comes from haystack, not needle
// needle can have a shorter lifetime
fn find_in<'a>(haystack: &'a str, needle: &str) -> &'a str {
    if haystack.contains(needle) {
        haystack
    } else {
        ""
    }
}

Here needle has no lifetime annotation because the return value never comes from needle. Only haystack needs 'a because the output is derived from it.

Multiple Inputs, Output Could Come From Either: Constrain Both

// Output might come from either input - both must share 'a
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() >= y.len() { x } else { y }
}

Reading Compiler Lifetime Errors

Lifetime errors are among the most informative in Rust, but they require practice to read. Here is a real error and how to decode it:

fn bad_return(s: &str) -> &str {
    let owned = String::from("local value");
    &owned // ERROR: returns reference to local data
}
// error[E0515]: cannot return reference to local variable `owned`
// `owned` is borrowed here but it is not valid at this point

The error says exactly what is wrong: owned is a local variable that gets dropped when the function returns. A reference to it would be dangling. The fix is to return owned data, not a reference:

fn good_return(s: &str) -> String {
    format!("local value based on: {}", s)
    // return owned String, not a reference to a local
}

Another common error pattern:

fn main() {
    let result;
    {
        let s = String::from("hello");
        result = &s; // borrow s
    } // s dropped here
    println!("{}", result); 
    // error[E0597]: `s` does not live long enough
    // `s` is borrowed here but dropped before `result` is used
}

The error points to exactly where s is dropped and where result is used after that. The fix is to ensure the owner lives at least as long as the reference:

fn main() {
    let s = String::from("hello"); // s now lives for all of main
    let result = &s;
    println!("{}", result); // fine: s is still alive
}

The Static Lifetime

'static is a special lifetime meaning “valid for the entire program.” String literals have 'static lifetime because they are baked into the binary:

let s: &'static str = "I am in the binary";

// This function accepts any string that lives for the whole program
fn needs_static(s: &'static str) {
    println!("{}", s);
}

You will also see 'static bounds on trait objects and generics, particularly in async and multithreaded code:

use std::thread;

// Thread closures require 'static because the thread might outlive the caller
fn run_in_thread(f: F) {
    thread::spawn(f);
}

A common mistake is reaching for 'static to make the compiler happy without understanding why it is required. If you see 'static bounds in error messages, it usually means you are trying to send borrowed data across a thread boundary. The fix is usually to clone the data or use Arc rather than forcing a 'static constraint.

Multiple Lifetime Parameters

Functions can have multiple lifetime parameters when inputs have independent lifetimes:

// s1 and s2 can have completely independent lifetimes
// return value is tied to s1 only
fn prefix_of<'a, 'b>(s1: &'a str, s2: &'b str) -> &'a str {
    if s2.starts_with(s1) {
        s1
    } else {
        &s1[..0] // empty string slice from s1
    }
}

Here 'a and 'b are independent. The caller can pass references with different scopes. The returned reference is valid for 'a (the lifetime of s1), regardless of how long s2 lives.

Lifetime Subtyping

Lifetimes have a subtype relationship: a longer lifetime is a subtype of a shorter one. If 'b outlives 'a, then &'b T can be used anywhere &'a T is expected. The notation for this constraint is 'b: 'a (read as “‘b outlives ‘a”):

// 'long must outlive 'short
// This lets us return a reference valid for 'short
// even if it actually points to data living for 'long
fn shorter<'short, 'long: 'short>(
    s1: &'short str,
    s2: &'long str,
) -> &'short str {
    if s1.len() < s2.len() { s1 } else { &s2[..s1.len()] }
}

You will not need this pattern often in application code, but it comes up in library APIs and when working with structs that hold multiple references with different lifetimes.

Practical Advice: When to Fight Lifetimes and When to Restructure

When you hit a lifetime error, you have three options:

  • Add the annotation the compiler needs. Sometimes the code is correct and you just need to be explicit about the relationship.
  • Adjust scope. Move declarations so owners live long enough for their borrows.
  • Change the design. Return owned data instead of references. Clone if the cost is acceptable. Rethink who owns what.

The third option is often the right one. Lifetime errors are sometimes the compiler telling you that your data ownership design has a flaw. A function that needs to return a reference to data it creates locally is a design problem, not a lifetime annotation problem.

A useful heuristic: if you are adding more than two lifetime parameters to a single function, stop and reconsider the design. Complex lifetime signatures are often a signal that ownership is not clearly defined in your data model.

What Comes Next

You now understand what lifetimes are, why they exist, and how to read and write basic lifetime annotations. But you may have noticed that most of your Rust code compiles fine without any annotations at all. That is because of lifetime elision - a set of rules the compiler applies automatically to fill in obvious lifetimes.

In Part 4, we cover the elision rules explicitly. Understanding them tells you exactly when you need to write annotations and when the compiler has you covered. It also explains why some lifetime errors are more surprising than others.

References

Written by:

637 Posts

View All Posts
Follow Me :
How to whitelist website on AdBlocker?

How to whitelist website on AdBlocker?

  1. 1 Click on the AdBlock Plus icon on the top right corner of your browser
  2. 2 Click on "Enabled on this site" from the AdBlock Plus option
  3. 3 Refresh the page and start browsing the site