Skip to content
All essays
ArchitectureMarch 14, 202413 min

Rust Lifetimes Deep Dive

Understand lifetimes for writing safe, flexible Rust code

Ü
Ümit Uz
Mobile & Full Stack Developer

Lifetimes are Rust's way of ensuring references are valid. They're a crucial concept that enables memory safety without garbage collection, preventing dangling references and use-after-free bugs at compile-time.

What Are Lifetimes?

Lifetimes describe the scope for which a reference is valid. Every reference in Rust has a lifetime, which is determined by how long the referenced data remains valid.

The Problem Lifetimes Solve

Without lifetimes, this code would be dangerous:

rust
fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }
    println!("{}", r); // x is already dropped!
}

Rust's lifetime checker prevents this at compile-time.

Lifetime Annotations

Lifetimes are annotated with an apostrophe followed by a name.

Basic Syntax

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

The 'a lifetime parameter ensures the returned reference is valid as long as both inputs.

Lifetime Names

Conventional lifetime names are short:

rust
// 'a - common default
// 'b - second lifetime
// 'input - descriptive name
fn func<'a, 'b>(x: &'a str, y: &'b str) -> &'a str

Lifetime Elision

Rust has lifetime elision rules that reduce annotation burden.

Rule 1: Input Lifetimes

Each parameter gets its own lifetime:

rust
// Written as:
fn foo(x: &str, y: &str)

// Elided from:
fn foo<'a, 'b>(x: &'a str, y: &'b str)

Rule 2: Single Input Lifetime

If there's one input lifetime, it's assigned to all outputs:

rust
// Written as:
fn parse(s: &str) -> Result<&str, Error>

// Elided from:
fn parse<'a>(s: &'a str) -> Result<&'a str, Error>

Rule 3: Methods

If there are multiple input lifetimes but one is &self or &mut self, that lifetime is assigned to all outputs:

rust
impl MyStruct {
    fn get(&self, s: &str) -> &str
    // elided from:
    fn get<'a, 'b>(&'a self, s: &'b str) -> &'a str
}

Structs with Lifetimes

Structs can hold references with explicit lifetimes.

Basic Example

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

let novel = String::from("Call me Ishmael...");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = ImportantExcerpt {
    part: first_sentence,
};

The struct can't outlive the reference it holds.

Multiple Lifetimes

Structs can have multiple lifetime parameters:

rust
struct Context<'s, 't> {
    code: &'s str,
    explanation: &'t str,
}

Lifetime Subtyping

Longer lifetimes can substitute for shorter ones.

Covariance

rust
fn choose_first<'a: 'b, 'b>(first: &'a str, second: &'b str) -> &'b str {
    first
}

The 'a: 'b syntax means "lifetime a outlives lifetime b".

In Practice

rust
fn mutate<'a, 'b>(x: &'a mut str, y: &'b str)
where
    'a: 'b,
{
    x.make_ascii_uppercase();
    println!("{}", y);
}

The 'static Lifetime

'static is a special lifetime that lasts for the entire program duration.

String Literals

rust
let s: &'static str = "I have a static lifetime.";

String literals are stored in the binary and available everywhere.

Static Variables

rust
static HELLO_WORLD: &str = "Hello, world!";

When to Use 'static

Use 'static for constants and global data:

rust
const MAX_SIZE: usize = 100;
static ERROR_MESSAGES: &[&str] = &[
    "File not found",
    "Permission denied",
];

Avoid Overusing 'static

Don't make everything 'static:

rust
// Don't do this:
fn process(s: &'static str) { }

// Do this instead:
fn process(s: &str) { }

Methods with Lifetimes

Methods can have lifetime parameters.

Basic Example

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

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }

    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}

Multiple Lifetime Parameters

rust
impl<'a> ImportantExcerpt<'a> {
    fn compare<'b>(&self, other: &'b str) -> bool
    where
        'a: 'b,
    {
        self.part == other
    }
}

Lifetime Bounds

Lifetimes can have bounds like generics.

T: 'a Syntax

rust
fn ref<'a, T>(x: &'a T) -> &'a T
where
    T: 'a,
{
    x
}

This means "T contains references with lifetime 'a or longer."

In Structs

rust
struct Ref<'a, T: 'a> {
    value: &'a T,
}

Generic Types with Lifetimes

Lifetimes work with generic types.

