DEV Community

Cover image for Lifetimes in Rust aren't that hard
Yashaswi Kumar Mishra
Yashaswi Kumar Mishra

Posted on

Lifetimes in Rust aren't that hard

Understanding lifetimes in Rust is crucial to mastering memory safety without garbage collection. In this post, we break down dangling references, lifetime annotations, and how Rust's borrow checker ensures your code is always safe and sound. Let's dive in.

Dangling References

Let’s take a look at the code snippet below.

fn main() {
    let x : &i32;

    {
        let y : i32 = 10;
       x = &y; // This line will cause a compile-time error
    }

    println!("x: {}", x);

}
Enter fullscreen mode Exit fullscreen mode

This causes a classic compile-time error in rust - “borrowed value does not live long enough”. Let’s understand.

y is a variable which is stack allocated and as soon as the scope ends, it is dropped.

     {
        let y : i32 = 10;
       x = &y; // This line will cause a compile-time error
    }
    //y gets dropped
Enter fullscreen mode Exit fullscreen mode
  • y is stack-allocated and only lives inside the inner block.
  • As soon as the block ends, **y is dropped**.
  • But x is trying to hold a reference to y outside its valid lifetime.
  • That would make x a dangling reference.

For this to compile correctly, y must simply outlive x.

fn main() {
    let y: i32 = 10;
    let x: &i32 = &y;

    println!("x: {}", x); // Works fine
}

Enter fullscreen mode Exit fullscreen mode

TL;DR

  • Rust won’t allow refs to outlive the data.
  • Borrow checker enforces that.

Generical Lifetime Annotations

They simply describe the relation between the lifetimes of multiple references.

fn longest (x : &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we get a compile time error :

missing lifetime specifier

this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from x or y

So we will change our function signature like this :

fn longest <'a> (x : & 'a str, y: & 'a str) -> & 'a str
Enter fullscreen mode Exit fullscreen mode

We specify a generic lifetime annotation (starting from an ‘). Here it is ‘a.

x, y and the return value, all use our lifetime annotation. But what does this mean?

There is a relationship between the lifetimes of x, y and the return value. The relationship is : The lifetime of the return value will be same as the smallest of all the arguments’ lifetimes. The return value must not outlive any of the arguments (can be checked by checking if it outlives the argument with the smallest lifetime).

fn main() {
   let name = String::from("pixperk");
   let name2 = String::from("pixperk2");

   let res = longest(&name, &name2);
    println!("The longest name is: {}", res);
}
Enter fullscreen mode Exit fullscreen mode

Here name and name2 do not outlive each other (equal lifetimes). When res is used, the smallest lifetime is still valid and hence res is not a dangling reference.

fn main() {
   let name = String::from("pixperk");
  {
   let name2 = String::from("pixperk2");
   let res = longest(&name, &name2);
   println!("The longest name is: {}", res);
   }
}
Enter fullscreen mode Exit fullscreen mode

Here name2 has the smaller lifetime. But when res is used, the smallest lifetime is still valid and hence res is not a dangling reference.

Let’s change it up a bit.

fn main() {
   let name = String::from("pixperk");
   let res;
  {
   let name2 = String::from("pixperk2");
   res = longest(&name, &name2);
   }
    println!("The longest name is: {}", res);
}
Enter fullscreen mode Exit fullscreen mode

We get - name2 does not live long enough”. And yes, the reference having the smaller lifetime is not valid, when res is used and hence here it is a dangling reference.

Let’s see what happens when we try returning reference to a local variable.

fn broken<'a>() -> &'a String {
    let s = String::from("oops");
    &s // `s` is dropped, can't return reference
}

Enter fullscreen mode Exit fullscreen mode

This is illegal because you're returning a reference to memory that’s already gone. We can fix this by returning an owned value.


Lifetimes Annotations in Struct Definitions

If we want to use references with structs, we need to specify lifetime annotations.

struct Excerpt<'a> {
    part: &'a str,
}

Enter fullscreen mode Exit fullscreen mode

Our struct must not outlive the reference passed to part. Let’s see this through different cases :

fn main() {
    let novel = String::from("Call me Ishmael.");
    let quote = &novel[..4]; // "Call"

    let excerpt = Excerpt { part: quote };
    println!("{}", excerpt.part);
}
Enter fullscreen mode Exit fullscreen mode

Here, novel owns the string. quote borrows a slice from novel. The struct instance is defined later and novel lives long enough, and hence excerpt is not a dangling reference.

