Skip to content

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.

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 b

After 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:

rust // editable

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.
  1. Each value has exactly one owner.
  2. When the owner goes out of scope, the value is dropped (its memory and resources are released — automatically, no garbage collector, no manual free).
  3. 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?

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 calling payload.into_record(…) consumes the payload. That’s exactly why it’s named into_…: the into_ / 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) moves self.values (the Vec<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.

A read-only exercise — use the playground.

Write a function fn first_dim(s: Shape) -> f64 that returns a shape’s first stored number (radius for Circle, width for Rectangle, 0.0 for Point) — using a match (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.

  • “Assignment copies the value.” For most types it moves ownership; only Copy types (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 Record through the DAG is cheap.

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.