The Three Rules That Annotate Your Signatures for You
After Lifetime Annotations, you might expect every Rust function that touches references to carry <'a>. In practice most do not, because rustc fills in the missing parameters itself using a fixed, published algorithm called elision. It is not machine learning and it is not inference across function bodies — it is three rules applied in order to the signature alone.
- Every elided input reference gets its own fresh lifetime parameter.
fn f(x: &str, y: &str)becomesfn f<'a, 'b>(x: &'a str, y: &'b str)— two independent parameters, not one. - If exactly one lifetime appears in the parameter list — elided or not — it is assigned to every elided output lifetime. The single-input case is so common that this rule alone justifies the feature.
- If there are multiple input lifetimes and one is the receiver (
self,&self,&mut self), the receiver's lifetime is assigned to every elided output. Methods have a natural "primary" argument, so the compiler bets on it.
If the output is a reference and none of these rules produces a unique answer, no guessing happens: you get error[E0106]: missing lifetime specifier exactly as in the previous topic. Here is the algorithm applied to real signatures:
| Signature as written | Elided to | Rule used |
|---|---|---|
fn f(x: &str) -> &str |
fn f<'a>(x: &'a str) -> &'a str |
1 + 2 |
fn f(x: &u32) -> u32 |
unchanged — output is not a reference | none needed |
fn f() -> &'static str |
unchanged — lifetime is explicit | none needed |
fn f(x: &str, y: &str) -> &str |
<'a, 'b> on inputs, output unresolved |
error |
fn f<'a>(x: &'a str, y: &str) -> &str |
'a explicit, fresh 'b for y; output unresolved |
error — two lifetimes in the parameters |
That last row deserves emphasis, because it is the mistake people make when "fixing" E0106: adding an explicit lifetime to an input does not rescue an elided output. The rule counts every lifetime in the parameter list, elided or not. fn f<'a>(x: &'a str, y: &str) -> &str still has two candidate inputs, so the only repair is to annotate the output too: -> &'a str.
| fn f(&self, x: &str) -> &str | output borrows from self | 1 + 3 |
Two details the rules quietly assume. First, the output must be a bare &T; -> Option<&str> and -> (&str, &str) are still elided, because elision looks through those types to the references inside. Second, rule 1 is not decoration: giving each input its own parameter is what lets a function compare or reborrow two arguments independently before rule 2 or 3 picks an output.
Interviewers ask for the rules verbatim surprisingly often. Recite them in order — fresh inputs first, single-input shortcut second, receiver third — and you have answered the question; then apply them to fn longest(x: &str, y: &str) -> &str to show why the shortcut fails when two candidates exist and no receiver is present.
When the Receiver Steals the Output Lifetime
Rule 3 makes methods compile without annotations, and it is also the source of the most common elision surprise: the output binds to self, not to the parameter you returned.
struct Session { id: u64 }
impl Session {
fn id_str(&self) -> &str { // elided: fn id_str<'a>(&'a self) -> &'a str
&self.buf
}
fn echo(&self, input: &str) -> &str { // output tied to &self, NOT to input
input // error: returns a value referencing `input`
}
}
echo looks harmless — you return the parameter, so surely the result borrows input? Elision says otherwise: there are two input lifetimes, one is &self, so rule 3 assigns the output 'a from the receiver. The body returns data with 'b (the parameter's span), the two do not match, and the compiler rejects it. The fix is an explicit annotation that overrides the default:
fn echo<'a>(&self, input: &'a str) -> &'a str { input }
This is the shape interviewers use to test whether you actually understand elision rather than memorizing that "methods don't need annotations". The takeaway is precise: methods don't need annotations when the result borrows from the receiver. The moment a method returns a borrow of a parameter, or ties two independent references together, the default is wrong and you must say so.
&mut self behaves the same and adds the familiar mutability constraint. In fn push(&mut self, s: &str) -> &str, the output is &'a str where 'a comes from the exclusive borrow of self; while the result is held, no other method may touch the session. Returning s instead fails exactly like echo above.
Here is the pattern set worth memorizing, because it covers nearly every method signature you will write:
| Method signature | Where the output binds | Annotate? |
|---|---|---|
fn id(&self) -> &u64 |
self |
no — result borrows the receiver |
fn get(&self, key: &str) -> Option<&V> |
self |
no — result borrows the map, not the key |
fn echo(&self, s: &str) -> &str (returns s) |
self (wrong for the body) |
yes: <'a>(&self, s: &'a str) -> &'a str |
fn push(&mut self, s: &str) -> usize |
nothing — output is not a reference | no |
fn choose(a: &str, b: &str) -> &str |
unresolved, no receiver | yes: <'a>(a: &'a str, b: &'a str) -> &'a str |
The mental test is one sentence long: look at what the body returns — if it comes from self, elision's default already matches reality; if it comes from a parameter, override the default. Returning self.buf.as_str() from id_str is legal precisely because the elided output lifetime is self's, so the borrow ends when the session borrow ends and not a moment later.
One more edge worth knowing: associated functions without a receiver fall back to rule 2, and if they take several references they fall straight into E0106.
impl Session {
fn parse(text: &str, fallback: &str) -> &str { // two inputs, no self -> E0106
if text.is_empty() { fallback } else { text }
}
}
Adding <'a>(text: &'a str, fallback: &'a str) -> &'a str fixes it — same function, same body, only the stated relationship changed.
Elision Runs Once, and Structs Never Get It
A property that surprises people who expect "compiler inference" is that elision is a single, deterministic pass over one signature. Rules 1–3 run, produce a result or an error, and are never invoked again on that signature. There is no second chance where the compiler notices your annotation was odd and adjusts it, and no propagation of the decision into other functions. What you wrote — or what elision produced — is exactly what every caller is checked against.
That has three practical consequences.
First, mixing explicit and elided lifetimes is legal, but rule 2 counts every lifetime in the parameter list — elided or not. So fn f<'a>(x: &'a str, y: &str) -> &str still fails with E0106: y gets a fresh 'b, there are now two candidate inputs, and no rule picks an output. The repair is explicit on both ends: -> &'a str (or two named parameters with the output naming one). By contrast, fn f<'a>(x: &'a str) -> &str compiles fine — a single parameter lifetime is assigned to the output even when you wrote that lifetime out yourself. And an explicitly written output lifetime (-> &'a str, -> &'static str) switches the elision machinery off for that position completely; the compiler checks it against your annotation rather than any rule. Whenever a signature mixes styles, write the output lifetime too, so no reader has to replay the algorithm to know what was inferred.
Second, '_ says "elide here, but I am spelling the position out". It is identical in effect to omitting the lifetime, but it documents intent and is required in a few positions, such as impl Trait for Struct<'_> and tuple-struct constructors where a bare &T would be rejected:
struct Slice<'a>(&'a [u8]);
let s = Slice(&[1, 2, 3]); // error: missing lifetime specifier
let s = Slice(&'_ [1, 2, 3]); // ok: '_ elides in expression position
let s: Slice<'_> = Slice(&[1, 2]); // ok: '_ in type position
Third — and this is the firm line — struct fields are never elided. Elision applies to function and method signatures only. struct Reader { data: &str } is a hard error: a stored borrow outlives any single call, so the definition must name how long it lives with a parameter like struct Reader<'a> { data: &'a str }. The same holds for enum variants, trait objects written inside type definitions, and impl headers — all of them require an explicit parameter, which is exactly the subject of Holding References in Structs.
A fourth place the same machinery runs, and a favorite of interview questions on higher-ranked types: function pointer signatures. Writing let f: fn(&str) -> &str = first_word; elides lifetimes in exactly the same way, and the resolved form is for<'a> fn(&'a str) -> &'a str — a higher-ranked pointer that must work for every lifetime, not just one chosen earlier. Function items coerce to that pointer type when their elided signatures match, which is how you pass functions as arguments without generics:
fn apply(f: fn(&str) -> &str, s: &str) -> &str { f(s) }
fn shout(s: &str) -> &str { s }
let out = apply(shout, "hello");
When closures appear in the same position, rustc often reports the elided form as for<'a> fn(&'a str) -> &'a str in its error notes — reading that notation back as "one lifetime, chosen fresh at each call" is the skill this topic is building.
The complete boundary, then: inside one signature, three rules, one pass, done. Everywhere a borrow is stored in a type, an explicit lifetime parameter, no exceptions. When a signature is short and single-input, let elision work; when it holds two references, a receiver you do not return, or a stored borrow, write the relationship down.
Quiz
1. What does elision produce for `fn longest(x: &str, y: &str) -> &str`?
2. In `impl Session { fn echo(&self, input: &str) -> &str }`, which borrow does the returned `&str` follow?
3. Which of these definitions is rejected no matter how the surrounding function signatures are written?
4. What is true about `'_` compared with omitting the lifetime entirely?
Flashcards
Question
State Rust's three lifetime elision rules in order.
Click to reveal answer
Answer
1) Each elided reference parameter gets its own fresh lifetime. 2) If there is exactly one input lifetime, it is assigned to every elided output lifetime. 3) If there are multiple input lifetimes and one is the receiver (`self`/`&self`/`&mut self`), the receiver's lifetime is assigned to all elided outputs. If the output still has no unique lifetime, compilation fails with E0106.
Question
Why does `fn f(x: &str, y: &str) -> &str` fail while `fn f(&self, x: &str) -> &str` compiles?
Click to reveal answer
Answer
The first has two input lifetimes and no receiver, so no rule assigns the output and E0106 fires. The second also has two inputs, but rule 3 exists for exactly this case: the receiver's lifetime is chosen for the output, giving a unique answer without any annotation.
Question
A method `fn echo(&self, input: &str) -> &str` returns `input` and fails to compile. Why, and what is the fix?
Click to reveal answer
Answer
Elision bound the output to `&self` (receiver rule), but the body returns data borrowed from `input`. Fix by overriding the default: `fn echo<'a>(&self, input: &'a str) -> &'a str { input }` — the output now tracks the parameter instead of the receiver.
Question
Where does elision apply, and where does it never apply?
Click to reveal answer
Answer
It applies only to function and method signatures (inputs, outputs, and references inside `Option<&T>`-style return types). It never applies to struct or enum fields, tuple-struct constructors, or `impl` headers — stored borrows always need an explicit lifetime parameter such as `struct Reader<'a> { data: &'a str }`.
Question
How many times do the elision rules run on a signature, and what does `'_` mean?
Click to reveal answer
Answer
Once. The rules run as a single deterministic pass over the signature, produce resolved lifetimes or an error, and never re-run or propagate across functions. `'_` is a placeholder meaning "elide here": it marks the position, then the same rules resolve it — identical in effect to omitting the lifetime, but explicit and required in positions like `impl ... for Struct<'_>`.
Revision Notes
Key Takeaways
- 1. Elision is three ordered rules applied once to a signature — fresh input lifetimes, single-input shortcut, receiver shortcut — never inference across function bodies
- 2. Two reference parameters plus an output reference always fail without an annotation, because no rule picks between two candidates
- 3. Methods default the output to `self`, so returning a parameter's borrow from a method requires an explicit `<'a>`
- 4. Elision covers references inside return types like `Option<&str>`, but struct and enum fields are never elided
- 5. `'_` documents an elided position without changing the outcome; explicit and elided lifetimes can mix in one signature, though writing both out is clearer
Interview Tips
- • "Recite Rust's lifetime elision rules" — give them in order and finish with what happens when they fail (E0106); order matters because rule 2 depends on rule 1 having created exactly one input lifetime
- • "Why does `fn f(&self, x: &str) -> &str` compile but `fn f(a: &str, b: &str) -> &str` not?" — the receiver gives rule 3 a unique answer; two plain inputs give no rule anything to choose from
- • "Debug this method: it returns a parameter and the compiler says the returned value references `input`" — name the receiver rule as the cause and show the `<'a>` override
- • "Where can lifetimes be elided?" — signatures only; state firmly that struct fields, enums, and impl headers always need explicit parameters, and mention `'_` for the positions that require a visible placeholder
Cheat Sheet
Lifetime Elision Rules
The three rules, in order:
- Every elided input reference → its own fresh lifetime parameter.
- Exactly one input lifetime → assigned to all elided output lifetimes.
- Multiple inputs + a receiver present → the receiver's lifetime goes to all elided outputs.
Still ambiguous →error[E0106]: missing lifetime specifier. Never a guess.
Signature outcomes:
| Written | Resolves to |
|---|---|
fn f(x: &str) -> &str |
<'a>(x: &'a str) -> &'a str (rule 2) |
fn f(x: &str, y: &str) -> &str |
E0106 — no unique output |
fn f(&self, x: &str) -> &str |
output borrows self (rule 3) |
fn f(&mut self, s: &str) -> &str |
output borrows self mutably |
fn f(x: &u32) -> u32 / -> &'static str |
untouched (no elided output reference) |
struct S { r: &str } |
always an error — fields are never elided |
Gotcha: a method returning a parameter's borrow must override rule 3:
fn echo<'a>(&self, input: &'a str) -> &'a str { input }
'_: placeholder for "elide here"; rules still run, effect unchanged. Required for impl ... for T<'_> and tuple-struct constructors.
Boundary: one signature, one pass, done — anything stored in a type needs an explicit parameter.