Ownership in Rust
Ownership is Rust's most distinctive feature — and its most powerful. It is what lets Rust guarantee memory safety without a garbage collector and without requiring you to manually call malloc and free. If you truly understand ownership, you understand why Rust works the way it does.
The Problem: Memory Management is Hard
Every program that runs needs to use memory. The difficult part is not allocating memory — it's knowing when to free it. Get it wrong and you get one of three classic bugs:
Memory leak — you forget to free memory. The program consumes more and more RAM until it crashes or is killed.
Dangling pointer — you free memory and then try to use it. The memory now holds garbage, or belongs to a different object. Reading it produces nonsense; writing to it corrupts other data.
Double free — you free the same memory twice. The allocator's internal bookkeeping gets corrupted, causing crashes or security vulnerabilities.
Languages take two broad approaches to this problem:
Garbage collection (Java, Python, Go) — a runtime periodically scans for memory that is no longer reachable and frees it automatically. Safe, but adds runtime overhead and unpredictable pauses.
Manual management (C, C++) — you call
malloc/freeornew/deleteyourself. Fast, but error-prone. The bugs above are common and often security-critical.
Rust takes a third path: ownership. Memory is managed by a set of compile-time rules that the compiler checks before your program ever runs. No garbage collector. No manual free. No runtime overhead.
The Three Rules of Ownership
The entire ownership system rests on three rules:
Each value in Rust has exactly one owner — a variable that is responsible for the value.
There can only be one owner at a time — you cannot have two variables both owning the same value.
When the owner goes out of scope, the value is dropped — its memory is freed automatically.
Rule 3 in Action: Automatic drop at End of Scope
When a variable's scope ends — the closing } — Rust automatically calls a special function called
drop on it. drop frees any heap memory the value owns. You never have to do this yourself:
{
let s = String::from("hello"); // s comes into scope
// s is the owner of the heap buffer "hello"
// do stuff with s...
println!("{}", s);
} // s goes out of scope here
// Rust calls drop(s) automatically
// the heap memory for "hello" is freed
// no malloc/free, no garbage collectorStack vs Heap
To understand ownership, you need to understand where data lives:
Property | Stack | Heap |
|---|---|---|
Size at compile time | Must be known | Can grow at runtime |
Allocation speed | Instant — just move the stack pointer | Slower — the allocator must find free space |
Example types |
|
|
Who frees it | Automatic when function returns | The owner, via |
Simple types like i32 live entirely on the stack. Their size is fixed and known at compile time, so allocating and freeing them is trivial. Complex types like String store a small header on the stack (a pointer, a length, and a capacity) but the actual string data lives on the heap, where it can grow.
// lives entirely on the stack — 4 bytes, fixed size
let x: i32 = 5;
// stack header (pointer + length + capacity) points to heap data
let s = String::from("hello");
// ^--- heap: h e l l oMove Semantics: One Owner at a Time
When you assign one String to another, Rust does not copy the heap data. Instead, it moves the ownership from the original variable to the new one — and invalidates the original:
let s1 = String::from("hello");
let s2 = s1; // s1 is MOVED into s2
println!("{}", s2); // OK — s2 is the new owner
println!("{}", s1); // COMPILE ERROR — s1 was movedOnly the stack header is copied — the heap buffer stays in place and s2's header now points to it. s1's header is no longer valid. This might look like a shallow copy, but because s1 is invalidated, Rust calls it a move.
Why Move Prevents Double-Free Bugs
Imagine if both s1 and s2 were valid and both pointed to the same heap buffer. When s1 goes out of scope, Rust would call drop on it, freeing the buffer. When s2 later goes out of scope, Rust would call drop again — freeing already-freed memory. That is a double-free bug.
By invalidating s1 the moment the move happens, Rust ensures only s2 will call drop on the buffer. One owner, one drop, one free. The compiler enforces this at zero runtime cost.
Ownership and Functions
Passing a value to a function moves it into the function, exactly as if you had assigned it to a variable. The caller can no longer use the value after the call:
fn takes_ownership(s: String) {
println!("{}", s);
} // s goes out of scope and drop is called — heap memory freed
fn main() {
let my_string = String::from("world");
takes_ownership(my_string); // my_string is moved into the function
// println!("{}", my_string); // COMPILE ERROR — moved!
}Compare that with an i32, which implements Copy. The function receives a copy of the integer, and the original variable in the caller remains valid:
fn makes_copy(n: i32) {
println!("{}", n);
} // n is dropped here, but this doesn't affect the caller's copy
fn main() {
let x = 5;
makes_copy(x); // x is COPIED into the function
println!("x = {}", x); // x is still valid — no move happened
}Returning Ownership from Functions
A function can transfer ownership back to the caller by returning a value. The caller becomes the new owner:
fn gives_ownership() -> String {
let s = String::from("from function");
s // ownership moves out to the caller
}
fn main() {
let received = gives_ownership(); // ownership transferred here
println!("{}", received);
} // received is dropped hereYou can also take ownership of a value, transform it, and return the (possibly modified) ownership back to the caller:
fn add_world(mut s: String) -> String {
s.push_str(", world");
s // move ownership back to caller
}
fn main() {
let s1 = String::from("hello");
let s2 = add_world(s1); // s1 moved in, result moved to s2
println!("{}", s2);
}The Ergonomics Problem
Moving ownership into a function and returning it back is correct — but verbose and cumbersome. Imagine needing to do this for every function call:
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length) // return the String AND the length so the caller gets ownership back
}
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}.", s2, len);
}This works, but returning a tuple just to give ownership back is ugly. What if you had five parameters? The tuple approach becomes completely impractical.
A Mental Model for Ownership
Think of ownership like a physical object. There is only ever one person who owns an object at a time. When you give the object to someone else (let s2 = s1), you no longer have it. When the owner leaves the room (goes out of scope), they take the object with them and it is gone. Nobody else can use it. No two people can own the same object simultaneously.
The compiler is the room monitor — it tracks who owns what at every point in the program and rejects any code that would violate these rules. You never have to track it yourself at runtime.
Summary of Ownership Rules
Each value has exactly one owner at any point in time.
Assigning a heap value to another variable moves it — the old variable is invalidated.
Passing a heap value to a function moves it — the caller loses access.
When an owner goes out of scope,
dropis called automatically and memory is freed.Stack-based types (
i32,bool, etc.) implementCopyand are duplicated rather than moved.Returning a value from a function transfers ownership to the caller.