$elementary.dev
[Courses][About]|
$elementary.dev

Text-first, project-driven courses for developers who want to level up by building real things.

CoursesAbout
Stay Updated

Get notified about new courses and articles.

// No spam, unsubscribe anytime

© 2026 elementary.dev
PrivacyTerms
// Course

Elementary Rust

6/11
  • 01Hello, Rust
  • 02Variables and Types
  • 03Functions and Control Flow
  • 04Why Memory Management Matters
  • 05Ownership: Rust's Solution
  • 06Borrowing and References
  • 07Slices
  • 08Structs
  • 09Enums and Pattern Matching
  • 10Organizing Rust Projects
  • 11Collections
courses/elementary-rust/06-borrowing

Borrowing and References

Use values without taking ownership through Rust's borrowing system.

~25 min|#Lesson 6/11

Borrowing and References

In the last lesson, we learned about ownership. Every value has one owner, and when ownership moves, the original variable becomes invalid. That works, but it creates a problem.

The Tedium of Ownership

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)  // Have to return s to use it again!
}

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("The length of '{}' is {}.", s2, len);
}

Look at that function signature. We take a String, do something with it, then have to return both the result AND the original string in a tuple. Otherwise s1 would be gone after the function call.

This is tedious. Imagine needing to pass a string through five functions - you'd be juggling tuples everywhere.

// Tip-This Pattern is a Code Smell

If you find yourself returning ownership just to use a value again, you're fighting the language. References are the idiomatic solution.

References to the Rescue

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);  // Pass a reference
    println!("The length of '{s1}' is {len}."); // s1 still valid!
}

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

The & symbol creates a reference to a value. Think of it as pointing to the data without owning it. The function borrows the value, uses it, and when the function ends, the reference goes away - but the original value remains with its original owner.

Here's what's happening under the hood:

Stack                                 Heap
+-------------+    +-------------+    +-------------+
| s (ref)     |    | s1 (owner)  |    |             |
| ptr --------+--->| ptr --------+--->| "hello"     |
+-------------+    | len: 5      |    +-------------+
                   | cap: 5      |
                   +-------------+

The reference s points to s1, which points to the actual data. When s goes out of scope, nothing happens to the data because s doesn't own it.

// Info-References vs Pointers

A reference is like a pointer, but with a guarantee: it will always point to valid data for its entire lifetime. No null pointers. No dangling pointers. Rust's compiler enforces this.

Understanding Borrowing

Creating a reference is called borrowing. The analogy is perfect: if I borrow your book, I don't own it. I have to give it back. While I'm reading it, you still own it, but you can't exactly use it yourself.

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("'{s1}' has length {len}");
    // s1 is still valid here - we only borrowed it
}

fn calculate_length(s: &String) -> usize {
    s.len()
} // s goes out of scope, but since it doesn't own what it refers to,
  // nothing happens

Notice we didn't need to return the string. The reference disappeared, the original stayed. Clean.

// Challenge

Reference Syntax

What symbol creates an immutable reference to a variable x?

$

References Are Immutable By Default

Just as variables are immutable by default, so are references.

fn change(s: &String) {
    s.push_str(", world");  // ERROR! Can't modify borrowed value
}

The compiler stops you cold:

error[E0596]: cannot borrow `*s` as mutable, as it is behind a `&` reference

This makes sense. We're not allowed to modify something we have a reference to.

// Tip-Dereferencing

The opposite of referencing with & is dereferencing with *. You'll see this when working with references directly.

Mutable References

Sometimes you DO need to modify borrowed data. That's where mutable references come in.

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
    println!("{s}");  // prints "hello, world"
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

Two things had to change:

  1. The original variable must be mut - you can't create a mutable reference to an immutable value
  2. We use &mut s instead of &s to create the mutable reference
// Warning-Mutable Data Requires Mutable Binding

A common mistake: trying &mut x when x isn't declared with mut. The original variable must be mutable if you want to modify it through a reference.

The Borrowing Rules

Now we get to the heart of Rust's safety guarantees. There are exactly two rules for references:

  1. You can have ONE mutable reference OR any number of immutable references (not both)
  2. References must always be valid (no dangling)

