Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 007 / 100

Rust lifetimes explained: a hands-on guide for engineers

How the compiler knows references stay valid.

Expona Admin9 min read

Why lifetimes matter in production systems

You've written safe, fast Rust code, and the compiler caught a null pointer bug before it reached staging. Now you're building a string utility function, and the borrow checker refuses to compile. The error message mentions lifetimes—abstract annotations that tell the compiler how long references are valid. Understanding Rust lifetimes explained: a hands-on guide for engineers transforms frustration into clarity, and it's essential when you're designing APIs that return references or building structs that hold borrowed data.

In this guide, we'll walk through why lifetimes exist, when the compiler infers them silently, when you need to write them explicitly, and common pitfalls we've seen in production systems across e-commerce platforms and real-time services.

What lifetimes are — names for how long references live

A lifetime is a name for a scope during which a reference remains valid. In Rust, every reference has a lifetime, even if you don't write it explicitly. The lifetime ensures that the reference never outlives the data it points to.

Think of it this way: if you borrow a book from a colleague, the book's "lifetime" is the time it's in your possession. You can't lend it to someone else after you've returned it. Rust's borrow checker enforces the same rule for references.

Lifetimes are written with a leading apostrophe: 'a, 'b, 'static. They're generic parameters, similar to type parameters like T or U. The most common lifetime is 'static, which means the reference lives for the entire program duration (string literals, for example).

rust
// A simple reference with lifetime 'a
let x = 5;
let r: &'a i32 = &x;  // r borrows x for lifetime 'a

Most of the time, you won't write this annotation. The compiler infers it automatically. But when you're returning references from functions or storing them in structs, you need to declare lifetimes explicitly so the compiler can verify correctness.

Usually inferred — elision rules handle most functions

Rust's lifetime elision rules let you omit lifetime annotations in many common cases. The compiler automatically inserts them based on patterns it recognizes. This is why simple functions work without any lifetime syntax:

rust
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    &s[..]
}

This compiles without explicit lifetimes. The compiler knows that the returned &str borrows from the input &str, so they share the same lifetime. This is covered by elision rule: "if there's exactly one input lifetime, the output lifetime is that same lifetime."

Here are the three main elision rules:

  1. Each parameter that is a reference gets its own lifetime parameter.
  2. If there's exactly one input lifetime parameter, it's assigned to all output lifetimes.
  3. If there are multiple input lifetimes but one of them is &self or &mut self, the lifetime of self is assigned to all output lifetimes.

Rule 3 is why methods on structs often work without explicit lifetimes—the compiler ties outputs to the lifetime of self.

rust
struct Article {
    title: String,
}

impl Article {
    // Elision rule 3: output lifetime comes from &self
    fn get_title(&self) -> &str {
        &self.title
    }
}

let article = Article { title: "Lifetimes 101".to_string() };
let title = article.get_title();  // Works without explicit lifetimes

When these rules don't apply—typically when you have multiple input references—you must write lifetimes explicitly. That's where the learning curve gets steep.

When you need them — functions returning references from inputs

Imagine you're building a utility function that returns the longer of two strings. Both inputs are references; the output is also a reference. The compiler can't use elision rules here because it doesn't know whether the returned reference borrows from the first input, the second input, or something else entirely.

rust
// Without lifetimes, this won't compile
fn longest(a: &str, b: &str) -> &str {
    if a.len() > b.len() { a } else { b }
}

// Error: expected named lifetime parameter

To fix this, you annotate with a lifetime parameter. The lifetime 'a tells the compiler that all three references—both inputs and the output—have the same lifetime:

rust
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

let str1 = "hello";
let str2 = "world";
let result = longest(str1, str2);
println!("{}", result);  // Works: "world"

This function signature says: "I accept two string references with the same lifetime, and I return a reference with that same lifetime." The lifetime constraint ensures that the returned reference is valid for as long as both input references are valid.

In practice, this means the caller can't use the returned reference after either input has been dropped. The compiler verifies this automatically:

rust
let str1 = "hello".to_string();
let str2 = "world".to_string();
let result = longest(&str1, &str2);

drop(str1);  // Invalid: str1 is dropped
println!("{}", result);  // Error: result may reference dropped value

