Jump to content

Why Rust Needs Explicit Lifetimes (Even When the Compiler Is Smart)

From JOHNWICK

If you’ve ever stared at a wall of 'a, 'b, 'static and thought “surely the compiler could figure this out,” you’re not alone. Rust’s borrow checker can see the shapes of your references and it will prevent use-after-free. So why does the language still make you write explicit lifetimes?

Short answer: safety is why lifetimes exist; clarity, stability, and precise contracts are why explicit lifetimes exist. They’re less about making your code safe and more about making your APIs understandable and robust.


A 30-second recap: what a lifetime actually is A lifetime is a name for “how long this reference is valid.” It’s not a runtime thing — it’s a static relationship. When you write 'a you’re not creating a longer lifetime; you’re describing that some reference lives at least as long as 'a. Think of 'a like a generic type parameter, but for time: struct Foo<'a> {

   x: &'a i32,

} This doesn’t extend x’s life; it says: “Foo only exists while some i32 reference lives; call that scope 'a.”


The famous pitfall (and what’s really going on) Consider this classic: struct Foo<'a> {

   x: &'a i32,

} fn main() {

   let x;
   {
       let y = &5;
       let f = Foo { x: y };
       x = &f.x;  // error: would outlive `f` and `y`
   }
   println!("{}", x);

}

The compiler refuses because x (outer scope) would hold a reference to f.x (inner scope). After the block, f and y die, so x would dangle. Key point: the compiler could detect this even without you naming 'a. The explicit lifetime on Foo didn’t create safety—it communicated it. Which leads to the real reason explicit lifetimes exist.


“Why write it if the compiler can infer it?” Same reason we write explicit return types: fn foo() -> &'static str { "" }

Yes, the compiler could infer &'static str. But signatures are contracts. They declare your intent, stabilize your API, and let callers (and tools) reason without spelunking into the body. Explicit lifetimes do the same for borrows:

  • Readability:
fn select(a: &u8, b: &u8) -> &u8 — which input does the output borrow from? Without lifetimes, you must read the body. With them, the relationship is visible in the signature.
  • API stability:
If the compiler inferred all top-level lifetimes from bodies, a small internal refactor could ripple into callers by changing inferred relationships. Explicit lifetimes firewall those effects: your signature promises a stable borrow relationship.
  • Compilation & tooling efficiency:
The compiler (and IDEs) can type-check uses from signatures alone. Humans can understand from the docs alone.


When do you actually need to write explicit lifetimes? There are two big buckets:

1) When elision rules don’t cover it Rust gives you lifetime elision to avoid noise. The rules (paraphrased):

  • Each elided input reference gets its own distinct lifetime parameter.
  • If there’s exactly one input lifetime (elided or explicit), outputs use that.
  • If a method has &self or &mut self, outputs use self’s lifetime.

