What are lifetimes in Rust

Lifetimes are Rust annotations that ensure references remain valid for the duration of their use, preventing dangling pointers at compile time.

The reference that outlives the data

You write a function that parses a configuration string and returns a reference to the first key it finds. The logic is straightforward. You scan the string, find the key, and return a slice pointing to it. The code looks correct. You run the compiler and get a wall of text mentioning lifetimes. You stare at the error and wonder why Rust cannot simply see that the reference is valid.

Rust can see it, but only if you define the rules. In languages like C or Python, references and pointers exist without strict guarantees. You can hold a pointer to memory that has already been freed, leading to use-after-free bugs and crashes. Rust prevents this by tracking how long every piece of data lives. A lifetime is the duration for which a reference is guaranteed to be valid. It is a compile-time contract that ensures references never outlive the data they point to.

What a lifetime actually is

A lifetime is not a runtime timer. It does not add overhead to your binary. It is an annotation that helps the borrow checker verify safety. Think of a reference as a lease on an apartment. The apartment is the data. The lease is the reference. The lease has a validity period. If you try to use the lease after the apartment is demolished, you have a problem.

In Rust, the compiler checks every lease. It assigns a lifetime to each reference based on where the data is created and where it is dropped. When you write a function that takes references and returns a reference, you must tell the compiler how the output lifetime relates to the input lifetimes. The compiler uses this information to prove that the returned reference will not dangle. If the proof fails, the code does not compile.

Minimal example: tying references together

Consider a function that returns the longer of two string slices. Both inputs are references. The output is also a reference. The function must guarantee that the returned reference is valid for as long as the data it points to exists.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    // Compare the lengths of the two slices.
    // Return the reference to the longer string.
    // The lifetime 'a ties the return value to both inputs.
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let string1 = String::from("long string");
    // string2 is shorter and lives in a smaller scope.
    let result;
    {
        let string2 = String::from("xyz");
        // longest requires both inputs to live at least as long as the output.
        // string2 lives long enough here, so this compiles.
        result = longest(&string1, &string2);
    }
    // string2 is dropped here.
    // result is still valid because it points to string1, which lives longer.
    println!("Longest string is {}", result);
}

The lifetime parameter 'a is a name for a duration. It appears in the function signature to link the references. The signature says: "This function takes two references with lifetime 'a and returns a reference with lifetime 'a." This means the returned reference lives at least as long as the shorter of the two inputs. If you tried to return a reference to data that dies sooner, the compiler would reject the code.

Trust the lifetime annotations. They document the relationship between references for both the compiler and human readers.

How the compiler checks lifetimes

When you compile, the borrow checker performs lifetime analysis. It walks through your code and assigns a lifetime to every reference. For the longest function, the checker verifies that whatever you return actually lives for 'a. If you return x, and x has lifetime 'a, the check passes. If you return y, and y has lifetime 'a, the check also passes.

The compiler also checks call sites. In main, string1 lives for the entire scope. string2 lives only inside the inner block. The call to longest requires both inputs to have the same lifetime 'a. The compiler infers that 'a must be the intersection of the lifetimes of string1 and string2. Since string2 dies at the end of the block, 'a is limited to that block. The result is assigned to result, which is used after the block. The compiler checks that result does not hold a reference to string2. Because longest can return either input, the compiler conservatively assumes the result might point to string2. However, in this specific code, string1 is longer, so result points to string1. The compiler does not perform data flow analysis to prove which branch is taken. It relies on the lifetime contract. The contract holds because string1 lives long enough.

If you swap the strings so string2 is longer, result would point to string2. Using result after the block would be a use-after-free. The compiler prevents this by enforcing that result cannot outlive string2. You would get an error if you tried to use result after string2 is dropped.

Lifetimes are a static analysis tool. They guarantee safety without runtime checks.

Lifetime elision: the rules you don't write

You rarely write lifetime annotations manually. Rust has elision rules that let the compiler infer lifetimes in common cases. These rules exist to reduce boilerplate. You only write lifetimes when the compiler cannot infer them safely.

The elision rules are:

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

These rules cover most functions. A function like fn first_word(s: &str) -> &str has one input reference. The compiler assigns that lifetime to the output. You do not need to write 'a.

A function like fn combine(a: &str, b: &str) -> &str has two input references. The compiler does not know which input the output depends on. It cannot guess. You must write the lifetimes explicitly. If the output depends on a, you write fn combine<'a>(a: &'a str, b: &str) -> &'a str. If it depends on b, you write the lifetime on b. If it depends on both, you tie them together as in longest.

