Ownership and Borrowing: The Core of Rust Memory Safety
This article is based on Rust 1.80+.
If you have a Go or Java background, the ownership system might be the most confusing concept in Rust. Go has a garbage collector (GC), and you rarely need to worry about when memory is freed. Rust takes a completely different approach—compile-time ownership rules guarantee memory safety, with zero runtime overhead.
The ownership system is the soul of Rust. It solves two classic problems: how to guarantee memory safety without using GC, and how to avoid data races. In other languages, these problems either require runtime checks or require extreme developer caution.
The Three Ownership Rules
Rust’s ownership system is built on three simple rules:
- Each value has a single owner
- At any given time, there can only be one owner
- When the owner leaves scope, the value is dropped (automatically freed)
Let’s understand each rule one by one.
Rule 1: Each Value Has a Single Owner
In Rust, each value is bound to a variable—that variable is the owner of the value.
| |
Rule 2: At Any Given Time, There Can Only Be One Owner
When ownership is transferred, the original owner immediately becomes invalid. This is Move semantics.
| |
This compile error usually looks like this:
| |
Rule 3: Automatically Freed When Leaving Scope
When the owner leaves scope, Rust automatically calls the drop function to clean up memory. This is similar to C++’s RAII, but without manual implementation.
| |
Move Semantics: Copy Types vs Move Types
Types in Rust are divided into two categories:
Copy Types (Simple Stack Types)
Types that implement the Copy trait are automatically copied on assignment, keeping the original variable valid:
| |
Common Copy types:
- All integer types (
i32,u64,usize, etc.) - Floating-point numbers (
f32,f64) - Boolean values (
bool) - Characters (
char) - Tuples (if all elements are Copy types)
Move Types (Complex Heap Types)
Types that don’t implement the Copy trait are moved on assignment:
| |
Common Move types:
StringVec<T>Box<T>HashMap<K, V>- User-defined
struct(default is Move type)
Why Distinguish?
Copy types have known size at compile time and are completely stored on the stack, so copying is cheap. Move types typically contain heap allocation, so copying is expensive—Rust chooses to move instead of deep copy to avoid unnecessary performance overhead.
If you need deep copy, explicitly call .clone():
| |
Ownership Transfer in Function Calls
Passing arguments also triggers ownership transfer:
| |
Borrowing: Access Without Ownership Transfer
What if we want a function to access data without taking ownership? Use references.
Immutable References (&T)
Use & to create an immutable reference, allowing multiple references to exist simultaneously:
| |
Mutable References (&mut T)
Use &mut to create a mutable reference, allowing data modification, but with strict restrictions:
| |
Borrowing Rules
Rust’s borrow checker enforces the following rules at compile time:
- At any given time, you can have one mutable reference OR multiple immutable references
- References must always be valid (no dangling)
- Cannot have both mutable and immutable references simultaneously
Rule 1: Mutable vs Immutable
| |
Compile error:
| |
But if you create a mutable reference after immutable references are no longer used, it’s allowed:
| |
Rule 2: Preventing Dangling References
Dangling references are references that point to freed memory—this is a common security vulnerability in C/C++. Rust’s compiler can completely prevent this at compile time:
| |
Compile error:
| |
The compiler tells you: the function returns a reference, but there’s no value to borrow from—because s will be dropped at the end of the function.
The correct approach is to return String itself (transfer ownership):
| |
Rule 3: Data Race Prevention
Data races occur when:
- Two or more pointers access the same data simultaneously
- At least one pointer is writing
- No synchronization mechanism
Rust’s borrowing rules can prevent data races at compile time:
| |
This code cannot compile because Rust knows data might be mutably borrowed in another thread while the main thread is accessing it.
The correct approach is to use concurrency primitives like Arc<Mutex<T>>:
| |
Comparison with Go’s Garbage Collection
Go’s Approach: Runtime GC
Go’s garbage collector tracks which variables are still in use at runtime and which can be safely freed. The advantage is good developer experience—you don’t need to manually manage memory. The disadvantages are:
- GC pauses: Collection suspends program execution, unpredictable latency
- Memory overhead: Needs to maintain GC metadata, actual memory usage doubles
- No full control: Tuning GC parameters is an esoteric art
Rust’s Approach: Compile-time Checks
Rust determines when to free memory at compile time through ownership rules, with zero runtime overhead:
| Dimension | Go (GC) | Rust (Ownership) |
|---|---|---|
| Memory release timing | Determined by runtime GC | Determined at compile time, freed immediately when leaving scope |
| Runtime overhead | GC pauses, metadata maintenance | Zero cost |
| Latency predictability | Unpredictable | Completely predictable |
| Developer control | Low | High |
| Memory safety | GC guarantees freeing, not leaks | Compiler guarantees |
| Concurrency safety | Requires developer care | Compiler prevents data races |
Why Rust Doesn’t Need GC?
Rust’s design philosophy is: replace runtime reclamation with compile-time checks.
When a variable leaves scope, the compiler inserts a drop call. Because ownership rules guarantee that each value has only one owner, the compiler can precisely know when to free memory without needing runtime tracking like GC.
| |
Common Misconceptions for Go Developers
- Thinking Rust has GC: Rust has no runtime GC, memory release is deterministic.
- Using
.clone()to avoid compile errors: This causes unnecessary deep copies, should use references for passing. - Thinking ownership rules are too strict: These rules become natural once familiar, and compiler error messages are very friendly.
Common Misconceptions for Rust Developers
- Overusing
.clone(): Prioritize references, clone only when necessary. - Ignoring lifetime annotations: Although the compiler can often infer, complex scenarios require explicit annotations.
- Avoiding
unsafe: Some low-level operations needunsafe, but be extremely cautious.
Introduction to Lifetimes
You might notice that the code above rarely uses lifetime annotations ('a). This is because Rust’s compiler is smart enough to infer in most cases.
| |
Need to explicitly annotate lifetime:
| |
The lifetime annotation 'a tells the compiler: the returned reference has the same shortest lifetime as the input references. This way the compiler can guarantee that the returned reference won’t dangle.
In-depth explanation of lifetimes is the content of the next article, here we just preview its existence.
Summary
The ownership system is Rust’s cornerstone, allowing the compiler to guarantee memory safety and concurrency safety at compile time. Key takeaways:
- Three ownership rules: one value has one owner, move semantics, automatically freed when leaving scope
- Copy vs Move types: simple types automatically copy, complex types transfer ownership
- Borrowing rules: at any given time either multiple immutable references OR one mutable reference, cannot mix
- Preventing dangling references: compiler guarantees references are always valid
- Comparison with Go GC: Rust replaces runtime GC with compile-time checks, zero cost but steep learning curve
- Lifetimes: annotate the valid range of references, prevent dangling references (deep dive in next chapter)
The ownership rules seem cumbersome at first, but once you understand the design philosophy behind them, you’ll find that they make Rust code’s memory behavior completely predictable and verifiable. This is exactly how Rust achieves memory safety without sacrificing performance.
The next article will dive deep into lifetime annotations, understanding how they let the compiler guarantee reference safety even in complex scenarios.