↓ Skip to main content
  1. Rust/

Lifetimes in Rust: Deep Dive into References, Elision, HRTB, and Variance

2607 words·13 mins· loading · loading · · ·Rust-middle

Introduction
#

Memory management in Rust relies on ownership and borrowing rules. A frequent question in interviews and code reviews is: how does the compiler verify at build time that a reference remains valid, ensuring the program never accesses freed memory?

The answer centers on lifetimes.

A lifetime (annotated in code with an apostrophe, such as 'a) represents the scope in the program during which borrowed data remains valid. In everyday code, the compiler infers lifetimes on its own. When you work with custom data structures, async tasks, callbacks, or high-throughput parsers, understanding how the borrow checker analyzes lifetimes becomes essential.

In this article, I examine:

  • How the compiler infers lifetimes without manual annotations (Lifetime Elision)
  • How references work inside structs
  • The difference between &'static str and the T: 'static trait bound
  • Higher-rank trait bounds (for<'a>) for closures
  • Why variance (covariance and invariance) prevents dangling pointers
  • Where to use lifetimes in production, and when owned types are a better choice

1. Lifetime elision rules
#

The Rust team designed lifetime elision rules to save developers from writing 'a on every function signature. These rules are deterministic heuristics that rustc applies during compilation.

The compiler follows three rules of lifetime elision:

  1. Rule 1 (Input references): The compiler assigns an independent lifetime parameter to each input reference parameter (fn parse<'a, 'b>(x: &'a str, y: &'b [u8])).
  2. Rule 2 (Single input reference): If there is exactly one input reference parameter, its lifetime is assigned to all output references (fn first_word<'a>(s: &'a str) -> &'a str).
  3. Rule 3 (Methods with &self): If there are multiple input reference parameters and one of them is &self or &mut self, the lifetime of self is assigned to all output references.

When a function accepts multiple references without a &self parameter and returns a reference, elision cannot determine which input lifetime belongs to the output. In that case, compilation stops until you add explicit annotations.

The interactive slides below illustrate each rule in code:

Lifetime Elision Rules
Step 1/3
 1// Step 1: Rules 1 and 2 — Single Input Reference
 2fn first_word(s: &str) -> &str {
 3    // No need to write <'a>(s: &'a str) -> &'a str.
 4    // The compiler assigns s lifetime 'a (Rule 1)
 5    // and passes the same lifetime 'a to the returned slice (Rule 2).
 6    s.split_whitespace().next().unwrap_or("")
 7}
 8
 9fn main() {
10    let greeting = String::from("Rust 2026: Concurrency");
11    let word = first_word(&greeting);
12    
13    println!("First word: {word}");
14}
15

1. Rules 1 and 2: Single Input Reference

  • Rule 1: The compiler assigns an independent lifetime parameter to each input reference.
  • Rule 2: If there is exactly one input lifetime parameter, that lifetime is assigned to all output references.
  • The signature stays clean and readable without manual 'a annotations.

2. References inside structs
#

When a struct borrows data instead of owning it, the compiler requires an explicit lifetime parameter in the type definition:

struct UserProfile<'a> {
    username: &'a str,
}

This annotation informs the borrow checker that an instance of UserProfile<'a> cannot outlive the string slice referenced by username.

Once the owner of that data drops out of scope, the compiler prevents any further access to the struct.

The slides below show how the borrow checker catches dangling references at compile time:

References in Structs and Dangling Reference Prevention
Step 1/2
 1// Step 1: Structs holding borrowed data
 2struct UserProfile<'a> {
 3    username: &'a str,
 4    role: &'a str,
 5}
 6
 7impl<'a> UserProfile<'a> {
 8    fn summary(&self) -> String {
 9        format!("{}: {}", self.role, self.username)
10    }
11
12    fn name_slice(&self) -> &'a str {
13        // Returns a reference tied to the lifetime of the profile's underlying data
14        self.username
15    }
16}
17
18fn main() {
19    let username = String::from("ferris_developer");
20    let role = String::from("admin");
21
22    let profile = UserProfile {
23        username: &username,
24        role: &role,
25    };
26
27    println!("User profile: {}", profile.summary());
28    println!("Username: {}", profile.name_slice());
29}
30

1. Structs with references

  • UserProfile<'a> does not own its strings; it borrows them through &'a str.
  • The 'a annotation ensures UserProfile cannot outlive the username and role variables.

3. Static memory: &‘static vs T: ‘static
#

A common point of confusion is the distinction between the reference type &'static str and the trait bound T: 'static.

  • &'static str is a concrete immutable reference pointing to data stored in the program’s read-only binary data section (such as string literals "hello"). It remains valid for the entire runtime of the process.
  • T: 'static is a trait bound. It requires that type T can safely live for an arbitrary duration. A type satisfies this bound if it owns its data outright (String, u32, Vec<T>) or contains only 'static references.
Tip

A dynamically allocated heap String satisfies T: 'static, even if the local variable drops right after creation. Because it owns its data without borrowing from any local stack frame, you can safely transfer it to tokio::spawn or background threads.


4. Higher-rank trait bounds and variance
#

So far, we passed references to functions and stored them in structs. What happens when a function accepts a callback and needs to pass a reference to data it just created locally?

