scipio avatar

Learn Rust Series (#10) - Lifetimes

scipio

Published: 28 Jul 2026 › Updated: 28 Jul 2026Learn Rust Series (#10) - Lifetimes

Learn Rust Series (#10) - Lifetimes

Learn Rust Series (#10) - Lifetimes

rust-banner.png

What will I learn

  • You will learn why lifetimes exist, and how they stop dangling references at compile time before they can ever crash your program;
  • how to read and write lifetime annotations like 'a, and what they actually promise;
  • the elision rules that quietly let you skip annotations in the common cases (which is why you have not had to write one until now);
  • how structs that hold references carry a lifetime parameter, and why the compiler insists on it;
  • what 'static really means, and why slapping it everywhere to silence an error is exactly the wrong move.

Requirements

  • A working Rust toolchain (via rustup) from episode 1;
  • A terminal and an editor (VS Code with rust-analyzer is still a fine pick);
  • Episodes 1 to 9 read, and especially ownership and borrowing from episode 3 fresh in your head -- lifetimes are the fine print on the borrowing contract we signed back then.

Difficulty

  • Beginner

Curriculum (of the Learn Rust Series):

Learn Rust Series (#10) - Lifetimes

Right, here we are. For nine episodes I have been dropping little hints about "those tick-marks" -- at the end of episode 3 when we first met borrowing, and again last time when we wrapped up modules. Today I finally pay off that promise. Lifetimes are the piece of Rust that scares newcomers more than any other, and I want to defuse that fear before we even start: a lifetime is not a new thing your program does at runtime. It is not a value, it is not memory, it does nothing when the program runs. A lifetime is purely a compile-time label the borrow checker uses to prove that a reference never outlives the data it points at. That is the whole idea. Everything else today is detail hanging off that one sentence.

Here is the reassuring part. You have been writing correct lifetimes since episode 3 without ever typing one, because the compiler infers them for you almost all of the time. The only reason lifetimes get a reputation is that occasionally the compiler cannot figure them out on its own, and then it asks you to spell them out -- and the syntax, 'a and friends, looks alien the first time you meet it. So my job today is not to teach you a hard new concept so much as to make the syntax stop looking scary and show you the handful of situations where you actually need it. Let us clear last episode's homework first, then dig in.

Solutions to Episode 9's exercises

Three exercises last time, all about modules, privacy, and paths. Here they are in full.

Exercise 1 asked for a temperature module with two conversion functions, called from main via a use:

mod temperature {
    pub fn c_to_f(c: f64) -> f64 {
        c * 9.0 / 5.0 + 32.0
    }

    pub fn f_to_c(f: f64) -> f64 {
        (f - 32.0) * 5.0 / 9.0
    }
}

use temperature::{c_to_f, f_to_c};

fn main() {
    println!("{}", c_to_f(100.0)); // 212
    println!("{}", f_to_c(32.0));  // 0
}

Both functions are marked pub, because without it main (which lives outside the module) could not reach them -- the private-by-default rule from last episode in action. The use temperature::{c_to_f, f_to_c}; line pulls both names into scope in one go, so the calls read cleanly without the temperature:: prefix every time.

Exercise 2 wanted a counter module with a struct whose field is private, proving encapsulation:

mod counter {
    pub struct Counter {
        count: u32, // private -- no `pub`, so it is invisible outside this module
    }

    impl Counter {
        pub fn new() -> Counter {
            Counter { count: 0 }
        }

        pub fn increment(&mut self) {
            self.count += 1;
        }

        pub fn value(&self) -> u32 {
            self.count
        }
    }
}

fn main() {
    let mut c = counter::Counter::new();
    c.increment();
    c.increment();
    c.increment();
    println!("{}", c.value()); // 3
    // c.count = 999; // would NOT compile: `count` is private to the module
}

The whole point is that commented-out line. From outside counter, you simply cannot touch count directly -- the only ways to change it are increment, and the only way to read it is value(). The module owns its invariant, and the compiler enforces it rather than trusting a polite comment.

Exercise 3 was the integrator: a nested child module plus a re-export so you could call a buried function by a short name.

mod temperature {
    pub fn c_to_f(c: f64) -> f64 {
        c * 9.0 / 5.0 + 32.0
    }

    pub mod labels {
        pub fn describe(c: f64) -> &'static str {
            if c <= 0.0 {
                "freezing"
            } else if c >= 30.0 {
                "hot"
            } else {
                "mild"
            }
        }
    }
}

pub use temperature::labels::describe;

fn main() {
    println!("{}", temperature::c_to_f(20.0)); // 68
    println!("{}", describe(35.0));            // hot, reached by the short name
    println!("{}", describe(-4.0));            // freezing
}

The pub use temperature::labels::describe; lifts describe up to the crate root, so main calls plain describe(35.0) without walking the full temperature::labels:: path. And -- look closely at that return type: &'static str. There is our tick-mark, sitting right there in code you already wrote and understood. It has been hiding in every string literal this whole series. Time to find out what it means.

Why lifetimes exist: the dangling reference

Let us go back to first principles. In episode 3 the rule was: a reference must never outlive the thing it borrows. Back then I stated it and moved on. Today we look at why that rule is worth a whole language feature.

In older systems languages like C, nothing stops you from returning a pointer to a local variable. The variable lives on the stack, the function returns, the stack frame is torn down -- and now your pointer aims at reclaimed, meaningless memory. That is a dangling pointer, and it is one of the most notorious sources of crashes and security holes in the history of software. The pointer looks fine. It compiles fine. It might even work by accident most of the time, until one day the reclaimed memory gets reused and your program reads garbage or gets exploited. This class of bug has cost the industry untold fortunes.

Rust's promise is that this bug category simply cannot happen in safe code, and lifetimes are the mechanism that delivers that promise. Watch the compiler refuse the classic mistake:

fn main() {
    let r;                 // r will be a reference -- not pointing anywhere yet
    {
        let x = 5;         // x is born inside this inner scope
        r = &x;            // r borrows x
    }                      // x dies here: its scope just ended
    println!("{}", r);     // ERROR: `x` does not live long enough
}

If you actually run this through rustc, it flatly refuses with "x does not live long enough". The borrow checker traced the region of code where x is alive (the inner braces), noticed that r still holds a borrow of x after that region ends, and stopped you cold. That "region of code where a value is valid" is precisely what a lifetime is. Every value has one; usually the compiler works it all out silently. The word "lifetime" is almost too grand for it -- it is really just "the span of lines over which this borrow is allowed to be used". Keep that plain-language version in your pocket; it demystifies everything that follows.

When the compiler needs your help: annotating functions

So if the compiler infers lifetimes on its own, when does it ever ask you to write one? The answer is: when a function returns a reference, and the compiler cannot tell which of the inputs that returned reference is borrowed from. Consider a function that returns the longer of two string slices:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let a = String::from("lifetimes");
    let b = String::from("rust");
    let winner = longest(a.as_str(), b.as_str());
    println!("the longer word is: {}", winner); // lifetimes
}

Look at the signature: fn longest<'a>(x: &'a str, y: &'a str) -> &'a str. The <'a> after the name declares a lifetime parameter called 'a, exactly the way <T> declared a generic type parameter back in episode 8 -- same brackets, same idea, it is just a placeholder. Then each &'a str says "this reference has lifetime 'a", and the return type &'a str says "the reference I hand back also has lifetime 'a".

Read as plain English, the whole signature promises: the returned reference is valid for the shorter of however long x and y are both valid. We are not setting the lifetimes -- 'a does not force x and y to live any particular length. We are relating them. We are telling the compiler "whatever region both inputs are alive for, the output is guaranteed valid for that same region, and no longer". Without that annotation the compiler genuinely cannot know whether the returned reference came from x or from y, so it cannot check callers for safety, so it refuses to guess and asks you to state the relationship. That is the entire reason the syntax exists.

Watch the annotation earn its keep

You might reasonably ask: fine, but what does that promise actually buy me? It buys you the exact same dangling-reference protection from before, now stretched across a function boundary. Because we told the compiler the output borrows from both inputs, it can catch a caller who lets one input die too early:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let a = String::from("lifetimes");
    let result;
    {
        let b = String::from("rust");
        result = longest(a.as_str(), b.as_str()); // ERROR: `b` does not live long enough
    }
    println!("{}", result);
}

This does not compile, and thank goodness. The returned result might be borrowing from b (the compiler cannot know at compile time which branch runs), and b dies at the closing brace of the inner scope while result is used after it. Because the signature declared the output lifetime is tied to both inputs, the borrow checker can prove the danger and reject it. Without the 'a annotations there would be no stated relationship, and this whole safety check would be impossible. The tick-marks are not bureaucracy -- they are the information the compiler needs to keep the earlier dangling-pointer promise across function calls too.

Elision: why you almost never write this

Now for the twist that makes lifetimes bearable in daily use. If functions returning references need lifetime annotations, why did first_word from way back, or any &str-returning helper you have written, compile without a single 'a? Because the Rust designers noticed that a few patterns are so overwhelmingly common that forcing annotations on them would be pure noise. So the compiler applies a small set of lifetime elision rules -- it fills the annotations in for you when the answer is obvious. Here is a function returning a reference, no annotation in sight, compiling happily:

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &b) in bytes.iter().enumerate() {
        if b == b' ' {
            return &s[..i];
        }
    }
    s
}

