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 how the declared schema turns decoded CSV text into typed values before downstream processing.
  • 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 ten shapes (null, bool, integer, float, decimal, string, date, datetime, array, map):

clinker-record ·value.rs ·Value type @3b343a4e
pub enum Value {
Null,
Bool(bool),
Integer(i64),
Float(f64),
Decimal(Decimal),
String(FieldStr),
Date(NaiveDate),
DateTime(NaiveDateTime),
Array(OwnedValues),
Map(OwnedMap),
}

It’s a closed set: a cell is exactly one of these ten, never something else. The CSV decoder produces text, and source admission applies the declared schema. Alice reaches the next node as a row of strings because this example deliberately declares string columns. A declared numeric column would already hold a parsed number at that boundary.

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

clinker-record ·mod.rs ·Record type @3b343a4e

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

pub struct Record {
schema: SharedStorage<Schema>, // the column names + order, shared by every row of a source
values: OwnedValues, // 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 OwnedValues 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 @3b343a4e
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 OwnedValues 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 SharedStorage<Schema> + OwnedValues.

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: OwnedValues = 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 OwnedValues 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 OwnedValues 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: OwnedValues = 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 SharedStorage<Schema>?

Section titled “Why-bridge: why a positional Vec and a shared SharedStorage<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.