When the compiler rejects your reference
You write a helper to pick the longer of two strings. You pass in two &str slices and return one. The logic is trivial. The compiler rejects you with a lifetime error. You stare at the code for ten minutes. The reference you're returning came from an argument. It should be fine. Rust disagrees.
The error message usually reads something like "borrowed value does not live long enough" or "lifetime may not live long enough." This is error code E0597. The compiler is telling you that it cannot prove the data you're pointing to will survive as long as the reference you're handing back. You need to give the compiler a map of how the references relate.
References need a guarantee
A reference is a pointer to data. That data must exist as long as the reference is used. If the data vanishes, the reference becomes a dangling pointer, and using it causes undefined behavior. Rust prevents this by tracking lifetimes.
Think of a lifetime as a survival guarantee. A reference is like a tour guide pointing at a building. The guide is useless if the building gets demolished while the tour is happening. The lifetime is the promise that the building stands for the entire duration of the tour.
When a function accepts references and returns one, the compiler has to decide which input the output depends on. If the function returns the first argument, the output lives as long as the first argument. If it returns the second, it lives as long as the second. If the function picks one or the other based on logic, the output must live as long as the shorter of the two. Otherwise, you could return a reference to data that vanishes the moment the function ends.
Lifetimes are not timers. They are names for scopes. They tell the compiler how references connect to each other, not how many seconds they last.
The minimal fix
Here is the classic scenario that triggers E0597. You try to return a reference, but the compiler doesn't know which input lifetime applies to the output.
// This function fails to compile.
// The compiler sees two input lifetimes and one output lifetime.
// It cannot assume the output is tied to either input.
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
The compiler rejects this because it has no way to link the return type to the inputs. It assumes the worst case: the return value might depend on a temporary that dies immediately. You must add an explicit lifetime parameter to state the relationship.
// The lifetime 'a ties the inputs to the output.
// This signature says: "The return value lives at least as long as both x and y."
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
The fix is adding <'a> to the function and annotating all references with &'a. This creates a contract. You promise the caller that the returned reference is valid for the scope 'a, and that both x and y are also valid for 'a. The compiler checks the body to ensure you only return data that satisfies this promise.
Convention aside: Rust has lifetime elision rules that save you from writing annotations in simple cases. If a function has exactly one input reference, the compiler automatically ties the output lifetime to that input. You only need explicit annotations when there are multiple inputs, or when the lifetime is tied to a struct. Elision keeps code clean; explicit lifetimes handle complexity.
How the compiler checks the contract
Lifetimes are aliases for scopes. When you write <'a>, you're introducing a variable that stands for "some scope". The signature fn longest<'a>(x: &'a str, y: &'a str) -> &'a str reads: "There exists a scope 'a. x is valid in 'a. y is valid in 'a. The return value is valid in 'a."
This forces the caller to ensure both inputs live long enough. The return value is tied to the shorter of the two inputs. This is the intersection of lifetimes.
Ah-ha reveal: The lifetime 'a does not mean x and y must have identical lifetimes. It means they share a common lifetime that is at least as long as 'a. If x lives for ten seconds and y lives for five seconds, the compiler resolves 'a to five seconds. The function works fine. The compiler picks the shortest scope that satisfies the constraint. You can pass arguments with different scopes; the borrow checker handles the math.
Lifetimes vanish at runtime. They are a compile-time only feature. The generated code has zero overhead for lifetime annotations. You pay nothing for the safety. The compiler uses lifetimes to prove correctness, then erases them completely.
Don't treat lifetimes as runtime metadata. They are proof terms that disappear before the binary runs.
Realistic scenario: picking a winner
Lifetime errors often appear in slightly more complex code than the longest example. Consider a game where you compare two players and announce the winner.
struct Player {
name: String,
score: u32,
}
// This fails with E0106: missing lifetime specifier.
// There are multiple input references, so elision cannot guess.
fn announce_winner(p1: &Player, p2: &Player) -> &str {
if p1.score > p2.score {
&p1.name
} else {
&p2.name
}
}
The function takes two references and returns a reference to a field inside one of them. Elision fails because the compiler doesn't know if the output should be tied to p1 or p2. You add the lifetime parameter to link them.
// 'a connects both players to the returned name.
// The name lives as long as the shorter-lived player.
fn announce_winner<'a>(p1: &'a Player, p2: &'a Player) -> &'a str {
if p1.score > p2.score {
&p1.name
} else {
&p2.name
}
}
This compiles. The caller must ensure both Player structs stay alive while the returned string slice is used. If p1 is dropped before you print the winner, the compiler catches it.
If the function created a new string, like format!("Winner: {}", p1.name), you would return a String instead of a &str. Owned types carry their data. They don't need lifetime annotations because they own the memory. References borrow; owned types allocate.
If the function creates new data, return an owned type. References can only point to what already exists.
Pitfalls and false friends
Adding lifetimes can sometimes make errors worse. A common mistake is over-constraining the signature.
// This signature is impossible to satisfy.
// It promises to return a String that lives as long as the input.
// But the function creates a new String inside.
fn bad<'a>(x: &'a str) -> &'a String {
&String::from("hello")
}
The compiler rejects this with E0515 (cannot return value referencing local variable). You cannot return a reference to data created inside the function. That data is dropped when the function ends. The lifetime 'a requires the data to survive beyond the function. No annotation can fix this logic error.
The fix is to return the owned value.
fn good(x: &str) -> String {
String::from("hello")
}
Another pitfall is returning a reference to a local variable.
fn make_greeting() -> &str {
let msg = String::from("Hi");
&msg // ERROR: E0515. msg is dropped here.
}
The reference points to msg, which lives only inside the function. The compiler blocks this. You must return an owned String or a &'static str literal.
Don't fight the borrow checker by adding lifetimes blindly. If the logic doesn't support the lifetime, no annotation will save you.
Decision: lifetimes versus alternatives
Choose the approach that matches your data flow. Borrowing avoids allocation but requires lifetime management. Owning is flexible but costs memory.
Use explicit lifetime parameters when your function takes multiple references and returns one, and the return value could come from any of them.
Use lifetime elision when there is exactly one input reference; the compiler infers the output lifetime automatically.
Use owned types like String or Vec when the returned data is computed inside the function or combines parts of different inputs.
Use Cow<'a, str> when you sometimes return a borrowed slice and sometimes return an owned string, avoiding allocation in the borrow case.
Reach for &'static str when the data is a string literal or embedded in the binary; it lives forever.
Pick the tool that matches the data flow. Borrowing is cheap; owning is flexible. Choose based on where the data comes from.