Type Implimplementation

Lifetime Analysis

To check a borrow, the compiler needs a region: the set of program points over which a reference must stay valid. Annotations exist because a signature is a contract and the checker will not look inside the caller — and non-lexical lifetimes were the change that made the rules match what programmers meant.

The question

Why does the compiler need a lifetime annotation when it can obviously see where the reference is used?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Each reference carries a region — a set of program points over which it must remain valid — rather than a scope or a duration. Regions are inferred within a function body by generating outlives constraints from the control-flow graph and solving them; at a function boundary they must be *declared*, because that boundary is where the checker stops looking.

What this phase may assume or do

A borrow is well-formed only if the region over which it is required to be live is contained in the region over which the borrowed place is guaranteed valid, and no conflicting access to that place occurs at any point in that region. "Conflicting" is decided by the exclusivity rule from [[ownership-types]]: a write conflicts with any other access, a read conflicts only with a write.

Key points

  • A lifetime is a region — a set of program points over which a reference must be usable — not a duration and not a scope.
  • Annotations exist because borrow checking is modular: a caller is checked against a signature and never against the callee's body.
  • Elision rules cover the cases with only one plausible answer, which is why most code needs no explicit lifetimes.
  • Non-lexical lifetimes replaced "to the end of the block" with "to the last use", computed by liveness over the control-flow graph.
  • NLL changed which programs are accepted, not what the safety rule is — the exclusivity invariant is identical.
  • The remaining rejections — conditional-return borrows, self-referential structs — are places the static approximation is coarser than the truth.
  • Lifetimes are erased before code generation and have no runtime representation or cost.

Why the signature needs an annotation at all

The usual complaint is reasonable: the compiler can see the whole function, so why must anything be written down? The answer is that it cannot see the whole *program*, and it has deliberately chosen not to try. Borrow checking is modular — each function is checked against the signatures of the functions it calls, never against their bodies. That is what makes the check scale, what makes it incremental, and what makes it work across a library boundary where the body may not be available at all.

So consider fn pick(a: &str, b: &str) -> &str. To check a caller, the checker must know how long the returned reference is valid. Is it tied to a, to b, or to both? The body knows. The signature does not, and the caller is only allowed to look at the signature. The annotation supplies exactly the missing fact and nothing more: fn pick<'a>(a: &'a str, b: &'a str) -> &'a str says the result is valid for as long as both inputs are.

This is the same argument as [[separate-compilation]] and [[interface-files]] make everywhere else in the domain. A signature is a contract precisely so that consumers need not read implementations, and any fact a consumer needs must be in the contract. Lifetimes are simply a fact that C-family signatures never carried and that a borrow checker requires.

The elision rules exist because most signatures have only one plausible answer. One input reference and one output reference: the output must come from the input. A method with &self: the output almost always comes from self. The compiler applies these rules and only asks when the answer is genuinely ambiguous, which is why most Rust code has no explicit lifetimes and the ones you do write are usually at the interesting boundaries.

The fact a caller cannot obtain from the signature
1// Rejected: which input does the result borrow from?
2// fn pick(a: &str, b: &str) -> &str { if a.len() > b.len() { a } else { b } }
3
4// The annotation supplies exactly that fact:
5fn pick<'a>(a: &'a str, b: &'a str) -> &'a str {
6 if a.len() > b.len() { a } else { b }
7}
8
9// Now this is checkable at the CALL SITE, with no access to the body:
10let long = String::from("a long string");
11let result;
12{
13 let short = String::from("short");
14 result = pick(&long, &short); // result borrows from both
15} // short dropped here
16// println!("{}", result); // error: `short` does not live long enough

The error is reported at the caller, from the signature alone. That is the whole reason the annotation exists — the checker never opened the body of pick.

A region is a set of program points, not a scope

implementationNon-lexical lifetimes shipped in the Rust 2018 edition and changed which programs rustc accepts, not what the language means — no previously accepted program became invalid. The current implementation computes regions from liveness over MIR. Polonius is an in-progress reformulation as a datalog-style constraint problem that accepts strictly more programs, has been under development for several years, and should be treated as future behaviour rather than current.

The word "lifetime" is misleading and has caused a great deal of confusion. A lifetime in this system is not a duration, not a scope, and not the lifetime of the referent. It is a region: a set of points in the control-flow graph over which the reference must be usable. That reframing is the whole content of non-lexical lifetimes, and everything that used to be surprising becomes obvious once it lands.

