Step 04 of 17

References & borrowing

Lend values with & and &mut, follow the borrowing rules, and use slices, String and &str correctly in function signatures.

17 min 8 sections

Moving a value into every function that needs to look at it would be unbearable. Instead, you borrow it: you hand out a reference, and ownership stays where it was. This lesson covers shared and mutable references, the one rule the borrow checker enforces, why that rule prevents real bugs, and slices — including the String versus &str question every Rust beginner asks.

Shared references

A reference, written &T, points at a value without owning it. Creating one is called borrowing.

rust
fn length(s: &String) -> usize {
    s.len()
} // s goes out of scope, but it doesn't own anything, so nothing is dropped

fn main() {
    let name = String::from("Ferris");
    let n = length(&name); // lend name to the function
    println!("{name} has {n} letters"); // name is still ours
}

&name creates a reference. The function takes &String and reads through it. When the function returns, the reference disappears, and name is untouched.

You don't need to write *s to read through a reference most of the time: method calls and field access auto-dereference, so s.len() works on a &String as it would on a String. You do write * explicitly when you assign through a mutable reference or compare a reference with a plain value.

Shared references are read-only:

rust
fn add_suffix(s: &String) {
    s.push_str("!"); // error[E0596]: cannot borrow `*s` as mutable, as it is behind a `&` reference
}

Mutable references

To modify a borrowed value, take a mutable reference, &mut T. The variable itself must be declared mut too:

rust
fn add_suffix(s: &mut String) {
    s.push_str("!");
}

fn main() {
    let mut greeting = String::from("hello");
    add_suffix(&mut greeting);
    println!("{greeting}"); // hello!

    let mut count = 0;
    let r = &mut count;
    *r += 1; // explicit dereference to assign through the reference
    println!("{count}"); // 1
}

The &mut at the call site is deliberate: when you read add_suffix(&mut greeting), you know greeting may change. Code that only borrows with & can't change it.

The borrowing rule

At any moment, a value can have either:

  • any number of shared references (&T), or
  • exactly one mutable reference (&mut T),

but not both. And every reference must be valid: it can't outlive the value it points to.

Put shortly: aliasing XOR mutation. Many readers or one writer, never a writer alongside anyone else.

rust
fn main() {
    let mut scores = vec![10, 20, 30];
    let first = &scores[0];   // shared borrow of scores
    scores.push(40);          // needs &mut scores
    println!("{first}");      // shared borrow still in use
}
text
error[E0502]: cannot borrow `scores` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let first = &scores[0];
  |                  ------ immutable borrow occurs here
4 |     scores.push(40);
  |     ^^^^^^^^^^^^^^^ mutable borrow occurs here
5 |     println!("{first}");
  |               ------- immutable borrow later used here

This isn't the compiler being fussy. push may need more capacity, in which case Vec allocates a new buffer, copies the elements and frees the old one. first would then point into freed memory. In C++ this exact bug (iterator invalidation) is a classic source of crashes and exploits. Rust rejects it at compile time.

The same rule prevents data races: a data race needs two accesses to the same memory with at least one write, and "one writer xor many readers" forbids exactly that. You'll see how this extends to threads in the concurrency lesson.

Borrows end at their last use

A reference's borrow lasts until the reference is last used, not until the end of the block. This is called non-lexical lifetimes, and it's why the following compiles:

rust
fn main() {
    let mut scores = vec![10, 20, 30];
    let first = &scores[0];
    println!("{first}");  // last use of first: the shared borrow ends here
    scores.push(40);      // fine: no other borrow is live
    println!("{scores:?}");
}

Often, fixing a borrow error just means reordering: finish reading before you start writing, or copy the value out (let first = scores[0]; copies the i32, so no borrow remains at all).

References can't dangle

rust
fn dangle() -> &String {
    let s = String::from("temporary");
    &s
} // s is dropped here, so the reference would point at freed memory

This doesn't compile; the compiler reports a missing lifetime specifier and suggests returning the String itself. The fix is to return an owned value and let ownership move to the caller:

rust
fn no_dangle() -> String {
    String::from("temporary")
}

How the compiler reasons about how long references live is the subject of the lifetimes lesson. For now: a reference can never outlive its owner.

Slices

A slice is a reference to a contiguous part of a collection. It's a "fat pointer": a pointer plus a length. &[T] is a slice of any array or vector of T:

rust
fn sum(values: &[i32]) -> i32 {
    let mut total = 0;
    for v in values {
        total += v;
    }
    total
}

