Skip to content
← All articles

The three elision rules, precisely

The rule rustc actually implements — which is not the one in the Book, and not quite the one in the Reference either. Both err in the loose direction, and this article shows the counterexamples that prove it, verified on rustc 1.95.

This is the highest-leverage article in the track, and it is the one where the standard teaching materials are subtly wrong.

Knowing elision is what lets you know when you don’t need 'a. That is worth more than knowing how to write one, because needless annotation is both the most common beginner smell and — on this site, where every submission is graded with clippy -D warnings — an outright failure via clippy::needless_lifetimes.

The rules, as rustc implements them

Rule 1. Each elided lifetime in the parameters becomes its own, distinct lifetime parameter.

fn f(a: &str, b: &str)          // becomes  fn f<'a, 'b>(a: &'a str, b: &'b str)

Two elided input lifetimes, two fresh names. They are unrelated. This rule never fails and never needs your help.

Rule 2. An elided output lifetime is supplied only when exactly one parameter carries a lifetime at all, and every lifetime position inside that one parameter resolves to the same lifetime. That lifetime is then assigned to every elided output position.

fn f(s: &str) -> &str           // becomes  fn f<'a>(s: &'a str) -> &'a str
fn f(s: &str, n: usize) -> &str // still one lifetime-carrying parameter: elides
fn f(a: &str, b: &str) -> &str  // TWO carriers: E0106

Rule 3. In a method, if a parameter is &self or &mut self, the lifetime of that receiver is assigned to every elided output lifetime — regardless of what the other parameters carry.

impl Foo {
    fn bar(&self, other: &str) -> &str    // output gets &self's lifetime
}

Rule 3 is about the receiver, not about being inside an impl block. A free-standing associated function with no self gets rules 1 and 2 only.

Why the textbook wording is wrong

Here is where you have to be careful, because two authoritative sources both state rule 2 more loosely than rustc implements it.

The Book says the output lifetime is assigned when there is “exactly one input lifetime parameter”. The Reference says “exactly one lifetime used in the parameters (elided or not)”. Both readings predict that this compiles:

fn f<'a>(x: &'a str, _y: &'a str) -> &str { x }

Exactly one lifetime is used in the parameters — 'a, twice. So by either wording, elision should fire. It does not:

error[E0106]: missing lifetime specifier
              expected named lifetime parameter

Same rejection with a non-reference carrier:

pub struct View<'a> { pub s: &'a str }

fn f<'a>(v: View<'a>, _w: View<'a>) -> &str { v.s }   // error[E0106]

rustc counts lifetime-carrying parameters, not distinct lifetimes. Two carriers, no elision, full stop — even if they carry the same one.

Now the converse, which is just as surprising:

pub struct View<'a> { pub s: &'a str }

fn f<'a>(x: &'a View<'a>) -> &str { x.s }   // compiles

Two lifetime positions (&'a and View<'a>), but one parameter, and both positions resolve to the same lifetime. Elision fires. Change it so the two positions are distinct and it breaks again:

fn f(x: &View<'_>) -> &str { x.s }          // error[E0106]

One parameter, but &_ and View<'_> are two different elided lifetimes. Rule 2’s second clause fails.

So the precise statement is: positions inside one parameter are fine as long as they agree; a second lifetime-carrying parameter is fatal.

💡Predict, for each of these, whether elision supplies the output lifetime. Then say which clause of rule 2 decides it. click to reveal
fn a(s: &str, n: usize) -> &str;
fn b(v: Vec<String>, s: &str) -> &str;
fn c(x: &str, y: &String) -> &str;
fn d(t: (&str, &str)) -> &str;

a — elides. usize carries no lifetime, so s is the only carrier, and it has one position. Rule 2 fires.

b — elides. Vec<String> is fully owned and carries no lifetime either. Same shape as a.

c — E0106. Two lifetime-carrying parameters. It does not matter that they are both &-of-string-ish; the count of carriers is two. (clippy::ptr_arg will also tell you to make y a &str, but that changes nothing about the elision.)

d — E0106. One parameter, but the tuple contains two distinct elided lifetimes, so the “every position resolves to the same lifetime” clause fails. This is the same failure as &View<'_>, wearing a tuple.

If you got d wrong, you are in good company — it is the case that most cleanly separates “count the parameters” from “count the positions”, and you need both halves of the rule to predict it.

The example that genuinely separates the Book from the Reference

The two sources really do disagree, and the disagreement shows up with a non-reference parameter that carries a lifetime:

pub struct View<'a> { pub s: &'a str }

