The problem ownership solves
Every program that allocates memory on the heap must eventually free it. In languages with manual memory management (C, C++), the programmer is responsible. Two categories of bugs are endemic to this model:
- Use-after-free: Memory is freed while a pointer still references it. The pointer becomes a dangling pointer — it points to memory that may now hold something else. Dereferencing it is undefined behavior.
- Double-free: Memory is freed twice. The allocator's internal state is corrupted. The program may crash or, worse, become exploitable.
Languages with garbage collection (Java, Python, Go) solve this by tracking references at runtime and freeing memory automatically. The trade-off is runtime overhead — GC pauses, additional memory for the GC bookkeeping, and non-deterministic timing of memory reclamation.
Rust takes a third path: the ownership system. A set of rules, enforced entirely at compile time, that proves memory is always freed exactly once and all references are always valid — without any runtime overhead whatsoever. The proof happens before the program runs. If the code compiles, it is memory-safe.
The cost is learning a new mental model. This article builds that model from the ground up.
Stack and heap — a quick recap
Rust's ownership rules are designed around two memory regions:
The stack stores values whose size is known at compile time. Allocation and deallocation are almost free — it is just a pointer increment or decrement. When a function returns, all of its stack-allocated variables disappear automatically. Stack variables are fast, but they cannot grow dynamically.
The heap stores values whose size is not known at compile time or may change at runtime. Allocation requires a system call. The allocator finds an appropriately-sized free block, reserves it, and returns a pointer. Deallocation must be explicitly requested — and this is exactly where the bugs live.
In Rust, types like i32, f64, bool, and tuples of fixed-size types live entirely on the stack. Types like String, Vec<T>, and Box<T> keep their metadata on the stack (a pointer, a length, and a capacity) but their actual content lives on the heap. Ownership governs when heap memory is freed.
The three ownership rules
Rust's entire ownership system flows from three rules:
- Every value in Rust has a single owner — a variable that owns the value.
- There can only be one owner at a time.
- When the owner goes out of scope, the value is dropped (memory is freed).
That is the whole model. Every other concept in ownership — moves, borrows, lifetimes — is a consequence of these three rules.
fn main() {
// s1 is the owner of the String "hello"
let s1 = String::from("hello");
// s1 still owns "hello" — we can use it
println!("{s1}");
} // s1 goes out of scope here — Rust automatically calls drop(s1)
// The heap memory for "hello" is freed
The drop call at the end of scope is not something you write — the compiler inserts it automatically. This is deterministic, predictable memory reclamation. The memory is always freed at the closing brace of the scope that owns it, not whenever a GC decides to run.
Scope and the drop function
A value's owner is always the innermost scope in which it was created. When that scope ends, the owner is dropped. Nested scopes have their own drop points:
fn main() {
let outer = String::from("outer"); // outer's scope begins
{
let inner = String::from("inner"); // inner's scope begins
println!("inner: {inner}");
} // inner drops here — heap memory freed immediately
println!("outer: {outer}"); // outer is still valid here
} // outer drops here
You can also call drop() explicitly to free a resource before the end of scope — this is useful for releasing lock guards, closing file handles, or freeing large allocations early:
fn main() {
let large_buffer = Vec::<u8>::with_capacity(1_000_000);
// ... do work with large_buffer ...
drop(large_buffer); // explicitly free 1 MB now rather than at end of function
// large_buffer is gone — using it here is a compile error
// println!("{}", large_buffer.len()); // error: use of moved value
// ... continue with other work that benefits from the freed memory ...
}
drop() on a value more than once — the compiler prevents it. Rule 3 guarantees every value is freed exactly once.Move semantics
Rule 2 says there can only be one owner at a time. When you assign a heap-allocated value to another variable, ownership moves — the original variable is no longer valid:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // ownership moves from s1 to s2
// s1 is now invalid — the compiler will reject this:
println!("{s1}"); // error[E0382]: borrow of moved value: `s1`
}
This is not a copy operation — the heap data is not duplicated. Only the ownership metadata (pointer, length, capacity) moves from s1's stack slot to s2's stack slot. The heap data stays where it is. The old stack slot (s1) is marked as invalid by the compiler, preventing a double-free when both would otherwise be dropped.
Moves happen in every context where a value is transferred: assignment, passing to a function, returning from a function:
fn take_ownership(s: String) {
println!("Got: {s}");
} // s drops here — heap memory freed
fn give_ownership() -> String {
let s = String::from("mine");
s // move out of the function — caller becomes owner
}
fn main() {
let s1 = String::from("hello");
take_ownership(s1); // s1 moves into the function
// s1 is invalid here — you can't use it after this line
let s2 = give_ownership(); // s2 becomes owner of the returned String
println!("{s2}");
}
// This works but is awkward — you have to return s to "give it back"
fn calculate_length(s: String) -> (String, usize) {
let len = s.len();
(s, len) // return s alongside the result to transfer ownership back
}
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("'{s2}' has length {len}");
}
// This pattern is why references (borrowing) exist — see next section
Copy types: the exception
Types that live entirely on the stack and have a fixed size implement the Copy trait. For these types, assignment creates an independent copy — both the original and the new variable are valid. There is no ownership transfer:
fn main() {
let x = 42; // i32 implements Copy
let y = x; // y gets a copy — x is still valid
println!("{x} {y}"); // 42 42 — both are usable
let a = true;
let b = a; // bool implements Copy — a is still valid
let p = (1.0_f64, 2.0_f64);
let q = p; // (f64, f64) implements Copy — p is still valid
}
Types that implement Copy include all integer types (i8 through i128, u8 through u128), usize, isize, floating-point types (f32, f64), bool, char, shared references (&T), and tuples or arrays where every element is Copy.
Types that do not implement Copy are anything that manages heap memory: String, Vec<T>, Box<T>, HashMap, and any custom struct or enum that contains non-Copy fields.
// A struct can derive Copy if ALL its fields are Copy
#[derive(Debug, Clone, Copy)]
struct Point {
x: f64,
y: f64,
}
fn main() {
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = p1; // copied — p1 is still valid
println!("{p1:?} {p2:?}");
}
Clone: explicit deep copy
When you genuinely need two independent copies of a heap-allocated value, call .clone(). This explicitly creates a full deep copy — the heap data is duplicated — and makes it clear in the code that an allocation is happening:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // deep copy — s2 has its own heap allocation
println!("s1 = {s1}, s2 = {s2}"); // both are valid
let v1 = vec![1, 2, 3];
let v2 = v1.clone(); // duplicates the Vec's heap-allocated content
println!("{v1:?} {v2:?}");
}
Borrowing with shared references
Passing ownership into a function and then returning it to get it back is awkward. The better solution is borrowing — passing a reference to a value without transferring ownership. The function uses the value, but ownership stays with the original variable:
// &String means "a reference to a String" — no ownership transfer
fn calculate_length(s: &String) -> usize {
s.len()
} // s (the reference) goes out of scope, but the String it points to is not dropped
// because this function never owned it
fn main() {
let s1 = String::from("hello");
// & creates a reference — s1 still owns the String
let len = calculate_length(&s1);
println!("'{s1}' has length {len}"); // s1 is still valid
}
A shared reference (&T) is read-only by default. The borrower can read the value but cannot modify it. Multiple shared references can coexist simultaneously — sharing read access is always safe:
fn main() {
let s = String::from("hello");
// Multiple shared references at once — fine, all are read-only
let r1 = &s;
let r2 = &s;
let r3 = &s;
println!("{r1} {r2} {r3}"); // all valid simultaneously
// Cannot modify through a shared reference
// r1.push_str(" world"); // error: cannot borrow `*r1` as mutable
}
Dereferencing
The dereference operator * accesses the value a reference points to. Rust often handles dereferencing automatically through deref coercion, but it is important to know the explicit syntax:
fn main() {
let x = 5;
let r = &x;
println!("r = {}", *r); // explicit deref: *r = 5
println!("r = {r}"); // deref coercion in format strings — same result
assert_eq!(5, *r); // true
assert_eq!(x, *r); // true
}
Mutable references
A mutable reference (&mut T) grants temporary exclusive write access to a value. The variable itself must be declared mut, and you use &mut to create the reference:
fn append_world(s: &mut String) {
s.push_str(" world"); // modifying through a mutable reference is allowed
}
fn main() {
let mut s = String::from("hello"); // s must be mut to allow &mut
append_world(&mut s); // pass a mutable reference
println!("{s}"); // hello world
}
The critical restriction: while a mutable reference exists, no other references of any kind can exist to the same value. One mutable reference means exclusive access:
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // error: cannot borrow `s` as mutable more than once
println!("{r1} {r2}");
}
fn main() {
let mut s = String::from("hello");
{
let r1 = &mut s;
r1.push_str(" world");
} // r1 drops here — mutable borrow ends
let r2 = &mut s; // now r2 can borrow
r2.push_str("!");
println!("{s}"); // hello world!
}
This exclusivity constraint is exactly why Rust programs cannot have data races: a data race requires two concurrent accesses to the same memory where at least one is a write. Rust makes it impossible to have two simultaneous mutable references, so data races cannot exist — they are a compile error, not a runtime crash.
The borrow checker rules
The borrow checker is the part of the Rust compiler that enforces ownership and borrowing rules. Its job is to prove that all references are valid for as long as they are used. The complete ruleset:
- References must not outlive the value they point to (no dangling references).
- At any given moment, you may have either any number of shared references (
&T) or exactly one mutable reference (&mut T) — but not both simultaneously.
fn dangle() -> &String { // error: missing lifetime specifier
let s = String::from("hello");
&s // returning a reference to s — but s will be dropped when dangle() returns!
} // s drops here — the reference would dangle
// The compiler rejects this. The fix: return the String itself (move ownership)
fn no_dangle() -> String {
let s = String::from("hello");
s // ownership moves to the caller — no dangling reference
}
fn main() {
let mut s = String::from("hello");
let r1 = &s; // shared borrow
let r2 = &s; // another shared borrow — fine
let r3 = &mut s; // error: cannot borrow as mutable while shared borrows exist
println!("{r1} {r2} {r3}");
}
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} and {r2}");
// r1 and r2 are last used above — their borrows end here (NLL)
let r3 = &mut s; // OK — r1 and r2 are no longer active
r3.push_str(" world");
println!("{r3}");
}
The Non-Lexical Lifetimes (NLL) feature (stable since Rust 2018) means a borrow ends at its last use, not at the end of the enclosing block. This makes the borrow checker significantly more ergonomic — you rarely need to add extra scopes to manage borrow boundaries.
Lifetimes: annotating reference validity
Lifetimes are compile-time annotations that describe the relationship between the validity of references. The compiler infers them in most situations through lifetime elision rules, but when the compiler cannot determine them, you annotate them explicitly with 'a syntax.
The most common case that requires explicit lifetimes is a function that takes multiple references and returns a reference — the compiler needs to know which input reference the output is tied to:
// Without lifetime annotation — compiler cannot determine which input
// the returned reference is valid for:
// fn longest(x: &str, y: &str) -> &str { ... } // error: missing lifetime
// With lifetime annotation — 'a means "the output lives as long as
// the shorter of x and y"
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() >= y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xy");
result = longest(s1.as_str(), s2.as_str());
println!("Longest: {result}"); // OK — result used before s2 drops
}
// println!("{result}"); // error if here — s2 already dropped
}
// A struct holding a reference must annotate the lifetime
// because the struct cannot outlive the data it references
struct Excerpt<'a> {
text: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
// Excerpt borrows from novel — its lifetime is tied to novel's
let excerpt = Excerpt { text: first_sentence };
println!("{}", excerpt.text);
} // novel and excerpt both drop here — safe
Slice references
A slice is a reference to a contiguous sequence of elements in a collection. Slices are always references — they do not own the data, they borrow it:
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return &s[0..i]; // return a slice of the original string
}
}
&s[..] // entire string if no space found
}
fn main() {
let s = String::from("hello world");
let word = first_word(&s); // word borrows from s
println!("{word}"); // hello
// The borrow checker protects the slice:
// s.clear(); // error: cannot mutate s while word borrows from it
// println!("{word}"); // word would be a dangling reference
}
// Vec slices work the same way
fn sum_first_half(data: &[i32]) -> i32 {
let half = data.len() / 2;
data[..half].iter().sum()
}
Using &str (a string slice) instead of &String as function parameters is idiomatic Rust — it is more flexible because it accepts both &String values (via deref coercion) and string literals (&'static str).
Ownership patterns in practice
With the full model in hand, here are the patterns you will use most frequently when writing real Rust code:
Pattern 1 — read-only access: pass &T
fn print_stats(data: &[f64]) {
let sum: f64 = data.iter().sum();
println!("count: {}, sum: {sum:.2}", data.len());
}
fn main() {
let prices = vec![12.5, 8.0, 34.75];
print_stats(&prices); // borrow, not move
print_stats(&prices); // can use prices again after the borrow ends
}
Pattern 2 — mutation without ownership: pass &mut T
fn normalise(data: &mut Vec<f64>) {
let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
for v in data.iter_mut() {
*v /= max;
}
}
fn main() {
let mut scores = vec![40.0, 80.0, 100.0];
normalise(&mut scores);
println!("{scores:?}"); // [0.4, 0.8, 1.0]
}
Pattern 3 — transfer ownership: pass T
fn process_and_discard(data: Vec<i32>) -> i32 {
data.iter().sum()
} // data drops here — intentional, the function consumes it
fn main() {
let nums = vec![1, 2, 3, 4, 5];
let total = process_and_discard(nums); // nums moved — no longer usable here
println!("total: {total}");
}
The choice between these three patterns comes down to what the function needs to do:
| Need | Parameter type | Caller retains ownership? |
|---|---|---|
| Read the value | &T or &[T] | Yes |
| Modify the value | &mut T | Yes |
| Consume the value | T | No — value moved in |
| Store a copy cheaply | T where T: Copy | Yes (copy happens implicitly) |
| Store a full duplicate | T with .clone() at call site | Yes — caller keeps original |
When in doubt, start with &T. If you need to modify the value, change to &mut T. Only take ownership (T) when the function genuinely needs to own the data — for example, when it will store the value in a struct or move it into a thread.