Borrowing and Lifetime in Rust through Examples

1. Overview

Borrowing and lifetime are two concepts that make Rust newcomers struggle the most with the compiler. This article explains borrowing (&T, &mut T) and lifetime annotation ('a) through familiar domain examples like Order and Customer. After reading, you’ll understand borrow checker error messages, know when to write lifetime annotations and when the compiler can infer them.

2. Ownership: A Quick Review

Before discussing borrowing, remember this: every value in Rust has exactly one owner. When you pass a value by value, ownership is moved.

struct Customer {
    name: String,
}

fn print_customer(customer: Customer) {
    println!("Customer: {}", customer.name);
}

fn main() {
    let customer = Customer { name: String::from("John Doe") };
    print_customer(customer);
    // println!("{}", customer.name); // error: customer has been moved
}

print_customer receives Customer by value, so customer is moved into the function. The commented line, if uncommented, will produce an error:

error[E0382]: borrow of moved value: `customer`

Borrowing was created to solve exactly this problem: let another function “borrow” data without taking ownership.

3. Immutable Borrowing: &T

Use &T when you only need to read data, not modify it.

struct Order {
    items: Vec<f64>,
}

fn calculate_total(order: &Order) -> f64 {
    order.items.iter().sum()
}

fn main() {
    let order = Order { items: vec![100.0, 250.0, 50.0] };
    let total = calculate_total(&order);

    println!("Total: {}", total);
    println!("Items: {}", order.items.len()); // order can still be used
}
Total: 400
Items: 3

calculate_total receives &Order — an immutable reference. order still belongs to main; the function only “temporarily borrows” it to read. Therefore, after the function call, main can still use order normally. You can create many &Order references simultaneously, as long as there are no &mut Order mixed in.

4. Mutable Borrowing: &mut T

When you need to modify data through a reference, use &mut T.

struct Order {
    items: Vec<f64>,
    discount_percent: f64,
}

fn apply_discount(order: &mut Order, percent: f64) {
    order.discount_percent = percent;
}

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
    apply_discount(&mut order, 10.0);

    println!("Discount: {}%", order.discount_percent);
}
Discount: 10%

Note two things: the order variable must be declared mut, and the function call must pass &mut order. Unlike &T, at any given time there can be only one &mut T, and no other &T can exist simultaneously.

5. Borrow Checker: The Rule of “One Owner, Many Readers or One Writer”

The core rule: with one piece of data, at any given time you can have either multiple &T or one &mut T, but not both mixed.

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };

    for item in &order.items {
        order.items.push(*item); // error: order.items is being borrowed immutably for the loop
    }
}

for item in &order.items creates an immutable reference that lives throughout the loop. Inside the loop, order.items.push(...) needs to borrow that same data mutably — a conflict. The compiler reports:

error[E0502]: cannot borrow `order.items` as mutable because it is
also borrowed as immutable

A common fix: collect the values to be added into a temporary Vec, then extend after the loop ends — in other words, separate the two borrows into different time periods.

Note: the borrow checker analyzes based on “actual scope of life” (non-lexical lifetimes), not just the {} block. Two borrows are still valid if their lifetimes don’t overlap, even if they’re in the same function.

6. What is Lifetime?

Lifetime is how Rust describes “how long this reference lives,” so the compiler ensures you never use a reference after the original data is dropped. Most of the time you don’t need to write lifetimes — the compiler infers them. But when a function takes multiple references and returns a reference, the compiler needs you to clarify which parameter the returned reference borrows from.

6.1. Lifetime Annotations on Functions

fn longest_name<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let customer1 = String::from("John Doe");
    let customer2 = String::from("Jane Smith longer");

    let longest = longest_name(&customer1, &customer2);
    println!("Longest name: {}", longest);
}
Longest name: Jane Smith longer

'a doesn’t create a new lifetime; it’s just a name for binding: a, b, and the return value must all live at least as long as 'a. The compiler doesn’t know at runtime whether the function returns a or b, so it forces both input and output to share the same lifetime to ensure safety in all cases.

If you write it wrong — returning a reference unrelated to the declared lifetime:

fn longest_name_broken<'a>(a: &'a str, _b: &str) -> &'a str {
    let local = String::from("temp");
    &local // error: local is dropped at end of function
}
error[E0515]: cannot return value referencing local variable `local`