fn main() {
    let sentence = String::from("ownership rules everything");
    println!("first word: {}", first_word(&sentence)); // ownership
}

The output clearly borrows from s -- there is only one input reference, so where else could it come from? The compiler reasons exactly that. The elision rules, in plain terms, are: (1) each input reference gets its own lifetime; (2) if there is exactly one input lifetime, it is assigned to all outputs; and (3) if one of the inputs is &self or &mut self (a method, which we will see in a moment), its lifetime is assigned to the outputs. When those rules settle the question, you write nothing. longest earlier needed an annotation precisely because it broke rule 2 -- it had two input references, so the compiler could not tell which one the output tracked. That is the tell: annotations become necessary right when there is genuine ambiguity, and stay invisible when there is not.

Structs that hold references

So far every struct we built back in episode 5 owned its data outright -- a String, an i32, a Vec. But sometimes you want a struct that only borrows some data it does not own, holding a reference to it. The moment a struct field is a reference, Rust needs to know the field cannot outlive the thing it points at -- otherwise you would have a dangling reference smuggled inside a struct. So a struct holding a reference must declare a lifetime parameter:

struct Excerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago I went sailing.");
    let first_sentence = novel.split('.').next().expect("no sentence found");
    let excerpt = Excerpt { part: first_sentence };
    println!("excerpt: {}", excerpt.part); // Call me Ishmael
}

