18.4 Lifetime Elision Rules: When You Can Omit Annotations
Alright, let’s talk about the one part of lifetimes that feels like a freebie: the rules that let you not write them. The compiler team looked at mountains of Rust code, noticed that 95% of lifetime annotations were written in the exact same way, and decided, “We’re better than this.” So they baked these common patterns directly into the compiler’s logic. We call these the lifetime elision rules.
Think of it like this: you’re telling the compiler, “You know what I mean.” And most of the time, it actually does. But you have to understand what it’s inferring, because when your code gets more complex, the guess will be wrong and you’ll need to step in and annotate it yourself. This isn’t magic; it’s just a very good pattern-matching algorithm.
The Three Rules of the Game
The compiler uses a simple, three-step checklist when it sees a function with references but no lifetime annotations. It goes through these rules in order to try and figure out what you meant. If it can assign lifetimes unambiguously using these rules, it compiles. If it can’t, it throws up its hands and tells you to annotate it yourself.
Rule 1: Each parameter that is a reference gets its own distinct lifetime parameter. This is the starting point.
// You write this:
fn example(x: &str, y: &str) -> &str { ... }
// The compiler initially interprets it as this:
fn example<'a, 'b>(x: &'a str, y: &'b str) -> &str { ... }
Notice that x and y get different lifetimes 'a and 'b. The output lifetime is still a mystery. This rule is why you can almost always omit lifetimes on the input side; the compiler just gives everything its own name.
Rule 2: If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters. This covers the overwhelmingly common case of a function that takes one reference and returns one.
// You write this:
fn first_word(s: &str) -> &str { ... }
// The compiler applies Rule 1 (one input, one lifetime 'a)
// Then applies Rule 2: one input? assign it to the output.
fn first_word<'a>(s: &'a str) -> &'a str { ... }
Boom. This is why the classic first_word function “just works” without annotations. The output is tied to the input, which is exactly what we want. The returned slice is only valid for as long as the input string slice is.
Rule 3: If there are multiple input lifetime parameters, but one of them is &self or &mut self (i.e., it’s a method), the lifetime of self is assigned to all output lifetime parameters. This is what makes methods ergonomic.
// You write this in an `impl` block:
fn split_first(&self, delimiter: &str) -> &str { ... }
// The compiler applies Rule 1: &self gets a lifetime, delimiter gets another.
// fn split_first<'a, 'b>(&'a self, delimiter: &'b str) -> &str
// Then Rule 3 kicks in: it's a method, so assign self's lifetime ('a) to the output.
fn split_first<'a, 'b>(&'a self, delimiter: &'b str) -> &'a str { ... }
This rule exists purely to save us from the tyranny of writing -> &'a str on every single method that returns a reference into self. Thank you, rule three.
Where the Rules Fall Flat (And You Have to Step In)
The elision rules are brilliant, but they’re also dumb. They only look at the signature, not the body. This is a feature, not a bug—it keeps the interface clear and the checks local. But it means the compiler’s guess can be syntactically valid but semantically disastrous.
The classic failure mode is when a function has multiple reference inputs but the output is only derived from one of them. The rules can’t express “the output is tied to this input, not that one.”
// Let's try to be clever and fail.
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
This won’t compile. The compiler applies Rule 1: fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str. Now it’s stuck. Rules 2 and 3 don’t apply (there are two inputs, and it’s not a method). It has no way to know if the return value should be tied to 'a or 'b. You, the human, know the returned reference will be valid only as long as the shorter of the two inputs? Nope, not even that. The truth is, it’s valid only as long as whichever one you picked. The compiler needs you to explicitly define this relationship, which is why you must write the signature yourself: fn longest<'a>(x: &'a str, y: &'a str) -> &'a str. This signature says, “I promise to only return a reference that lives as long as the shorter of the two input lifetimes.” The compiler then checks your function body to ensure you uphold that promise.
The takeaway? Use elision joyfully and without guilt when the rules apply. It’s standard, idiomatic Rust. But the moment you have a function that returns a reference derived from only some of its inputs, or creates a new relationship, you’ve left the comfortable suburbs of elision and are heading into the city. Time to annotate.