Lifetimes in Rust aren't that hard

    Lifetimes in Rust aren't that hard

    RustProgrammingBeginners
    May 26, 2025
    5 min
    YM
    Yashaswi Mishra
    5 min

    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.

    rust
    fn main() {
        let x : &i32;
    
        {
            let y : i32 = 10;
           x = &y; // This line will cause a compile-time error
        }
    
        println!("x: {}", x);
        
    }

    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.

    rust
    {
            let y : i32 = 10;
           x = &y; // This line will cause a compile-time error
        }
        //y gets dropped
    • 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.

    rust
    fn main() {
        let y: i32 = 10;
        let x: &i32 = &y;
    
        println!("x: {}", x); // Works fine
    }

    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.

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

    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 :

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

    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).

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

    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.

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

    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.

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

    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.

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

    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.

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

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

    rust
    fn main() {
        let novel = String::from("Call me Ishmael.");
        let quote = &novel[..4]; // "Call"
        
        let excerpt = Excerpt { part: quote };
        println!("{}", excerpt.part);
    }

    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.

    rust
    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!!
    }

    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.

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

    This won't compile. Why?

    Because Rust sees this like:

    rust
    fn foo<'a, 'b>(x: &'a str, y: &'b str) -> &str { ... }

    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:

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

    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:

    rust
    fn identity(s: &str) -> &str {
        s

    This works because Rust infers:

    rust
    fn identity<'a>(s: &'a str) -> &'a str {
        s
    }

    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:

    rust
    struct Book {
        title: String,
    }
    
    impl Book {
        fn get_title(&self) -> &str {
            &self.title
        }
    }

    Rust automatically infers:

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

    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.

    rust
    let s: &'static str = "hello";

    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:

      rust
      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:

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

    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:

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

    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.

    Want more like this?
    Buy me a coffeeSupport