Closures and Higher-Rank Trait Bounds (for<'a>)
#

Suppose we write a function called process_event that accepts an event handler callback. Inside the function, we parse a payload into a local variable and pass a reference to that variable into our callback:

fn process_event<F>(handler: F)
where
    F: Fn(&'a str), // Where does 'a come from?
{
    let local_data = String::from("payload contents");
    handler(&local_data); // This reference lives only inside process_event!
}

Declaring 'a on the function signature (fn process_event<'a, F>(handler: F)) will fail to compile. The reason is straightforward:

  • The caller of a function chooses its generic parameters.
  • That means the caller would have to provide the data before calling process_event.
  • But local_data is created inside process_event and dropped before it returns. The caller cannot know its lifetime.

To solve this, the closure must accept a reference with any lifetime determined dynamically at the call site. Rust provides the for<'a> syntax for this, known as Higher-Rank Trait Bounds (HRTB):

fn process_event<F>(handler: F)
where
    F: for<'a> Fn(&'a str),
{
    let local_data = String::from("payload contents");
    handler(&local_data); // Works as intended
}

This reads: “For any lifetime 'a provided at call time, type F can handle a reference &'a str”.

Note

You likely use HRTB regularly without writing it explicitly. When you write F: Fn(&str), rustc desugars it into F: for<'a> Fn(&'a str). Explicit for<'a> syntax is needed primarily in web framework middleware (Axum, Actix) and complex trait definitions.

Variance: why lifetime substitution is restricted
#

Variance answers a simple question:

If a function expects a reference with lifetime 'a, can I pass a reference that lives longer, such as 'static?

In Rust, the answer depends on whether the reference is immutable or mutable:

  1. Covariance (&'a T): Yes. If a function expects a short-lived reference and you pass a string literal (&'static str), the compiler shortens the lifetime to match 'a. The data will not disappear prematurely, so this is safe.
  2. Invariance (&'a mut T): No. Mutable references cannot have their lifetimes shortened or substituted. Through &mut, values can be overwritten. If rustc allowed passing &mut &'static str where &mut &'a str was expected, external code could overwrite the long-lived variable with a reference to a temporary local string. Once the function returned and that local string dropped, the outer variable would point to deallocated memory. To prevent dangling references, Rust enforces invariance on mutable references.

Run through the examples below to see 'static, for<'a>, and variance rules in action:

Static Memory, HRTB, and Variance
Step 1/3
 1// Step 1: &'static str reference vs T: 'static trait bound
 2fn print_static(msg: &'static str) {
 3    println!("String literal embedded in the binary data section: {msg}");
 4}
 5
 6fn spawn_worker<T: 'static>(val: T) {
 7    // T: 'static means: type T either owns its data without references (String, Vec, u32),
 8    // or contains only 'static references.
 9    println!("Value is self-contained and can live for an arbitrary duration.");
10}
11
12fn main() {
13    print_static("Hello from the binary!");
14    
15    let owned_string = String::from("Dynamically allocated owned String");
16    spawn_worker(owned_string);
17}
18

1. Static Memory ‘static

  • &'static str points to data embedded in the binary that persists for the entire program execution.
  • The T: 'static bound does NOT mean the value lives forever; it ensures T contains no short-lived references.

Practical applications
#

While lifetime rules can feel theoretical, they solve concrete engineering problems:

1. Zero-copy parsing
#

When a service processes high-volume network streams or large log files, creating a separate String for every parsed token wastes memory and CPU cycles.

Libraries like serde, nom, and httparse deserialize data directly into borrowed structs:

#[derive(Deserialize)]
struct LogRecord<'a> {
    level: &'a str,
    message: &'a str,
}

The fields reference the underlying network buffer without extra allocations. The compiler uses 'a to ensure the program never reads LogRecord fields after the buffer is recycled or freed.

2. Async tasks and threads (tokio::spawn)
#

Anyone writing async code in Tokio or spawning threads in std::thread encounters the compiler error: the parameter type T must satisfy the 'static bound.

Knowing the difference between &'static references and the T: 'static bound makes the fix obvious:

  • Local references from the stack cannot be moved into spawn because background tasks may outlive the caller.
  • Owned structs (String, Vec<u8>) or values wrapped in Arc<T> satisfy T: 'static. Move them into the task, and the compiler accepts the code.

3. Framework middleware (HRTB)
#

When building plugins or middleware for Axum, Actix Web, or Tower, your handler receives a temporary reference to incoming HTTP request context. That context exists only while processing that specific request. Frameworks rely on for<'a> Fn(&'a Context) signatures to pass that temporary slice into handler closures.

When to avoid complex lifetimes
#

A practical rule for Rust architecture: do not add lifetime parameters to domain structs unless performance measurements require it.

For business logic and standard API services, prefer owned types (String, Vec). The negligible cost of occasional clones is a worthwhile trade-off for clean signatures and straightforward refactoring.

Lifetimes are an optimization tool. Use them for hot paths, binary parsers, and low-level libraries where zero-copy semantics matter.


Test your knowledge
#

Check your understanding of lifetimes and borrowing with this quick quiz:

Article read
Please rate how helpful and clear this article was to you
▶ Article series
About Rust - This article is part of a series.

Related