The Lifetime Elision Rules
Every reference in Rust carries a lifetime — the span of code for which it is guaranteed to point at valid data. Function signatures that take or return references technically need lifetime parameters describing how those lifetimes relate, so the compiler can verify at compile time that nothing ever outlives the data it borrows. If you had to write an explicit lifetime on every single reference in every signature, ordinary Rust code would be buried in <'a> noise. The lifetime elision rules exist to prevent that: a small, deterministic algorithm the compiler runs over every function and method signature, filling in lifetime parameters automatically whenever the pattern is unambiguous. This lesson explains exactly what that algorithm does, traces it by hand on real code, and — just as importantly — shows precisely where it gives up and forces you to write lifetimes yourself.
It helps to be clear about what elision is not. It is not a relaxation of the borrow checker, and it makes code neither more nor less safe. Every function with references in its signature still has fully-specified lifetime parameters after compilation — elision only decides whether you type them or the compiler infers them silently. A function written with explicit lifetimes and the same function written so elision fills them in produce identical compiled signatures. Elision is purely a matter of surface syntax, applied before borrow checking even begins.
Overview: Why Elision Exists
In pre-1.0 Rust, almost every reference in a signature needed an explicit lifetime annotation. The language designers noticed that the overwhelming majority of real signatures followed one of three predictable shapes, so those shapes were promoted into compiler rules. Today, when you write a function signature without lifetime annotations, the compiler does not skip lifetime checking — it silently computes the lifetimes using the rules below, then proceeds exactly as if you had written them out. Only when the rules cannot determine an output lifetime does the compiler stop and ask you to be explicit.
The Three Elision Rules
The compiler applies these three rules, in order, to every function and method signature that omits lifetime annotations. If every reference in the signature has a lifetime after applying them, elision succeeds and you never see the lifetimes at all. If any output reference is still ambiguous afterward, compilation fails with an error until you add explicit lifetimes.
Rule 1 — every elided input reference gets its own lifetime
Each reference parameter without an explicit lifetime gets a fresh, distinct lifetime parameter. A function written as fn foo(x: &i32, y: &i32) is treated internally as fn foo<'a, 'b>(x: &'a i32, y: &'b i32) — two unrelated lifetimes, one per parameter — even though nothing in the source mentions them.
Rule 2 — a single input lifetime flows to every output
If, after rule 1, there is exactly one input lifetime parameter (elided or written explicitly), that lifetime is assigned to every elided output reference. This is why fn first_word(s: &str) -> &str needs no annotations: there is only one reference parameter, so the returned &str must be borrowed from s.
Rule 3 — &self wins
If there are multiple input lifetime parameters but one of them is &self or &mut self — meaning this is a method, not a free function — the lifetime of self is assigned to every elided output lifetime, regardless of how many other reference parameters the method takes. This exists because methods overwhelmingly return data borrowed from the receiver (getters, .iter()-style accessors, builder patterns), so it is the sensible default.
If none of the three rules pins down every output lifetime — most commonly a free function with two or more reference parameters and no self — elision fails outright. The compiler genuinely cannot know which input the output borrows from, so it refuses to guess.
Syntax: Elided vs. Desugared Signatures
Elision has no special syntax of its own — it is simply the absence of annotations that would otherwise be required. The table below shows signatures as you would actually write them next to the fully explicit form the compiler derives internally.
| Rule applied | As written (elided) | Compiler’s desugared form |
|---|---|---|
| Rule 1 only | fn foo(x: &i32, y: &i32) |
fn foo<'a, 'b>(x: &'a i32, y: &'b i32) |
| Rule 1 + Rule 2 | fn first(s: &str) -> &str |
fn first<'a>(s: &'a str) -> &'a str |
| Rule 1 + Rule 3 | fn get(&self, key: &str) -> &str |
fn get<'a, 'b>(&'a self, key: &'b str) -> &'a str |
// Elided (what you write)
fn get(&self, key: &str) -> &str { /* ... */ }
// Desugared (what the compiler actually resolves)
fn get<'a, 'b>(&'a self, key: &'b str) -> &'a str { /* ... */ }
Note that elision only ever applies to function and method signatures. Struct and enum field references always require an explicit lifetime parameter on the type itself — there is no elision for struct Excerpt { part: &str }; it must be struct Excerpt<'a> { part: &'a str }.
Examples
Example 1: Rule 2 — one input, one output
A function with exactly one reference parameter never needs annotations, because rule 2 ties the output straight to it.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
s
}
fn main() {
let sentence = String::from("hello world");
let word = first_word(&sentence);
println!("First word: {}", word);
}
Output:
First word: hello
Under the hood the compiler treats this as fn first_word<'a>(s: &'a str) -> &'a str. There is only one input lifetime, so rule 2 hands it straight to the return type — the borrow checker then confirms the returned slice’s lifetime never exceeds s‘s.
Example 2: Rule 3 — the self shortcut
A method that takes &self plus other reference parameters still gets its output lifetime tied to self automatically, thanks to rule 3.
struct Excerpt<'a> {
part: &'a str,
}
impl<'a> Excerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let excerpt = Excerpt { part: first_sentence };
let part = excerpt.announce_and_return_part("New chapter");
println!("Excerpt part: {}", part);
}
Output:
Attention please: New chapter
Excerpt part: Call me Ishmael
Even though announce_and_return_part takes two reference parameters (&self and announcement), rule 3 fires because one of them is &self. The elided return type resolves to the lifetime of self, which is exactly what the body does — it returns self.part, never announcement.
Example 3: Overriding the self shortcut
Sometimes a method genuinely needs to return data borrowed from a parameter other than self. Rule 3’s default is wrong for that case, so you write the lifetime explicitly to override it.
struct Parser<'a> {
input: &'a str,
}
impl<'a> Parser<'a> {
fn first_word_of<'b>(&self, other: &'b str) -> &'b str {
other.split_whitespace().next().unwrap_or("")
}
}
fn main() {
let text = String::from("hello from rust");
let source = String::from("ignored source text");
let parser = Parser { input: &source };
let word = parser.first_word_of(&text);
println!("Parser input: {}", parser.input);
println!("First word of other: {}", word);
}
Output:
Parser input: ignored source text
First word of other: hello
By writing <'b> and tying the return type to &'b str explicitly, this signature overrides what rule 3 would have inferred (tying the output to self). Explicit lifetimes always take precedence — elision only fills in what you leave out.
How It Works Step by Step
Consider a free function that compares two string slices and returns whichever is longer. Walk through what the compiler does when it sees the elided signature fn longest(x: &str, y: &str) -> &str:
- Step 1 — Rule 1: two reference parameters with no annotations, so each gets its own fresh lifetime:
x: &'a str,y: &'b str. - Step 2 — Rule 2 check: there are now two distinct input lifetimes, not one, so rule 2 does not apply.
- Step 3 — Rule 3 check: neither parameter is
&self— this is a free function — so rule 3 does not apply either. - Step 4 — failure: no rule resolved the output lifetime, so the compiler stops and reports
error[E0106]: missing lifetime specifier, refusing to guess whether the return value borrows fromx, fromy, or from neither.
The fix is to name the relationship yourself. Writing <'a> once and reusing it for both parameters and the return type asserts that the output is valid for as long as both inputs are valid — specifically, for the shorter of the two borrows:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let string1 = String::from("long string is long");
let string2 = String::from("short");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {}", result);
}
Output:
The longest string is long string is long
The borrow checker then enforces that result cannot outlive whichever of string1 or string2 goes out of scope first — exactly the guarantee the shared lifetime 'a promises.
Common Mistakes
Mistake 1: Assuming two reference parameters always elide
Beginners often expect the compiler to figure out a two-parameter, one-output signature the same way it handles the single-parameter case. It cannot — rule 2 only fires with exactly one input lifetime.
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
The compiler rejects this with error[E0106]: missing lifetime specifier, because the return type contains a borrowed value but the signature never says whether it comes from x or y. The fix is the explicit-lifetime version from the previous section: fn longest<'a>(x: &'a str, y: &'a str) -> &'a str.
Mistake 2: Thinking a method inside an impl<'a> block gets automatic lifetimes
Being inside impl<'a> Excerpt<'a> does not, by itself, trigger rule 3. Rule 3 specifically requires the method to take &self or &mut self as a parameter — an associated function without self gets no special treatment, even inside that block.
impl<'a> Excerpt<'a> {
fn pick_longer(a: &str, b: &str) -> &str {
if a.len() > b.len() {
a
} else {
b
}
}
}
pick_longer takes no self, so rule 3 never applies; rule 1 gives a and b independent lifetimes, and just like the free-function case this fails with error[E0106]: missing lifetime specifier. The fix is the same as Mistake 1: name an explicit lifetime shared by both parameters and the return type.
Mistake 3: Forgetting that rule 3 ties output to self, not to other parameters
If a method needs to return data borrowed from a parameter other than self, leaving the signature elided silently applies rule 3 anyway — tying the output to self‘s lifetime, which does not match what the body actually returns.
impl<'a> Parser<'a> {
fn first_word_of(&self, other: &str) -> &str {
other.split_whitespace().next().unwrap_or("")
}
}
Elision resolves the return type to self‘s lifetime, but the function body returns a slice borrowed from other, whose lifetime has no guaranteed relationship to self‘s. This fails with error[E0623]: lifetime mismatch. The fix, shown earlier in Example 3, is to give other and the return type their own explicit shared lifetime — fn first_word_of<'b>(&self, other: &'b str) -> &'b str — which overrides rule 3’s default.
Best Practices
- Let elision do its job for the common cases (single-reference-in, or a method returning data from
self) — writing lifetimes the compiler would infer anyway only adds noise. - The moment the compiler reports
E0106orE0623, read it as information about your function’s actual data flow, not just an obstacle — it is telling you which input the output really borrows from. - When a method needs to return borrowed data from a parameter other than
self, add an explicit lifetime rather than fighting the default rule 3 behavior. - Reach for owned types (
String,Vec<T>) instead of chasing lifetimes when a function’s relationship between inputs and outputs is genuinely unclear — not every function needs to borrow. - Remember elision never applies to struct or enum field declarations — those always need an explicit lifetime parameter on the type.
- When in doubt about what a signature desugars to, write it out by hand once; it is the fastest way to build intuition for which rule applies.
Practice Exercises
- Write a function
fn last_word(s: &str) -> &strthat returns the last whitespace-separated word of a string slice. Explain out loud which elision rule lets you skip lifetime annotations, then compile it and confirm it works. - Take the broken
pick_longerassociated function from Mistake 2 and fix it by adding an explicit lifetime parameter shared across both parameters and the return type. Write a smallmainthat calls it and prints the result. - Write a method
fn describe(&self, other: &OtherStruct) -> &stron some struct that deliberately needs to return a field fromotherrather than fromself. Predict which compiler error you’ll get before you add the explicit lifetime, then verify by compiling both the broken and the fixed version.
Summary
- Lifetime elision is a compiler algorithm, not a relaxation of safety guarantees — every elided signature still has real, fully-resolved lifetime parameters after compilation.
- Rule 1: every elided input reference gets its own distinct lifetime parameter.
- Rule 2: if there is exactly one input lifetime, it is assigned to every elided output lifetime.
- Rule 3: if one of the input parameters is
&selfor&mut self, its lifetime is assigned to every elided output lifetime, regardless of other parameters. - When none of the rules resolve every output lifetime — typically a free function with multiple reference parameters — the compiler fails with
E0106and you must annotate explicitly. - Explicit lifetimes always override what elision would have inferred, which is how you correct rule 3’s self-shortcut when a method needs to return borrowed data from a different parameter.
- Elision never applies to struct or enum field declarations — only to function and method signatures.