The struct Excerpt<'a> declaration says "an Excerpt holds a reference, and I am naming that reference's lifetime 'a". The field part: &'a str then uses it. What this buys us: the compiler now guarantees an Excerpt can never outlive the novel string its part borrows from. If novel were dropped while the excerpt still lived, the borrow checker would reject the program -- same protection as before, now enforced through a struct. This is a genuinely common pattern once you start writing parsers, views over buffers, and other zero-copy code that looks at data without copying it. Copying would sidestep lifetimes entirely, but references are how Rust lets you be fast and safe at the same time.

Methods on such a struct follow naturally, and here elision rule 3 does us a favour:

struct Excerpt<'a> {
    part: &'a str,
}

impl<'a> Excerpt<'a> {
    fn announce(&self, note: &str) -> &str {
        println!("attention: {}", note);
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago I went sailing.");
    let first = novel.split('.').next().unwrap();
    let e = Excerpt { part: first };
    println!("{}", e.announce("reading now")); // Call me Ishmael
}

Notice impl<'a> Excerpt<'a> -- when a struct has a lifetime parameter, the impl block declares and uses it too, mirroring how impl<T> worked for generic structs in episode 8. The method announce returns a &str with no explicit lifetime, and it compiles anyway: elision rule 3 kicks in because &self is a parameter, so the output is assumed to borrow from self. The rules are doing the paperwork so you do not have to.

The special one: 'static

There is a single lifetime with a reserved name: 'static. It means "this reference is valid for the entire duration of the program". You have been creating 'static references since episode 1 without knowing it, because every string literal has the type &'static str. Literals are baked directly into the compiled binary, so they are always there -- from the first instruction to the last -- which is exactly what 'static promises.

fn main() {
    let motto: &'static str = "ownership is forever";
    println!("{}", motto); // ownership is forever
}

Here is where I have to wave a warning flag, though. Beginners meet a confusing lifetime error, see 'static suggested somewhere in the message, and reach for it like a magic word to make the red text go away. Please do not. Annotating something 'static does not make the data live forever -- it claims it does, and if that claim is false you have either shifted the error somewhere less obvious or forced yourself into keeping data alive far longer than you should. 'static is correct for genuine program-long constants and string literals; it is almost never the right fix for "the compiler is complaining about a borrow in my function". When you hit a lifetime error, the honest fix is nearly always to rethink who owns the data and how long it needs to live, not to stamp 'static on it and hope. Reach for it when the data truly is static, and treat it with suspicion everywhere else.

Why separate lifetimes sometimes matter

One last refinement, so the tick-marks do not feel like a single mysterious blob. A function can have more than one lifetime parameter, and sometimes it must. If two references genuinely have nothing to do with each other -- the output only ever tracks one of them -- then tying them to the same 'a would be a lie that needlessly restricts your callers:

fn first<'a, 'b>(x: &'a str, _y: &'b str) -> &'a str {
    x // we only ever return x, so the output tracks 'a alone
}

