Rust Basics #3 Ownership: Move by Default, and How Memory Is Freed Without a GC

5 min read

In Part 1 we said Rust fixes the moment of memory deallocation at compile time. This part is the substance of that claim: ownership. It is the place where Rust learners get stuck most often, but the rules themselves are three lines — the rest is a matter of how those rules play out across assignment, function calls, and scope. Once you are over this hill, the later parts are actually the flat stretch.

The three rules #

  1. Every value has a variable called its owner.
  2. There is exactly one owner at any given time.
  3. When the owner goes out of scope, the value is freed (dropped).

Start with rule 3. A GC language tracks “who still uses this value” at runtime and reclaims what nobody uses. Rust does not need the tracking: because there is only one owner, the point where the owner leaves scope is the point of deallocation. The compiler plants the cleanup code there in advance. No runtime tracking cost, and the timing of deallocation can be predicted just by reading the code.

src/main.rs
{
    let log = String::from("request started");
    // use log
} // end of scope — log's heap memory is freed here

So what happens when you assign a value or pass it to a function? If there were two owners, rule 2 would break and the same memory would be freed twice. This is where Rust’s default behavior, the move, comes in.

Move: assignment is not a copy #

src/main.rs
let s1 = String::from("hello");
let s2 = s1;            // ownership moves to s2
println!("{s1}");       // compile error
Compile error
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:4:15
  |
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 borrowed here after move

A String keeps the actual character data on the heap; the variable holds only the information pointing at it. In let s2 = s1, only that pointing information is copied — the heap data stays single. In Python or JavaScript, the two variables would now share one object and the GC would handle the aftermath. Rust instead moves ownership to s2 and invalidates s1. The owner is still exactly one, so double frees and uses of freed values become structurally impossible. Use-after-free, which caused decades of accidents in C++, ends in Rust as the single compile error above.

The message value borrowed here after move is the sentence Rust beginners meet most often. It means “used after it moved,” and the ways out are spread across this part and the next.

Copy: the exception for fixed-size stack types #

src/main.rs
let a = 5;
let b = a;
println!("{a} {b}"); // fine — 5 5

Integers, floating-point numbers, bool, char, and tuples containing only such types are copied rather than moved. They are small, compile-time-sized stack values, so copying is essentially free and there is no heap ownership problem. These types are said to implement the Copy trait — it even appeared as a clue in the error above: which does not implement the Copy trait. Traits themselves are the subject of Part 8; for now, the criterion is all you need: owns heap data → move; fits on the stack → Copy.

clone: a copy with visible cost #

When you want the heap data duplicated as well, you ask explicitly.

src/main.rs
let s1 = String::from("hello");
let s2 = s1.clone();     // heap data copied too
println!("{s1} {s2}");   // both valid

In GC languages, the code does not show whether something is a copy or a share. In Rust, every place a heap copy happens has .clone() stamped on it — meaning that when you chase performance, one grep finds all the copying costs. At the beginner stage, getting past a compile error with .clone() is a legitimate strategy: make it work first, then trim with borrowing from the next part.

The same rule at function boundaries #

src/main.rs
fn print_report(report: String) {
    println!("{report}");
} // report goes out of scope and is freed

let summary = String::from("monthly summary");
print_report(summary);   // ownership moves into the function
println!("{summary}");   // compile error: value borrowed here after move

Passing a value to a function is a move, exactly like assignment. Ownership of summary went into print_report, and the moment the function ended, the value was freed too. But if this were the whole story, every call to a function that merely reads a value would cost you ownership, and keeping the value would mean the clumsy pattern of returning it back.

src/main.rs
fn print_report(report: String) -> String {
    println!("{report}");
    report // hand ownership back
}
let summary = print_report(summary); // take it back

It works, but nobody wants to write this every time. What is needed is a way to “leave ownership where it is and lend read access for a moment” — and that is references and borrowing, the subject of the next part.

Summary #

  • The ownership rules are three lines: every value has one owner, and when the owner leaves scope the value is freed. That alone fixes deallocation timing at compile time, without a GC.
  • Assigning or passing a heap-owning type is a move. The original variable is invalidated, and further use is the value borrowed here after move compile error.
  • Fixed-size stack types like integers are Copy and duplicate freely. Heap → move, stack → Copy is the criterion.
  • Heap copies happen only through .clone(), so copying cost is visible in the code. Getting past errors with clone first and trimming later is a valid strategy.
  • Next up: references and borrowing — using a value without moving ownership, and the borrow-checker errors beginners hit most.
X