Skip to content
← All articles

Hidden, elided, named: the '_ rule and mismatched_lifetime_syntaxes

The one lifetime-adjacent lint that will fail your build without you having written a single `'a`. Three syntax groups, one consistency rule, and why "I didn't use lifetimes and it's yelling about lifetimes" is exactly what it looks like.

You write a one-line function. It has no lifetime annotations in it anywhere. The build fails with a message about lifetimes.

pub fn items(scores: &[u8]) -> std::slice::Iter<u8> {
    scores.iter()
}
warning: hiding a lifetime that's elided elsewhere is confusing
  = help: use `'_` for type paths

Warn-by-default since Rust 1.89 — and since this site grades with -D warnings, a hard error. If nobody has briefed you, this is maximally confusing. Here is the briefing.

Three ways to spell one lifetime

Every lifetime in a signature is written in one of three groups:

group how it looks examples
named you wrote a name &'a str, Person<'a>, Iter<'a, u8>
elided you wrote a placeholder &str, &'_ str, Person<'_>, Iter<'_, u8>
hidden nothing visible at all Person, Iter<u8>

The first two are familiar. The third is the one that catches people.

std::slice::Iter is declared Iter<'a, T>. It has a lifetime parameter. But Rust lets you write a path with its lifetime arguments entirely omitted, so Iter<u8> is legal and means Iter<'_, u8> — a lifetime with no visual indication whatsoever that it exists. Same for a bare Person when the type is Person<'a>.

The rule

mismatched_lifetime_syntaxes fires when one lifetime is referred to using syntaxes from different groups within a single signature.

That is the whole rule. It is about grouping, not spelling.

pub fn items(scores: &[u8]) -> std::slice::Iter<u8>   // elided input, hidden output -> fires
pub fn items(scores: &[u8]) -> std::slice::Iter<'_, u8>  // both elided -> quiet
pub fn items<'a>(scores: &'a [u8]) -> std::slice::Iter<'a, u8>  // both named -> quiet

All three of these compile and mean exactly the same thing. Only the first is rejected, and it is rejected for being confusing to read, not for being wrong.

The lint has three wordings depending on which groups collided, and they are worth recognising:

hiding a lifetime that's elided elsewhere is confusing     // Iter<u8>  vs  &[u8]
hiding a lifetime that's named elsewhere is confusing      // V         vs  &'a str
eliding a lifetime that's named elsewhere is confusing     // &u8       vs  &'a u8

And crucially, what does not fire:

pub fn f(x: &u8) -> &'_ u8 { x }     // quiet

&u8 and &'_ u8 are different spellings within the same group. The lint does not care. Anyone who tells you '_ is mandatory has mis-stated the rule.

💡Which of these four fire the lint? Say which two groups collide in each one that does. click to reveal
pub struct V<'a> { s: &'a str }

pub fn a<'x>(v: &'x V<'x>) -> &str;
pub fn b(v: V) -> V;
pub fn c(s: &str) -> std::str::Chars<'_>;
pub fn d<'x>(s: &'x str) -> std::str::Chars<'x>;

a fires. 'x is named in the parameters and elided in the return (&str). Named vs elided → “eliding a lifetime that’s named elsewhere is confusing”. Note this signature is otherwise fine — elision even supplies the output lifetime, since &'x V<'x> is one parameter whose positions agree. It still fails the gate.

b is quiet. Both V occurrences are hidden. One group, no collision. This is legal, passes the gate, and is arguably the least readable line here — which tells you the lint is a consistency check, not a readability oracle.

c is quiet. &str and Chars<'_> are both elided.

d is quiet. Both named.

The takeaway from b: the lint will not stop you writing something opaque. It only stops you from writing something inconsistent. Choosing to be clear is still your job.

'_ does not mean “no lifetime”

This is the misreading that turns the fix into a mystery. '_ means:

“There is a lifetime here. Infer it.”

It is an explicit placeholder — a visible marker that the type is lifetime-parameterised, with the actual value left to inference. That is why it is the fix rather than a way to opt out: it moves the lifetime from hidden to elided, matching the &[u8] parameter, and the inconsistency is gone.

Two names you will see in older material

elided_named_lifetimes was the older, narrower lint that mismatched_lifetime_syntaxes replaced. Posts and issues written before 1.89 use that name; the lint no longer exists under it.

elided_lifetimes_in_paths is the lint that would ban hidden lifetimes outright — it fires on Iter<u8> and Person regardless of consistency. It is still allow-by-default, and the compiler’s own description says “hidden lifetime parameters in types are deprecated”. Some codebases turn it on as a house style; the default toolchain does not, and it is not part of this site’s gate.

So: '_ in paths is not mandatory in general. Consistency within a signature is.

💡This fails the gate. Apply the mechanical fix — then say what the fix just revealed, and why that is the real value of the lint. click to reveal
pub struct Config<'a> { root: &'a str }

pub fn load(text: &str) -> Config { .. }

The mechanical fix: &str is elided, Config is hidden, so this is “hiding a lifetime that’s elided elsewhere is confusing”. Write -> Config<'_> and it passes.

Now the interesting part. Before the fix, the signature said Config, which reads like an owned, self-contained value. After the fix it says Config<'_>, which announces that the returned config is a view into text — the caller must keep text alive for as long as they hold the config.

That is a significant fact about the API, and it was completely invisible. For a config object, which typically gets stored and consulted much later, borrowing is often the wrong shape, and Config { root: String } would serve callers better. The lint did not tell you that. It made the question askable by putting the lifetime on the page.

This is exactly why hiding lifetimes is discouraged: a reader who cannot see the parameter cannot notice the design decision, let alone challenge it.

The practical checklist

When this lint fires:

  1. Find the lifetime it is complaining about. The message names both spans.
  2. Pick one group and use it in both places. Usually that means writing '_ in the path.
  3. Get the slot right. Split is Split<'a, P> — two parameters, lifetime first. Split<'_, char> is correct; Split<char, '_> is a syntax error and Split<'_> is missing a type argument.
  4. If the type has several parameters and you are unsure, hover it or check the docs. Guessing produces a confusing second error.

And a note on the alternative you will sometimes prefer: returning impl Iterator<Item = &u8> + '_ instead of a concrete Iter<'_, u8> also satisfies the lint, hides the concrete type from your callers, and gives you freedom to change the implementation later. Different tradeoff, equally legitimate.