The contract behind the reference
You write a function to pick the longer of two strings. It feels trivial. You pass in two slices, compare lengths, return the winner. The compiler screams. It doesn't just say "error." It talks about 'a, 'b, and scopes. You haven't even used the word "lifetime" yet. This is the moment Rust stops being a language and starts being a contract negotiation.
In Python or JavaScript, you return a reference and the garbage collector keeps the data alive as long as something points to it. Rust has no garbage collector. The compiler must prove at compile time that every reference points to valid memory. Lifetime bounds are the tool that lets you write that proof. They are annotations that tie the validity of references to specific scopes, ensuring you never hand out a pointer to data that has already been dropped.
What a lifetime bound actually is
A lifetime bound is a promise about how long a reference stays valid. It does not change how long data lives. It does not extend scopes. It describes a relationship between references and the data they point to. When you add a lifetime bound, you are telling the compiler, "This reference will only be used while the data it points to exists."
Think of a lifetime bound like a rental agreement. You hand someone a key to an apartment. The agreement states the apartment must remain open and furnished for the duration of the rental. If the building gets demolished while the key is still in someone's pocket, the agreement is violated. The lifetime bound is the clause that guarantees the building stays standing as long as the key is valid. If you try to demolish the building early, the contract prevents you from handing out the key in the first place.
Lifetime bounds are purely a compile-time mechanism. They generate zero overhead at runtime. The compiler uses them to check safety, then erases them completely. The generated machine code contains no lifetime information. You get the safety of a garbage collector with the performance of manual memory management.
Minimal example: tying inputs to outputs
The most common place you encounter lifetime bounds is a function that returns a reference. The compiler needs to know which input reference the output is tied to.
/// Returns the longer of two string slices.
///
/// The lifetime parameter 'a ties the output to the inputs.
/// The compiler verifies the returned reference is valid for the duration of 'a.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
// Compare lengths and return the reference.
// Both x and y must live at least as long as 'a.
// The result inherits that guarantee.
if x.len() > y.len() {
x
} else {
y
}
}
The syntax <'a> introduces a lifetime parameter. Just like T stands for "some type," 'a stands for "some duration." The compiler substitutes actual scopes for 'a when the function is called.
The annotation &'a str means "a string slice that lives at least as long as 'a." By putting 'a on both inputs and the output, you are stating a constraint: the returned reference must be valid for the duration 'a, and both inputs must also be valid for that duration. This forces the result to be tied to the shorter of the two input lifetimes.
The compiler does not know which branch executes. It sees x and y both have lifetime 'a. It concludes the return value must also have lifetime 'a. This is the safety guarantee. The caller cannot use the result longer than both inputs survive.
The lifetime parameter is the bridge. Without it, the compiler cannot prove the output is safe.
How the compiler checks the promise
When you call longest, the compiler performs a constraint check. It looks at the actual lifetimes of the arguments and tries to find a 'a that satisfies all bounds.
Lifetimes have a hierarchy. A longer lifetime is a subtype of a shorter lifetime. If a value lives for the entire program, it also lives for five seconds. If a value lives for five seconds, it also lives for one second. This subtyping allows flexibility.
fn main() {
let s1 = String::from("long string");
// s1 lives until the end of main.
let result;
{
let s2 = String::from("short");
// s2 lives only inside this block.
// The compiler infers 'a as the lifetime of s2.
// s1 lives longer than s2, so it satisfies the bound.
result = longest(&s1, &s2);
}
// s2 is dropped here.
// result cannot be used here because its lifetime is tied to s2.
// println!("{}", result); // ERROR: E0597 - borrowed value does not live long enough
}
The compiler sees s2 has a short lifetime. It sets 'a to that short lifetime. It checks s1 and sees it lives longer. Since longer is a subtype of shorter, s1 satisfies the bound. The result inherits the short lifetime. When s2 is dropped, result becomes invalid. The compiler blocks any attempt to use result after the block ends.
This check happens for every call site. Lifetime bounds are generic over time, just as type parameters are generic over types. The same function works with short-lived locals and long-lived statics, as long as the constraints hold.
Convention aside: the name 'a is arbitrary. It carries no semantic meaning. You could name it 'short or 'input and the code behaves identically. The community uses 'a, 'b, 'c for simple cases because brevity reduces visual noise. The compiler treats all lifetime names as distinct variables within their scope.
Realistic example: structs with borrowed data
Lifetime bounds become essential when structs store references. A struct that borrows data must declare how long that borrow lasts. This prevents the struct from outliving the data it references.
/// A view that holds a reference to a row in a database result.
///
/// The lifetime 'a ensures the view cannot outlive the data buffer.
struct RowView<'a> {
/// Reference to the raw data.
data: &'a [u8],
/// Offset into the data.
offset: usize,
}
impl<'a> RowView<'a> {
/// Creates a new view from a slice.
fn new(data: &'a [u8], offset: usize) -> Self {
RowView { data, offset }
}
/// Reads a value from the view.
fn get(&self) -> &[u8] {
// Returns a slice of the inner data.
// The lifetime of the result is tied to self.data via elision rules.
&self.data[self.offset..]
}
}
The struct definition RowView<'a> declares a lifetime parameter. The field data: &'a [u8] ties the reference to that parameter. Any instance of RowView is only valid while 'a holds.
The impl block also needs <'a>. This tells the compiler that the methods operate on a specific lifetime. The method new takes data: &'a [u8] and returns Self. The return type Self expands to RowView<'a>, so the returned struct inherits the lifetime of the input data.
Usage follows the same rules. If you create a RowView from a local vector, the view dies when the vector drops.
fn process_data() {
let buffer = vec![1, 2, 3, 4, 5];
let view = RowView::new(&buffer, 1);
let chunk = view.get();
// chunk is valid here.
}
// buffer drops here. view and chunk are invalid.
If you try to return the view, the compiler blocks you.
fn bad_usage() -> RowView<'static> {
let buffer = vec![1, 2, 3];
// ERROR: E0597 - borrowed value does not live long enough
RowView::new(&buffer, 0)
}
The function promises a RowView with 'static lifetime, meaning it must live forever. The buffer is local and drops at the end of the function. The compiler rejects the code. You must either return an owned type or ensure the data lives long enough.
Structs with lifetimes are zero-cost views. They give you the speed of pointers with the safety of ownership.
Pitfalls and compiler errors
Lifetime errors usually indicate a scope mismatch, not a syntax problem. The compiler is telling you that data is dying too soon.
The most common error is returning a reference to a local variable.
fn bad_example() -> &str {
let s = String::from("hello");
// ERROR: E0515 - returns a value referencing data owned by the current function
&s
}
The variable s is dropped when the function returns. The reference would dangle. The compiler rejects this with E0515. The fix is to return an owned String instead of a reference, or to pass the data in as an argument.
Another pitfall is over-constraining. Using the same lifetime for unrelated references can make code unusable.
/// This function is overly restrictive.
fn bad_bounds<'a>(x: &'a str, y: &'a str) -> String {
// Returns an owned String, so the lifetimes are irrelevant.
// Requiring 'a on inputs forces the caller to keep both strings alive
// even though the result does not depend on them.
format!("{} {}", x, y)
}
The function returns an owned String. It does not borrow from the inputs. The lifetime bounds on x and y are unnecessary. They force the caller to ensure both inputs live for the same duration 'a, even though the result is independent. This makes the function harder to use. Remove the bounds when the output does not borrow from the inputs.
The compiler often infers lifetimes automatically through elision rules. For simple functions, you rarely need to write bounds manually. Elision applies when there is exactly one input reference lifetime, or when the method has &self. If elision does not apply, the compiler emits E0106 (missing lifetime specifier). At that point, you add the bounds to clarify the relationship.
If the compiler complains about lifetimes, the data is dying too soon. Fix the scope, not the annotation.
When to use lifetime bounds
Lifetime bounds are constraints, not features. Add them only when the compiler demands the proof.
Use lifetime bounds when a function returns a reference derived from its arguments. Use lifetime bounds when a struct stores references to data it does not own. Use lifetime bounds when you need to express that two references must not overlap in mutability, though the borrow checker often handles this automatically. Reach for owned types like String or Vec when the data needs to outlive the scope where it was created. Reach for Rc<T> or Arc<T> when multiple owners are needed.
Lifetimes are constraints, not features. Add them only when the compiler demands the proof.