fn main() {
    let excerpt;
    {
        let novel = String::from("Call me Ishmael.");
        let quote = &novel[..4]; 
        excerpt = Excerpt { part: quote };
    }
    // novel is dropped here
    println!("{}", excerpt.part); // Dangling reference!!
}
Enter fullscreen mode Exit fullscreen mode

Here, excerpt, the instance of Excerpt outlives “novel”, and when we try to use excerpt, “novel” is invalid. Therefore, excerpt becomes a dangling reference here and we get a compiler error.


Lifetime Elision

Sometimes, the compiler can deterministically infer the lifetime annotations. It is done by checking three elision rules :

1. Each parameter that is a reference gets its own lifetime parameter

If a function takes multiple references as input, and you don’t explicitly annotate them, Rust assigns a separate lifetime to each.

fn foo(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }

Enter fullscreen mode Exit fullscreen mode

This won't compile. Why?

Because Rust sees this like:

fn foo<'a, 'b>(x: &'a str, y: &'b str) -> &str { ... }
Enter fullscreen mode Exit fullscreen mode

But it doesn’t know whether to return 'a or 'b. Hence, the error:

"lifetime may not live long enough."

You must explicitly annotate the output lifetime:

fn foo<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Enter fullscreen mode Exit fullscreen mode

Now Rust knows that the returned reference is valid for the shortest of both inputs.

2. If there is exactly one input lifetime, that lifetime is assigned to all output lifetime parameters

When your function has one reference input, and you return a reference, Rust assumes the returned reference lives as long as the input.

Example:

fn identity(s: &str) -> &str {
    s
Enter fullscreen mode Exit fullscreen mode

This works because Rust infers:

fn identity<'a>(s: &'a str) -> &'a str {
    s
}
Enter fullscreen mode Exit fullscreen mode

There’s only one input lifetime 'a, so it’s safe to assign it to the output.

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 lifetime parameters

This rule exists primarily for methods, and it enables a natural feel when writing object-oriented-style code.

Example:

struct Book {
    title: String,
}

impl Book {
    fn get_title(&self) -> &str {
        &self.title
    }
}

Enter fullscreen mode Exit fullscreen mode

Rust automatically infers:


fn get_title<'a>(&'a self) -> &'a str {
    &self.title
}

Enter fullscreen mode Exit fullscreen mode

Even if the method returns a reference to a field, Rust understands that the output reference must not outlive self.

This makes method-writing ergonomic without sacrificing safety.


‘static

A reference with a 'static lifetime means the data it points to will never be deallocated during the program’s execution. It's essentially "immortal" from the compiler’s perspective.

let s: &'static str = "hello";
Enter fullscreen mode Exit fullscreen mode

In this case, "hello" is a string literal stored in the binary’s read-only data segment. It lives for the entire duration of the program, so the reference to it is safely 'static.

Common Sources of 'static Data

  1. String Literals

    Always have 'static lifetimes because they are embedded in the binary and never go out of scope.

  2. Heap-Leaked Data

    You can deliberately "leak" heap data so that it lives for the program's entire runtime:

    let leaked: &'static str = Box::leak(Box::new("persistent string".to_string()));
    
    

    This is useful in situations where global, non-dropping data is required, such as plugin systems or global caches.

'static in Practice

Rust often requires 'static in multithreaded or asynchronous contexts where the compiler can't guarantee how long the data will be needed. A classic example is spawning threads:

std::thread::spawn(|| {
    println!("Runs independently");
});

Enter fullscreen mode Exit fullscreen mode

Here, the closure must be 'static because the thread might outlive any borrowed data from the parent scope.

When Not to Use 'static

A common mistake is to annotate a function like this:

fn longest<'static>(x: &'static str, y: &'static str) -> &'static str

Enter fullscreen mode Exit fullscreen mode

Unless you're certain the data truly lives for the full program duration, this is incorrect. You're promising more than the code can guarantee, which may cause subtle bugs or force you into unnecessary constraints. 'static should not be used to silence the borrow checker.

When It’s Justified

  • Global constants or config
  • Singleton patterns
  • Plugin registries
  • Global caches
  • Thread-safe lazy initialization (once_cell, lazy_static)

Lifetimes might feel tricky at first, but they’re what make Rust so powerful. Once you understand the flow of data and references, writing safe and efficient code becomes second nature. Keep experimenting, and lifetimes will click.

Top comments (0)