Example

rust
struct Holder<'a, T> {
    value: &'a T,
}

impl<'a, T> Holder<'a, T> {
    fn new(value: &'a T) -> Self {
        Holder { value }
    }
}

With Bounds

rust
fn print_ref<'a, T>(t: &'a T)
where
    T: Display + 'a,
{
    println!("{}", t);
}

Combining Lifetimes and Traits

Lifetimes interact with trait bounds.

Example

rust
use std::fmt::Display;

fn longest_with_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Advanced Lifetime Patterns

The Borrow Checker's Understanding

Rust understands non-lexical lifetimes (NLL):

rust
let mut v = vec![1, 2, 3];
let r = &v[0];
v.push(4); // Error with old borrow checker
println!("{}", r); // But r is no longer used!

With NLL, Rust sees that r isn't used after v.push.

Multiple Mutable References (with Splits)

rust
let mut v = vec![1, 2, 3, 4, 5];
let (left, right) = v.split_at_mut(2);
left[0] = 10;
right[0] = 20;

This works because the slices are guaranteed not to overlap.

Lifetime Variance

Lifetimes have variance relationships.

Covariant

rust
fn covariance<'a, 'b>(x: &'a str) -> &'b str
where
    'a: 'b,
{
    x
}

Longer lifetime can substitute for shorter.

Invariant

Mutable references are invariant:

rust
fn invariant<'a>(x: &'a mut str) -> &'a mut str {
    x
}

Common Lifetime Errors

Dangling Reference

rust
fn dangle() -> &String { // Error!
    let s = String::from("hello");
    &s
}

Fix: Return owned String.

Lifetime Mismatch

rust
fn returns_str() -> &str {
    let s = String::from("hello");
    &s[..] // Error!
}

Fix: Use 'static or return owned value.

Missing Lifetime

rust
fn parse(s: &str) -> Result<&str, Error>
// Error: missing lifetime specifier

Fix: Add lifetime annotation or rely on elision.

Debugging Lifetime Issues

The compiler provides excellent error messages.

Reading Error Messages

"borrowed value does not live long enough" "returned value references local data" "lifetime may not live long enough"

Common Solutions

  1. 1Clone the data
  2. 2Restructure code
  3. 3Add explicit lifetime annotations
  4. 4Use owned types instead of references

Practical Examples

Parsing

rust
fn parse_command<'a>(input: &'a str) -> Command<'a> {
    let mut parts = input.split_whitespace();
    Command {
        name: parts.next().unwrap(),
        args: parts.collect(),
    }
}

Windowing

rust
struct Window<'a, T> {
    items: &'a [T],
    start: usize,
    end: usize,
}

impl<'a, T> Window<'a, T> {
    fn new(slice: &'a [T]) -> Self {
        Window {
            items: slice,
            start: 0,
            end: slice.len(),
        }
    }
}

Caching

rust
struct Cache<'a> {
    key: String,
    value: &'a str,
}

impl<'a> Cache<'a> {
    fn get(&self) -> &'a str {
        self.value
    }
}

Lifetime and Closures

Closures capture with lifetimes:

rust
fn apply<'a, F>(f: F, s: &'a str) -> &'a str
where
    F: Fn(&'a str) -> &'a str,
{
    f(s)
}

Best Practices

  1. 1Let the compiler guide you
  2. 2Prefer explicit lifetimes when elision isn't clear
  3. 3Keep lifetimes as short as possible
  4. 4Use 'static sparingly
  5. 5Consider returning owned types if lifetimes get complex
  6. 6Document lifetime requirements in docs

Performance Implications

Lifetimes have zero runtime cost:

rust
// All these are the same at runtime
let s = String::from("hello");
let r: &str = &s;
let r: &'static str = "hello";

Lifetimes are compile-time only.

Conclusion

Lifetimes are Rust's mechanism for ensuring references are always valid. They prevent use-after-free and dangling reference bugs at compile-time, enabling safe systems programming without a garbage collector.

Master lifetimes, and you'll write Rust code that's both safe and flexible. The compiler will guide you toward correct code, catching entire classes of bugs before they can cause problems.

Practice with lifetimes until they become intuitive. They're a key part of what makes Rust special, enabling memory safety without sacrificing performance.

Enjoyed this essay? Share it.

Related essays

Next essay
React Testing Library Complete Guide