All blogs
10 min readEN

The Ownership System — Miracle of Rust

  • Rust
  • Memory Safety
  • Systems Programming
  • Ownership
On this page

Memory safety without a garbage collector

Every programming language has to answer the same question: who cleans up the memory your program allocates? Different languages give very different answers, and Rust's answer is the reason it can promise memory safety without a garbage collector. That answer is the ownership system, and in this post we'll walk through what it is, the rules behind it, how it works under the hood, and why it ends up being such a big win for developers.

The Three Approaches to Memory Management

Some languages, like Java, Go, or Python, have a garbage collector that regularly looks for no-longer-used memory while the program runs. In other languages, like C and C++, the programmer must explicitly allocate and free the memory themselves.

Rust uses a third approach: memory is managed through a system of ownership with a set of rules that the compiler checks. If any of the rules are violated, the program won't even compile. And here is the miracle part: none of these checks slow your program down while it's running, because all of them happen at compile time.

This is why Rust needs no garbage collector. The compiler already knows, for every single value in your program, exactly when it will be freed. That knowledge is encoded directly into the binary as drop calls at exactly the right places. No background process scanning memory, no pauses, no runtime overhead.

The Rules of Ownership

The whole system rests on just three rules:

  • Each value in Rust has an owner.
  • There can only be one owner at a time.
  • When the owner goes out of scope, the value will be dropped.

Everything else — moves, borrows, lifetimes — is a consequence of these three sentences. Let's see them in action.

A Type That Actually Needs Ownership: String

To illustrate the rules, we need a data type more complex than a simple integer. Integers have a known size at compile time, so they live entirely on the stack and can be copied cheaply. String is different: it manages data allocated on the heap, so it can store text whose size is unknown at compile time — like user input.

Compare that with a string literal, where the contents are known at compile time and hardcoded directly into the final executable. That's why string literals are fast and efficient, but also immutable and inflexible. For anything dynamic, you need String, and String needs ownership.

Scope and Drop: Memory That Frees Itself

In languages with manual memory management, pairing every allocate with exactly one free is a constant source of bugs: forget to free and you leak memory, free too early and you get an invalid pointer, free twice and you corrupt memory.

Rust takes a different path: the memory is automatically returned once the variable that owns it goes out of scope.

{
    let s = String::from("hello"); // s is valid from this point forward
    // do stuff with s
} // this scope is over, and s is no longer valid

When a variable goes out of scope, Rust calls a special function for us called drop, where the author of String put the code to return the memory. Rust calls drop automatically at the closing curly bracket. There is no way to forget it — the compiler inserts the call for you, deterministically, every time.

Moves: Why Copying Is Not What You Think

Here is where things get interesting. Multiple variables can interact with the same data in different ways. With integers, this behaves as you'd expect:

let x = 5;
let y = x;

This binds the value 5 to x, then makes a copy of the value and binds it to y. Now both x and y equal 5, because integers are simple values with a known, fixed size that live entirely on the stack.

Now look at the String version:

let s1 = String::from("hello");
let s2 = s1;

This looks very similar, so you might assume it works the same way. It doesn't. A String is made of three parts, all stored on the stack: a pointer to the heap memory holding the contents, a length, and a capacity. The actual text lives on the heap.

Figure 1: The representation in memory of a String holding the value "hello" bound to s1
Figure 1: The representation in memory of a String holding the value "hello" bound to s1

When we assign s1 to s2, only the stack data is copied — the pointer, the length, and the capacity. The heap data is not copied.

Figure 2: s2 has a copy of the pointer, length, and capacity of s1
Figure 2: s2 has a copy of the pointer, length, and capacity of s1

Rust does not copy the heap data, because if the string were large, that copy could be very expensive at runtime:

Figure 3: What s2 = s1 might do if Rust also copied the heap data — it doesn't
Figure 3: What s2 = s1 might do if Rust also copied the heap data — it doesn't

But now we have a problem. Earlier we said that when a variable goes out of scope, Rust calls drop and frees the heap memory. Figure 2 shows two pointers pointing at the same location — so when s2 and s1 both go out of scope, they would both try to free the same memory. This is the infamous double free error, and freeing memory twice can lead to memory corruption and security vulnerabilities.

Rust's solution is the core of the ownership system: after let s2 = s1;, Rust considers s1 no longer valid. Only s2 owns the data now, so only s2 will free it. Try to use s1 afterwards and the program refuses to compile:

let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, world!"); // this code does not compile!

