Rust Borrow Checker Lifetimes: Fix Dangling Fixes
Return owned String instead of &str, annotate only what elision can't infer, and never default to 'static.
20+ years shipping production backend systems. Written from production experience, not tutorials.
- ✓Basic Rust syntax
- ✓Ownership and borrowing
- ✓Running cargo commands
- Lifetime errors mean a reference might outlive its data — the checker blocks dangling pointers at build time
- Learn the three elision rules so you annotate only genuinely ambiguous signatures instead of every function
- Return owned String or Vec instead of borrowed &str when the data is built inside the function
- Don't slap 'static on everything — it demands data lives forever and just moves the error somewhere harder
- Tie outputs to inputs with named lifetimes (&'a str) so callers see which data must outlive the result
Imagine lending a friend a book, then moving away before they return it — they show up at your empty apartment holding nothing. Rust's lifetime rules prevent exactly that with references: every borrow must be returned (stop being used) before the owner moves or drops the data. When the compiler can't prove the timing works, it refuses with a lifetime error. The fix is usually owning the book outright (return a String) instead of lending what you don't hold.
Lifetime errors (E0106, E0515, E0597, missing lifetime specifier) are the borrow checker reasoning about time instead of aliasing. Article 9 covered who may access data; lifetimes cover how long each access stays valid. Every reference carries an invisible expiry date, and the compiler proves no use happens past it. When a function builds a String locally and returns &str into it, the expiry is the function's end — the reference dangles, and the build stops instead of shipping a use-after-free.
Elision covers the common cases silently: one input lifetime flows to the output, &self lends to returns, and obvious shapes need no annotation. Errors cluster where elision gives up — multiple inputs with one borrowed output, structs holding references, and helpers storing borrows past the call. The instinct to annotate 'static everywhere compiles nothing real; it demands immortality the data doesn't have.
This guide teaches the time-model directly: elision's three rules, owned-versus-borrowed return design, struct lifetime parameters, and the named-lifetime patterns that express tie-this-output-to-that-input. You'll stop sprinkling annotations and start choosing ownership — which is what the checker wanted all along.
Lifetimes Are Expiry Dates, Not Annotations
Every reference in Rust carries a lifetime — the span during which the pointed-to data is guaranteed alive. The compiler infers most of them and proves every use falls inside its referent's span. A lifetime error means the proof failed: some use might outlive the data. The annotation syntax (&'a str) doesn't create validity; it names relationships so the checker can verify them across function boundaries.
The time-model makes errors readable. does not live long enough prints two spans: where the owner drops and where the borrow is still used. The fix always moves one of those two points — hoist the owner earlier (longer life) or end the borrow sooner (shorter use). Returns-a-value-referencing-data-owned-by-function means the owner's span ends at return while the borrow escapes — the only fix is ownership transfer (return the String itself).
Internalize this before touching syntax: draw owner spans and borrow uses on a timeline, exactly like article 9's live ranges but for validity instead of aliasing. Most lifetime errors resolve to hoisting a variable, returning ownership, or tying an output to an input with a name. Annotations are the vocabulary for expressing timing you already understand — learn the timing first and the syntax becomes obvious.
Elision: The Three Rules That Cover 90 Percent
Elision assigns lifetimes without writing them, and three rules handle nearly every signature. Rule one: each elided input reference gets its own lifetime — fn f(a: &str, b: &str) treats them as 'a and 'b, distinct. Rule two: with exactly one input lifetime, it flows to every elided output — fn first(s: &str) -> &str ties output to input automatically. Rule three: &self methods lend self's lifetime to outputs — fn get(&self) -> &str lives as long as the borrow of self.
Errors appear where rules run out: multiple inputs with one borrowed output can't infer which input the output ties to, so the compiler demands a name. That's the whole mystery of missing lifetime specifier — ambiguity, not complexity. Naming both inputs 'a (or 'a and 'b with the output tied to one) resolves it by stating the relationship elision couldn't guess.
The practical stance: write signatures plain first and add names only where the compiler asks. A codebase drowning in explicit 'a everywhere is fighting elision instead of using it — noise that obscures the few signatures where the relationship genuinely matters. When the compiler does ask, tie outputs to the input they derive from and keep 'static out of it unless the data truly lives forever.
Return Owned String, Not Borrowed &str
Functions that build data must return owned values — String, Vec<u8>, or structs of owned fields. A &str return borrows from somewhere, and when that somewhere is a local buffer the borrow dangles at return by construction. No annotation fixes it: naming the lifetime just moves the complaint to the caller, who can't supply immortality either. Ownership transfer is the only correct shape.
The performance objection rarely survives measurement. The parser's 3 small allocations per request added 0.4ms — invisible at p99 against network and IO. Small-string patterns, buffer reuse across calls, and Cow<str> for maybe-borrowed shapes cover the cases where allocation genuinely matters. And correctness dominates: 2M clean requests beat 43 crashes per hour at any allocation budget.
Design APIs around this from the start. Parsers return owned documents, builders return owned strings, formatters write into caller-supplied buffers or return String. Reserve &str returns for views into caller-provided input (first(s: &str) -> &str) where elision ties output to input honestly. When a function both computes and returns text, owned is the default — borrowed is the optimization you prove with benchmarks, not the starting point.
Structs That Borrow vs Structs That Own
A struct holding &str must declare struct View<'a> { src: &'a str } — the parameter advertises that the struct borrows and can't outlive its source. This infects everything downstream: constructors need matching annotations, collections of views carry the parameter, and APIs crossing module boundaries drag lifetimes along. Borrowing structs suit tight zero-copy pipelines where the source clearly outlives all views and performance is measured, not assumed.
Owning structs (src: String) erase all of it: no parameters, no annotations, no constraints on storage or threads. The parser's Config with owned Strings crosses modules, queues, and threads freely — the 3 allocations buy architectural simplicity that borrowed views can't offer at any price. Default to owned for public APIs and cross-cutting types; reserve borrowed structs for hot inner loops with proven allocation pressure.
The migration path is mechanical: change &str fields to String, delete the lifetime parameters, replace slices with to_owned at construction. Each deletion removes a constraint callers had to satisfy. When borrowing is genuinely required (zero-copy parsers over huge inputs), scope it narrowly — borrow inside the hot function, produce owned outputs at its boundary. Lifetimes then live where they're cheapest: inside one function body, never in a public type.
'static Overuse: Demanding Immortality
The 'static bound means data lives for the entire program — string literals, constants, leaked boxes, owned values moved into threads. Slapping it on a helper to silence the checker demands immortality the caller's data doesn't have, so compilation fails at the call site instead, with a harder error blaming innocent code. The annotation didn't fix timing; it relocated the complaint and encrypted it.
Legitimate 'static is narrow and recognizable. Literals (&'static str) carry it naturally. thread::spawn requires it because threads may outlive any scope — the fix is moving owned Strings in, not borrowing harder. Global caches and registries hold it via Box::leak or OnceLock, deliberate forever-allocations with documented intent. Each case involves data that genuinely never drops.
For everything else, name the relationship instead. Tie outputs to inputs with 'a, scope threads with std::thread::scope for borrowed slices, and store owned values where forever is actually needed. The named relationship compiles at the call site because it states a fact the caller can satisfy.. When you catch yourself typing 'static to make an error vanish, stop — the checker is reporting a timing fact about your program, and overruling facts with annotations is how staging segfaults get scheduled.
A Repeatable Lifetime Workflow
Work lifetime errors as timing facts, not syntax puzzles. First, run rustc --explain on the code and read both spans — owner drop versus borrow use. Classify the shape: local escaping (return ownership), short-lived owner (hoist it), ambiguous multi-input (name the tie), or 'static demand (supply ownership or scope the consumer).
Second, fix the timing before the syntax. Hoist owners above uses, return String instead of &str, narrow borrowed structs to hot interiors, replace 'static with scoped threads or moved values. Rebuild after each change — lifetime errors resolve one relationship at a time, and each fix simplifies the next message considerably. Third, prove memory shape with miri on parsers plus a volume soak (100K requests) that exercises real lifetimes under load.
Fourth, encode the decisions durably: owned returns in API guidelines, 'static-review rules for non-test code, elision-first style in the linter, soak tests over request-shaped fixtures. Lifetime errors feel philosophical until the workflow makes them fully mechanical — spans, timing, ownership, proof. Teams running it stop annotating defensively and start designing owned boundaries the checker never questions.
A Config Parser Returned 900 Dangling Slices a Minute
- Return owned values from functions that build data — borrowed views into locals dangle by construction.
- Never 'static your way past a lifetime error; the annotation overrules the guard instead of fixing the timing.
- Prove parsers with miri plus volume soaks — memory-shape bugs hide at 10 requests and detonate at 900 rpm.
| File | Command / Code | Purpose |
|---|---|---|
| spans.rs | fn main() { | Lifetimes Are Expiry Dates, Not Annotations |
| elision.rs | fn first(s: &str) -> &str { | Elision |
| owned.rs | struct Config { host: String, path: String } | Return Owned String, Not Borrowed &str |
| structs.rs | struct View<'a> { src: &'a str } | Structs That Borrow vs Structs That Own |
| stc.rs | use std::thread; | 'static Overuse |
| rustc --explain E0515 | A Repeatable Lifetime Workflow |
Key takeaways
Common mistakes to avoid
5 patternsAnnotating 'static to silence the checker
Returning &str into a function-local buffer
Annotating every signature defensively
Borrowed fields in public cross-module types
Testing parsers with 10 requests
Interview Questions on This Topic
What is a lifetime in one sentence?
Frequently Asked Questions
20+ years shipping production backend systems. Written from production experience, not tutorials.
That's Core. Mark it forged?
5 min read · try the examples if you haven't