When reading isn't enough to own
You are building a text processor. The tokenizer needs to scan the input string to find keywords. The parser needs to scan the same string to build a syntax tree. The linter needs to scan it again to check for style violations. If the tokenizer takes ownership of the string, the parser gets nothing. If the parser takes ownership, the linter crashes. If you copy the string three times, you waste memory and time.
Rust solves this with borrowing. An immutable reference (&T) is a read-only view of data. It lets you look at the value without taking control of it. The owner stays in charge. You can have as many immutable references as you want. You can pass them around freely. You just cannot change the data, and you cannot keep the reference alive after the owner drops the value.
Borrowing is the mechanism that makes sharing data safe and cheap. It replaces the garbage collector's work with compile-time checks. You get the flexibility of references without the runtime cost or the risk of dangling pointers.
The read-only contract
Ownership in Rust means you hold the value. You decide when it lives and when it dies. Borrowing means someone else holds the value, and you are granted temporary access. An immutable reference is the most common form of borrowing.
The syntax is a single ampersand: &. When you write &value, you create a reference to value. The type becomes &T where T is the original type. The reference points to the same memory as the original. It does not copy the data. It does not move the data. It just points at it.
The compiler enforces two rules for immutable references:
- You can have multiple immutable references to the same data at the same time.
- You cannot modify the data through an immutable reference.
These rules guarantee that reading data is safe. If no one can write, no one can corrupt the data while you are reading it. The compiler tracks these rules statically. It rejects code that violates them before the program runs.
fn main() {
let owner = String::from("Rust is safe");
// Create an immutable reference. The `&` borrows the value.
let view = &owner;
// `view` points to the same data as `owner`.
println!("Owner: {owner}, View: {view}");
// `owner` is still usable. The borrow doesn't steal anything.
// You can create more references.
let another_view = &owner;
println!("Another view: {another_view}");
}
Convention aside: In Python or JavaScript, variables are references by default. Rust makes borrowing explicit. You must write & to borrow. This clarity prevents accidental aliasing. If you see &, you know the function is not taking ownership. If you see a plain type, you know ownership is moving. The syntax tells you the contract.
What happens under the hood
When you create a reference, the compiler inserts a check. It ensures the reference does not outlive the value it points to. This is called lifetime checking. The compiler tracks scopes. It knows when owner goes out of scope. It rejects any code that tries to use view after owner is dropped.
At runtime, a reference is just a memory address. It has the same size as a pointer. Creating a reference costs nothing. Passing a reference to a function costs nothing. The safety checks happen at compile time. This is the "zero-cost abstraction" promise. You get safety without runtime overhead.
The compiler also optimizes references aggressively. If it can prove a reference is only used in one place, it might eliminate the indirection entirely. You write safe code with references, and the compiler generates fast code that looks like it used values directly.
Convention aside: The community calls this "lifetime elision" when the compiler infers lifetimes automatically. You rarely need to write lifetime annotations for simple references. The compiler has rules to figure it out. Trust the compiler here. Add annotations only when the error message asks for them.
Realistic example: Slices and functions
Immutable references shine when you pass data to functions. Functions often need to read data without taking ownership. If a function takes ownership, the caller loses the data. That forces the caller to clone the data or restructure the code. Borrowing avoids this friction.
Consider a function that analyzes a string. It needs to read the string, but it doesn't need to own it. The function signature uses &str:
/// Counts the number of words in a string slice.
fn count_words(text: &str) -> usize {
// `text` is a borrowed string slice.
// We don't own `text`, so we can't drop it.
// We can read it freely.
text.split_whitespace().count()
}
fn main() {
let message = String::from("Hello, world! This is Rust.");
// Pass a reference to `count_words`.
let words = count_words(&message);
// `message` is still valid here.
println!("Words: {words}");
// You can pass the reference again.
let words_again = count_words(&message);
println!("Words again: {words_again}");
}
The function takes &str, not String. This is a key pattern in Rust. &str is a string slice. It is a borrowed view into a string. It contains a pointer and a length. It does not own the underlying buffer. Functions that take &str can accept &String, &str, or string literals. The compiler handles the conversion via deref coercion. This makes your API flexible.
Slices work the same way for arrays and vectors. &[T] is a slice of elements. It borrows a contiguous sequence from a collection.
/// Finds the maximum value in a slice of integers.
fn find_max(numbers: &[i32]) -> Option<i32> {
// `numbers` is a borrowed slice.
// We iterate over the slice without copying the data.
numbers.iter().max()
}
fn main() {
let data = vec![10, 4, 8, 2, 9];
// Borrow a slice of the vector.
let max = find_max(&data);
println!("Max: {:?}", max);
// `data` is still usable.
data.push(15);
}
Convention aside: Functions should take slices (&[T], &str) instead of owned collections (Vec<T>, String) whenever possible. Slices are more general. They accept vectors, arrays, and owned strings. They avoid unnecessary allocations. If a function only reads data, it should take a reference. If it needs to modify data, it should take a mutable reference. If it needs to own the data, it should take the value. Match the signature to the intent.
Pitfalls and the borrow checker
The borrow checker rejects code that violates the rules. The most common error involves mixing immutable and mutable references. You can have many immutable references, or one mutable reference. You cannot have both at the same time.
If you try to mutate data while an immutable reference exists, the compiler stops you with E0502 (cannot borrow as mutable because it is also borrowed as immutable).
fn main() {
let mut data = String::from("safe");
let view = &data; // Immutable borrow starts.
data.push_str(" data"); // Error: E0502.
// Cannot borrow `data` as mutable because it is also borrowed as immutable.
println!("{view}");
}
The compiler rejects this to prevent data races. If view is being read, and data changes, view might see inconsistent state. In a multi-threaded program, this is a crash waiting to happen. Rust prevents it by enforcing exclusive access for mutation.
The fix is usually to adjust scopes. Make the immutable reference end before the mutation starts.
fn main() {
let mut data = String::from("safe");
{
let view = &data;
println!("{view}");
// `view` goes out of scope here. The borrow ends.
}
data.push_str(" data"); // OK. No active immutable borrows.
println!("{data}");
}
Another pitfall is holding a reference longer than needed. Beginners sometimes create a reference and then use it across a large block of code. This extends the borrow scope. It can block other operations. Keep references short. Create them close to where you use them. Let the compiler drop them as soon as possible.
Convention aside: The community calls this "non-lexical lifetimes" or NLL. The compiler tracks the last use of a reference, not just the end of the scope. This allows more code to compile. You don't need manual scope blocks as often. Still, writing tight scopes helps readability. It signals to humans that the borrow is short-lived.
Decision: References vs ownership
Choosing between references and ownership is a core skill in Rust. The wrong choice leads to compiler errors or inefficient code. Use this decision matrix to guide your choices.
Use &T when you need to read data without taking ownership and multiple readers might exist. References are cheap and safe. They allow sharing without copying. Most functions should take &T or slices for read-only arguments.
Use &mut T when you need to modify data and you can guarantee no one else is looking at it. Mutable references provide exclusive access. They are the only way to change data in place. Use them for updates, mutations, and state changes.
Use T (ownership) when the function needs to control the lifetime of the data or move it to a new scope. Ownership transfers responsibility. Use it when the function consumes the value, stores it in a collection, or passes it to another owner.
Use Rc<T> when multiple owners need to share the same data and you don't know who will drop it last. References require a single owner. Shared ownership requires reference counting. Reach for Rc<T> only when references are impossible.
Reach for plain references before shared ownership. References are simpler and faster. Shared ownership adds complexity and runtime overhead. Start with &T. Move to Rc<T> only when the borrow checker forces you to.
Trust the borrow checker. It usually has a point. If it rejects your code, the fix is often a small adjustment to scopes or a change to take a reference instead of ownership. Fighting the compiler rarely pays off.