Let's see rule #1 in action:

let mut s = String::from("hello");

let r1 = &s;     // ok - immutable reference
let r2 = &s;     // ok - another immutable reference
let r3 = &mut s; // ERROR! Can't have mutable while immutable exist

println!("{}, {}, and {}", r1, r2, r3);

The compiler complains:

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

Multiple readers? Fine. One writer? Fine. Readers AND a writer at the same time? Absolutely not.

// Info-Why This Rule Exists

This rule prevents conflicts where one part of your code is reading data while another part is modifying it. The compiler catches these at compile time instead of leaving them as runtime surprises.

Reference Scope: It's About Usage, Not Blocks

Here's a subtlety that trips people up. A reference's scope lasts from where it's introduced to where it's last used - not to the end of the block.

let mut s = String::from("hello");

let r1 = &s;      // r1 scope starts
let r2 = &s;      // r2 scope starts
println!("{r1} and {r2}");
// r1 and r2 are never used after this point

let r3 = &mut s;  // ok! r1 and r2 scopes ended
println!("{r3}");

This compiles because r1 and r2 aren't used after the first println!. The compiler is smart enough to see that their scopes end there, making room for the mutable reference.

// Tip-When Confused, Follow the Last Use

If the borrow checker rejects your code, trace where each reference is last used. Often, restructuring your code so references don't overlap fixes the issue.

Why These Rules Matter

Let's see what could go wrong without these rules. Imagine we could have a mutable and immutable reference at the same time:

// Hypothetical dangerous code (doesn't compile)
let mut data = vec![1, 2, 3];

let first = &data[0];    // Points to the first element
data.push(4);            // This might reallocate the vector!
println!("{first}");     // first might point to freed memory!

If push causes the vector to reallocate, the memory where first points gets freed. first would become a dangling pointer. Accessing it would be undefined behavior - maybe a crash, maybe garbage data, maybe something worse.

Rust's rules make this impossible:

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

The compiler catches this at compile time.

Dangling References

Rule #2 says references must always be valid. Rust prevents dangling references at compile time.

fn dangle() -> &String {  // Returns a reference to a String
    let s = String::from("hello");
    &s  // Return a reference to s
}   // s goes out of scope and is dropped. Danger!

The compiler catches this:

error[E0106]: missing lifetime specifier
  --> src/main.rs:1:16
   |
1  | fn dangle() -> &String {
   |                ^ expected named lifetime parameter

The string s is created inside the function. When the function ends, s is dropped. If we could return a reference to it, that reference would point to freed memory.

The solution? Return the value itself, transferring ownership:

fn no_dangle() -> String {
    let s = String::from("hello");
    s  // Ownership moves to caller
}
// Info-Lifetimes Explained Later

The error message mentioned "lifetime specifier." Lifetimes are Rust's way of tracking how long references are valid. We'll cover them in a future lesson. For now, just know that the compiler uses lifetimes to enforce Rule #2.

Summary: The Complete Picture

Borrowing and references are how you use values without taking ownership. Here are the key points:

Creating References:

  • &x creates an immutable reference to x
  • &mut x creates a mutable reference to x (requires x to be mut)

The Two Rules:

  1. At any time, you can have ONE mutable reference OR any number of immutable references
  2. References must always be valid

Why It Matters:

  • Prevents conflicts between reading and writing at compile time
  • Prevents dangling pointers at compile time
// Challenge

Borrow Rules

Can you have a mutable reference while immutable references exist?

$

The borrow checker might feel strict at first. But over time, you'll realize it's catching real bugs before your code even runs.

In the next lesson, we'll look at slices - a special kind of reference that lets you reference a portion of a collection rather than the whole thing.

<-[prev]Ownership: Rust's Solution[next]->Slices
Progress: 0%
// On this page
  • The Tedium of Ownership
  • References to the Rescue
  • Understanding Borrowing
  • References Are Immutable By Default
  • Mutable References
  • The Borrowing Rules
  • Reference Scope: It's About Usage, Not Blocks
  • Why These Rules Matter
  • Dangling References
  • Summary: The Complete Picture