Ownership in Rust: Memory Safety Without GC
1. Overview
Ownership is the core mechanism that helps Rust ensure memory safety without needing a garbage collector (GC) like Java or Go, and without requiring you to manually malloc/free like in C. This article explains the three ownership rules, the concepts of borrowing, lifetimes, and common compile-time errors. After reading, you’ll understand why Rust is “picky” during compilation, but in exchange has no dangling pointers, double frees, or data races at runtime.
2. The Problem: Memory Management Without GC
Languages like Java use GC for cleanup — convenient but CPU-intensive and causes unpredictable pauses. C/C++ gives you full control but is prone to double free, use-after-free, and memory leaks. Rust takes a third approach: the compiler tracks who “owns” which data and automatically frees it when the owner exits its scope. No runtime overhead, no need for a GC thread.
fn main() {
let s = String::from("Non La Dev");
println!("{s}");
} // s exits scope here, Rust automatically calls drop(s) to free heap memory
The line String::from allocates memory on the heap. When variable s exits the }, Rust automatically inserts a call to drop — similar to finally { free(s) } but generated by the compiler, so you don’t have to write it manually.
3. Three Ownership Rules
Rust has exactly three rules; memorize them and you’ll grasp 80% of the mechanism:
- Each value in Rust has a variable called its owner.
- At any given time, there is only one owner.
- When the owner exits scope, the value is dropped (freed).
3.1. Move Semantics
fn main() {
let s1 = String::from("hello");
let s2 = s1; // ownership "move" from s1 to s2
println!("{s2}");
// println!("{s1}"); // compile error: s1 has been moved
}
Unlike Java (where assigning a variable just copies the reference, both point to the same object, and GC handles the rest), Rust treats let s2 = s1 as transferring ownership. After that line, s1 is no longer valid. If you uncomment the last line, you’ll get an error:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:20
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}");
| ^^^^ value borrowed here after move
This prevents double free at its root: if both s1 and s2 held pointers to the same heap region, when both exit scope, Rust would try to drop twice — causing a crash. The compiler prevents this right at compilation time.
3.2. Clone When You Need Both
If you truly need two independent copies, call .clone() explicitly:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // deep copy the heap region
println!("{s1} - {s2}"); // both are valid
}
.clone() allocates a new heap region and copies the contents. The runtime cost is explicit, and Rust forces you to call it deliberately so nobody accidentally clones a huge data structure without realizing — unlike many languages that copy silently.
3.3. Copy Trait for Simple Types
fn main() {
let x = 5;
let y = x; // copy, not move
println!("x = {x}, y = {y}"); // both still work
}
Primitive types like i32, bool, char, or tuples containing only such types implement the Copy trait. Since they have fixed size and live entirely on the stack, copying is cheaper than moving, so Rust copies automatically instead of invalidating the original variable.
Note: a type cannot implement both
CopyandDrop. If a type owns resources needing cleanup (heap, file handle…), it cannot beCopy.
4. Ownership When Passing to Functions
Passing a variable to a function follows move semantics exactly like assigning to a variable:
fn print_order(order: String) {
println!("Order: {order}");
} // order is dropped here, not in the caller
fn main() {
let order = String::from("ORDER-001");
print_order(order);
// println!("{order}"); // error: order has been moved into the function
}
When you call print_order(order), ownership of order moves into the function’s parameter. The function ends, the value is dropped inside the function — main no longer has the right to use order. This is why borrowing (next section) is almost always necessary when you just want to “lend out” data without transferring ownership.
5. Borrowing: Lending Without Transferring Ownership
5.1. Immutable Reference (&T)
fn print_order(order: &String) {
println!("Order: {order}");
}
fn main() {
let order = String::from("ORDER-001");
print_order(&order); // borrow, no move
println!("Still usable: {order}");
}
The & symbol creates a reference — lending data without taking ownership. The print_order function just reads and doesn’t own order. After the function runs, main still has full rights to the variable.
5.2. Mutable Reference (&mut T)
fn add_discount(total: &mut f64) {
*total -= 10.0;
}
fn main() {
let mut total = 100.0;
add_discount(&mut total);
println!("Total: {total}"); // Total: 90
}
&mut allows borrowing for modification. To use it, the original variable must be declared mut. The * dereferences and assigns a new value to the memory the reference points to.
5.3. Borrowing Rules
Rust enforces two lending laws at compile-time, collectively called the borrow checker:
- At any moment, you can have one mutable reference, OR many immutable references — but not both.
- A reference never lives longer than the data it borrows from (preventing dangling references).
fn main() {
let mut total = 100.0;
let r1 = &total;
let r2 = &total; // OK: multiple immutable references at once
println!("{r1} {r2}");
let r3 = &mut total; // error if r1, r2 are still in use after this
println!("{r3}");
}
If r1, r2 are no longer used after the first println!, the compiler (thanks to Non-Lexical Lifetimes) allows r3 to borrow mutably below. But if you try to use r1 after r3 appears, you’ll get:
error[E0502]: cannot borrow `total` as mutable because it is also borrowed as immutable
This rule eliminates data races at compile-time: you can never have someone reading while someone else writes to the same memory simultaneously.
6. Lifetimes: Ensuring References Don’t Point to “Thin Air”
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let order1 = String::from("ORDER-001");
let order2 = String::from("ORDER-002-LONG");
let result = longest(order1.as_str(), order2.as_str());
println!("Longer: {result}");
}
The notation 'a is a lifetime annotation, telling the compiler: “the returned reference lives no longer than both x and y”. Rust can’t automatically infer the lifetimes of multiple references in this case, so you must declare explicitly. Most of the time Rust infers lifetimes (lifetime elision) so you rarely write 'a by hand — only when a function takes multiple references and returns one of them.
error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ^ expected named lifetime parameter
Without 'a, the compiler can’t tell if the returned reference is tied to x or y.
7. When You Need Multiple Owners: Rc and RefCell
Single-ownership can be too rigid sometimes — for example, building tree or graph structures where multiple nodes need to reference the same value.
<!-- No extra dependency needed; Rc/RefCell are in std -->
use std::cell::RefCell;
use std::rc::Rc;
struct Customer {
name: String,
balance: RefCell<f64>,
}
fn main() {
let customer = Rc::new(Customer {
name: String::from("Nón Lá"),
balance: RefCell::new(0.0),
});
let c1 = Rc::clone(&customer);
let c2 = Rc::clone(&customer);
*c1.balance.borrow_mut() += 50.0;
*c2.balance.borrow_mut() += 30.0;
println!("{} balance = {}", customer.name, customer.balance.borrow());
println!("Reference count: {}", Rc::strong_count(&customer));
}
Rc<T> (Reference Counted) allows multiple owners to point to the same immutable data, counts references at runtime, and automatically drops when the count hits zero. RefCell<T> enables mutable borrow at runtime instead of compile-time (interior mutability) — in exchange, if you violate borrowing rules (e.g., two borrow_mut() calls simultaneously), your program will panic instead of failing to compile.
Nón Lá balance = 80
Reference count: 3
Note:
Rc/RefCellare for single-threaded use only. For sharing data across threads, useArc(atomic reference count) withMutexorRwLock. Don’t scatterRc<RefCell<T>>everywhere — it loses the advantage of “the borrow checker catches errors at compile-time,” which is Rust’s greatest strength.
8. Writing Tests to Verify Ownership Behavior
#[derive(Clone)]
struct Order {
id: String,
amount: f64,
}
fn apply_discount(order: &mut Order, percent: f64) {
order.amount -= order.amount * percent / 100.0;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_keep_original_order_untouched_when_cloned() {
let original = Order { id: "ORD-1".into(), amount: 100.0 };
let mut copy = original.clone();
apply_discount(&mut copy, 10.0);
assert_eq!(original.amount, 100.0); // original unchanged
assert_eq!(copy.amount, 90.0);
}
#[test]
fn should_mutate_through_mutable_borrow() {
let mut order = Order { id: "ORD-2".into(), amount: 200.0 };
apply_discount(&mut order, 25.0);
assert_eq!(order.amount, 150.0);
}
}
Run cargo test and you’ll see:
running 2 tests
test tests::should_keep_original_order_untouched_when_cloned ... ok
test tests::should_mutate_through_mutable_borrow ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured
The first test proves .clone() creates an independent copy — modifying copy doesn’t affect original. The second confirms &mut actually changes the original data through the reference.
9. Common Errors
- Use after move: trying to use a variable after it’s moved elsewhere (function, new variable, struct). Fix by
.clone()if you need two copies, or switch to passing a reference (&T). - Cannot borrow as mutable more than once: holding one
&mutwhile creating another&or&mutsimultaneously. Narrow the scope of the old reference (use a block{}or let it go out of scope sooner) to fix. - Dangling reference: returning a reference to a local variable that’s dropped when the function ends. Rust prevents this entirely at compile-time — you’ll see an error like
missing lifetime specifierorborrowed value does not live long enough. - Overusing
.clone()to dodge the borrow checker: valid to compile but wastes performance. Treat each.clone()as a question: “do I really need an independent copy, or am I just avoiding understanding ownership?”
10. When Strict Ownership Isn’t Necessary
If you’re writing a short script, prototyping quickly, or your data is always small and doesn’t demand high performance or strict safety, the cost of learning and following the borrow checker might not be worth it — languages like Python or Go will let you iterate faster. Ownership shines most in systems requiring high performance and strict memory safety: operating systems, embedded systems, game engines, or backend services that can’t tolerate GC pauses.
11. Conclusion
Ownership is the foundation letting Rust achieve what seems contradictory: memory safety like garbage-collected languages, but performance on par with C/C++. The three rules of owner-move-drop, the borrowing mechanism with its one-mutable-or-many-immutable law, and lifetime annotations work together so the compiler catches memory-unsafe bugs at compilation instead of letting them crash at runtime. At first, arguing with the borrow checker can be frustrating, but once you’re used to it, you’ll write code with far fewer bugs without any runtime guardian needed.