fn main() {
    let kept = String::from("I survive");
    let result;
    {
        let thrown_away = String::from("I get dropped early");
        result = first(kept.as_str(), thrown_away.as_str());
    } // `thrown_away` dies here -- and that is totally fine
    println!("{}", result); // I survive
}

This compiles, and it should, because result only ever borrows from kept -- the <'a, 'b> split tells the compiler that the second argument's lifetime is irrelevant to the output. Had we written both parameters as the same 'a, the compiler would have conservatively demanded that thrown_away live as long as kept, and this perfectly safe program would have been rejected. Distinct lifetimes are how you say "these two borrows are independent" and give your callers the maximum freedom the code actually allows. You will not need this often, but when you do, it is the difference between a function that works and one that fights you for no good reason.

Try it yourself

Three exercises, gentle to chewier as always. Full solutions open the next episode.

  1. Write a function fn wider<'a>(a: &'a str, b: &'a str) -> &'a str that returns whichever of the two slices has the fewer characters (use .len()), the mirror image of the longest function above. Call it from main with two String values and print the shorter one. Make sure you understand why the 'a on both parameters and the return type is what lets it compile.

  2. Define a struct Tagged<'a> with two fields: an owned id: u32 and a borrowed label: &'a str. Add an impl<'a> Tagged<'a> block with a method fn describe(&self) -> String that returns a String like "#7: important". In main, create a String, build a Tagged that borrows it as the label, and print describe(). Notice the method returns an owned String, so no lifetime annotation is needed on its output -- can you say why?

  3. Write a function fn longest_word(sentence: &str) -> &str that returns the longest whitespace-separated word in the input (split on spaces, track the longest slice you have seen). It takes one reference and returns one, so thanks to elision you should need zero explicit lifetime annotations -- prove that to yourself by writing it without any, then try adding <'a> by hand and confirm it means the same thing.

Exercise 2 is the one that ties it together: an owned field and a borrowed field living in the same struct, with a method that borrows self but returns something owned. If that clicks, structs-holding-references have stopped being scary.

So what did we actually cover?

  • A lifetime is a compile-time label naming the region of code over which a reference is valid. It has zero runtime cost and does nothing when the program runs -- it exists purely so the borrow checker can prove references never dangle.
  • The borrow checker uses lifetimes to reject dangling references -- a reference used after the data it points at has died -- the same class of bug that plagues C and C++, made impossible in safe Rust.
  • Functions that return a reference sometimes need explicit lifetime annotations (fn longest<'a>(x: &'a str, y: &'a str) -> &'a str) when the compiler cannot tell which input the output borrows from. The annotation relates lifetimes, it does not set them.
  • Elision rules fill annotations in automatically for the common cases (one input reference, or a &self method), which is why you have written correct lifetimes for nine episodes without typing one.
  • Structs that hold references carry a lifetime parameter (struct Excerpt<'a>), guaranteeing the struct cannot outlive the data its fields borrow.
  • 'static means "valid for the whole program" and is the lifetime of every string literal -- correct for real constants, and almost never the right way to silence a borrow error.

And that is the monster tamed. Lifetimes get built up in reputation into this towering obstacle, but strip away the unfamiliar 'a syntax and what remains is the same modest promise we made in episode 3: a borrow may not outlive what it borrows. Everything today was just the compiler and you agreeing, in writing, on how to keep that promise across functions and structs. You now have the entire core of Rust -- ownership, borrowing, pattern matching, structs and enums, error handling, collections, traits and generics, modules, and now lifetimes. That is the language's spine, and from here on we build on it in stead of adding new load-bearing ideas.

Next time we pick up something you have been leaning on quietly since episode 7 without ever looking under the hood -- those little .iter() and .map()-style calls that make Rust collections such a pleasure to work with. There is a surprising amount of power hiding in that pattern, and with ownership and traits both in hand, you are finally equipped to see how it all fits together. After that, we will look at what to do when a single owner is not enough and you need data shared in ways plain borrowing cannot express -- but one thing at a time.

That is lifetimes demystified. Thanks for sticking with me through the hard one, and see you in the next episode! ;-)

scipio@scipio

Leave Learn Rust Series (#10) - Lifetimes to:

Written by

Does it matter who's right, or who's left?

Read more #stem posts


Best Posts From scipio

We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.

More Posts From scipio