Skip to content
← All articles

A lifetime is a constraint, not a duration

Every wrong model of lifetimes reduces to "the annotation controls how long something lives". Replace it with the region model — a lifetime is a set of program points, and `'a: 'b` means one set contains the other — and the rest of the topic stops being mysterious.

If you take one idea from this entire track, take this one. Almost every lifetime misconception people carry for years collapses into a single false belief:

“The annotation controls how long something lives.”

It does not. It never has. The Book says so in as many words — annotations

“don’t change how long any of the references live … they describe the relationships of the lifetimes of multiple references to each other.”

Read that twice. Annotations are descriptions of relationships, checked by the compiler. They are not instructions to the runtime, because there is no runtime component to instruct.

The region model

Here is the model that actually predicts compiler behaviour.

A lifetime is a set of program points.

Not a duration. Not a stretch of wall-clock time. Not a stack frame. A set of points in the control-flow graph over which a particular reference is required to be valid.

When you write &'a str, you are saying: “this reference must be usable at every point in the set 'a.” The compiler’s job is then to check that the referent is alive at every one of those points. If it is, the program is accepted. If not, you get an error naming the point where the promise breaks.

Two lifetimes relate by containment:

'a: 'b       // read: "'a outlives 'b"
             // means: the set 'a contains the set 'b

So 'a: 'b says every point where a &'b is needed is also a point where the &'a is valid — which is exactly the condition under which a &'a T can be used wherever a &'b T is expected. That is subtyping, and it is the whole mechanism.

Most people read 'a: 'b backwards on first contact, because the colon looks like “is a kind of”. Draw the picture instead: two nested boxes, 'b inside 'a. The bigger box outlives the smaller one.

💡Given 'a: 'b, which of these is legal — passing a &'a str where a &'b str is expected, or passing a &'b str where a &'a str is expected? click to reveal

The first. A &'a str can be used wherever &'b str is wanted.

Reason it out with the sets rather than by intuition. The context expecting &'b str will use that reference at some subset of the points in 'b. Since 'b ⊆ 'a, the &'a reference is valid at every one of those points too. Handing over a longer-lived reference where a shorter-lived one was requested is always safe — you are over-delivering.

The other direction is exactly the bug the borrow checker exists to catch: a context expecting &'a str may use it at points outside 'b, where the &'b reference has no guarantee of validity.

In type-system terms, &'a T is a subtype of &'b T when 'a: 'b. References are covariant in their lifetime. That single fact is what makes ordinary code work without you ever thinking about it — every time you pass a long-lived reference into a function with a short-lived parameter, this rule fires silently.

Regions are control-flow shaped, not brace shaped

Before 2018, Rust really did tie borrows to lexical scopes, and it was miserable. Non-Lexical Lifetimes (NLL) replaced that with regions computed from actual use. This is why the following compiles:

fn main() {
    let mut v = vec![1, 2, 3];

    let first = &v[0];        // shared borrow of `v` starts
    println!("{first}");      // ...and ends here, at its last use

    v.push(4);                // mutable borrow: fine, nothing is borrowed now
    println!("{v:?}");
}

The variable first is in scope until the closing brace. The borrow it holds has a region that stops after the println!. Under the old lexical rule this was an error; under NLL it is obviously fine, because the region is “the points between the borrow and its last use”, and v.push(4) is not one of them.

Regions can be genuinely non-contiguous, too — a borrow used in one branch of an if and not the other has a region that follows the branch, not the braces. “Set of program points” is the literal truth, not a metaphor.

💡Does this compile? If so, why — and what would the pre-NLL rule have said? click to reveal
fn main() {
    let mut s = String::from("hi");
    let r = &s;
    if s.len() > 100 {
        println!("{r}");
    }
    s.push('!');
    println!("{s}");
}

It compiles.

The borrow r is used only inside the if body, so its region covers the points from the let r = &s; through that println! — and crucially, s.len() is itself a shared borrow, which coexists with r happily. By the time control reaches s.push('!'), no shared borrow is live, so the mutable borrow is unopposed.

The pre-NLL, lexical rule would have rejected it: r is in scope until the closing brace of main, so under that rule s was borrowed at the point of push, and you would get E0502. This exact pattern — a borrow used early and a mutation later in the same block — is why NLL was worth an entire compiler rewrite.

Note what did not change: the set of sound programs. NLL made the checker more precise, not more permissive about actual bugs.

Reading longest with the region model

The canonical function:

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

The wrong reading: “a and b must live equally long.” That is not what it says, and it is not what happens.

The right reading: there exists a region 'a over which both inputs are valid, and the result is valid over that same region. At every call site the compiler solves for 'a, and because references are covariant it can shrink each input’s region to fit. In practice 'a becomes the overlap of the two inputs’ validity — the largest set of points where both are still good.

Which is why this is fine:

fn main() {
    let long = String::from("a long string");
    {
        let short = String::from("short");
        let winner = longest(&long, &short);
        println!("{winner}");          // inside the overlap: fine
    }
    // `winner` would be invalid out here, and the compiler knows it
}

long and short have wildly different scopes, and the call still works. Nothing was extended and nothing was shortened. 'a was simply solved as the inner region, and every use of the result is inside it.

💡A learner has this and gets an error. They "fix" it by changing the return type to &'static str. Predict what happens. click to reveal
fn first_line(s: &String) -> &str {
    s.lines().next().unwrap_or("")
}

First, that function compiles as written (one lifetime-carrying parameter, so the output lifetime is inferred from s) — though clippy::ptr_arg will tell you to take &str instead of &String. So assume the error came from somewhere else: probably the caller tried to keep the result alive past the string.

Now the “fix”. Changing the return to &'static str does not make the caller’s problem go away; it moves the error into first_line itself. The body returns a slice of s, whose region is whatever the caller gave, and the signature now promises the whole program. The compiler rejects the body. The learner has traded an error they could have understood for one they cannot, and has learned the wrong lesson: that 'static is a magic word that sometimes works.

The real fix always lives at one of two places — return an owned String so there is nothing to outlive, or restructure the caller so the source data lives long enough. Which one is right is a design decision, and the compiler cannot make it for you. Do not let the compiler’s own help: text talk you into 'static; it suggests that far more often than it is the right answer.

Erased before codegen

Say it once more, plainly: lifetimes do not exist at runtime. They are erased before code generation. Two functions differing only in their lifetime annotations compile to byte-identical machine code. There is no per-reference metadata, no check, no cost.

That is the payoff of the whole design — the checking is done statically, so the running program pays nothing for it — and it is also a hard constraint on how you can ever be graded. No test can observe a lifetime. Only the compiler can.

The three sentences to keep

  1. A lifetime is a set of program points, not a duration.
  2. 'a: 'b means the set 'a contains the set 'b.
  3. Annotations describe relationships between lifetimes; they never change when anything lives or dies.

Everything else in this track is a consequence.