Common Lifetime Patterns and How to Apply Them

Apply Rust lifetime patterns by annotating references with parameters like 'a to guarantee references remain valid for the duration of their use.

The parser that won't compile

You're writing a log parser. You read a massive file into a String buffer. You want to extract specific error messages and store them in a list of structs. You write a function that takes the buffer and returns a slice pointing to the error text. The compiler rejects you. It says the reference might outlive the data. You stare at the code. The data is right there. It's not going anywhere. Why does Rust think it's vanishing?

This is the moment where Rust forces you to think about memory layout and scope. The compiler isn't being difficult. It's protecting you from a dangling pointer. The solution involves lifetime annotations. Lifetimes tell the compiler how references relate to each other so it can verify that every reference points to valid memory.

Lifetimes are relationships, not timers

Lifetimes do not control when memory gets freed. They do not start or stop clocks. A lifetime is a name for a scope. When you write &'a str, you are saying "this reference is valid for some scope 'a." The compiler uses these names to track relationships.

Think of a lifetime as a promise. A reference promises that the data it points to exists for at least the duration of the reference. If you return a reference from a function, you are promising the caller that the data behind the reference won't be dropped before the caller finishes using it. The compiler checks if that promise holds. If the data dies, the reference becomes a dangling pointer. Lifetimes prevent that by forcing you to prove the data outlives the reference.

Lifetimes are the compiler's way of asking for proof before it lets you point at memory.

Minimal example: tying outputs to inputs

The most common pattern is a function that returns a reference derived from its arguments. The output reference must borrow from one of the inputs. The compiler needs to know which input the output is tied to.

/// Returns the longer of two string slices.
/// The returned slice borrows from one of the inputs,
/// so it cannot outlive either input.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    // Tie the output lifetime to both inputs.
    // The compiler infers that the result lives at least as long as the shorter input.
    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");
        // This fails: string2 is dropped before `result` is used.
        // result = longest(string1.as_str(), string2.as_str());
    }
    // result is used here, but string2 is gone.
    // println!("{}", result);
}

The annotation 'a links the lifetimes of x, y, and the return value. It tells the compiler that the returned reference is valid for some scope 'a, and both inputs must be valid for that same scope 'a. The compiler ensures this constraint holds at every call site.

The annotation isn't magic. It's a contract. If the contract breaks, the code doesn't compile.

What the compiler checks at the call site

Lifetime parameters are variables. They get instantiated with concrete scopes when the function is called. The compiler looks at the actual lifetimes of the arguments and checks if they satisfy the constraints.

In the longest example, the compiler sees two inputs. It calculates the overlap of their scopes. The output lifetime 'a must be shorter than or equal to that overlap. If string1 lives for the whole program and string2 lives only inside a block, the overlap is the block. The return value can only be used inside the block.

If you try to use the return value outside the block, the compiler rejects the code. It knows string2 is dropped at the end of the block. The reference would dangle. The error message points out the mismatch. You get E0515 (cannot return value referencing local variable) if you try to return a reference to data that dies inside the function. You get E0308 (mismatched types) if the lifetimes of the arguments don't align with the expected lifetime.

The compiler doesn't guess. It follows the rules you wrote. If the rules are too restrictive, the code won't compile. If the rules are missing, the compiler asks you to add them.

Realistic example: structs that borrow

Structs often hold references. This avoids copying data. A config parser might store references to keys and values inside the raw config string. The struct borrows from the source. The struct cannot outlive the source.

/// A struct that holds references to parts of a configuration string.
/// The struct cannot outlive the source string.
struct ConfigEntry<'a> {
    key: &'a str,
    value: &'a str,
}

/// Parses a single line into a ConfigEntry.
/// Returns None if the line is malformed.
fn parse_entry<'a>(line: &'a str) -> Option<ConfigEntry<'a>> {
    // Split the line once.
    // The returned struct borrows from `line`.
    if let Some((key, value)) = line.split_once('=') {
        Some(ConfigEntry {
            key: key.trim(),
            value: value.trim(),
        })
    } else {
        None
    }
}

impl<'a> ConfigEntry<'a> {
    /// Returns the key as a string slice.
    /// The return value borrows from `self`, so it lives as long as the entry.
    fn get_key(&self) -> &str {
        self.key
    }
}

fn main() {
    let config = String::from("timeout=30\nretries=3");
    let entries: Vec<ConfigEntry> = config
        .lines()
        .filter_map(|line| parse_entry(line))
        .collect();

    // `entries` borrows from `config`.
    // `config` must stay alive as long as `entries` is used.
    for entry in &entries {
        println!("{} = {}", entry.get_key(), entry.value);
    }
}

The struct ConfigEntry has a lifetime parameter 'a. Both fields borrow from the same source. The function parse_entry returns a ConfigEntry that borrows from the input line. The caller must ensure line stays alive. In main, config lives for the whole function. entries borrows from config. The borrow checker verifies that config is not dropped before entries is used.

Community convention favors descriptive lifetime names in complex structs. Use 'a, 'b for quick functions, but name them 'config or 'input when a struct holds multiple references. It makes the API self-documenting. Descriptive names help readers understand which data the struct depends on.

Borrowing saves allocations, but it ties your struct to the source data. Keep the source alive, or own the data.

Pitfalls: when lifetimes bite

Lifetimes introduce constraints. Those constraints can block valid logic if you don't model them correctly. The most common pitfall is returning a reference to a local variable.

fn bad() -> &str {
    let s = String::from("hello");
    &s // Error!
}

The variable s is dropped when the function returns. The reference dangles. The compiler catches this with E0515 (cannot return value referencing local variable). You cannot fix this by adding a lifetime annotation. The data simply doesn't exist long enough. You must return an owned value, or pass the storage in as a parameter.

Another pitfall is using a single lifetime for references that come from different sources. If a struct holds a reference to a config and a reference to a log, and they have different scopes, one lifetime forces them to share the shortest scope. That breaks valid use cases.

struct Processor<'a, 'b> {
    config: &'a str,
    log: &'b str,
}

Separate lifetimes give flexibility. config can live longer than log, or vice versa. The struct is valid as long as both references are valid. The compiler tracks them independently.

You cannot annotate your way out of a dangling reference. If the data dies, you must own a copy.

Decision: choosing the right pattern

Lifetimes are a tool. You choose when to use them based on the data flow and performance needs.

Use explicit lifetime annotations when a function returns a reference derived from multiple inputs, or when a struct holds references. The compiler needs the names to track the relationships.

Use lifetime elision when a function has a single input reference, or when a method takes &self. The compiler infers the lifetime automatically, and adding annotations adds noise.

Use &'static str when the data lives for the entire duration of the program, like string literals. This signals to callers that the reference never dangles.

Use owned types like String when the data needs to outlive the scope that created it, or when you need to mutate the value. Borrowing is cheap, but ownership gives you control over the lifecycle.

Use separate lifetime parameters in structs when references come from different sources with different scopes. One lifetime forces all references to share the shortest scope, which often breaks valid use cases.

Reach for borrowing when performance matters and the scope is clear. Reach for ownership when the data needs to travel further than its creator.

Where to go next