Convention aside: When you must write lifetimes, use descriptive names for complex relationships. 'a and 'b are fine for simple cases. For functions with multiple inputs and outputs, names like 'input, 'config, or 'data make the signature easier to read. Do not name lifetimes 'life or 'duration. Those names suggest runtime behavior. Lifetimes are compile-time scopes.

Trust the elision rules. They handle the common cases so you can focus on the logic.

Realistic example: structs and references

Lifetimes appear in structs when the struct holds references. The struct cannot outlive the data it references. You must parameterize the struct with a lifetime.

struct Excerpt<'a> {
    // The struct holds a reference to a string slice.
    // The lifetime 'a ensures the struct cannot outlive the source data.
    part: &'a str,
}

impl<'a> Excerpt<'a> {
    // The method returns a reference with the same lifetime as the struct.
    // The compiler knows the data lives as long as the struct.
    fn get_part(&self) -> &'a str {
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    // Create an excerpt that borrows from the novel.
    let first_sentence = Excerpt { part: &novel[..14] };

    // This works. The novel is still alive.
    println!("Excerpt: {}", first_sentence.get_part());

    // If we dropped novel here, first_sentence would be invalid.
    // The compiler prevents you from using first_sentence after novel is gone.
}

The struct Excerpt<'a> carries the lifetime parameter. Every instance of Excerpt is tied to a specific lifetime. When you create an Excerpt, you borrow data with a certain lifetime. The compiler ensures that the Excerpt is dropped before the borrowed data is dropped.

In the impl block, you repeat the lifetime parameter. impl<'a> Excerpt<'a> declares that this implementation applies to any Excerpt with any lifetime 'a. The method get_part returns &'a str. This tells the compiler that the returned reference lives as long as the data stored in the struct.

Convention aside: When implementing methods, prefer &self over self unless you need to consume the struct. Borrowing the struct is usually sufficient and allows the caller to reuse the struct. If you return a reference from a method, the elision rule for &self often applies. You may not need to write the lifetime explicitly.

If the lifetime annotations make your struct definition harder to read than the struct body, consider storing an owned String instead.

Pitfalls and compiler errors

Lifetimes cause friction when you try to return references to local data or mix references with different lifetimes. The compiler catches these errors with specific codes.

Returning a reference to a local variable is a common mistake. The local variable is dropped when the function returns. The reference would dangle.

fn bad_function() -> &str {
    let s = String::from("hello");
    // This fails. s is dropped at the end of the function.
    // The reference would point to freed memory.
    &s
}

The compiler rejects this with E0515 (cannot return value referencing local data). The error tells you that the local variable s does not live long enough. You cannot return a reference to it. The fix is to return an owned value or restructure the code so the data lives longer.

Another pitfall is storing a reference in a struct when the data is temporary.

fn create_excerpt() -> Excerpt<'static> {
    let text = String::from("temporary");
    // This fails. text is dropped at the end of the function.
    // Excerpt requires data that lives for 'static.
    Excerpt { part: &text }
}

The compiler rejects this with E0597 (borrowed value does not live long enough). The error indicates that text does not live long enough to satisfy the lifetime requirement of Excerpt. You tried to create an Excerpt with a 'static lifetime, but text is local. The fix is to return an owned String or pass the data into the function from a longer-lived scope.

Don't fight the compiler here. If you cannot satisfy the lifetime constraints, switch to owned types.

When to use lifetimes vs alternatives

Lifetimes are powerful, but they are not the only tool. Choosing the right approach depends on your data ownership model and performance requirements.

Use lifetimes when you need to borrow data without copying and the data lives longer than the borrow. Use lifetimes when you are building a zero-cost abstraction that operates on slices or references. Use owned types when the data is small, or when the ownership relationship is complex and lifetimes make the API unreadable. Use Rc<T> or Arc<T> when multiple owners are needed and you can't express the relationship with simple lifetimes.

Lifetimes add complexity to function signatures. If the lifetime annotations obscure the purpose of the function, the abstraction may be too tight. Owned types transfer ownership and remove lifetime constraints. They are safer and easier to use, but they require allocation and copying. Weigh the cost of allocation against the cost of lifetime complexity.

Counter-intuitive but true: the more you use lifetimes, the harder the rest of your code becomes to reason about. Keep lifetimes local. Encapsulate complex lifetime relationships inside small modules. Expose simple APIs to the rest of the codebase.

Where to go next