If none of these apply, you must be explicit. Examples:

  • Multiple inputs, output borrows one of them
  • // Which one does the output borrow from? Need names. fn pick_first<'a, 'b>(a: &'a str, _b: &'b str) -> &'a str { a }
  • Output borrows from both inputs (i.e., must live as long as the shorter)
  • fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() >= y.len() { x } else { y } }
  • Here we say: the output’s lifetime is 'a, and both inputs must be at least 'a. That communicates the “depends on both” relationship.
  • Structs and enums that store references
  • You can’t elide here; types form the API surface:
  • struct Cursor<'a> { buf: &'a [u8] } enum Token<'a> { Ident(&'a str), Number(&'a str) }
  • Trait impls that expose borrowed data
  • struct Lines<'a> { s: &'a str } impl<'a> Iterator for Lines<'a> { type Item = &'a str; // ... }
  • impl Trait that captures a borrow
  • struct Bag<T>(Vec<T>); impl<T> Bag<T> { // The iterator yields references tied to `&self` fn iter<'a>(&'a self) -> impl Iterator<Item = &'a T> + 'a { self.0.iter() } }
  • (Sometimes elision is enough here; when it isn’t, making 'a explicit clarifies the capture.)
  • Higher-ranked trait bounds (HRTBs)
  • When you need a function/closure to work for any borrow it’s given:
  • fn apply_to_any_str<F>(f: F) where F: for<'a> Fn(&'a str) -> &'a str { /* ... */ }
  • The for<'a> says the closure must handle all lifetimes, not just one particular 'a.
  • Outlives relationships between parameters
  • fn bridge<'long, 'short>(x: &'long str, y: &'short str) where 'long: 'short // `'long` outlives `'short` { /* ... */ }
  • These are crucial in generic code to encode “this borrow lasts at least as long as that.”
  • Choosing 'static on purpose
  • // Spinning a thread often requires `'static` data fn spawn_with_label(label: &'static str) { /* ... */ }
  • Being explicit here prevents accidental capture of short-lived borrows.

2) When you want to tighten or loosen the contract Elision sometimes infers a too-wide contract or a too-narrow one.

  • Too wide (over-constrains callers):
  • // Elision might tie output to *all* inputs, // but we only borrow from `a`. fn id_first<'a, 'b>(a: &'a str, _b: &'b str) -> &'a str { a }
  • By naming 'a and 'b, you free callers to pass an arbitrarily short-lived b.
  • Too narrow (compiler can’t prove it):
  • Sometimes you know a relationship the compiler can’t infer from elision. Writing it explicitly lets the checker verify uses without peeking inside your body.


What explicit lifetimes are not

  • They’re not memory management.
They don’t decide when things drop; they only constrain how references are used.
  • They don’t “extend” a value’s life.
If data dies, no amount of 'a will resurrect it. You must restructure ownership instead (e.g., move the value, Arc, String instead of &str, etc.).
  • They’re not always required.
Thanks to non-lexical lifetimes (NLL) and elision, you can often skip them inside function bodies. You mostly write them on signatures and types.


A quick mental model that actually helps

  • Start from the signature.
Ask: “What does the output borrow from? For how long?” If you can answer without reading the body, you’ve encoded the contract.
  • Name lifetimes to separate concerns.
Different inputs get different names ('a, 'b). Tie the output to whichever inputs it truly depends on.
  • Prefer ownership when APIs get awkward.
If you keep tripping over lifetimes, sometimes returning an owned String/Vec<T> (or using Arc) makes the API simpler.


A few small, real-world sketches

1) Parser that slices the input struct Parser<'src> { s: &'src str } impl<'src> Parser<'src> {

   fn next_token(&mut self) -> Option<Token<'src>> { /* ... */ }

} Tokens borrow from the original source. Explicit lifetimes document that fact and make it impossible to accidentally return a token that outlives the input.

2) Cache that hands out views struct Cache { bytes: Vec<u8> } impl Cache {

   fn view<'a>(&'a self, range: std::ops::Range<usize>) -> &'a [u8] {
       &self.bytes[range]
   }

} The borrow is tied to &self. Callers can’t hold the slice longer than they hold the cache.

3) Higher-order utilities fn map_lines<F>(s: &str, f: F) where

   F: for<'a> Fn(&'a str)

{

   for line in s.lines() {
       f(line);
   }

} for<'a> forces f to accept any line’s lifetime, preventing it from capturing a particular one.


So… are explicit lifetimes “needed to prevent errors”? No — Rust prevents the errors either way. The borrow checker tracks actual scopes whether you name them or not. Explicit lifetimes are needed to:

  • Communicate intent in signatures and types.
  • Make APIs stable across refactors.
  • Constrain or relax relationships precisely (beyond elision).
  • Express advanced relations (for<'a>, 'a: 'b, trait impls, impl Trait that captures borrows).
  • Document behavior for humans and tools.

If you remember one line, make it this: Lifetimes keep you safe; explicit lifetimes keep your interfaces honest.


Handy cheat-sheet

  • ✅ You must write lifetimes on types that store references (struct, enum, type aliases).
  • ✅ You must write them on functions when elision doesn’t apply (multiple inputs, ambiguous outputs).
  • ✅ Use for<'a> for “works for any borrow” in higher-order code.
  • ✅ Use outlives bounds ('a: 'b, T: 'a) to encode relationships in generics.
  • ✅ Prefer ownership when lifetimes make APIs contort.
  • ❌ Don’t try to “extend” a lifetime with 'a. That’s not how it works.

With that lens, the sprinkle of 'as stops feeling like ceremony and starts reading like a crisp, durable contract.