Skip to content

Who owns a record's data

A run over a million rows reads each record’s fields over and over; every predicate, every projection, every coercion starts by looking at a cell. So one question decides the engine’s hot path: when code reads a field, who keeps owning that data, and does the read copy it or just point at it? You just took a Value apart with an exhaustive match; now the question is what happens to that Value when you hand it around. The Rust tool that answers it is ownership: every value has exactly one owner, handing it off moves it (the source goes invalid), and lending it with & is a borrow that copies nothing and keeps the owner intact. Borrowing pays off twice, in order. First, safety (the borrow checker allows either many shared readers or one writer, never both, and never lets a reference outlive what it points to, all at compile time). Then performance as the consequence (no copy, so reading a field a million times costs nothing). This is where the engine lives; The Rust Book, ch. 4 is the canonical treatment if you want ownership from first principles.

  • State the borrow checker’s one rule (either many readers or one writer, never both).
  • Distinguish move, borrow, and clone, and predict which one a given line performs.
  • Predict whether a value is still usable after it has been moved out, and read the compiler error when it isn’t.
  • Explain why FieldResolver::resolve hands back a borrow of a field (&Value) rather than a copy.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • ownership: exactly one owner per value, no garbage collector.
  • move: assigning or passing transfers ownership; the source goes invalid.
  • borrow: &value, used without taking ownership; safe, and cheap.
  • clone: an explicit, possibly-expensive deep copy you opt into.

When CXL evaluates status == "active", it has to read the status field of the record. The trait that resolves a field name to its value is FieldResolver:

clinker-record ·resolver.rs ·FieldResolver trait @19acdcb4
pub trait FieldResolver {
fn resolve(&self, name: &str) -> Option<&Value>;
// ^^^^^^^^^^^^^^^^
// a BORROW of the value, not a copy
}

That return type, Option<&Value>, is the whole lesson. &Value is a borrow of the value living inside the record. The resolver doesn’t hand back a Value (which would mean copying it out); it hands back a window onto the one that’s already there. CXL can read status, compare it, and move on without ever duplicating it. (We’ll cover the Option part, what the None means, in the next lesson.)

Why does this matter so much? Because copying a Value isn’t always cheap. A Value::Integer is a few bytes, sure. But a Value::String owns its text and a Value::Array owns a whole Vec. Cloning those allocates. Now multiply by every field access, of every record, in a million-row file. Borrowing is free; cloning is a tax. So the engine’s rule is: borrow to read, and only pay for a clone at the one spot that genuinely needs to keep a value (a topic we finish in the zero-copy lesson).

You met move, borrow, and clone above. Now you run them, fill a gap, and write the whole thing, one new idea per rung.

Here is a fully annotated, runnable example. Each line is labeled with which of the three operations it performs. Run it, then uncomment the line after the move and watch the borrow checker explain that status no longer holds anything.

rust // editable

The two read(&status) calls are the borrow checker’s “many readers” rule in action: nothing here writes, so any number of shared & borrows coexist. The .clone() is the one line that allocates a fresh String; the move is the one line that retires status.

This compiles, but two annotations are missing. Each // ??? marks a line. Decide whether it is a borrow, a move, or a clone, and why last_seen is still usable on the final line while owned_copy came from a deliberate allocation. Predict before you reveal.

rust // editable
💡 Hint 1
Line (1) passes &last_seen; the & is the tell. Line (2) calls .clone() explicitly. One of these leaves last_seen owning its String; the other makes a second, independent String. Neither one retires last_seen, which is why the final println! can still print it.
Show solution
let n = length_of(&last_seen); // (1) BORROW — &last_seen lends a window; ownership stays put
let owned_copy = last_seen.clone(); // (2) CLONE — a brand-new String is allocated alongside the original

Because neither line is a move, last_seen is still the owner of its Value on the last line, so printing it is fine. Contrast the worked example, where let moved = status; did move and the later use was rejected. Borrow and clone both leave the source intact; only a move retires it.

Faded: write the function and trigger the error

Section titled “Faded: write the function and trigger the error”

Now write it yourself, with only a skeleton. The goal: a function summarize that borrows a Value and returns an owned String describing it (so the caller keeps its Value), then a main that borrows the same value twice, makes one clone, and finally moves it, and a commented-out line that would be a use-after-move if you uncommented it.

rust // editable
Show solution
fn summarize(v: &Value) -> String {
match v {
Value::Integer(n) => format!("integer {n}"),
Value::Text(s) => format!("text of {} bytes", s.len()),
}
}
fn main() {
let cell = Value::Text(String::from("active"));
println!("{}", summarize(&cell)); // borrow — cell unaffected
println!("{}", summarize(&cell)); // borrow again — many readers, fine
let kept = cell.clone(); // clone — independent allocation
let moved = cell; // move — cell is retired here
// println!("{}", summarize(&cell)); // error[E0382]: borrow of moved value: `cell`
println!("kept = {kept:?}, moved = {moved:?}");
}

summarize takes &Value, so each call only borrows; that is what lets you call it twice and still clone and move afterward. The exhaustive match inside it is the same exhaustiveness rule from the previous lesson: drop the Integer arm and this won’t build. Uncomment the last summarize(&cell) and the compiler points at exactly the move that retired cell.

Why-bridge: borrowing is the engine’s hot-path default

Section titled “Why-bridge: borrowing is the engine’s hot-path default”

Why does the engine reach for a borrow at nearly every field read? Because the alternative is a copy, and copies cost. Reading is the most common thing the engine does: every predicate, every projection, every coercion starts by looking at a Value. If each look duplicated the value, a Value::String or Value::Array would re-allocate on every touch, and the per-record cost would scale with the data, not the work.

A borrow sidesteps that: resolve returns &Value, the caller reads through the reference, and the original stays exactly where it is. The single owner, the record, keeps the value alive for as long as the borrow needs it; the borrow checker proves at compile time that no reader ever outlives the record it points into.

You understand why records flow on borrows: ownership stays with the record, reads go through cheap &Value windows, and a clone is a deliberate cost you pay only when a value must outlive its source. Next: the two types that encode “maybe absent” and “maybe failed”, namely Option and Result, which you already half-met in that Option<&Value>.

Go deeper on the Rust (optional, one-directional; for the same concepts taught from first principles in The Rust Book):

Glossary terms used: ownership, move, borrow, clone.