Under the original, lexical rule, a borrow lasted from its creation to the end of the enclosing block, because a scope was the only thing the checker knew how to talk about. That rejected an enormous number of obviously fine programs — take a reference, use it, then mutate the collection three lines later, still inside the same block, with the reference dead. The reference is not used again; the human sees no conflict; the checker saw a borrow that was still lexically in scope.

Non-lexical lifetimes replaced scopes with liveness over the CFG. A borrow's region is now the set of points where the reference may still be used — computed by the same liveness analysis described in [[liveness-analysis]], applied to references. Once the last use is passed, the region ends, and a conflicting borrow after that point is fine.

This is why the rules suddenly matched intuition: programmers were reasoning about last use all along, and the compiler had been reasoning about closing braces. Aligning the two was not a relaxation of the safety rule — the exclusivity invariant is unchanged — it was a more precise computation of when the rule applies.

The region of r under lexical scoping and under liveness
  1. b0entryentry
    let mut v = vec![1, 2, 3];
    let r = &v[0];
    The borrow of `v` starts here.
  2. b1use
    println!("{}", r);
    Last use of `r`. Under NLL the region ends at the end of this block.
  3. b2mutate
    v.push(4);
    A mutable borrow of `v`. Legal under NLL, rejected under lexical scoping.
  4. b3exit
    // end of block: v dropped
    Where the lexical rule would have ended the region of `r`.
Edges
  • b0b1
  • b1b2
  • b2b3

Read it asLexical region of r: {b0, b1, b2, b3} — everything to the closing brace, so the mutable borrow in b2 conflicts. NLL region of r: {b0, b1} — the points where r may still be used, so b2 is outside it and there is no conflict. The invariant did not change; the computation of where it applies did.

What is still rejected, and why

NLL removed most of the false positives but not all of them, and the remaining cases are instructive because they show exactly where the analysis is weaker than the reasoning it approximates.

The famous one is the conditional-return borrow: a function that looks up a key, returns the borrow if it is present, and otherwise inserts and returns a borrow of the new entry. Every path is fine. The current checker rejects it, because the borrow taken for the lookup is considered live along the path where the lookup failed — the analysis is location-sensitive but not path-sensitive enough to see that the failing path cannot use it. This is precisely the class Polonius is designed to accept.

The second is self-referential structures: a struct holding both a buffer and a reference into that buffer. There is no lifetime that expresses "a region tied to another field of the same struct", because the struct can be moved and the reference would not follow. This is not a weakness of the analysis; it is a genuine unsoundness the rules correctly forbid, and the workaround — index instead of reference, or pin the value — is real work.

The third is that regions are inferred within a body and declared at boundaries, so a signature can be too weak even when the body is fine. A function returning a reference tied to one argument, called in a context needing it tied to another, fails at the signature. Widening the signature is the fix, and it is a public API change.

The general shape of these is worth stating: a borrow checker is a static approximation of a dynamic property, so it has false positives and no false negatives. Every rejection of a correct program is a place the approximation was coarser than the truth, and the history of the feature is a sequence of making it less coarse without ever making it unsound.

What lifetimes are not

Three clarifications remove most remaining confusion, and they are all corollaries of "a lifetime is a region, not a duration".

A lifetime annotation does not change how long anything lives. It is a constraint the checker must satisfy, not an instruction it carries out. Writing a longer lifetime does not extend anything; it merely makes a claim that must be justified elsewhere, and if it cannot be, the error moves rather than disappearing.

A lifetime is not the lifetime of the value. It is a property of the *reference*, describing the region over which the reference must be valid, which is generally shorter than the value's existence. Reading 'a as "how long the data lives" makes the elision rules and the variance rules incomprehensible.

And lifetimes are erased before code generation. They exist for the borrow checker and are gone by the time MIR is lowered — there is no runtime representation, no cost, and nothing a debugger can show you. This puts them in the same category as everything in [[type-erasure]]: a compile-time fact with no runtime residue, which is why the entire mechanism is free at run time and expensive only at build time and in the effort of writing it.

How it works

