Skip to content

What is a record?

Back in Orientation you ran customer_etl and Clinker reported “5 records.” A Record’s Journey follows one of those records all the way through the engine. But first: what is a record? It’s the unit of data that flows through every pipeline, and it’s built from three vocabulary types you’ll see everywhere: a cell, a row, and the columns that name the cells.

  • Name the three types that make up a record (Value is a cell, Record a row, Schema the columns) and say what each one holds.
  • Explain why a freshly-read CSV record is entirely Value::String, and what later turns a string into a number.
  • Trace how a cell is located: how values[i] finds the right column through the shared Schema.
  • Write a miniature record (a schema plus a positional Vec of values) and print each cell against its column name.

(We meet these types shallowly here; Data & Representation opens each one up: the optimized string type, the Arc sharing, the per-record context.)

New terms in this lesson (each is named and defined here before it appears below):

  • Value: one cell of data.
  • Record, one row: a list of cells bound to a schema.
  • Schema: the column names and their order.
  • Arc, a shared handle, so every row points at the same schema.

Every single cell of data in Clinker is one Value, a closed set of nine shapes (null, bool, integer, float, string, date, datetime, array, map):

clinker-record ·value.rs ·Value type @19acdcb4
pub enum Value {
Null,
Bool(bool),
Integer(i64),
Float(f64),
String(FieldStr),
Date(NaiveDate),
DateTime(NaiveDateTime),
Array(Vec<Value>),
Map(Box<IndexMap<Box<str>, Value>>),
}

It’s a closed set: a cell is exactly one of these nine, never something else. When the CSV source reads Alice’s row, it doesn’t guess types; it reads every field as a string (Value::String), exactly as you predicted. So right after reading, Alice is a row of strings; coercing "15200" to a number is a later transform’s job.

A Record is one row: a list of Values lined up against a Schema that names the columns.

clinker-record ·mod.rs ·Record type @19acdcb4

The real definition, trimmed to the part that matters now:

pub struct Record {
schema: Arc<Schema>, // the column names + order, shared by every row of a source
values: Vec<Value>, // the cells, positional — values[i] belongs to column i
// ... plus per-record scoped vars and document context, for later
}

Two ideas to take away. First, the cells are a plain Vec<Value> indexed by position; there’s no name stored next to each cell. Second, the schema is held behind an Arc, a shared handle, so a million rows from one source all point at the same schema instead of each carrying their own copy. (Why Arc, and what that costs, is a Data & Representation question.)

The Schema is the list of column names and their order; it’s what lets values[5] mean “lifetime_value”:

clinker-record ·schema.rs ·Schema type @19acdcb4
pub struct Schema {
columns: Vec<Box<str>>, // column names, in order
field_metadata: Vec<Option<FieldMetadata>>,
index: HashMap<Box<str>, usize>, // name -> position, for O(1) lookup
}

This is the join that makes a positional Vec<Value> readable. To find “lifetime_value”, the schema’s index maps the name to a position (say 5); then values[5] is the cell. The names live once, in the schema; the cells are bare values keyed by their slot. In customer_etl, the source declared the schema right in the YAML: customer_id, first_name, last_name, email, status, lifetime_value, zip_code. That’s the schema every customer record is bound to.

Now build a record yourself, scaffolded down from a fully worked model to one you write from scratch. Each rung adds exactly one new idea.

Here’s Alice’s row as the reader first sees it: column names plus a Vec of values, every cell a string. Run it and read the output, each column name printed against its positional cell.

rust // editable

The zip is the whole point in miniature: the names and the cells are stored apart (one schema, one Vec) and re-joined by position when you need them, exactly how the real Record uses Arc<Schema> + Vec<Value>.

Below, the same record is missing its last column. The schema already lists zip_code, but alice has only four cells, so the zip stops short and zip_code never prints. Add the fifth cell so every column has a value. (Like the CSV reader, make it a Value::Str, not an integer.)

rust // editable
💡 Hint 1
zip pairs by position and stops at the shorter side. The schema has five names; alice has four cells, so zip_code is dropped. Push one more Value::Str(...) onto the Vec: a zip code like "94103", read as a string, just like every other freshly-read cell.
Show solution
let alice: Vec<Value> = vec![
Value::Str("1001".to_string()),
Value::Str("Alice".to_string()),
Value::Str("active".to_string()),
Value::Str("15200".to_string()),
Value::Str("94103".to_string()), // zip_code — a string, like every read cell
];

A record’s Vec<Value> must have exactly one cell per schema column, in order. The zip code is text on disk, so it arrives as Value::Str, not Value::Integer, even though it looks numeric. The schema declares which columns exist; the Vec supplies one positional cell for each.

Your turn with much less scaffolding. The schema is given. Build the Vec<Value> for Bob’s row (1002, Bob, active, 8400, 10001) as the CSV reader would first produce it (every cell a string), then print each column against its cell with the same zip loop. The Value enum and main skeleton are provided; you write the Vec and the loop.

rust // editable
Show solution
fn main() {
let schema = ["customer_id", "first_name", "status", "lifetime_value", "zip_code"];
let bob: Vec<Value> = vec![
Value::Str("1002".to_string()),
Value::Str("Bob".to_string()),
Value::Str("active".to_string()),
Value::Str("8400".to_string()),
Value::Str("10001".to_string()),
];
for (column, value) in schema.iter().zip(&bob) {
println!("{column:>15} = {value:?}");
}
}

Five columns, five string cells, joined by position. Note that lifetime_value is "8400" (a string) here, not 8400 (an integer); coercion is the next transform’s job, not the reader’s. You just built, by hand, the exact shape the CSV source hands the rest of the pipeline.

Why-bridge: why a positional Vec and a shared Arc<Schema>?

Section titled “Why-bridge: why a positional Vec and a shared Arc<Schema>?”

Two design choices in the real Record are worth a sentence of why, since you’ll lean on them all through Data & Representation.

  • Cells are positional, not named per cell. Storing the column name next to every cell would repeat the same seven strings on every one of a million rows. Instead the names live once in the Schema, and a cell is found by its slot: values[i]. The cost is that a bare cell is meaningless without its schema, which is why a Record always carries one.
  • The schema is shared behind Arc, not copied. Every row of one source has the same columns, so copying the schema per row would be pure waste. Arc lets a million records share one schema: cloning a record’s schema handle bumps a reference count, it doesn’t duplicate the column list.

You now have the vocabulary of a record: a Value is a cell, a Record is a positional row of cells, and a Schema names the columns once and is shared via Arc. Next: how the YAML pipeline you wrote becomes a plan the engine can actually run.

See it from the pipeline author’s side (optional, one-directional; the schema and record concept where you declare the schema: in YAML rather than read the Rust types):

Glossary terms used: Value, Record, Schema, Arc.