Ownership: Why Rust doesn't have a garbage collector (and doesn't need one)

The first of three tutorials on Rust's ownership model. Frames ownership not as a syntax rule to memorize but as the language's answer to a single question — "who is responsible for this memory?" Covers the one-owner rule, move semantics (why `let s2 = s1` invalidates `s1`), the `Copy` trait, deterministic destruction, and the stack/heap mental model. Ends with a hands-on exercise that surfaces move errors and three different ways to resolve them.

ownership move_semantics copy_trait stack_vs_heap drop deterministic_destruction by johndoe

The bug everyone has hit (in some language)

You're 5 years deep in Python. Watch this:

def add_user(users, name):
    users.append(name)
    return users

main_users = ["alice"]
session_users = main_users  # "snapshot," right?
add_user(session_users, "bob")

print(main_users)  # ['alice', 'bob'] — wait, what?

You expected main_users to be unchanged. But session_users = main_users didn't copy — it just gave you another name for the same list. There's one list, with two references pointing at it. Mutation through one shows up through the other.

This is annoying in Python. It's catastrophic in C:

char* greeting = malloc(6);
strcpy(greeting, "hello");
char* alias = greeting;     // same pointer, no copy
free(alias);                // freed the memory
printf("%s", greeting);     // use-after-free. Undefined behavior.

Both languages fail to answer one question: who is responsible for this memory? Python papers over it with a garbage collector and reference counts. C just hands you a loaded gun and trusts you not to shoot yourself.

Rust takes a third path: it answers the question. And the answer is the foundation of the entire language.

The rule

Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped (memory freed). You cannot have two owners.

That's it. That's ownership.

Everything else — moves, borrows, lifetimes, why String and &str are different types, why the borrow checker yells at you — is downstream consequence of this single rule.

What "owner" actually means

Let's get concrete. Here's a String in Rust:

fn main() {
    let greeting = String::from("hello");
    println!("{}", greeting);
} // <- greeting goes out of scope here. Memory freed. Automatically.

greeting is the owner of that string's heap allocation. The moment main returns, Rust inserts (at compile time) the equivalent of free(greeting). No garbage collector ran. No reference count was decremented. The compiler statically knew that line was the end of greeting's life and emitted the free call there.

This is called deterministic destruction and it's a big deal. In Python you don't really know when __del__ fires (and CPython's behavior here is an implementation detail, not a language guarantee). In Java you definitely don't. In Rust, you can point at the exact line where any given value dies.

Now the part that breaks your Python brain

Watch:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{}", s1); // <- compile error
}

Your Python brain says: two names, one string, both work. Your C brain says: two pointers, same memory, both work (until something explodes later).

Rust says: no. One owner, remember? When you wrote let s2 = s1, the ownership moved from s1 to s2. s1 is no longer a valid name. Using it is a compile error:

error[E0382]: borrow of moved value: `s1`
  --> src/main.rs:4:20
   |
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
4  |     println!("{}", s1);
   |                    ^^ value used here after move

This isn't bureaucracy. It's the language preventing the C bug from a moment ago. If both s1 and s2 were valid owners, then when the function ended, Rust would try to free the same heap allocation twice. Double-free. Undefined behavior. Crash.

By making let s2 = s1 transfer ownership rather than duplicate it, Rust guarantees that exactly one variable is responsible for freeing each allocation, exactly once. The compiler can prove this statically — and that proof is precisely why Rust doesn't need a runtime garbage collector.

"But my integers work fine?"

You'll notice this works:

fn main() {
    let x = 5;
    let y = x;
    println!("{}, {}", x, y); // "5, 5" — no error
}

x is still valid after let y = x. Why?

Because i32 lives on the stack. There's no heap allocation to free. Copying an i32 is just copying 4 bytes — the bytes ARE the value. There's no question of "who owns the memory" because there's no separate memory to own.

Types like this implement the Copy trait. Roughly: small, stack-only, with no resources to manage — integers, floats, booleans, chars, and tuples of those. The compiler error you saw above said "does not implement the Copy trait" — that was the hint.

String doesn't implement Copy because it owns a heap allocation. Copying it would mean either (a) sharing the heap pointer (the C bug), or (b) duplicating the heap data (potentially expensive — could be megabytes). Rust picks neither by default. It moves. If you actually want option (b), you write .clone() and opt in.

