Module 1: Lifetimes in Anger
Rust Lifetimes
Learning objectives
- Explain the core mental model behind Rust Lifetimes
- Apply Rust Lifetimes within Lifetimes in Anger
- Identify important boundaries, trade-offs, and failure modes
- Produce concrete evidence from the practice exercise
Related: Rust Borrowing and References | Rust Ownership | Rust Generics and Monomorphization | Rust Structs and Enums | Rust Index
What a Lifetime Actually Is
A lifetime is a region of code during which a reference is guaranteed to be valid. It is not a duration in time, not a garbage collection scope, and not something that exists at runtime. 'a is a compile-time label on a region of the control-flow graph, and it is erased entirely before code generation.
The problem lifetimes solve is narrow and specific: when a function takes references in and gives a reference out, the compiler needs to know which input the output borrows from.
fn longest(a: &str, b: &str) -> &str { // ✗ which one does the result borrow?
if a.len() > b.len() { a } else { b }
}error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(a: &str, b: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the
signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
|
1 | fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {The compiler cannot infer this, and deliberately does not try, because the answer is part of the API contract, not an implementation detail. If it inferred from the body, changing the body would silently change what callers are allowed to do.
Verification analogy that holds: a lifetime is the scope annotation on a virtual interface handle. The handle is only meaningful while the physical interface it points at is elaborated and alive. In SystemVerilog, a virtual interface pointing at a de-elaborated scope is a null-handle crash at runtime; in Rust the equivalent is caught before the build finishes. Where it breaks down: SV scope is a hierarchy position, whereas a Rust lifetime is a region of code, and one value can be borrowed for several distinct, shorter regions over its life.
Reading the Syntax
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}Read this as: "for some region 'a, given two string references both valid for at least 'a, I return a reference valid for 'a." You are not telling the compiler how long anything lives. You are stating a relationship the compiler must then verify at every call site.
at the call site, 'a is inferred as the OVERLAP of the inputs' actual regions:
let s1 = String::from("long string"); s1 ├──────────────────────────┤
{
let s2 = String::from("xy"); s2 ├───────────┤
let r = longest(&s1, &s2); 'a ├───────────┤ ← the shorter one
println!("{r}"); r ├───────────┤ ✓ used inside
}
// println!("{r}"); ✗ r is dead here'a becomes the shorter of the two input regions. The result cannot outlive it. That is the whole mechanism.
Concretely rejected:
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xyz");
result = longest(&s1, &s2); // 'a = lifetime of s2
} // s2 dropped
println!("{result}"); // ✗
}error[E0597]: `s2` does not live long enoughElision: Why You Rarely Write Them
Most functions never need explicit lifetimes because the compiler applies three mechanical elision rules. Knowing them tells you exactly when you must write one.
Rule 1: every elided INPUT reference gets its own fresh lifetime.
fn f(x: &T, y: &U) → fn f<'a,'b>(x: &'a T, y: &'b U)
Rule 2: if there is EXACTLY ONE input lifetime, it is assigned to all outputs.
fn f(x: &T) -> &U → fn f<'a>(x: &'a T) -> &'a U ✓ elided
Rule 3: if one of the inputs is &self or &mut self, SELF's lifetime
is assigned to all outputs.
fn f(&self, x: &T) -> &U → fn f<'a,'b>(&'a self, x: &'b T) -> &'a U ✓
if none of these determine the output lifetime → you must write it.fn first_word(s: &str) -> &str { ... } // rule 2 — elided, fine
fn get(&self, k: &str) -> &Value { ... } // rule 3 — elided, fine
fn longest(a: &str, b: &str) -> &str { ... } // ✗ neither appliesRule 3 is why methods almost never need annotations. It also encodes a real assumption: a method returning a reference is assumed to be returning something out of self, which is true the overwhelming majority of the time. When it is not, you annotate:
impl Parser<'_> {
// explicitly: the result borrows from the ARGUMENT, not from self
fn echo<'b>(&self, input: &'b str) -> &'b str { input }
}Lifetimes in Structs
Any struct holding a reference must declare a lifetime parameter. This is the annotation that appears most often in real code.
struct LogView<'a> {
source: &'a str, // borrows from something outside
errors: Vec<&'a str>, // and so do these
}
impl<'a> LogView<'a> {
fn new(source: &'a str) -> Self {
let errors = source.lines().filter(|l| l.contains("UVM_ERROR")).collect();
LogView { source, errors }
}
fn count(&self) -> usize { self.errors.len() }
}
fn main() {
let text = std::fs::read_to_string("sim.log").unwrap();
let view = LogView::new(&text); // view borrows `text`
println!("{} errors", view.count());
} // view must die before text — enforcedstruct LogView<'a> reads as: "a LogView cannot outlive the data it borrows." The compiler enforces the ordering at every use site. Note that errors: Vec<&'a str> holds slices into the original string — no copying at all. A 2 GB log file is parsed into a view with zero allocation of the text itself. This zero-copy parsing style is one of the strongest practical arguments for lifetimes and is why crates like nom and serde are as fast as they are.
The alternative, owning design:
struct LogReport {
source: String, // owns — no lifetime parameter, simpler, copies
errors: Vec<String>,
} BORROWED LogView<'a> OWNED LogReport
──────────────────────── ─────────────────────────
zero allocation one allocation per error line
cannot outlive the source fully independent, 'static-friendly
viral: callers deal with 'a no lifetime plumbing anywhere
great for parsers, iterators great for anything stored or sent
across threads
default to OWNED. reach for BORROWED when you have measured the copy cost
or when the data genuinely is a view.That guidance matters: beginners over-reach for <'a> and end up with lifetimes propagating through their entire codebase. Own your data until profiling says otherwise.
'static and What It Really Means
'static is the longest lifetime: valid for the entire program.
let s: &'static str = "baked into the binary"; // string literals are 'static
static LIMIT: u32 = 100; // statics are 'static
let leaked: &'static str = Box::leak(String::from("x").into_boxed_str());The trap is the difference between &'static T and T: 'static:
&'static T "this REFERENCE points to data that lives forever"
→ genuinely restrictive
T: 'static "this TYPE contains no references with a shorter lifetime"
→ satisfied by every OWNED type: String, Vec<u8>, i32, ...
→ NOT satisfied by LogView<'a> for a short 'aT: 'static is a bound you see constantly on thread::spawn, Box<dyn Error + 'static>, and channel sends. It does not mean "lives forever" — it means "does not borrow anything that might die". A String you just created satisfies T: 'static. This distinction confuses nearly everyone once.
fn spawn_it<T: Send + 'static>(v: T) { /* thread::spawn wants this */ }
spawn_it(String::from("fine")); // ✓ owned, satisfies T: 'static
let local = 5;
spawn_it(&local); // ✗ &i32 with a short lifetimeLifetime Bounds and Multiple Lifetimes
When lifetimes genuinely differ, name them separately and relate them only where needed:
// two independent lifetimes; result borrows only from `a`
fn pick_first<'a, 'b>(a: &'a str, _b: &'b str) -> &'a str { a }
// 'long outlives 'short
fn shorten<'long: 'short, 'short>(x: &'long str) -> &'short str { x }'long: 'short reads as "'long outlives 'short", the same way T: Display reads as "T implements Display". Because a longer-lived reference can always be used where a shorter one is expected, &'long T coerces to &'short T automatically — this is covariance, and it is why you almost never write the bound by hand.
Combining with generics:
struct Cache<'a, T: 'a> { // T must not contain refs shorter than 'a
entries: &'a [T],
}
fn print_all<'a, T>(items: &'a [T]) where T: std::fmt::Debug + 'a {
for i in items { println!("{i:?}"); }
}Modern Rust infers most T: 'a bounds, so you write them less often than older tutorials suggest.
Examples
Example 1: Zero-copy field extraction from a log line
#[derive(Debug)]
struct UvmMsg<'a> {
severity: &'a str,
component: &'a str,
text: &'a str,
}
fn parse<'a>(line: &'a str) -> Option<UvmMsg<'a>> {
// UVM_ERROR @ 1200 ns: uvm_test_top.env.sb [MISMATCH] exp=1 got=0
let (sev, rest) = line.split_once(' ')?;
let (_time, rest) = rest.split_once(": ")?;
let (comp, text) = rest.split_once(' ')?;
Some(UvmMsg { severity: sev, component: comp, text })
}
fn main() {
let log = String::from("UVM_ERROR @ 1200 ns: uvm_test_top.env.sb [MISMATCH] exp=1 got=0");
let msg = parse(&log).unwrap();
println!("{:?}", msg);
}Every field of UvmMsg is a slice into log. Parsing a million-line file allocates nothing for the text. The <'a> is the price of that guarantee, and the compiler ensures no UvmMsg can outlive the buffer it points into.
The owning alternative for comparison:
struct UvmMsgOwned { severity: String, component: String, text: String }
// three heap allocations per line — for a 1M-line log that is 3M allocationsExample 2: The error everyone hits, and its three fixes
fn first_error(log: &str) -> &str {
for line in log.lines() {
if line.contains("UVM_ERROR") { return line; }
}
let fallback = String::from("none");
&fallback // ✗ returns a ref to a local
}error[E0515]: cannot return reference to local variable `fallback`
--> src/main.rs:6:5
|
6 | &fallback
| ^^^^^^^^^ returns a reference to data owned by the current function// fix 1 — return a 'static literal (free, no allocation)
fn first_error(log: &str) -> &str {
log.lines().find(|l| l.contains("UVM_ERROR")).unwrap_or("none")
}
// fix 2 — return an Option and let the caller decide
fn first_error(log: &str) -> Option<&str> {
log.lines().find(|l| l.contains("UVM_ERROR"))
}
// fix 3 — return an owned String (allocates, but no lifetime plumbing)
fn first_error(log: &str) -> String {
log.lines().find(|l| l.contains("UVM_ERROR")).unwrap_or("none").to_string()
}Fix 2 is idiomatic: "absent" is information, and the caller should handle it — see Rust Error Handling. Note that unwrap_or("none") typechecks in fix 1 only because a string literal is &'static str, which coerces to any shorter 'a.
Example 3: A struct that must not outlive its source, demonstrated
struct Cursor<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Cursor<'a> {
fn new(data: &'a [u8]) -> Self { Cursor { data, pos: 0 } }
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
// ^^ note: 'a, not the &mut self lifetime.
// the returned slice borrows the SOURCE buffer, not the cursor, so the
// caller may hold it while continuing to advance the cursor.
let end = self.pos.checked_add(n)?;
let out = self.data.get(self.pos..end)?;
self.pos = end;
Some(out)
}
}
fn main() {
let packet = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01];
let mut c = Cursor::new(&packet);
let header = c.take(4).unwrap();
let payload = c.take(2).unwrap();
println!("{header:02X?} {payload:02X?}"); // ✓ both alive at once
}If take had returned &[u8] with elision, rule 3 would have tied the result to &mut self, and holding header while calling take again would be a second mutable borrow — rejected. Writing -> Option<&'a [u8]> explicitly says the data comes from the underlying buffer. This is the single most useful thing explicit lifetimes buy you, and it is exactly the case elision gets wrong.
What Trips People Up
- "Lifetimes make my values live longer." They do not. They are pure static description.
&'static TvsT: 'static. The bound is satisfied by every owned type. It does not- Adding
<'a>to a struct to "fix" an error. If the struct should own its data, the fix - Fighting elision rule 3. A method returning data from an argument rather than from
'aon the impl block.impl<'a> Foo<'a>declares then uses.impl Foo<'_>is the- Self-referential structs.
struct S { data: String, view: &str /* into data */ }is - Expecting lifetimes to solve a design problem. If two things need to mutate one value
Box<dyn Error>needing+ 'staticsometimes. It is implied by default; you only see
Adding 'a never changes when anything is dropped.
mean immortal.
is String/Vec, not a lifetime parameter. Lifetimes are viral — once one struct has one, everything containing it needs one.
self needs an explicit lifetime, and the error message is confusing until you know why.
anonymous shorthand when the body does not name it.
impossible. Use indices, ouroboros, or Pin (Rust Async and Futures).
at unpredictable times, no annotation helps — you need Rc<RefCell<T>> (Rust Interior Mutability) or an arena/index design.
it when you deliberately want a shorter-lived trait object.
Related
- Rust Borrowing and References — what lifetimes annotate
- Rust Ownership — why the compiler cares in the first place
- Rust Structs and Enums — structs holding references
- Rust Generics and Monomorphization — lifetimes as generic parameters
- Rust Interior Mutability — when the static model genuinely cannot express your design
- Rust Async and Futures —
Pin, self-reference, and lifetimes across await points - Rust Common Pitfalls — the error catalogue
- Rust Index — full topic map
Practice lab
Build the smallest runnable Rust program that demonstrates Rust Lifetimes. Add a focused test or Clippy check, trigger one relevant compiler or runtime failure deliberately, and explain the evidence used to correct it. Add an operational constraint such as concurrency, recovery, security, latency, or cost, and defend the resulting design trade-off.
Review questions
- What problem does Rust Lifetimes solve, and what assumptions does it rely on?
- Which boundary or failure case is easiest to miss, and how would you expose it?
- What alternative design would you consider, and what trade-off would change the decision?
- What artifact, trace, test, or metric proves that your implementation is correct?
Completion evidence
- A working artifact, annotated trace, or reproducible experiment
- At least one normal case and one deliberately failing or boundary case
- A concise explanation of the design choice and its trade-offs
- Saved output showing how correctness was evaluated