The steps, in the order the compiler takes them.

  • The body is lowered to a control-flow graph over which the analysis runs; regions are sets of points in that graph.
  • A liveness analysis computes, for each reference, the points at which it may still be used — that set is the region the borrow must cover.
  • Constraints are generated: a borrow's region must be contained in the region for which the borrowed place is valid, and subtyping between reference types generates outlives constraints between their regions.
  • The constraint set is solved by propagating region membership along the CFG until a fixed point is reached — the same fixed-point machinery as [[fixed-point-iteration]].
  • Conflicts are then checked: for each borrow, no access conflicting under the exclusivity rule may occur at any point in its region.
  • At a function boundary the regions are not inferred but taken from the declared lifetime parameters, so the caller is checked against the declaration alone.
  • Once checking succeeds, regions are discarded; nothing about them survives into code generation.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A function that reads correctly and terminates correctly is rejected with "does not live long enough", and the engineer restructures data around an analysis limitation rather than a real bug.
  • A signature is annotated more tightly than the body requires; every caller that needs the looser form fails, and the error is reported at the callers rather than at the over-tight signature.
  • A struct is given a lifetime parameter to hold a reference, and the parameter propagates outward through every type that contains it until it reaches the application's top-level state — at which point the design has to be redone with owned data or indices.
  • A closure captures a reference and is stored, and the resulting error names a lifetime the programmer never wrote, generated by the compiler for the closure's environment.
  • The conditional-return borrow pattern is hit, the error is unintelligible against the obviously correct code, and the workaround chosen — looking the key up twice — silently doubles the cost of a hot path.
  • A team upgrades editions, NLL changes which programs are accepted, and a previously rejected pattern now compiles — leaving an older workaround in the codebase that nobody knows is no longer needed.

When it helps

  • Any borrow that crosses a function boundary, which is where the declared region is the only information the caller has.
  • Returning a reference derived from an argument, where the annotation states which argument and lets callers reason locally.
  • Data structures holding references, where the parameter makes the dependency explicit rather than implicit and unsound.
  • Reasoning about why a program was rejected: reading the error as "these two regions overlap and one of them writes" turns an obstacle into a diagnosis.

When it hurts

  • Self-referential structures, which have no expressible answer and require indices, pinning, or a redesign.
  • Long-lived application state, where a lifetime parameter propagates through every containing type and eventually forces a switch to owned data or handles.
  • The remaining analysis gaps — conditional-return borrows in particular — where the workaround costs real performance and the reason is a solver limitation.
  • Learning: the vocabulary suggests durations, the mechanism is regions, and the mismatch is responsible for a large share of the language's reputation.

What it costs

Every one of these is paid by something.

  • Modular checking buys scalability, incrementality and the ability to check against a library whose source is absent, and pays by putting lifetimes into public signatures — where they become part of the API and a change to them is a breaking change.
  • Region-based analysis buys precision far beyond scopes and pays in implementation complexity and in error messages that must explain a set of program points, which is much harder to render than a scope.
  • Non-lexical lifetimes bought acceptance of many correct programs and paid with a substantially more complicated implementation and diagnostics that now have to describe liveness rather than braces.
  • Erasing lifetimes before codegen buys zero runtime cost and pays by making the mechanism invisible to every runtime tool: there is nothing to inspect, so a rejected program cannot be debugged by running it.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Garbage collection removes the question by making every reference valid until nothing points at it, at the cost of pauses and of no help with non-memory resources.
  • Reference counting gives dynamic lifetime management with no static analysis and no annotations, paying per-operation cost and leaking cycles.
  • Region inference without annotations, as in Cyclone's later work and in some research systems, infers regions whole-program instead of per-function — more programs accepted, at the cost of losing modularity and separate compilation.
  • Indices instead of references: store an integer into an arena or a slab rather than a pointer, which sidesteps the analysis entirely and gives up compile-time validity guarantees in exchange for the ability to express any graph shape.
  • Dynamic borrow checking through interior mutability moves the same rule to runtime, accepting every program the static analysis rejected and panicking when the rule is actually violated.

See it for yourself