fn main() {
    let array = [1, 2, 3, 4, 5];
    let vector = vec![10, 20, 30];
    println!("{}", sum(&array));       // whole array
    println!("{}", sum(&vector));      // whole Vec: &Vec<i32> coerces to &[i32]
    println!("{}", sum(&array[1..4])); // 2 + 3 + 4 = 9
}

Range syntax picks the part: &v[1..4] is indexes 1, 2, 3; &v[..2] is the first two; &v[2..] is from index 2 to the end. Out-of-range slices panic, like indexing.

Taking &[T] instead of &Vec<T> makes a function more useful: it accepts arrays, vectors and sub-ranges of either, at no cost.

String and &str

Rust has two main string types, and the difference is ownership.

String&str
WhatAn owned, growable UTF-8 buffer on the heapA borrowed slice of UTF-8 bytes
Owns its dataYes, freed on dropNo, points into someone else's data
Can growYes (push_str, push)No
Created byString::from("…"), "…".to_string(), format!String literals, &my_string, &my_string[a..b]

A string literal like "hello" is a &'static str: a slice pointing into the program binary, valid for the whole run.

Take &str in function parameters unless you need to own or modify the string. A &String coerces to &str automatically (this is called deref coercion), so callers can pass either:

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

fn main() {
    let owned = String::from("hello world");
    println!("{}", first_word(&owned));       // &String -> &str
    println!("{}", first_word("rust lang"));  // literal is already &str
}

Return String when you build a new string; you can't return a reference to something you created inside the function.

Because strings are UTF-8, a character can take one to four bytes. That's why s[0] doesn't compile: indexing by byte could split a character. Slice by byte ranges you know are on character boundaries (like those find returns), or iterate with s.chars():

rust
fn main() {
    let s = "héllo";
    println!("{}", s.len());             // 6 bytes: é is two bytes
    println!("{}", s.chars().count());   // 5 characters
    println!("{:?}", s.chars().nth(1));  // Some('é')
    // &s[0..2] would panic: byte 2 is in the middle of 'é'
}

Practice

  1. Write fn longest_word(text: &str) -> &str that returns the longest whitespace-separated word (use text.split_whitespace()). If two words tie, return the first. Call it with both a literal and a &String.
  2. Write fn normalize(names: &mut Vec<String>) that trims each name and converts it to lower case in place, then removes empty entries (look at Vec::retain).
  3. Explain why this doesn't compile, then fix it two different ways:
rust
fn main() {
    let mut names = vec![String::from("ana")];
    let first = &names[0];
    names.push(String::from("bo"));
    println!("{first}");
}
Show solution
rust
fn longest_word(text: &str) -> &str {
    let mut best = "";
    for word in text.split_whitespace() {
        if word.len() > best.len() {
            best = word;
        }
    }
    best
}

fn main() {
    let owned = String::from("the quick brown fox");
    println!("{}", longest_word(&owned));          // quick
    println!("{}", longest_word("borrow checker")); // checker
}

The returned &str points into the input, which is why the function can return a reference without owning anything. len() counts bytes; for non-ASCII text, compare chars().count() instead.

rust
fn normalize(names: &mut Vec<String>) {
    for name in names.iter_mut() {
        *name = name.trim().to_lowercase();
    }
    names.retain(|n| !n.is_empty());
}

iter_mut yields &mut String, so *name = … replaces each string in place. retain keeps elements for which the closure returns true.

  1. first is a shared borrow of names, and it's used after push, which needs a mutable borrow. push can reallocate the buffer, leaving first dangling.

Fix A: finish using first before pushing.

rust
let first = &names[0];
println!("{first}");
names.push(String::from("bo"));

Fix B: take an owned copy, so no borrow remains.

rust
let first = names[0].clone();
names.push(String::from("bo"));
println!("{first}");

Key takeaways

  • &T borrows a value read-only; &mut T borrows it with permission to change it. Ownership doesn't move.
  • The rule: many shared references or one mutable reference, never both at once. It rules out iterator invalidation and data races.
  • A borrow lasts until its last use, so reordering code often fixes borrow errors.
  • References can never outlive what they point to; return owned values from functions that create them.
  • Take &str and &[T] in parameters; return String and Vec<T> when you build something new.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Which signature is the most flexible for a function that only reads a list of numbers?

  2. 2.Why does Rust reject holding &v[0] across a call to v.push(…)?

  3. 3.let s = String::from("día"); What is s.len()?

  4. 4.let mut x = 5; let a = &x; println!("{a}"); let b = &mut x; *b += 1; — does it compile?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.