background-shape
Rust Lifetimes Without the Fear
March 7, 2022 · 7 min read · by Muhammad Amal programming

TL;DR — Lifetimes are how the compiler tracks “this reference can’t outlive what it points to.” 'a is a name. Most functions don’t need explicit lifetimes thanks to elision. When you do need them, the patterns are predictable: shared input/output lifetime, struct holding a reference, multiple input lifetimes.

Ownership is the first hurdle. Lifetimes are the second. They get a worse reputation than they deserve, mostly because the syntax ('a, <'a>, for<'a>) looks intimidating before you know what it means.

A lifetime is just a name for “the region of code where a reference is valid.” That’s it. The compiler always tracks lifetimes; sometimes it asks you to name them explicitly so it can match them up.

Why lifetimes exist

A reference must always point to valid memory. In a language with no GC and no runtime, the only way to guarantee that is at compile time, by proving the value being referenced lives at least as long as the reference.

fn dangling() -> &i32 {
    let x = 5;
    &x  // x is dropped at function return — this reference would dangle
}
// error[E0106]: missing lifetime specifier
//                ... cannot return reference to local variable `x`

The compiler refuses to compile this. In Go, returning a pointer to a local is fine (escape analysis moves the variable to the heap). In Rust, the contract is “no allocation without you asking” — so x lives on the stack and dies at function exit, and a reference to it would dangle.

The ‘a syntax

'a is a generic lifetime parameter. Like type generics (<T>), but for lifetimes. It’s a name the compiler uses to relate two or more references.

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

Read this as: “for some lifetime 'a, this function takes two &str references that both live at least 'a, and returns a reference that also lives 'a.” The compiler doesn’t know which of x or y gets returned, so it requires both inputs and the output to share a lifetime. It then enforces at the call site that the returned reference can’t outlive the shorter of the two inputs.

The names don’t mean anything special. You could name it 'banana if you wanted. By convention people use 'a, 'b, 'c.

Lifetime elision: why you usually don’t write ‘a

Three “elision rules” let the compiler infer lifetimes in most cases without you writing them:

  1. Each input reference gets its own lifetime.
  2. If there’s exactly one input reference, the output reference (if any) gets the same lifetime.
  3. If &self or &mut self is one of the inputs, the output (if any) gets self’s lifetime.

These cover the vast majority of method signatures. Example: this function has no explicit lifetimes:

fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

Rule 2 applies — one input, one output, both share an implicit lifetime. Equivalent to:

fn first_word<'a>(s: &'a str) -> &'a str {
    s.split_whitespace().next().unwrap_or("")
}

You write the elided version. The compiler fills in the explicit one.

When you DO have to write lifetimes

Three situations force you to spell out lifetimes.

Situation 1: Multiple input references, returning one of them

The longest example above. Two inputs, one of which is returned. Elision can’t infer which, so you have to say.

Situation 2: A struct holding a reference

struct UserView<'a> {
    name: &'a str,
}

impl<'a> UserView<'a> {
    fn new(name: &'a str) -> Self {
        UserView { name }
    }
}

The 'a says: a UserView is only valid as long as the &str it points at is. Trying to outlive that source is a compile error.

In practice, most backend code avoids this by making structs own their data (String instead of &str). The overhead of one String allocation is irrelevant; the simplicity is worth it. Lifetime-parameterized structs are mostly for zero-copy parsers and similar performance-critical code.

Situation 3: Multiple lifetimes that should NOT be tied together

Sometimes inputs should have independent lifetimes:

fn split<'a, 'b>(s: &'a str, delimiter: &'b str) -> Vec<&'a str> {
    s.split(delimiter).collect()
}

The returned slices reference s, not delimiter. So they should share s’s lifetime, not delimiter’s. Two separate lifetimes make that explicit.

Most of the time you don’t need this; lazy single-lifetime annotations work. Reach for multiple lifetimes only when the compiler complains that they should be different.

The ‘static lifetime

'static is a special lifetime meaning “lives for the entire program.” String literals are &'static str:

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

You’ll see 'static in two places:

  • Function arguments that need a value to outlive any spawned task (e.g., Tokio’s tokio::spawn requires 'static futures)
  • String literals and compile-time constants

When a function signature requires 'static, you usually have to either pass an owned value (String) or wrap in Arc. The most common appearance is in async code.

tokio::spawn(async move {
    // closure captures must be 'static — i.e., owned or 'static refs
    println!("running");
});

Lifetimes in method signatures

The third elision rule means methods on &self rarely need explicit lifetimes:

impl Foo {
    fn name(&self) -> &str {
        &self.name
    }
}

The returned &str borrows from &self. Elision rule 3 handles it.

Where you’ll see explicit lifetimes on impls is when the struct itself has a lifetime parameter (situation 2 above):

impl<'a> UserView<'a> {
    fn name(&self) -> &'a str {
        self.name
    }
}

Reads as: “the UserView’s lifetime is 'a; the name reference lives at least 'a.”

When the compiler asks for help

The borrow checker will sometimes ask for an explicit lifetime even when you didn’t see one coming:

error[E0106]: missing lifetime specifier
help: consider introducing a named lifetime parameter

The right move: add the lifetime, see if it compiles. If you’re confused, accept that the code wants ownership instead of a reference and use String / Vec<T>. The cost is allocation; the saving is brain cycles.

A pattern I use a lot

For backend code, I lean on this pattern:

  • Function inputs: &str, &[T], &User — borrows
  • Function outputs: String, Vec<T>, User — owned

This avoids most lifetime questions. The function’s outputs don’t have to share lifetimes with anything because they’re owned. The cost is allocation. For request-handling code (handlers, business logic), the allocation is negligible compared to the database query in the middle.

When I do hit a hot path where allocation matters, I’ll add lifetimes — but only after profiling shows it matters.

Common Pitfalls

Adding lifetimes everywhere out of fear. The compiler will tell you when you need them. Don’t pre-annotate.

Treating 'static as “magic ownership.” 'static means “valid forever.” It doesn’t make a non-owned thing become owned. If a function needs 'static and you have a &str that isn’t, you usually need to switch to String.

Confusing struct lifetimes with method lifetimes. UserView<'a> is the struct’s lifetime. Methods on it can have their own additional lifetimes. The compiler distinguishes; you have to too.

Trying to extend lifetimes by transmuting. Don’t. The compiler will accept it (in unsafe code) and your program will segfault when the underlying memory is freed.

Believing “lifetimes are runtime overhead.” They’re compile-time only. The compiled binary doesn’t know they existed. There is no runtime cost to lifetime annotations.

Reading lifetimes as part of the type. They’re related to the type but not part of it. &'a str and &'b str have the same type (&str) but different lifetimes. The compiler tracks the lifetime separately.

Wrapping Up

Lifetimes are tracking metadata that the compiler usually infers. When elision can’t, you spell out a name. The patterns are predictable; the worst lifetime errors are at struct definitions and one-shot edge cases, not in everyday code. Wednesday: error handlingResult, the ? operator, thiserror, anyhow, and the discipline that replaces try/catch.