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 enough

Elision: 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 applies

Rule 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 — enforced

struct 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 'a

T: '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 lifetime

Lifetime 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 allocations

Example 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.
  • Adding 'a never changes when anything is dropped.

  • &'static T vs T: 'static. The bound is satisfied by every owned type. It does not
  • mean immortal.

  • Adding <'a> to a struct to "fix" an error. If the struct should own its data, the fix
  • is String/Vec, not a lifetime parameter. Lifetimes are viral — once one struct has one, everything containing it needs one.

  • Fighting elision rule 3. A method returning data from an argument rather than from
  • self needs an explicit lifetime, and the error message is confusing until you know why.

  • 'a on the impl block. impl<'a> Foo<'a> declares then uses. impl Foo<'_> is the
  • anonymous shorthand when the body does not name it.

  • Self-referential structs. struct S { data: String, view: &str /* into data */ } is
  • impossible. Use indices, ouroboros, or Pin (Rust Async and Futures).

  • Expecting lifetimes to solve a design problem. If two things need to mutate one value
  • at unpredictable times, no annotation helps — you need Rc<RefCell<T>> (Rust Interior Mutability) or an arena/index design.

  • Box<dyn Error> needing + 'static sometimes. It is implied by default; you only see
  • 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

  1. What problem does Rust Lifetimes solve, and what assumptions does it rely on?
  2. Which boundary or failure case is easiest to miss, and how would you expose it?
  3. What alternative design would you consider, and what trade-off would change the decision?
  4. 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