Ownership & moves — who holds the value
The Rust question for this lesson: when you assign a value to a second variable — or hand it to a function — what happens to the original? In most languages, both names end up pointing at the same thing and you stop thinking about it. Rust makes you think about it, because the answer is the single most important rule in the language: ownership moves.
Every value has exactly one owner. The previous three lessons quietly relied on this; now we make it explicit, because once you have it, borrowing (lesson 5) and lifetimes both fall into place.
What “move” means
Section titled “What “move” means”Take our Shape from the earlier lessons and assign one variable to another:
let a = Shape::Rectangle(3.0, 4.0);let b = a; // ownership MOVES from a into bAfter that second line, b owns the rectangle and a is no longer usable. This
isn’t a copy and it isn’t a shared pointer — ownership has moved from a to b.
Try to use a again and the program won’t compile. Run this, then do the experiment
in the comment:
> output appears here — press Run
So there are two behaviours, and the type decides which:
- Move (the default, for types that own resources —
Shape,String,Vec): assignment transfers ownership; the old name is invalidated. - Copy (for small, plain types —
i32,f64,bool,char): assignment makes a cheap bitwise copy; both names stay valid.
The three rules of ownership
Section titled “The three rules of ownership”- Each value has exactly one owner.
- When the owner goes out of scope, the value is dropped (its memory and
resources are released — automatically, no garbage collector, no manual
free). - Assigning or passing a value moves ownership, unless the type is
Copy.
Passing to a function is just another move. describe below takes ownership of the
shape, so the caller can’t use it afterward:
fn describe(s: Shape) -> String { format!("{s:?}")} // s goes out of scope here and is dropped
let sq = Shape::Rectangle(3.0, 4.0);let text = describe(sq); // sq is MOVED into describe …// println!("{sq:?}"); // … so this would not compile(If you wanted to keep sq, you’d borrow it instead — the entire next lesson.)
// quick check
After `let b = a;` where a is a Shape, why can't you use `a` anymore?
Shape is a move type, so the assignment transfers ownership to b. Rust invalidates a to guarantee a single owner — no aliasing, no double-free.
The same idea, moving real records
Section titled “The same idea, moving real records”Clinker moves data instead of copying it, and the type system makes that safe. A clear
example is how a RecordPayload (the portable, on-the-wire form of a row) becomes a
live Record:
clinker-record ·mod.rs ·into_record fn @47d2e12
pub fn into_record(self, schema: Arc<Schema>, doc_ctx: Arc<DocumentContext>) -> Record { let mut record = Record::new(schema, self.values); // …}Two ownership signals to read here:
- The method takes
self(by value, not&self) — so callingpayload.into_record(…)consumes the payload. That’s exactly why it’s namedinto_…: theinto_/from_convention tells you at a glance whether a method takes ownership (into_record(self)) or just borrows (from_record(&record)). Record::new(schema, self.values)movesself.values(theVec<Value>of cells) into the new record. The cells aren’t copied — ownership of that buffer transfers.
Multiply this across a pipeline: rows are moved from node to node through the DAG, not deep-copied. Ownership transfer is cheap, and the compiler guarantees no node keeps a stale handle to a row it already passed on.
Your turn
Section titled “Your turn”A read-only exercise — use the playground.
Write a function
fn first_dim(s: Shape) -> f64that returns a shape’s first stored number (radius forCircle, width forRectangle,0.0forPoint) — using amatch(lesson 2). Call it on a shape, then try to print that same shape afterward. What does the compiler say, and why — in terms of ownership?
💡 Hint 1
first_dim(s: Shape) takes the shape by value, so calling it moves the shape in.
After the call the original binding has given up ownership. What does using it now
trigger?
Show solution
fn first_dim(s: Shape) -> f64 { match s { Shape::Point => 0.0, Shape::Circle(r) => r, Shape::Rectangle(w, _) => w, }}
let sq = Shape::Rectangle(3.0, 4.0);let w = first_dim(sq); // sq is MOVED into first_dim// println!("{sq:?}"); // ❌ "borrow of moved value: sq"println!("first dim = {w}");Because first_dim takes Shape by value, the call moves sq; the original binding no
longer owns anything, so using it is a compile error. The fix you’ll learn next is to
take &Shape (a borrow) so the caller keeps ownership — which is exactly what
RecordStorage does when it reads a field without consuming the record.
Common misconceptions
Section titled “Common misconceptions”- “Assignment copies the value.” For most types it moves ownership; only
Copytypes (small scalars) are duplicated. The compiler tracks the difference. - “A move is an expensive deep copy.” A move transfers ownership — it never deep-copies
heap data. At most a few bytes (a pointer/length) are bitwise-moved; the owned buffer
itself is not duplicated. That’s why moving a
Recordthrough the DAG is cheap.
Where this leads
Section titled “Where this leads”Moving works, but it’s often the wrong tool: you frequently want to read or tweak a value without taking it away from its owner — like reading a field out of a record that needs to keep living. For that you borrow it with a reference, the subject of lesson 5.