6.2. Lifetime in Structs

Structs containing references also need lifetime annotations, because Rust must ensure the struct doesn’t “live longer” than the data it borrows.

struct Customer {
    name: String,
}

struct OrderSummary<'a> {
    customer: &'a Customer,
    total: f64,
}

fn build_summary<'a>(customer: &'a Customer, total: f64) -> OrderSummary<'a> {
    OrderSummary { customer, total }
}

fn main() {
    let customer = Customer { name: String::from("Alice") };
    let summary = build_summary(&customer, 500.0);

    println!("{} - total: {}", summary.customer.name, summary.total);
}

OrderSummary<'a> doesn’t own the Customer; it only holds a reference. The lifetime 'a ensures summary cannot live longer than customer. If you let customer be dropped before using summary, the compiler stops you at compile-time — no dangling pointer can slip through to runtime.

6.3. When You Don’t Need to Write Lifetime (Elision)

Rust has 3 rules for automatic lifetime inference (lifetime elision) that apply to most functions:

  1. Each reference parameter is assigned its own lifetime.
  2. If the function has exactly one input lifetime, it’s assigned to the output.
  3. If there’s &self or &mut self, the lifetime of self is assigned to the output.
fn first_word(text: &str) -> &str {
    // rule 2: only 1 input lifetime → automatically assign to output
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

This function doesn’t need 'a because there’s only one reference parameter — the compiler infers the output borrows from text. You only need to write lifetime manually when there are more than one reference inputs and the output doesn’t fit cases 2 or 3.

7. Real Example: Parser Returning a Slice

fn first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

fn main() {
    let order_note = String::from("fast delivery today");
    let word = first_word(&order_note);

    println!("First word: {}", word);
}
First word: fast

first_word doesn’t create a new String; it only returns a slice (&str) within the memory of order_note. This is a very common pattern in Rust parsers: avoid allocating more memory, just “point to” the original data. Lifetime ensures word cannot live longer than order_note.

Try writing it wrong to see how the compiler protects you:

fn first_word_broken() -> &str {
    let text = String::from("fast delivery");
    first_word(&text) // text is dropped when function ends
}
error[E0106]: missing lifetime specifier

The compiler immediately refuses to compile because there’s no way to assign a valid lifetime to the output — text will definitely die before the returned value is used at the call site.

Tests for first_word

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tra_ve_dung_tu_dau_tien() {
        let note = "fast delivery today";
        assert_eq!(first_word(note), "fast");
    }

    #[test]
    fn tra_ve_ca_cau_khi_khong_co_khoang_trang() {
        let note = "ship";
        assert_eq!(first_word(note), "ship");
    }
}

Run cargo test:

running 2 tests
test tests::tra_ve_dung_tu_dau_tien ... ok
test tests::tra_ve_ca_cau_khi_khong_co_khoang_trang ... ok

8. Common Errors

  • Returning a reference to a local variable: the variable is dropped at the end of the function, but the reference still “lives” at the call site → the compiler blocks it with a lifetime error, never letting it slip through to runtime.
  • Holding &T too long before borrowing &mut T: common when you store the result of .iter() in a variable then mutate the original struct in the same scope. Fix: narrow the scope of the immutable borrow with a {} block, or split the read/write logic into two steps.
  • Abusing clone() to dodge the borrow checker: clone() always compiles but costs data copying. Use it only when you truly need an independent copy, not to “put out fires” with borrow errors.
  • Writing unnecessary lifetime annotations: if a function has only one reference input, don’t write 'a manually — the elision rule handles it; extra annotations just clutter the code.

Note: if you find yourself adding 'static just to make code compile, stop and reconsider your design. 'static forces data to live for the entire program; it’s often a sign you’re avoiding the real ownership issue instead of solving it.

9. Conclusion

Borrowing lets you borrow data without taking ownership, with the “multiple readers or one writer” rule checked by the borrow checker at compile-time. Lifetime is the language for the compiler and you to agree on how long a reference lives, especially important when a function returns a reference or a struct holds references inside. In most cases the compiler infers lifetime through elision rules; you only need to write it manually when there are multiple reference inputs with ambiguous origins for the output.

rustborrowinglifetimeownershipmemory-safety