The mental model: a String is three things

Picture a String in memory:

Stack                          Heap
+--------------+               +-----------------+
| s1           |               |                 |
|  ptr ────────┼──────────────►| h | e | l | l |o|
|  len: 5      |               |                 |
|  cap: 5      |               +-----------------+
+--------------+

The stack holds three things: a pointer, a length, and a capacity. The heap holds the actual character bytes. s1 owns the heap allocation through that pointer.

Now let s2 = s1:

Stack                          Heap
+--------------+               +-----------------+
| s1 (invalid) |               |                 |
|  (ptr X)     |               | h | e | l | l |o|
|  (len X)     |               |                 |
|  (cap X)     |               +-----------------+
+--------------+                       ▲
                                       │
+--------------+                       │
| s2           |                       │
|  ptr ────────┼───────────────────────┘
|  len: 5      |
|  cap: 5      |
+--------------+

The stack data was bitwise-copied into s2's slot. Rust then marks s1 as invalidated — the compiler will reject any further use of it. The heap allocation is untouched: still there, still pointed to, just now owned by s2. When s2 goes out of scope, the heap memory is freed exactly once.

This is why moves are cheap. Rust isn't copying the heap data — it's just transferring ownership of the existing allocation. A move is literally the same machine code as a copy of the stack bytes; the "ownership transfer" is a compile-time bookkeeping change with no runtime cost.

Where you'll feel this in your CLI tool

Imagine the start of your future log parser:

fn read_log() -> String {
    std::fs::read_to_string("app.log").unwrap()
}

fn main() {
    let contents = read_log();
    let copy = contents;
    println!("{}", contents); // <- compile error
}

read_to_string allocated a String on the heap — could be megabytes, your whole log file. When read_log returns, ownership of that String moves out of the function and into contents. No copy of the data — just the stack-side bookkeeping (ptr/len/cap) is transferred from read_log's frame into main's.

Then let copy = contents moves it again. contents is now invalid. When main ends, copy goes out of scope and the entire log file's memory is freed — automatically, deterministically, without a GC pause and without you writing free().

If you actually wanted both contents and copy to hold the log, you'd write let copy = contents.clone() — explicitly opting into the cost of duplicating megabytes. The Python equivalent would have silently shared a reference; you'd never have known until something far away mutated it. The Rust equivalent forces you to confront the question: do I really want two copies of this data, or did I mean to lend it out?

That second option — lending — is what Tutorial 2 is about. (It's the answer to "do I have to clone everything?" The answer is no.)

What you've actually learned

You now have a mental model that should make a lot of Rust make sense:

  • Every value has one owner. When the owner goes out of scope, the value is dropped.
  • let y = x moves ownership for non-Copy types. x becomes unusable.
  • Copy types (small, stack-only — integers, floats, bools, chars, tuples thereof) get duplicated instead of moved. That's the exception, not the rule.
  • .clone() is opt-in deep duplication for when you actually need two of something heavy.
  • No garbage collector is needed because the compiler can statically identify the exact moment any value's owner goes out of scope, and emit the free call there.

The borrow checker — the thing that "yells at you" — is enforcing exactly this. When you understand ownership, the yelling stops being mysterious. It's the compiler saying: "you broke the one-owner rule, and here's where."

Try this

Run cargo new ownership_practice, open src/main.rs, and replace it with:

fn main() {
    let log_line = String::from("2026-05-07 ERROR: connection refused");
    let archived = log_line;

    println!("Original: {}", log_line);
    println!("Archived: {}", archived);
}

It won't compile. Read the error message carefully — Rust's compiler errors are unusually good, and the language up to here was designed so that error makes sense. It'll tell you exactly which line moved the value and which line tried to use it after.

Then fix it three different ways and notice what each fix implies:

  1. Comment out the Original: line. (You moved, you accept the move. The simplest fix; only valid if you genuinely don't need log_line anymore.)
  2. Use log_line.clone() instead of just log_line when assigning to archived. (You opt into duplication. Now there are two independent Strings, two heap allocations. Both will be freed when main ends.)
  3. Reorder so archived is created and used before trying to use log_line. Will this work? Try it and see — then think about why the answer is what it is.

If anything about the third one surprises you, bring it back here and we'll dig in. That surprise is the doorway into Tutorial 2.