The contract behind the reference
You write a function that takes two string slices and returns the longer one. The logic is trivial. You compile it, and the borrow checker rejects you with missing lifetime specifier. You stare at the code. The function returns a reference to data it received. Nothing is being created or destroyed. The compiler should know the return value is safe.
It doesn't. Or rather, it refuses to guess. Rust requires you to state explicitly how the output relates to the inputs. You add 'a to the signature, the error vanishes, and the code runs. But adding symbols to make the compiler happy is not engineering. Understanding what those symbols mean is.
Lifetime annotations are not runtime metadata. They do not add overhead. They are a static contract that tells the compiler which references are tied to which data. When you read 'a, you are reading a promise: "This reference is valid as long as the data labeled 'a is valid."
Lifetimes are relationships, not durations
A common trap is thinking of a lifetime as a length of time. 'a does not mean "5 seconds" or "until the function returns." It means "some scope." The compiler uses lifetimes to prove that a reference never outlives the value it points to.
Think of a lifetime like a lease agreement. The reference is the tenant. The data is the apartment. The lease clause 'a links the tenant to the building. The clause doesn't say how many years the lease lasts. It says the tenant can stay as long as the building stands. If the building is demolished, the lease is automatically void. The annotation is the clause that enforces that link.
When you see &'a str, read it as "a string slice that is valid for the scope 'a." If a function returns &'a str, the return value is bound to that same scope. The compiler ensures that whatever data backs the return value lives at least as long as 'a.
Lifetimes describe relationships, not clock time.
The minimal contract
Start with the canonical example. A function compares two slices and returns one of them.
/// Returns the longer of two string slices.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
// Compare lengths to decide which reference to return.
if x.len() > y.len() {
x
} else {
y
}
}
The annotation 'a appears on both inputs and the output. This creates a constraint. The return value borrows from either x or y. The compiler enforces that the return value cannot outlive the shorter of the two inputs. If x is dropped, the result is invalid. If y is dropped, the result is invalid. The annotation ties the output to the inputs.
When you call this function, the compiler infers the concrete lifetime for 'a.
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xyz");
// 'a is inferred to be the scope of s2, which is shorter than s1.
result = longest(&s1, &s2);
// s2 is dropped here. The scope of 'a ends.
}
// This line would fail. result is tied to 'a, which ended.
// println!("{}", result);
}
The compiler substitutes 'a with the actual scope at the call site. It picks the shortest scope that satisfies all constraints. In this case, s2 dies first, so 'a becomes the scope of s2. The result is restricted to that scope. The compiler prevents you from using result after s2 is gone.
The annotation binds the output to the inputs. If the inputs vanish, the output vanishes.
Realistic usage: structs with references
Lifetimes appear most often when a struct holds a reference. The struct cannot outlive the data it points to. The lifetime annotation on the struct enforces that rule.
/// Holds a reference to text and its position.
struct Excerpt<'a> {
part: &'a str,
start: usize,
}
impl<'a> Excerpt<'a> {
/// Creates a new excerpt from a text.
fn new(text: &'a str) -> Self {
// Extract the first sentence as the excerpt.
let end = text.find('.').unwrap_or(text.len());
Excerpt {
part: &text[..end],
start: 0,
}
}
}
The struct Excerpt<'a> carries the lifetime parameter. Every instance of Excerpt is tied to a specific scope 'a. The field part borrows from that scope. The impl block also declares 'a so the methods can access the lifetime.
Usage shows the constraint in action.
fn main() {
let text = String::from("Hello world. This is a test.");
let excerpt = Excerpt::new(&text);
// excerpt is valid because text is alive.
println!("{}", excerpt.part);
// text is dropped here. excerpt becomes invalid.
}
If you try to store excerpt in a global variable or return it from a function where text is local, the compiler rejects you. The struct is a hostage to the data. Free the data, and the struct falls apart.
Multiple lifetimes and flexibility
A function can have multiple lifetime parameters. This allows you to express more precise relationships. Not all references need to share the same lifetime.
/// Returns the first argument, ignoring the second.
fn first<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
x
}
Here, x and y have different lifetimes. The return value is tied only to x. The caller can pass a short-lived y without restricting the result. This is more flexible than using a single 'a for everything.
Convention aside: use distinct lifetime names when the constraints differ. If a function only returns a reference derived from one input, give that input its own lifetime. Don't force unrelated references to share a label. The compiler can handle multiple lifetimes, and precise annotations make the API easier to use.
Pitfalls and compiler errors
Lifetime errors usually fall into two categories. The data dies too soon, or the annotation is wrong.
Returning a reference to a local variable is the most common mistake.
fn invalid() -> &str {
let s = String::from("hello");
&s
}
The compiler rejects this with E0515 (returned value does not live long enough). s is dropped at the end of the function. The return value would point to freed memory. No lifetime annotation can fix this. The data must live longer than the function.
Another frequent error is borrowing for too short a scope.
fn main() {
let result;
{
let text = String::from("temporary");
result = &text;
}
// E0597: borrowed value does not live long enough
println!("{}", result);
}
The compiler emits E0597. text is dropped when the inner block ends. result outlives text. The fix is to extend the scope of text or clone the data.
Lifetime elision can also cause confusion. Rust has rules that let you omit lifetimes in simple cases. If a function has one input reference, the output gets that lifetime. If a method has &self, the output gets the lifetime of self.
// These are equivalent.
fn trim1<'a>(s: &'a str) -> &'a str { s.trim() }
fn trim2(s: &str) -> &str { s.trim() }
Convention aside: rely on elision for simple functions. Write explicit annotations only when the compiler asks or when you have multiple inputs with different lifetimes. Extra annotations clutter the signature without adding value. The community prefers minimal signatures.
When the compiler complains about lifetimes, it's usually telling you the data is dying too soon. Fix the scope, not the annotation.
Decision: when to use lifetimes
Choosing between lifetimes, ownership, and other tools depends on the data flow. Use the right tool for the job.
Use lifetime annotations when a function returns a reference derived from its inputs. The output borrows data, so the compiler needs the link.
Use lifetime annotations when a struct stores references. The struct must be tied to the data it points to.
Reach for owned types like String or Vec<T> when the function creates new data or needs to take ownership. Ownership avoids lifetime complexity entirely.
Pick Cow<str> when you want to accept either borrowed or owned data without forcing the caller to clone. This bridges the gap between performance and flexibility.
Use 'static when the data lives for the entire program duration, such as string literals or data loaded at startup. 'static is a special lifetime that means "no borrowed references to shorter-lived data."
Ownership is the escape hatch. If lifetimes are making you miserable, take ownership.