How to Use Multiple Lifetime Parameters in Rust

Use multiple lifetime parameters in Rust by listing them after the function name and annotating references to define their specific validity scopes.

When one lifetime ties your hands

You are writing a function that searches a large text buffer for a keyword and returns the slice containing the match. The text buffer lives for the entire duration of the program. The keyword comes from a temporary string you construct on the fly. You write the function, and the compiler rejects it. It complains that the temporary keyword doesn't live long enough.

The problem isn't your data. The problem is your lifetime annotations. You told the compiler that the return value depends on the keyword's lifetime. The compiler believes you. It enforces the rule that the keyword must outlive the result, even though the result is just a slice of the text buffer. The keyword is irrelevant to the result's validity.

Multiple lifetime parameters solve this. They let you tell the compiler exactly which inputs the output depends on. You can link the return value to the text buffer while leaving the keyword completely independent. The keyword can be a temporary. The text buffer can be long-lived. The result stays valid.

Lifetimes as scope contracts

A lifetime is not a duration. It is a scope. When you write &'a str, you are making a contract: "This reference is valid for scope 'a." The compiler tracks these scopes to ensure you never use a reference after the data it points to is gone.

Think of lifetimes like leases on storage units. You have two leases. Lease A covers a warehouse full of inventory. Lease B covers a small locker with a key card. You want to hand someone a receipt that points to an item in the warehouse. The receipt is valid as long as Lease A is active. Lease B expiring doesn't matter. The key card in the locker can vanish, and the receipt still points to valid inventory.

If you use a single lifetime parameter, you are using a single lease for everything. The compiler assumes the receipt depends on both the warehouse and the locker. If the locker lease expires, the compiler assumes the receipt is invalid. That's too strict. You need two leases to describe the reality.

In Rust, naming a lifetime links scopes. If two references share the name 'a, the compiler requires them to live for the same scope. If they have different names, like 'a and 'b, the compiler treats them as independent. The output can depend on 'a while ignoring 'b.

Minimal example: unlinking inputs

Consider a function that returns the first string it receives, ignoring the second. The second string is just noise. It doesn't affect the result.

/// Returns the first string reference.
/// The second parameter is ignored and can have a different lifetime.
fn first<'a>(s1: &'a str, s2: &str) -> &'a str {
    s1
}

The function signature declares one named lifetime, 'a. The parameter s1 uses 'a. The return type uses 'a. This links s1 and the return value. They must share the same scope.

The parameter s2 has no named lifetime. It gets an anonymous lifetime. The compiler assigns a unique, short scope to it automatically. s2 can be a temporary value that dies immediately after the call. The return value is unaffected because it only depends on 'a.

If you used a single lifetime for everything, the signature would look like this:

/// This version forces s2 to live as long as the result.
/// That is unnecessary and restricts callers.
fn first_bad<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    s1
}

Now s2 is tied to 'a. The compiler requires s2 to live as long as the return value. If you pass a temporary for s2, the compiler rejects the call. You've added a constraint that doesn't exist in the logic. The function doesn't use s2. The constraint is artificial. Multiple lifetimes remove artificial constraints.

Naming lifetimes is the mechanism. Same name means linked scopes. Different names mean independent scopes. Use this to match your function's actual dependencies.

Walkthrough: what the compiler checks

Lifetimes exist only at compile time. They are erased completely before the binary runs. There is zero runtime overhead. No counters, no checks, no indirection. The compiler uses lifetimes to prove safety, then discards the proof.

When you call a function with multiple lifetimes, the compiler performs a substitution. It looks at the arguments you pass and assigns concrete scopes to the lifetime parameters.

Suppose you call first like this:

let text = String::from("hello");
let result = first(&text, "temporary");

The compiler sees &text. It assigns the scope of text to 'a. It sees "temporary". It assigns a short, anonymous scope to the anonymous lifetime of s2. It checks the return type. The return is &'a str. The compiler verifies that 'a (the scope of text) is valid at the call site. It is. The call succeeds.

Now look at the bad version:

let text = String::from("hello");
// This fails. "temporary" is a short-lived literal in some contexts,
// or more clearly, a local variable that dies.
let sep = String::from(" ");
let result = first_bad(&text, &sep);

Here sep is a local variable. Its scope ends at the end of the block. The compiler tries to assign sep's scope to 'a. It also assigns text's scope to 'a. It requires both to live for the same duration. The return value needs 'a. The compiler checks: "Does sep live long enough?" No. sep dies before the result is used. The compiler emits an error.

The error is usually E0597 (borrowed value does not live long enough). The compiler is protecting you from a dangling reference. In the bad version, the constraint is real. If the function actually returned s2, the error would be correct. Since the function returns s1, the error is a false positive caused by over-constraining. Multiple lifetimes fix this by allowing s2 to have a shorter scope than the result.

Realistic example: search with a transient needle

A common pattern is a search function. You have a haystack and a needle. You return a slice of the haystack. The needle can be anything. It might be a string literal, a user input that gets dropped, or a computed value. The result must only depend on the haystack.

/// Searches for `needle` in `haystack`.
/// Returns a slice of `haystack` if found.
/// The needle can be short-lived; it is only used for comparison.
fn find<'a, 'b>(haystack: &'a str, needle: &'b str) -> Option<&'a str> {
    if let Some(pos) = haystack.find(needle) {
        // Return a slice of haystack.
        // The slice shares haystack's lifetime 'a.
        // The needle's lifetime 'b is irrelevant to the result.
        Some(&haystack[pos..])
    } else {
        None
    }
}

