Posted on

Table of Contents

Lifetimes in Rust are often one of the most confusing topics for beginners. In fact, learning about lifetimes is actually same as learning why Rust is forcing you to write them. I'll try to keep this post as very simple and short, so if you are already familiar with Rust and lifetimes, this post definitely not for you.

What Are Lifetimes and Why Do We Need Them?

Lifetimes help the Rust compiler understand how long references (borrowed data) are valid. Imagine you have two pieces of paper borrowed from friends. You need to know how long you can safely use each piece before one of your friends asks for it back. Without knowing this, you might accidentally rely on a note that’s no longer available. That’s what lifetimes prevent in your code—they ensure references never outlive the data they point to.

A Broken Example: When Lifetimes Are Missing

Consider this function:

// This function doesn't compile because Rust doesn't know how long the returned reference should be valid.
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("Hello");
    let string2 = String::from("World");
    let result = longest(&string1, &string2); // Compiler error: missing lifetime annotations
    println!("The longest string is {}", result);
}

Output:

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`

Now, What’s the problem? The compiler is confused. It doesn’t know whether the returned reference is tied to string1 or string2, or how long that reference should remain valid. Without this information, Rust can’t guarantee that the reference won’t point to data that no longer exists.

// The Compiler's View
                                                  
&string1 ──┐                 
           ├─▶ longest() ──▶ returns &str from... where?
&string2 ──┘                 

// Rust can't tell if the returned reference will outlive its source!

Now let's simply fix this by adding lifetimes:

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

fn main() {
    let string1 = String::from("Hello");
    let string2 = String::from("World");
    let result = longest(&string1, &string2);
    println!("The longest string is {}", result);
    println!("We can still use string1: {}", string1); // Still valid! thanks to lifetimes!!
}

&string1 ('a) ──┐                 
                ├─▶ longest<'a>() ──▶ returns &'a str 
&string2 ('a) ──┘                 

// Now Rust knows the returned reference lives as long as both inputs!
  • We added a lifetime parameter 'a to the function signature.
  • We used this lifetime parameter to specify that the returned reference will live as long as the references passed in as arguments.
  • Now, the compiler knows how long the returned reference should be valid, and the code compiles successfully.

When Do You Need Lifetimes?

I think this is the most important question to understand lifetimes. Generally, lifetimes are only necessary when you work with references. If you write a function that takes ownership of values, like a simple subtraction or sum, lifetimes aren’t needed. For example:

fn sum(x: i32, y: i32) -> i32 {
    x + y
}
fn main () {
    let x = 5;
    let y = 10;
    let result = sum(x, y);
    println!("The sum is {}", result);
    println!("We can still use x: {}", x); 
}

Here, no lifetimes are required because:

  • No references (&x or &y): The function simple takes ownership of x and y.
  • Returns a new value: The result x + y is a brand-new i32, not a reference.
  • Nothing borrowed: Rust doesn't need to track how long x or y live because the values are already copied.

Extra note: You might ask why this still works:

    println!("We can still use x: {}", x); 

The reason x is still usable after calling sum is that integers are "Copy" types in Rust. When you pass them to a function, they get copied, not moved. This is because integers are small, simple values that are cheap to duplicate.

The Lifetime Elision Rules

Good thing is when you program in Rust in real-world projects, you don't need to write lifetimes all the time. Rust has smart defaults that often let you skip writing lifetimes explicitly. These are called "lifetime elision rules." You can read more about them in the official Rust book.

But still, since I think that understanding the concept of lifetimes is an important cornerstone in understanding the overall paradigm of the Rust programming language, if I were to start Rust again, I would refer to and experiment with lifetimes even where I don't need to.