The flag, dump or tool that shows you this directly.

  • rustc --explain E0597 and the sibling borrow-check codes give the canonical explanation of each rejection class, which is more useful than it sounds because the categories are few.
  • rustc -Z dump-mir=all (nightly) writes the MIR the borrow checker analyses, including the inserted drops, which is where "why does the borrow end there" is actually answerable.
  • cargo build error output under recent rustc names the three points that matter — where the borrow starts, where the conflicting access is, and where the borrow is later used — and reading them in that order is the diagnostic technique.
  • #![feature(nll)] history is no longer needed, but comparing a 2015-edition and a 2018-edition build of the same crate demonstrates the lexical-to-liveness change directly on real code.
  • There is no runtime inspection, and that is worth stating: lifetimes are erased, so no debugger, profiler or trace will ever show one. The compile error is the only observation point.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "A lifetime annotation makes the value live longer." It makes a claim the checker must verify. Nothing is extended; if the claim cannot be justified the error simply moves.
  • "The lifetime is how long the data lives." It is the region over which the *reference* must be valid, which is normally much shorter. Confusing the two makes elision and variance unintelligible.
  • "The borrow ends at the closing brace." It ends at the last use, and has since NLL. Reasoning in scopes will predict rejections that no longer happen.
  • "Lifetimes cost something at runtime." They are erased before code generation. The cost is compile time and author effort, and there is no runtime residue at all.

Misconceptions

The claim, and what is actually true.

Lifetime annotations tell the compiler how long to keep values alive.
They constrain what the checker must prove. Storage decisions are made by ownership and drop placement, not by annotations.
Every reference needs an explicit lifetime.
Elision covers the unambiguous cases, which is most of them. Explicit lifetimes appear where a signature has genuinely more than one plausible reading.
The borrow checker analyses the whole program.
It analyses one function at a time against the signatures of the others. That modularity is the reason annotations are needed at boundaries at all.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

When you hold a reference to something, the compiler needs to know over which stretch of the program that reference must stay valid, so it can check that nothing frees or modifies the target in the meantime. That stretch is called a lifetime. Inside a function the compiler works it out itself; at a function boundary you sometimes have to write it down, because the caller is only allowed to look at the signature.

practical

Read a borrow error as three locations rather than as a rejection: where the borrow was taken, where the conflicting access is, and where the borrow is used afterwards. Removing any one of the three fixes it, and which one to remove is a design decision — shorten the borrow, copy the data, or restructure so the two do not overlap. Reach for indices into an arena when you need a shape the analysis cannot express, such as a graph with back-edges; that is the standard answer, not a defeat. And if a lifetime parameter starts propagating up through your types toward application state, treat that as the signal to switch to owned data, because it will not stop on its own.

advanced

The real subject here is that a borrow checker is an approximation of a dynamic property by a static analysis, so it is characterised entirely by the shape of its false positives. Lexical lifetimes approximated "may still be used" by "is still in scope", which is sound and coarse. NLL approximated it by liveness over the CFG, which is sound and much finer, and cost a rewrite of the implementation. Polonius reformulates the whole thing as a datalog constraint problem over loans and points, which is finer still and accepts the conditional-return borrow that defeats the current solver. At each step the safety rule is untouched — the exclusivity invariant from [[ownership-types]] has never changed — and only the precision of "where does the rule apply" improves. That is the general pattern for every static analysis in this domain, and it is why the honest way to describe a borrow error is not "your program is wrong" but "the analysis could not prove your program right".

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationNon-lexical lifetimes are the region formulation used by rustc since the 2018 edition; before it, borrows lasted to the end of the enclosing scope, and that older behaviour is what much pre-2019 writing about Rust describes. Polonius, a further reformulation that accepts additional programs including the conditional-return borrow, has been in development for several years and is not the current default — describe it as future work rather than as how the compiler behaves.
specLifetime elision rules are specified in the Rust reference: one input reference gives its lifetime to all outputs, and a &self or &mut self parameter gives its lifetime to all outputs. These are language rules, so a signature that elides is exactly equivalent to the expanded form — elision never infers anything the rules do not state.
implementationThat lifetimes have no runtime representation is a property of rustc's lowering: regions are consumed by borrow checking on MIR and are not present in the LLVM IR that follows. It is also what makes the mechanism uninspectable at runtime, which is a genuine cost when teaching and when debugging a rejection.

If you were asked this in an interview

  • Why does a function signature need a lifetime annotation when the compiler can see the whole body?
  • What changed with non-lexical lifetimes, and what deliberately did not change?
  • A borrow error names three locations. What are they, and what are your three options for fixing it?

Connections