The compiler tells you exactly what happened:

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`,
  |            which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move

If you've heard the terms shallow copy and deep copy in other languages, copying the pointer, length, and capacity without copying the heap data sounds like a shallow copy. But because Rust also invalidates the first variable, it's not called a shallow copy — it's called a move. We say that s1 was moved into s2.

Figure 4: The representation in memory after s1 has been invalidated
Figure 4: The representation in memory after s1 has been invalidated

That solves the double free: with only s2 valid, when it goes out of scope it alone frees the memory. There's also an important design choice implied here: Rust will never automatically create deep copies of your data. Any automatic copying can be assumed to be cheap.

Reassignment Drops the Old Value Immediately

The inverse is also true: when you assign a completely new value to an existing variable, Rust calls drop and frees the original value's memory right away.

let mut s = String::from("hello");
s = String::from("ahoy");
println!("{s}, world!");

After the second line, nothing refers to the original "hello" on the heap anymore, so Rust frees it immediately — not at the end of the scope, not whenever a collector feels like it, but at the exact moment it becomes unreachable. The final print outputs ahoy, world!.

Figure 5: The representation in memory after the initial value has been replaced
Figure 5: The representation in memory after the initial value has been replaced

Clone: When You Actually Want a Deep Copy

If you do want to deeply copy the heap data, not just the stack data, you use the clone method:

let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");

This works fine and produces the expensive behavior from Figure 3, where the heap data really is copied. When you see a call to clone, you know arbitrary code is being executed that may be expensive. It's a deliberate visual indicator that something different is going on — in Rust, cost is never hidden from you.

Stack-Only Data: The Copy Trait

But wait — the integer example earlier still worked without clone, and x stayed valid after let y = x;. Doesn't that contradict the move rule?

No: types like integers have a known size at compile time and are stored entirely on the stack, so copies of the actual values are trivially cheap. There's no reason to invalidate x after creating y — there's no difference between a deep and shallow copy here.

Rust has a special annotation called the Copy trait for such types. If a type implements Copy, variables using it don't move; they're trivially copied and stay valid after assignment. Types that implement Copy include:

  • All the integer types, such as u32
  • The boolean type, bool
  • All floating-point types, such as f64
  • The character type, char
  • Tuples, but only if all their members also implement Copy(i32, i32) is Copy, (i32, String) is not

As a general rule: any group of simple scalar values can be Copy, and nothing that requires allocation or manages a resource can be. In fact, Rust won't let you implement Copy on a type that implements Drop — the two are fundamentally incompatible.

Ownership and Functions

Passing a value to a function works just like assignment: it moves or copies. This annotated example shows both cases:

fn main() {
    let s = String::from("hello"); // s comes into scope
    takes_ownership(s);            // s's value moves into the function...
                                   // ... and is no longer valid here
    let x = 5;                     // x comes into scope
    makes_copy(x);                 // i32 implements Copy, so x does NOT
                                   // move and is still usable afterward
} // x goes out of scope, then s. But s's value was moved, so nothing happens.
fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // some_string goes out of scope and `drop` is called. Memory is freed.
fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // some_integer goes out of scope. Nothing special happens.

If you tried to use s after the call to takes_ownership, you'd get a compile-time error. These static checks protect you from mistakes before the program ever runs.

Return Values Transfer Ownership Too

Returning a value from a function also moves ownership:

fn main() {
    let s1 = gives_ownership();        // moves its return value into s1
    let s2 = String::from("hello");    // s2 comes into scope
    let s3 = takes_and_gives_back(s2); // s2 moves in, return value moves into s3
} // s3 is dropped. s2 was moved, nothing happens. s1 is dropped.
fn gives_ownership() -> String {
    let some_string = String::from("yours");
    some_string // returned and moved out to the calling function
}
fn takes_and_gives_back(a_string: String) -> String {
    a_string // returned and moved out to the calling function
}

The pattern is always the same: assigning a value to another variable moves it, and when a variable that owns heap data goes out of scope, the value is cleaned up by drop — unless ownership was moved elsewhere first.

The Tedium Problem, and Why Borrowing Exists

This all works, but taking ownership and returning it from every function is tedious. What if we want a function to use a value without taking it? You could return the value back in a tuple:

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("The length of '{s2}' is {len}.");
}
fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)
}

But that's a lot of ceremony for something that should be simple. Luckily, Rust has a feature for using a value without transferring ownership: references.

A reference is like a pointer — an address you can follow to access data owned by some other variable. Unlike a raw pointer, a reference is guaranteed to point to a valid value of a particular type for the entire life of that reference. Here's calculate_length rewritten with a reference:

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    println!("The length of '{s1}' is {len}."); // s1 is still valid!
}
fn calculate_length(s: &String) -> usize {
    s.len()
} // s goes out of scope, but it doesn't own the value, so nothing is dropped

The ampersands are references, and they let you refer to a value without taking ownership of it:

Figure 6: &String s pointing at String s1
Figure 6: &String s pointing at String s1

We call this act of creating a reference borrowing. Just like in real life, if you borrow something, you can use it — but you have to give it back, because you don't own it. When the reference goes out of scope, nothing is freed, because the reference never owned the data.

Borrowing comes with its own compiler-enforced rules — for example, you can have either any number of immutable references or exactly one mutable reference to a value at any given time, and references can never outlive the data they point to, so dangling pointers are impossible. That deserves its own post, but the key point is this: borrowing is how Rust lets data flow through your program freely, while ownership keeps exactly one party responsible for cleaning it up.

Why This Is Such a Big Deal

Let's step back and look at what the ownership system buys you:

  • Memory safety without a garbage collector. Double frees, use-after-free, dangling pointers, and memory leaks are caught at compile time. Whole categories of vulnerabilities that plague C and C++ simply cannot compile in Rust.
  • Zero runtime cost. No collector threads, no stop-the-world pauses, no reference counting unless you explicitly opt into it. All the checks happen at compile time; the compiled binary frees memory at exactly the right moment.
  • Predictable performance. Memory is freed deterministically the instant its owner goes out of scope — critical for game engines, embedded systems, and anything latency-sensitive.
  • Explicit costs. Rust never deep-copies your data behind your back. If something expensive is happening, you see a .clone() in the code. Performance surprises are designed out of the language.
  • A compiler that teaches you. As the E0382 error above shows, the compiler doesn't just reject your code — it explains the ownership violation and often suggests the fix. Fighting the borrow checker is frustrating at first, but it's really a pairing session with a very pedantic memory-safety expert.
  • The foundation for fearless concurrency. The same ownership rules that prevent double frees also prevent data races at compile time — but that's a story for another post.

The ownership system asks you to think about who owns your data — something good systems programmers do anyway — and in exchange it removes an entire dimension of runtime infrastructure and an entire class of bugs. That's the miracle: safety and speed, without compromise.

Memory diagrams in this post are from The Rust Programming Language book (Chapter 4), which is licensed under MIT/Apache-2.0.

References