pub fn f(v: View<'_>, _n: usize) -> &str { v.s }

This compiles, and it passes clippy-driver --edition 2024 -D warnings clean. The Book’s rule 1 only assigns fresh lifetimes to parameters that are references, so on the Book’s telling View<'_> never gets a lifetime, there are zero input lifetimes, and the function should be E0106. The Reference — and rustc — count View<'_> as a lifetime-carrying parameter, so rule 2 fires.

Two details in that snippet are load-bearing and are easy to get wrong when you try it yourself:

  • The struct must be pub, like the function. A pub fn taking a private type trips the private_interfaces lint, which under -D warnings fails for reasons that have nothing to do with lifetimes.
  • The parameter’s lifetime must stay elided (View<'_>), never named. Write View<'a> with a named 'a alongside an elided output and you trip mismatched_lifetime_syntaxes — warn-by-default since 1.89, and therefore a hard error under this site’s gate. The same trap catches fn f<'a>(x: &'a View<'a>) -> &str from the previous section: it does elide, and it does warn, because one lifetime is named in one place and elided in another.

That last point is worth sitting with. A demonstration of elision can fail the build for a reason unrelated to elision. Two separate rule systems are running over the same signature.

Two elision contexts nobody mentions

const and static reference types are implicitly 'static.

const GREETING: &str = "hi";     // means &'static str
static NAME: &str = "world";     // likewise

Which is why writing the 'static yourself gets you clippy::redundant_static_lifetimes telling you to delete it.

Trait objects have their own default-lifetime rules. Box<dyn Error> means Box<dyn Error + 'static>; &'a dyn Error means &'a (dyn Error + 'a). These defaults are not the function-elision rules above — they are a separate table, and they are the source of some genuinely startling behaviour. They get their own article.

Two things elision is not

Elision never changes which programs are accepted. It is purely a keystroke-saving device: it decides what you have to type, not what the compiler will allow. Every elided signature has exactly one desugaring, and the compiler checks that desugaring. If you write the annotations out by hand and they match what elision would have produced, nothing whatsoever changes (except that clippy will tell you to delete them).

Elision does not apply to closures. This compiles:

fn identity(x: &i32) -> &i32 { x }

and this does not:

let identity = |x: &i32| x;
// error: lifetime may not live long enough

Closure signature inference assigns independent inference variables to the parameter and the return instead of applying rule 2. It is a real compiler limitation with open issues, it surprises people badly, and it gets its own article. Mention it here only so that when it happens you do not conclude you misunderstood elision.

💡Under the gate used on this site, which of these three fails, and why is the failure not about lifetimes being *wrong*? click to reveal
pub fn one(s: &str) -> &str { s.trim() }
pub fn two<'a>(s: &'a str) -> &'a str { s.trim() }
pub fn three<'a>(s: &'a str, t: &str) -> &'a str { let _ = t; s.trim() }

two fails.

All three are correct. All three compile. two is rejected by clippy::needless_lifetimes — warn-by-default, and this site compiles with -D warnings, so a warning is a hard error. Its annotation is exactly what elision would have produced, so writing it out adds noise and nothing else.

one is the elided form of two, so it is fine. three genuinely needs its annotation: two lifetime-carrying parameters means rule 2 cannot fire, so without 'a you would get E0106. needless_lifetimes is careful here and stays silent on annotations that are actually linking something — it fires only when elision would have produced the identical signature.

The framing worth keeping: the compiler tells you when an annotation is missing; clippy tells you when one is redundant. Between them they pin you to exactly one spelling, and that spelling is the one a reviewer expects.

The summary card

  1. Every elided input lifetime becomes its own fresh parameter.
  2. Elided outputs are filled in only when one parameter carries a lifetime and all its positions agree.
  3. &self / &mut self, when present, wins and supplies every elided output.

Plus: const/static refs are 'static; trait objects have their own defaults; elision changes typing, never semantics; and closures do not play.