Advanced Rust Series Part 9: Lifetime Patterns in Production Code – Common Mistakes and How to Fix Them

Advanced Rust Series Part 9: Lifetime Patterns in Production Code – Common Mistakes and How to Fix Them

Parts 1 through 8 covered the theory and mechanics. This post is about application. Real codebases produce ownership and lifetime errors that do not look like textbook examples. The patterns are messier, the error messages longer, and the fixes less obvious. Here are the production patterns, the common mistakes, and the strategies that resolve them without rewriting half your code.

Pattern 1: The Config-Everywhere Problem

A common early Rust codebase looks like this: a top-level Config struct that every subsystem needs to read. Developers who are new to Rust often try to clone it everywhere or struggle with lifetimes when passing references through multiple layers.

// Naive approach: clone everywhere
#[derive(Clone, Debug)]
struct Config {
    host: String,
    port: u16,
    timeout_ms: u64,
    max_connections: u32,
}

struct Server {
    config: Config, // owns a clone
}

struct Database {
    config: Config, // owns another clone
}

// Every subsystem owns its own copy - works but wasteful
// A 10KB config cloned into 20 subsystems = 200KB just for config

The clean production pattern for widely-shared immutable config is Arc<Config>:

use std::sync::Arc;

#[derive(Debug)]
struct Config {
    host: String,
    port: u16,
    timeout_ms: u64,
    max_connections: u32,
}

struct Server {
    config: Arc,
}

struct Database {
    config: Arc,
}

fn main() {
    let config = Arc::new(Config {
        host: String::from("localhost"),
        port: 8080,
        timeout_ms: 5000,
        max_connections: 100,
    });

    let server = Server { config: Arc::clone(&config) };
    let db = Database { config: Arc::clone(&config) };

    // One allocation, multiple owners, zero copying of config data
    println!("Server host: {}", server.config.host);
    println!("DB timeout: {}", db.config.timeout_ms);
}

Pattern 2: The Borrow-Then-Modify Loop

This pattern trips up almost every Rust developer at some point. You iterate over a collection and want to modify it based on what you find:

fn broken(items: &mut Vec) {
    for item in items.iter() {         // shared borrow of items
        if item.starts_with("remove:") {
            items.retain(|x| x != item); // ERROR: mutable borrow while shared exists
        }
    }
}

// Fix 1: Collect what to remove, then remove
fn fixed_collect(items: &mut Vec) {
    let to_remove: Vec = items
        .iter()
        .filter(|i| i.starts_with("remove:"))
        .cloned()
        .collect();

    items.retain(|item| !to_remove.contains(item));
}

// Fix 2: Use retain directly
fn fixed_retain(items: &mut Vec) {
    items.retain(|item| !item.starts_with("remove:"));
}

// Fix 3: Use indices instead of references
fn fixed_indices(items: &mut Vec) {
    let indices: Vec = items
        .iter()
        .enumerate()
        .filter(|(_, item)| item.starts_with("remove:"))
        .map(|(i, _)| i)
        .collect();

    for i in indices.into_iter().rev() {
        items.remove(i);
    }
}

Pattern 3: Returning References From Methods on Temporary Values

This pattern appears when you create a temporary value and try to return a reference into it from a method chain:

fn broken() -> &str {
    let s = String::from("hello world");
    s.split_whitespace().next().unwrap() 
    // ERROR: returns reference to local data
    // s is dropped at end of function
}

// Fix 1: Return owned data
fn fixed_owned() -> String {
    let s = String::from("hello world");
    s.split_whitespace()
     .next()
     .unwrap_or("")
     .to_string() // convert &str to owned String
}

// Fix 2: Accept the string as input (caller provides the owner)
fn fixed_borrow(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

fn main() {
    let text = String::from("hello world");
    let word = fixed_borrow(&text); // text is the owner, lives long enough
    println!("{}", word);
}

Pattern 4: The Lifetime Propagation Chain

In large codebases, a struct that holds a reference causes lifetime parameters to propagate through every struct that contains it. This can feel like a cascade of annotations:

struct Buffer<'a> {
    data: &'a [u8],
}

struct Parser<'a> {
    buffer: Buffer<'a>, // Buffer has a lifetime, so Parser must too
}

struct Pipeline<'a> {
    parser: Parser<'a>, // Parser has a lifetime, so Pipeline must too
}

// Every wrapper in the chain carries 'a
// This is correct behavior - the lifetime is real - but it is verbose

// When this becomes burdensome, consider owning the data instead:
struct OwnedBuffer {
    data: Vec,
}

struct OwnedParser {
    buffer: OwnedBuffer, // no lifetime needed
}

// Or use Arc to share without lifetimes:
use std::sync::Arc;
struct SharedBuffer {
    data: Arc>,
}

struct SharedParser {
    buffer: SharedBuffer, // no lifetime needed
}

The trade-off is clear: borrowing is zero-cost but propagates lifetime constraints. Owning or sharing via Arc adds allocation cost but eliminates the lifetime cascade. For hot paths, borrowing is worth the complexity. For startup configuration or infrequent data, owning is simpler.

Pattern 5: The Entry API and Other Borrow-Split Patterns

Many standard library types provide APIs that split borrows intentionally to avoid conflicts. Learning to recognize these prevents you from writing workarounds:

use std::collections::HashMap;

// HashMap entry API - avoids double lookup and borrow conflicts
fn word_count(text: &str) -> HashMap<&str, u32> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word).or_insert(0) += 1;
    }
    counts
}

// Vec split_at_mut - split a vec into two non-overlapping mutable slices
fn process_halves(data: &mut Vec) {
    let mid = data.len() / 2;
    let (left, right) = data.split_at_mut(mid);
    // Both halves mutably borrowed simultaneously - no conflict
    left.sort();
    right.sort_unstable();
}

// get_many_mut (Rust 1.86+) - multiple mutable references into a HashMap
// For older Rust, use the index-based pattern shown earlier

Diagnosing Hard Borrow Checker Fights

When a borrow checker error has no obvious fix, use this diagnosis process:

flowchart TD
    A[Borrow checker error] --> B{What does the error say?}
    B -->|"cannot borrow X as mutable because also borrowed as immutable"| C[Find where immutable borrow starts\nNarrow its scope or clone the value]
    B -->|"does not live long enough"| D[Find where the owner is created\nMove it to a wider scope]
    B -->|"use of moved value"| E[Decide: borrow instead, clone, or restructure ownership]
    B -->|"lifetime may not be long enough"| F[Add lifetime annotation\nor change to owned data]
    C --> G{Can you narrow the borrow scope?}
    G -->|Yes| H[Use explicit block or restructure expression order]
    G -->|No| I[Clone the value or redesign ownership]
    D --> J{Can you move the owner earlier?}
    J -->|Yes| K[Declare owner before the borrower]
    J -->|No| L[Change to owned data or Arc]

The Borrow Checker Is Usually Right

One of the most important mindset shifts for experienced Rust developers: when the borrow checker rejects your code, it is almost always identifying a real problem. The question is not “how do I convince the compiler this is safe” but “what does this error tell me about my design.”

// This error is telling you something real
struct Database {
    connection: Connection,
    query_cache: HashMap>,
}

impl Database {
    fn cached_query(&mut self, sql: &str) -> &Vec {
        if self.query_cache.contains_key(sql) {
            &self.query_cache[sql] // shared borrow of query_cache
        } else {
            let results = self.connection.execute(sql); // mutable borrow of self
            // ERROR: self is mutably borrowed through connection
            //        while query_cache is immutably borrowed
            self.query_cache.insert(sql.to_string(), results);
            &self.query_cache[sql]
        }
    }
}

// The borrow checker identified that you are trying to read from query_cache
// and mutate self simultaneously. Restructure to avoid the overlap:
impl Database {
    fn cached_query(&mut self, sql: &str) -> &Vec {
        if !self.query_cache.contains_key(sql) {
            // Mutation phase: no borrows of query_cache active
            let results = self.connection.execute(sql);
            self.query_cache.insert(sql.to_string(), results);
        }
        // Borrow phase: mutation is done
        &self.query_cache[sql]
    }
}

Common Lifetime Mistakes in Production Code

Mistake 1: Storing References in Long-Lived Structs

References in structs are great for short-lived views and parsers. They are painful for long-lived application state. If your struct lives for most of the program, prefer owned data or Arc.

Mistake 2: Returning References to Computed Values

If a method computes a new value and tries to return a reference to it, that is almost always wrong. Return the owned value instead. References should point to data that already exists somewhere – not data you just created.

Mistake 3: Over-using Lifetime Annotations to Force Compilation

Adding lifetime annotations to make the compiler accept code without understanding why is a way to introduce subtle bugs. The annotations change the constraints the compiler enforces. If you do not understand what constraint you are adding, you may be silencing a real safety check.

Mistake 4: Ignoring the NLL Borrow Scope

With Non-Lexical Lifetimes, borrows end at last use. If you are getting unexpected borrow conflicts, check whether you can reorder code so the borrow’s last use comes before the conflicting operation. Sometimes a single line reorder resolves the error.

fn main() {
    let mut data = vec![1, 2, 3];
    
    // Conflict version
    let first = &data[0];
    data.push(4);          // ERROR: mutable borrow while shared borrow exists
    println!("{}", first); // borrow still needed here
    
    // Reordered - NLL resolves it
    let first = &data[0];
    println!("{}", first); // last use of first - borrow ends here
    data.push(4);          // no conflict
}

Tools for Debugging Ownership Issues

  • rustc –explain E0XXX: detailed explanation of any error code with examples
  • cargo check: faster than cargo build for iterating on ownership errors
  • rust-analyzer: highlights borrow issues inline in your editor before you compile
  • cargo clippy: catches patterns like unnecessary clones, prefer-borrow suggestions, and misuse of smart pointers

What Comes Next

Part 10 closes the series with the advanced tricks that experienced Rust developers reach for when basic lifetime annotations are not enough: variance, phantom data, covariance, contravariance, and writing zero-cost abstractions that the compiler can fully optimize away. These are the tools that library authors use to build safe, expressive APIs.

References

Written by:

641 Posts

View All Posts
Follow Me :
How to whitelist website on AdBlocker?

How to whitelist website on AdBlocker?

  1. 1 Click on the AdBlock Plus icon on the top right corner of your browser
  2. 2 Click on "Enabled on this site" from the AdBlock Plus option
  3. 3 Refresh the page and start browsing the site