When you work with web frameworks or data processing pipelines, this pattern appears frequently. For example, a JSON parser might return a reference to a field in the parsed JSON, and you need to ensure the JSON document outlives the reference.

Structs with references — prefer owned data in structs when possible

Structs can hold references, but they must declare lifetimes for those references. This often adds unnecessary complexity. Consider:

rust
// Struct holding a reference
struct Article<'a> {
    title: &'a str,
    content: &'a str,
}

let title = "Rust in Production".to_string();
let content = "...article body...".to_string();
let article = Article {
    title: &title,
    content: &content,
};

// Now 'title' and 'content' must outlive 'article'
println!("{}", article.title);

This works, but it introduces lifetime annotations and ownership constraints throughout your codebase. Every function that takes or returns Article must now specify the lifetime:

rust
fn process<'a>(article: &Article<'a>) {
    println!("{}", article.title);
}

In production systems, we've found that structs are cleaner and more maintainable when they own their data:

rust
// Struct owning its data
struct Article {
    title: String,
    content: String,
}

let article = Article {
    title: "Rust in Production".to_string(),
    content: "...article body...".to_string(),
};

fn process(article: &Article) {
    println!("{}", article.title);
}

process(&article);  // No lifetime parameters needed

This is simpler and more flexible. The struct owns its data, so there's no risk of dangling references. You lose a small amount of memory efficiency (allocating String instead of holding a reference), but you gain clarity and flexibility.

However, there are legitimate cases for references in structs. If you're parsing a large file and want to return references into it without copying, or if you're building a cache layer where data must remain in scope for the lifetime of the cache, use lifetimes. Just be deliberate about it:

rust
// Parsing library: references to borrowed data
struct JsonValue<'a> {
    raw: &'a str,
    parsed: serde_json::Value,
}

impl<'a> JsonValue<'a> {
    fn from_str(s: &'a str) -> Self {
        let parsed = serde_json::from_str(s).expect("valid json");
        JsonValue { raw: s, parsed }
    }
}

In this case, the struct genuinely needs to hold a reference: it keeps the original string and a parsed representation. The lifetime constraint ensures the parsed data never outlives the source.

Common mistakes

Mistake 1: Different lifetimes where one is needed. If two inputs have different lifetimes, the compiler treats them as independent:

rust
fn compare<'a, 'b>(a: &'a str, b: &'b str) -> &'a str {
    // This compiles, but it always returns a reference with lifetime 'a
    // You can't mix lifetimes from different borrows
    a
}

If you need both inputs to live for the same scope, use a single lifetime parameter.

Mistake 2: Forgetting that lifetimes are tied to references, not data. An owned value doesn't have a lifetime annotation—only the references to it do.

rust
// Wrong: lifetimes don't apply to owned data
fn process<'a>(s: 'a String) {
    // Syntax error
}

// Right: lifetime applies to the reference
fn process<'a>(s: &'a String) {
    // Correct
}

Mistake 3: Over-complicating structs with unnecessary lifetimes. In a microservice handling customer orders, storing a reference to a parsed request in a struct often leads to borrow checker friction later. Prefer cloning or copying data when the overhead is acceptable:

rust
// Cleaner: owns the customer ID
struct Order {
    customer_id: u64,
    amount: f64,
}

// Harder to use: borrows from request
struct Order<'a> {
    customer_id: &'a str,
    amount: f64,
}

Key takeaways

  • Lifetimes are names for scopes during which references are valid. They tell the compiler how long borrowed data must remain accessible.
  • Most functions don't require explicit lifetime annotations thanks to elision rules. The compiler infers them when the pattern is clear.
  • When a function returns a reference from multiple inputs, you must declare which input the output borrows from using explicit lifetime parameters.
  • Structs can hold references, but they must declare the lifetime of those references. In most cases, owned data is simpler and more maintainable.
  • Use lifetimes deliberately: for parsing libraries that must preserve references to source data, for zero-copy APIs, or for performance-critical code. Avoid them in business logic where clarity matters more than memory efficiency.
  • The compiler is your ally. If code doesn't compile, carefully read the error message—it usually tells you exactly which lifetime constraint is violated.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles