skip to content

In a columnar table, how does an engine rebuild a full row when each column is stored separately?

level: middleimportance: should knowfreq 50%

answer

  1. no row id is stored beside the values
  2. the chunks of a slice are the same length
  3. the k-th entry means the same thing everywhere
  4. a null still has to occupy its slot
  5. removing one value would shift everything after it

basics

~20 s

By ordinal position. Within one row group every column chunk holds the same rows in the same order, so the k-th value of each chunk belongs to the k-th row. No row identifier is stored beside the values; the shared ordering is the join.

solid answer

~50 s

Row reassembly — often called stitching — is positional. All the column chunks of a single row group cover the same rows in the same order and contain the same number of values, so the engine rebuilds row *k* by taking the *k*-th value from each chunk it needs. Nothing links the values explicitly: there is no per-value row id and no key, only the invariant that the orderings agree. Two consequences follow. First, a column can never be sorted or reordered independently of its siblings — a sort is a property of the whole slice. Second, missing values must still occupy a position, so nulls are tracked in a validity bitmap or equivalent structure alongside the values rather than by omitting them. Deletion is handled the same way: engines mark positions as deleted rather than physically removing one value from one chunk, because removal would shift every downstream position out of alignment.

code

text · 7 lines
text
row group 0, rows 0..4
  user_id : [ 17, 42, 42,  9, 31 ]
  country : [ FR, FR, DE, FR, US ]
  amount  : [ 10, 25,  0, 80, 12 ]
  valid?  : [  1,  1,  0,  1,  1 ]   -- amount is NULL at position 2

row 3  =  (user_id[3], country[3], amount[3])  =  (9, FR, 80)

go deeper

for a junior

Recall the core rule: within a slice, the k-th value of every column belongs to the k-th row, and no identifier is stored to say so.

for a middle

Explain the invariant and its direct consequences — validity bitmaps for nulls, offsets arrays for variable-length values, and why sorting must permute every column together.

for a senior

Draw the operational conclusions: why deletes are marked rather than removed, why an update becomes a delete plus an append, and why a scattered positional gather can cost nearly as much as a full chunk decode.

for a principal

Frame it as the constraint that shapes the whole write path — immutability, marker-based deletes and rewrite-based sorting all fall out of one alignment rule, and any design that pretends otherwise pays for it in rewrites.

## The problem In a row store, a row is a contiguous unit; retrieving it is one read. In a columnar table the values of one row are scattered across as many chunks as the row has columns, potentially spread across a large file. Something has to tell the engine which values belong together. The answer used by essentially every columnar engine is deliberately minimal: **ordinal position within a row group**. ## The invariant Within one row group, every column chunk satisfies three properties: it contains a value for every row of that slice, the values appear in the same row order as every other chunk in the slice, and the count matches. Given that, the *k*-th entry of the `user_id` chunk and the *k*-th entry of the `amount` chunk are, by construction, the same row. Reassembly is a parallel walk over the chunks the query needs, or a gather at specific positions if only some rows survive a filter. This is why nothing resembling a row identifier is stored next to each value. Storing one would cost as much as the data for narrow columns and would be redundant, since position already carries the information. Some engines expose a virtual row-position pseudo-column derived from the ordinal, but it is computed, not stored. ## What follows from the invariant **A column cannot be reordered on its own.** Sorting is a property of the entire slice: to sort by `event_ts`, the writer must permute every column's values identically. That is exactly why sort or cluster keys are declared on the table and applied at write time rather than per column, and why re-sorting existing data means rewriting all of its columns, not just the sorted one. **Nulls must occupy a position.** If a column simply omitted absent values, its chunk would be shorter than its siblings and every position after the first null would be misaligned. Engines therefore keep the absence information separately — most commonly a validity bitmap with one bit per row — while the values array either contains a placeholder or is compacted with the bitmap describing how to expand it. Either way the logical position count is preserved. **Variable-length values need an offsets array.** For strings or binary blobs, the *k*-th value is not at a fixed byte multiple. The chunk carries an array of lengths or offsets alongside the bytes, so that position *k* can be located without scanning from the start. This is why gathering scattered positions from a string column is more expensive than from a fixed-width integer column. **Deletes cannot physically remove a value.** Removing entry *k* from one chunk would shift every subsequent entry and break alignment against every other column; removing it from all chunks means rewriting the entire slice. Engines therefore mark deleted positions — a deletion bitmap or an equivalent marker consulted at read time — and reclaim the space later when the slice is rewritten. (How and when that rewrite happens is the compaction story, owned elsewhere; the reason a marker is needed at all is this alignment invariant.) **Updates are the same problem.** Changing one field of one row cannot be an in-place edit of one chunk in an immutable layout; it becomes a delete marker plus a new row appended in a later slice. ## Reassembly cost Stitching is not free, and its cost has a shape worth knowing: - It is proportional to the number of **columns projected** times the number of **rows surviving**. Adding a column to the SELECT list adds a chunk to gather from for every surviving row. - Sequential reassembly (all rows of a slice, in order) is cheap: it is a linear walk of each chunk with excellent cache behaviour. - Positional gather (only the rows that passed a filter) is more expensive, and its cost depends on the encoding. A fixed-width, uncompressed chunk supports direct indexing; a run-length or delta-encoded chunk may require decoding from the start of a block to reach position *k*, so a scattered gather can end up decoding nearly the whole chunk anyway. That last point is exactly why deferring reassembly is a planning decision rather than an automatic win. ## Across row groups The positional invariant is scoped to a row group. Two chunks from different slices say nothing about each other, and the slices themselves are independent units — which is what allows them to be read in parallel and in any order. A row identity that must survive across slices (for a delete map, or for an upsert key) has to be expressed as something durable: a file or slice identifier plus an ordinal, or an explicit key column. ## Interview framing The crisp answer is one sentence — "the k-th value of every chunk in a row group is the k-th row" — followed by the consequences, because the consequences are what the interviewer is actually testing: why sorting is table-wide, why nulls need a bitmap, why deletes are marked rather than removed, and why gathering a filtered subset can cost more than reading everything.

  • Why can't you sort one column of a columnar table without touching the others?
    Because ordering is what identifies rows. Permuting one chunk would break the positional correspondence with every other chunk in the slice, so the values of a row would no longer line up. Sorting is applied to the whole slice at write time, permuting every column identically — which is why changing a sort key means rewriting the data, not just one column.
  • How are NULLs represented so positions stay aligned?
    With a separate validity structure, typically a bitmap of one bit per row in the chunk. The value array may hold a placeholder at that position or be compacted with the bitmap describing where to reinsert gaps. Either way the logical value count matches the slice's row count, so ordinal alignment across columns holds.
  • Why is gathering 1,000 scattered rows from a string column costlier than from an integer column?
    Fixed-width integers can be indexed arithmetically — position k is at a known byte offset. Variable-length values need an offsets or lengths array to locate entry k, and if the chunk is encoded or compressed in blocks, reaching a scattered position may require decoding from the start of its block. Scattered access therefore amplifies into near-full decoding.

Think of several equal-length lists written on separate sheets of paper with no names on them, only line numbers implied by position. Line 7 on each sheet describes the same person — as long as nobody ever inserts or removes a line from one sheet alone.

saying these in an interview costs you the question

  • Believes each value stores a row id or key alongside it
  • Thinks a column can be sorted independently of the others
  • Says NULLs are simply omitted from the column's values
  • Claims a delete removes one value from each chunk in place
  • Assumes positional gather is as cheap as a sequential walk

context