DevgainsDevgainsDevgains
All articles

Lifetimes Explained Without the Scary Diagrams

·5 min read
Lifetimes Explained Without the Scary Diagrams

Photo: Unsplash

Lifetimes have a reputation. Search for an explanation and you will find color-coded boxes, arrows spanning multiple stack frames, and Greek-letter notation that makes a simple idea look like a graduate seminar. I bounced off those diagrams several times before something clicked, and what clicked was almost embarrassingly simple. So let me try to explain lifetimes the way I eventually understood them: in plain sentences, with no diagrams at all.

Here is the entire concept in one line. A lifetime is the compiler's way of guaranteeing that a reference never outlives the data it points to. Everything else is detail.

The problem lifetimes solve

A reference, written &T, is a pointer that borrows data it does not own. The danger with any borrowed pointer is that the original data might be freed while the pointer still exists. Point at freed memory and you have a dangling reference, the source of countless crashes and security bugs in other languages.

Rust forbids this at compile time. To do so, the compiler needs to know, for every reference, how long the borrowed data stays valid. That duration is the lifetime. Most of the time the compiler figures it out silently. Lifetime annotations only appear when it needs your help connecting the dots.

fn main() {
    let reference;
    {
        let value = String::from("temporary");
        reference = &value; // borrow value
    } // value is dropped here
    // println!("{reference}"); // ERROR: value does not live long enough
}

The compiler rejects this because reference would point at value after value has been dropped. There is no annotation in sight here; the compiler tracked the lifetimes on its own and caught the mistake. This is lifetimes doing their job invisibly, which is how they work the vast majority of the time.

Why you usually never write one

If lifetimes were always explicit, Rust code would be unreadable. They are not, thanks to lifetime elision: a set of rules the compiler applies to fill in the obvious cases. For a function that takes one reference and returns one, the compiler assumes the output borrows from that input. You write no annotation and it just works.

// no lifetime annotation needed; elision handles it
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

The returned &str borrows from the input s, and the compiler infers that automatically. The Rust Book's chapter on lifetimes lays out the elision rules, but the practical takeaway is that you can write a great deal of Rust without ever typing an explicit lifetime. They are there, the compiler just spells them for you.

Mental model: every reference already has a lifetime, the same way every variable already has a type. Annotations like 'a are not adding a new concept, they are just naming a lifetime so you can describe a relationship the compiler cannot infer on its own.

When you do have to write one

Elision gives up when the relationship between input and output references is ambiguous. The classic case is a function taking two references and returning one. The compiler cannot guess which input the output borrows from, so it asks you to say.

// which input does the result borrow from? we must tell the compiler.
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() >= y.len() { x } else { y }
}

Read 'a as a label, like a variable name but for a duration. The signature says: "there is some lifetime I will call 'a; both inputs live at least that long, and the output lives exactly that long." In plain English, the returned reference is valid only as long as both inputs remain valid. That is a true statement about this function, because the result could be either x or y, so it can only be trusted while both are alive.

You are not setting the lifetime. The data already has whatever lifetime it has. You are describing the relationship so the compiler can check callers against it. If a caller tries to use the result after one of the inputs has been dropped, the borrow checker rejects it, using exactly the relationship 'a encoded.

Lifetimes in structs

The other place annotations show up is structs that hold references. If a struct stores a &str rather than an owned String, the struct cannot outlive the data it borrows, and you have to say so.

struct Excerpt<'a> {
    text: &'a str,
}
 
fn main() {
    let article = String::from("Rust makes borrows explicit. That is the point.");
    let first_sentence = article.split('.').next().unwrap();
    let excerpt = Excerpt { text: first_sentence };
    println!("{}", excerpt.text);
    // excerpt cannot outlive `article`, and the compiler enforces it
}

The <'a> on the struct declares that an Excerpt is tied to the lifetime of whatever string it borrows. The compiler will not let an Excerpt survive past the data behind text. The standard library documentation is full of types that carry lifetimes for exactly this reason, like the iterators and slice views you use every day, even though you rarely name their lifetimes yourself.

The reframe that made it click

What finally dissolved my confusion was this: lifetime annotations do not do anything. They do not change how long data lives, they do not allocate, they do not free. They are pure description. The 'a in a signature is a claim about reference relationships that the compiler then verifies. If your claim is wrong, you get an error; if it is right, the annotation vanishes at compile time and costs nothing at runtime.

Once you stop thinking of 'a as machinery and start thinking of it as a sentence you are writing for the compiler, the scary diagrams become unnecessary. You are just telling the truth about which references depend on which, and letting the borrow checker hold you to it.

Takeaways

  • A lifetime is the guarantee that a reference never outlives the data it points to. That is the whole concept.
  • Most lifetimes are invisible; lifetime elision lets the compiler infer them so you rarely write annotations.
  • You write explicit lifetimes when a relationship is ambiguous, typically functions returning one of several input references, or structs that store references.
  • An annotation like 'a is a name for a duration that describes a relationship; it never changes how long data actually lives.
  • Lifetimes are pure compile-time description with zero runtime cost. Treat 'a as a sentence for the compiler, not a diagram to memorize.
5 min read

Read next