This function takes two lifetime parameters. 'a is for the haystack and the result. 'b is for the needle. The return type is Option<&'a str>. The result is tied to 'a.

You can call this with a temporary needle:

let data = "The quick brown fox";
// "quick" is a string literal with 'static lifetime, but even if it were
// a temporary variable, this would work.
let match_result = find(data, "quick");

Or with a needle that dies immediately:

let data = "The quick brown fox";
let match_result = {
    let needle = String::from("brown");
    // needle is created here.
    find(data, &needle)
    // needle is dropped here.
    // match_result is still valid because it depends only on data.
};

The block creates needle. find is called. needle is dropped. match_result holds a reference to data. data is still alive. The code compiles. The multiple lifetimes allowed the needle to be transient.

If you used a single lifetime, the compiler would require needle to live as long as match_result. The code would fail with E0597. You would be forced to keep needle alive unnecessarily, or refactor the code to avoid the temporary. Multiple lifetimes give you the flexibility to write natural code.

Structs with independent fields

Multiple lifetimes also appear in structs. A struct can hold references with different lifetimes. This is useful when the fields come from different sources.

/// A node that references a parent and a child.
/// The parent and child can come from different scopes.
struct Node<'a, 'b> {
    parent: &'a Node<'a, 'b>,
    child: &'b Node<'a, 'b>,
}

This struct has two lifetime parameters. parent uses 'a. child uses 'b. The fields are independent. You can construct a Node where the parent lives in one scope and the child lives in another.

fn build_tree() -> Node<'static, 'static> {
    // In a real scenario, these might come from different arenas or buffers.
    // For this example, we use static references to simplify.
    // The point is the struct allows independent lifetimes.
    todo!()
}

If you used a single lifetime, Node<'a>, both fields would share 'a. The parent and child would have to come from the same scope. This restricts how you build the tree. You might need to allocate everything in a single buffer, even if the parent and child are logically independent. Multiple lifetimes remove this restriction.

However, structs with multiple lifetimes can be tricky to work with. Functions that take the struct often need to propagate the lifetimes. If a function only uses one field, you still have to carry both lifetimes in the signature. This can lead to verbose code. Use multiple lifetimes in structs only when the fields truly have independent scopes. If the fields always come from the same source, a single lifetime is cleaner.

Pitfalls and compiler errors

Multiple lifetimes introduce complexity. The compiler helps you, but you need to understand the patterns.

Over-constraining with one lifetime. This is the most common mistake. You write a function that returns a reference to one input, but you annotate all inputs with the same lifetime. The compiler forces all inputs to live as long as the result. You get E0597 when you pass a temporary. The fix is to split the lifetimes. Give the output and its dependency one lifetime. Give the other inputs anonymous or different lifetimes.

Under-constraining. This is rarer. You return a reference that depends on an input, but you forget to link the lifetimes. The compiler might infer a lifetime that is too short, or it might reject the code because it can't prove the reference is valid. If you return &'a str but the value comes from a parameter with lifetime 'b, and you don't link 'a and 'b, the compiler emits E0308 (mismatched types) or a lifetime mismatch error. The fix is to ensure the return lifetime matches the source lifetime.

Struct lifetime propagation. When you have a struct with multiple lifetimes, methods on the struct often need to specify which lifetime they use. If a method returns a reference to a field, the return lifetime must match the field's lifetime.

impl<'a, 'b> Node<'a, 'b> {
    /// Returns a reference to the parent.
    /// The return lifetime is 'a, matching the parent field.
    fn parent(&self) -> &'a Node<'a, 'b> {
        self.parent
    }
}

If you forget 'a on the return type, the compiler might assume the return lifetime is tied to &self, which is shorter than 'a. This can cause errors when you try to use the parent reference outside the method call. Always annotate return types in structs with multiple lifetimes.

Convention aside: Naming lifetimes 'a, 'b, 'c is standard. Don't use descriptive names like 'input1 or 'haystack unless the code is genuinely confusing. Short names reduce noise. The position in the signature usually makes the meaning clear. If you have three lifetimes and it's hard to track, add comments. Don't reach for long names first.

Convention aside: Prefer &str over &String for parameters. &str is a slice. It works with string literals, String, and Cow. It's more flexible. Lifetime annotations work the same way, but &str avoids unnecessary allocations and type conversions.

Decision: when to use multiple lifetimes

Use a single lifetime when the function returns a reference derived from any of the inputs, and you cannot guarantee which input the result comes from. The compiler needs to assume the result depends on all inputs. This is safe and simple.

Use multiple lifetimes when the return value depends on a specific subset of inputs, and other inputs are transient helpers. The helpers can be short-lived. The result must only depend on the long-lived data. Splitting lifetimes gives callers the flexibility to pass temporaries.

Use multiple lifetimes in structs when fields reference data from independent scopes. The fields can be constructed from different sources. A single lifetime would force all fields to share a scope, which may be impossible or inefficient.

Reach for lifetime elision when the pattern is simple. If a function has one input reference and returns a reference, the compiler infers the link automatically. You don't need to write lifetimes. Elision keeps code clean. Use explicit lifetimes only when elision fails or when you need to break the default assumption.

Where to go next

Naming lifetimes links scopes. Giving references different names breaks the link. That's the entire mechanism. Trust the compiler to enforce the links you declare. Don't fight the borrow checker with unsafe. Give it the precise lifetime map it needs. Lifetimes are erased at runtime. You're paying zero cost for this precision. Use it to write flexible, safe code.