Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 006 / 100

Borrowing and references: a hands-on guide for engineers

Use values without taking ownership.

Expona Admin7 min read

Introduction

Ownership is Rust's most famous feature, but borrowing is what makes it practical. Without the ability to borrow, every function that reads data would force you to move it, leaving the caller unable to use the value afterward. This guide on borrowing and references: a hands-on guide for engineers covers shared references, mutable references, and the fundamental rule that keeps Rust's memory safety guarantees intact—rules we'll apply to real production scenarios you've likely encountered in e-commerce, APIs, and data processing pipelines.

Shared references — &T — many readers allowed

A shared reference, written as &T, lets you read a value without taking ownership. Multiple shared references can exist at the same time, making them ideal when you need to pass data to several functions or threads without moving it around.

Think of shared references like handing out photocopies of a document. Everyone gets a copy, everyone can read it, but no one can mark it up. The original owner keeps the document.

Here's the canonical example:

rust
fn len(s: &String) -> usize {
    s.len()
}

fn main() {
    let name = String::from("Expona");
    let n = len(&name);
    println!("Length: {}", n);
    println!("Name: {}", name); // still usable
}

The function len takes a reference &String instead of ownership. Inside main, we call it with &name—the & operator creates a reference. After the call, name is still valid and we can use it again.

In a real service, this pattern appears everywhere:

rust
// In a CRM system, fetch customer details
fn validate_email(email: &str) -> bool {
    email.contains('@')
}

fn log_signup(email: &str, username: &str) {
    println!("User {} signed up with {}", username, email);
}

fn main() {
    let user_email = String::from("alice@example.com");
    let user_name = String::from("alice");
    
    if validate_email(&user_email) {
        log_signup(&user_email, &user_name);
        // Both strings still owned by main, can continue using them
    }
}

Because both functions borrow instead of take ownership, main retains control over the data. No cloning needed.

Mutable references — &mut T — exactly one writer at a time

A mutable reference, written as &mut T, lets you modify the borrowed value. But there's a strict rule: only one mutable reference can exist at a time for a given value. This prevents data races and unexpected mutations.

Imagine a shared spreadsheet. One person can edit it exclusively at a time. While they're editing, no one else—not even readers—can see the intermediate states.

Basic example:

rust
fn increment(counter: &mut i32) {
    *counter += 1; // dereference and mutate
}

fn main() {
    let mut count = 0;
    increment(&mut count);
    println!("Count: {}", count); // 1
}

The &mut count borrows count mutably. Inside increment, we use the dereference operator * to access and modify the value. After the borrow ends, main regains exclusive access.

In a trading system, mutable borrows are essential for safe state updates:

rust
struct Portfolio {
    cash: f64,
    shares: i32,
}

fn buy(portfolio: &mut Portfolio, share_price: f64) {
    if portfolio.cash >= share_price {
        portfolio.cash -= share_price;
        portfolio.shares += 1;
    }
}

fn main() {
    let mut my_portfolio = Portfolio { cash: 1000.0, shares: 0 };
    buy(&mut my_portfolio, 250.0);
    println!("Cash: {}, Shares: {}", my_portfolio.cash, my_portfolio.shares);
}

The &mut my_portfolio borrow ensures only buy modifies the portfolio during the call. No concurrent mutations can happen accidentally.

The rule — Either many readers or one writer, never both

This is Rust's core borrowing rule, enforced at compile time. At any point in your program, you can have either:

  • Many immutable (shared) references to a value, or
  • Exactly one mutable reference to a value

You cannot have both at the same time. This eliminates entire classes of bugs—use-after-free, data races, iterator invalidation—without runtime overhead.

The compiler tracks the scope of each reference. Once a reference is no longer used, its borrow ends and the value becomes available again.

Here's where the rule prevents bugs:

rust
fn main() {
    let mut data = vec![1, 2, 3];
    
    let r1 = &data;     // immutable borrow
    let r2 = &data;     // another immutable borrow, OK
    println!("{}, {}", r1, r2);
    
    // r1 and r2 are no longer used after the println
    let w = &mut data;  // mutable borrow, OK now
    w.push(4);
    println!("{:?}", w);
}

If you tried to use r1 or r2 after the mutable borrow began, the compiler would reject it. Borrows have scopes, and the compiler is precise about them.

Compare to a violation:

rust
// This does NOT compile
fn main() {
    let mut data = vec![1, 2, 3];
    
    let r1 = &data;
    let w = &mut data;  // ERROR: cannot borrow as mutable
    println!("{}", r1);  // r1 still in use
}

The compiler sees that r1 is in scope and might be used later, so it forbids the mutable borrow. This is a compile-time check, not a runtime panic.

Example — Borrow instead of moving

Let's build a more realistic scenario: processing customer orders in an e-commerce system.

rust
struct Order {
    id: u64,
    customer_name: String,
    amount: f64,
}

fn process_payment(order: &Order) -> bool {
    println!("Processing payment for {} (${:.2})", order.customer_name, order.amount);
    order.amount > 0.0
}

fn send_confirmation_email(order: &Order) {
    println!("Email sent to {}", order.customer_name);
}

fn log_order(order: &Order) {
    println!("Logged order {}", order.id);
}

fn main() {
    let order = Order {
        id: 12345,
        customer_name: String::from("Alice"),
        amount: 99.99,
    };
    
    if process_payment(&order) {
        send_confirmation_email(&order);
        log_order(&order);
    }
    
    // order is still ours and valid
    println!("Final order: {} - {}", order.id, order.customer_name);
}

Without borrowing, you'd have to clone or move the order around, losing ownership and requiring defensive copies. With shared references, every function borrows what it needs and returns control. The business logic stays clean.

Common mistakes

Forgetting the & operator: Many engineers reach for values directly and then wonder why the borrow checker complains. Remember: if a function expects &T, you pass &value.

rust
fn takes_ref(x: &i32) {}

fn main() {
    let num = 42;
    takes_ref(num);  // ERROR: expected &i32, found i32
    takes_ref(&num); // Correct
}

Mixing mutable and immutable borrows carelessly: If you hold an immutable borrow, you cannot create a mutable one in the same scope before the immutable one is dropped.

rust
fn main() {
    let mut x = 5;
    let r1 = &x;
    let r2 = &mut x;  // ERROR: cannot borrow as mutable while immutable borrow is active
}

Assuming dereference is automatic: When you have a mutable reference and need to modify the value it points to, use * to dereference. Rust won't do it for you in all cases.

rust
fn increment(x: &mut i32) {
    *x += 1;  // necessary dereference
}

Over-borrowing in loops: Creating a borrow at the start of a loop scope can unexpectedly extend its lifetime. Be explicit about scopes if needed.

rust
fn main() {
    let mut list = vec![1, 2, 3];
    
    for _ in 0..2 {
        let r = &list;
        println!("{:?}", r);
        // r's borrow ends here, within the loop iteration
    }
    
    list.push(4); // OK, no active borrows
}

Key takeaways

  • Shared references &T let multiple readers access data without moving ownership.
  • Mutable references &mut T allow one writer at a time, preventing data races and unexpected mutations.
  • The borrowing rule—either many immutable or one mutable borrow, never both—is enforced at compile time and eliminates entire categories of bugs.
  • Borrows are scoped. Once a reference is no longer used, the borrow ends and the value becomes available again.
  • Always pass references when you don't need ownership; this keeps APIs flexible and data flowing through your codebase without unnecessary moves.
  • In production systems (CRM, trading, e-commerce), borrowing replaces manual lifetime management and defensive cloning with compiler-verified safety.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles