The mystery of the missing lifetime
You write a function that takes a string slice and returns a string slice. The compiler rejects it with a demand for lifetime specifiers. You open the standard library documentation, find a function with the exact same signature, and it compiles without a single 'a in sight. The compiler isn't being inconsistent. It's applying a set of shortcuts you haven't learned yet.
Rust requires every reference to have a lifetime. Writing them out for every function makes code noisy and hard to read. Lifetime elision is the compiler's way of filling in those lifetimes automatically when the intent is obvious. It's not magic. It's a deterministic pre-processing step that rewrites your signatures before type checking begins.
How elision works
When the compiler sees a function signature, it runs the elision rules. If the signature matches a rule, the compiler inserts lifetime parameters and annotations. If it doesn't match, the compiler stops and asks you to annotate manually.
Elision happens before type checking. This means elided lifetimes are real lifetimes. They participate in the borrow checker exactly as if you had written them. Elision cannot fix a logic error. If your function returns a reference that doesn't come from an input, elision will assign lifetimes, and the type checker will reject the code. Elision reduces boilerplate, not safety.
Rule 1: The single input
The most common case is a function with exactly one input reference. The compiler assumes the output reference lives as long as that input.
/// Returns the first word of a string, or the whole string if no space exists.
fn first_word(s: &str) -> &str {
// The compiler sees one input reference and one output reference.
// It assigns the input lifetime to the output automatically.
// This is rewritten as: fn first_word<'a>(s: &'a str) -> &'a str
match s.find(' ') {
Some(idx) => &s[..idx],
None => s,
}
}
This rule covers the vast majority of helper functions. If you take a reference and return a slice of it, or a reference to a field inside it, elision handles it. The output is tied to the input. If the input dies, the output dies.
The compiler guesses based on patterns, not magic. Learn the patterns.
Rule 2: The method shortcut
Methods are special. In Rust, methods are just functions with a self parameter. The compiler treats &self and &mut self as input references, but with a priority boost.
If a function has a &self or &mut self parameter, the compiler assigns the lifetime of self to all output references. This rule applies even if there are other input references.
struct TextBuffer {
content: String,
}
impl TextBuffer {
/// Returns a slice of the buffer up to the first newline.
fn first_line(&self) -> &str {
// &self is the input. Elision assigns self's lifetime to the return.
// This is rewritten as: fn first_line<'a>(&'a self) -> &'a str
match self.content.find('\n') {
Some(idx) => &self.content[..idx],
None => &self.content,
}
}
}
This rule makes method signatures clean. You rarely see explicit lifetimes on methods in idiomatic Rust. The community relies on this rule heavily. Writing explicit lifetimes on a simple method is a signal that something complex is happening.
&mut self follows the same rule. The mutability doesn't change the elision logic. The output lifetime still ties to self.
The trap of Rule 2
Rule 2 is powerful, but it can bite you when you have multiple inputs. Because &self wins the lifetime assignment, the compiler might tie the output to self when you actually want it tied to another argument.
struct Context {
name: String,
}
impl Context {
/// This signature elides to return a reference tied to self.
/// The compiler rewrites this as:
/// fn process<'a, 'b>(&'a self, input: &'b str) -> &'a str
///
/// If you return `input`, the compiler checks if `input` lives as long as `self`.
/// This is often not what you want.
fn process(&self, input: &str) -> &str {
input
}
}
If you call process with a temporary string, the compiler rejects you. The elided signature demands the input live as long as self. You get E0597 (borrowed value does not live long enough). The compiler isn't wrong. The elided signature says the output lives as long as self. Returning input violates that contract unless input also lives that long.
When the compiler refuses to guess, it's protecting you from a guess that would be wrong. Annotate the relationship.
To fix this, you must break elision and write explicit lifetimes. You tell the compiler exactly which input the output ties to.
impl Context {
/// Explicit lifetimes clarify that the output ties to `input`, not `self`.
/// The output can be shorter than self's lifetime.
fn process<'a, 'b>(&'a self, input: &'b str) -> &'b str {
input
}
}
Now the output lifetime is 'b, tied to input. The function works with short-lived inputs. Explicit lifetimes here aren't boilerplate. They're a correction of the elision guess.
Rule 3: No inputs, no guess
If a function has no input references, the compiler refuses to assign a lifetime to the output. There's nothing to tie it to. You must annotate the output manually.
/// This fails to compile.
/// fn get_default() -> &str { "default" }
/// Error: missing lifetime specifier [E0106]
The compiler sees an output reference but no input. It can't apply Rule 1 or Rule 2. It stops and asks for clarification. In this case, the string literal "default" has a 'static lifetime. It lives for the entire program. You need to write that explicitly.
/// Returns a static string slice.
fn get_default() -> &'static str {
"default"
}
String literals are 'static. This is a convention and a feature. The compiler knows literals live forever. You don't need to annotate the literal, but you do need to annotate the return type when elision can't help.
If there's no input, there's no guess. Annotate the output.
When elision fails
Elision fails in two main scenarios.
First, multiple input references without &self. The compiler doesn't know which input the output ties to. It won't guess.
/// This fails. Two inputs, no &self.
/// fn longest(x: &str, y: &str) -> &str { ... }
/// Error: missing lifetime specifier [E0106]
You must pick one. Or pick both if the output ties to both.
/// Explicit lifetimes tie the output to both inputs.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Second, complex relationships where the output ties to a subset of inputs, or where you need to document a specific constraint. Elision assigns lifetimes mechanically. It doesn't understand your intent. If the mechanical assignment is too restrictive or too loose, you need explicit annotations.
Elision is for the common case. Explicit lifetimes are for the exceptions. Don't fight the rules; use them to keep signatures clean.
Decision: Elision vs explicit
Write bare references when the function has exactly one input reference. The compiler assigns that lifetime to all outputs, which matches the intent in almost every case.
Write bare references when the method takes &self or &mut self as the only input reference. The compiler assigns self's lifetime to outputs, keeping method signatures readable.
Write explicit lifetime annotations when multiple input references exist and the output depends on one of them. The compiler cannot guess which input matters.
Write explicit lifetime annotations when the function returns a reference but takes no input references. You must specify the output lifetime, usually 'static for literals.
Write explicit lifetime annotations when &self exists alongside other inputs and the output ties to one of the other inputs. Rule 2 would assign self's lifetime incorrectly.
Write explicit lifetime annotations when you need to document a specific lifetime relationship that elision would obscure. Explicit lifetimes serve as documentation for complex signatures.