OLTP/OLAPGENERALFORMAT-SPECIFICSCALE-SPECIFIC

Row vs Column Storage

The same four columns of the same table, written to disk two ways — and exactly which bytes each query is then obliged to move as a result.

What actually happensHow to build itCan I trust it?

Who needs this, what one row is, and why the obvious build breaks

Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.

The question

If two systems hold identical data, why does the physical arrangement decide which queries are possible?

Who needs this

Every analytical query anyone will ever write against this dataset, and every operational lookup that might also want it. Neither consumer chooses the layout, both are completely determined by it, and only one of them can be optimised for (OLTP vs OLAP).

What one row is

The unit here is not a row — it is the block of bytes that has to be read as a whole. A page in a row store holds whole rows; a column chunk holds one attribute of many rows. Everything in this lesson follows from the fact that storage is addressed in blocks and never in fields.

The obvious build

Think of a table as a grid and assume the storage engine can fetch any cell of it. SELECT avg(spend) names one column, so surely the engine reads one column. This is how almost everyone models storage until something forces them not to, and it is a perfectly reasonable model of what SQL *means*.

Why it breaks

On a row store, SELECT avg(spend) FROM users reads every byte of every row in the table, including the ninety columns the query never mentioned, because the page is the unit of I/O and a page contains whole rows (Pages: The Unit of Everything, Records on Disk).

How it breaks with real data
  • On a row store, SELECT avg(spend) FROM users reads every byte of every row in the table, including the ninety columns the query never mentioned, because the page is the unit of I/O and a page contains whole rows (Pages: The Unit of Everything, Records on Disk).
  • Adding an index on spend does not fix it. The index gives you spend values in order, but the query needs all of them, so scanning the index is scanning the table with extra indirection (Why Is This Query Slow? Indexes).
  • The team switches to a columnar format and a different query gets slower: SELECT * FROM users WHERE id = 3 now costs one read per column plus reassembly, where the row store needed one page read. Nothing was misconfigured; the trade simply ran the other way (OLTP Workloads).
  • Compression ratios that looked wonderful on the country column do nothing at all for session_id, and the team's storage estimate — extrapolated from the first column they tested — is wrong by a large factor (Dictionary, Run-Length, Delta and Bit Packing).
  • A pipeline starts issuing single-row updates against the columnar table. Each one has to rewrite or shadow a whole chunk, small files accumulate, and read performance degrades until someone runs compaction (File Compaction, File Size and the Small-Files Problem).
SourceIngestionRawTransformationValidationStorage ModelServingConsumerObservability

What is actually happening

  • Storage devices, page caches and CPU caches all move fixed-size blocks. Nothing anywhere in the stack can fetch four bytes in isolation — the smallest unit that moves is a sector, a page or a cache line, and whatever else is inside that unit comes along for free and for nothing (Memory Moves in Lines, Not Variables, The Memory Hierarchy).
  • A row-oriented layout puts one record's columns adjacent. That makes "give me this whole record" one block fetch, and makes "give me this one attribute of every record" a fetch of every block with most of each one discarded (How Is Database Data Physically Stored?, Slotted Pages).
  • A column-oriented layout puts one attribute's values adjacent across many records. That inverts both statements exactly: one attribute of everything is contiguous, and one whole record is scattered across as many places as it has columns (Physical Layouts Compared: Heap + Secondary Index vs Clustered Index).
  • Column adjacency also changes what the *data* looks like to a compressor. Values in a column share a type, a domain and usually a great deal of local similarity, so run-length and dictionary encodings apply; a row mixes an integer, a two-character code, a small integer and a decimal, and there is far less structure to exploit (Why Analytical Data Compresses, Dictionary, Run-Length, Delta and Bit Packing).
  • Real analytical formats are hybrids rather than pure columns. Parquet and ORC split a file into row groups first, then store each column's chunk inside the row group, so a horizontal slice of rows stays local to one place in the file while remaining column-addressable inside it. That hybrid is what makes both partition pruning and projection work on the same file (Parquet Internals).
  • The same argument scales down to the CPU. A row store fills a cache line with one record's mixed fields; a column store fills it entirely with values the loop is about to use, which is the same reasoning as choosing a structure of arrays over an array of structures (Array of Structs, or Struct of Arrays?, Spatial Locality).

The same table, written twice

SIMPLIFIEDDrawn as eight rows and four columns so the arrangement is visible. Real column chunks hold hundreds of thousands of values and carry a header, a null bitmap and encoded rather than literal values — none of which changes the adjacency argument, which is the part that matters.

Take four columns of a users table — id, country, age, spend — and write them to storage in the two possible orders. The values are identical, the schema is identical, and every query returns the same answer against both. What differs is which bytes end up inside the same block, and therefore which bytes a query is forced to move in order to reach the ones it wanted.

Read the sketch below twice, once for each query written in it. The first query wants one column of every row; the second wants every column of one row. Each layout makes one of them a single contiguous read and the other a scatter, and no third layout makes both contiguous, because "adjacent" is a single ordering and there are two ways to want it.

Nothing here is an optimisation the engine applies or declines. The row store cannot read spend without reading id, country and age, because they are inside the same physical block. That is not a limitation of the query planner; it is what the block contains.

ROW-ORIENTED   one page holds whole records, back to back

  page 42
  +-------------------------+-------------------------+-------------------------+
  | 1 | DE | 34 | 120.00    | 2 | FR | 51 |  80.00    | 3 | DE | 29 | 400.00    |
  +-------------------------+-------------------------+-------------------------+
    ^------ one record, contiguous ------^

  SELECT avg(spend) FROM users
     -> read every page of the table
     -> id, country and age travel from storage into memory and through
        the CPU cache whether the query wanted them or not
     -> roughly three quarters of the bytes moved are discarded on arrival

  SELECT * FROM users WHERE id = 3
     -> one index descent, one page read, done.  ROW STORAGE WINS THIS.


COLUMN-ORIENTED   one chunk per column, records split apart

  id       chunk  ->  [ 1   , 2   , 3   , 4   , 5   , 6   , 7   , 8    ]
  country  chunk  ->  [ DE  , FR  , DE  , IT  , DE  , FR  , ES  , DE   ]
  age      chunk  ->  [ 34  , 51  , 29  , 44  , 38  , 61  , 27  , 45   ]
  spend    chunk  ->  [120.00,80.00,400.00,15.00,38.50,210.00,9.99,77.00]

  SELECT avg(spend) FROM users
     -> open the spend chunk, read it end to end
     -> the id, country and age chunks are never opened, so their bytes
        never leave storage at all.  COLUMN STORAGE WINS THIS.

  SELECT * FROM users WHERE id = 3
     -> four separate chunk reads plus reassembly of position 3
     -> and finding position 3 means scanning the id chunk, because
        there is no index descent here to find it with.

Which bytes each query is obliged to move

The compare below states the same thing as the sketch in the language of cost. The key phrase is "there is no way to ask for less": in a row layout, "read only the spend column" is not an expressible request, because the smallest thing storage will hand you already contains the other three.

The reverse case deserves to be stated with equal force, because the columnar literature usually skips it. Reconstructing a whole row from a column store means one read per column plus a positional join to line them back up, and without a row-store index there is not even a cheap way to find which position you want. That is why nobody runs their checkout on a columnar table.

What makes this the hinge of the domain is that it converts a vague preference into a physical fact. "Analytics is different" is a slogan. "The block contains whole rows, so declining to read a column is not expressible" is a mechanism, and every later topic — Parquet row groups, partition pruning, projection pushdown, scan cost — is an elaboration of it.

  • One column, all rows (avg(spend)) — column layout reads one chunk; row layout reads the whole table.
  • All columns, one row (SELECT * WHERE id = 3) — row layout reads one page; column layout reads one chunk per column and reassembles.
  • Update one field of one row — row layout rewrites one page; column layout rewrites or shadows a chunk, and repeated at volume this is what produces the small-file problem (File Size and the Small-Files Problem).
  • Append many rows — row layout appends pages; column layout must accumulate a batch before it can encode a chunk worth writing, which is where the freshness pressure comes from (Cost vs Freshness).
`SELECT avg(spend) FROM users` under each layout
Row-oriented storage
The engine reads every page of the table in range. Each page contains complete records, so `id`, `country` and `age` are transferred from storage into memory and through the CPU cache alongside the `spend` value the query wanted, and are discarded immediately on arrival.
Column-oriented storage
The engine opens the `spend` chunk and reads it end to end. The other three chunks are never opened, so their bytes never move. The values that are read are adjacent, uniform in type, and can be decoded and summed in bulk rather than one record at a time.

Storage is addressed in blocks, so what a block contains decides what "read less" can mean. Under a row layout the smallest fetchable unit already contains every column, and no planner, index or hint changes that. Under a column layout the smallest fetchable unit contains one attribute, which turns "read only what the query named" from a wish into the default behaviour of the reader.

What decides how much columnar actually buys you

FORMAT-SPECIFICWhich encodings are available and how statistics are stored differ between Parquet, ORC and native warehouse formats, so the second and third drivers vary in strength between them. The first driver — projection — behaves the same in all of them because it is a property of the layout rather than of the encoding.

Columnar is not a uniform speedup and treating it as one produces bad estimates in both directions. Its benefit is a product of several independent properties of your data and your queries, and if any one of them is absent the corresponding term drops out. A five-column table queried with SELECT * on randomly ordered high-cardinality data gets almost nothing from it.

The drivers below are ordered relative to each other within one comparison. They are not measurements and they do not transfer to a specific dataset — quoting a compression ratio or a scan-speed multiple from someone else's benchmark is the characteristic error in this area, because every one of these terms is data-dependent.

The last driver is the one that points the other way, and it is the honest summary of the whole trade: every benefit above it assumes the data is written once, read often, and read narrowly. A workload that violates the first assumption erodes all of them at once, which is why analytical formats and frequent point updates are a poor pairing until a table format is doing the reconciliation explicitly (Open Table Formats).

How much a column layout helps, and what decides it
Fraction of columns the query does not reference

The primary driver. Four columns used out of ninety is close to the whole win; four out of five is close to none. This is why SELECT * is the construct that defeats the format.

Repetitiveness within each column

Adjacent values of one attribute share a type and a domain, so dictionary and run-length encodings apply. A column of unique identifiers has almost nothing for an encoder to exploit.

How sorted the data is on the predicate column

Decides whether block min/max statistics can skip anything. On randomly ordered rows, every block's range covers the predicate and nothing is skipped no matter how good the statistics are.

Rows per block or row group

Larger blocks amortise metadata and encode better, and simultaneously raise the floor on the smallest possible read. It is a trade with a middle, not a value to maximise.

Rate of single-row updates

Works against the layout rather than for it. Every driver above assumes write-once-read-many, and frequent point mutation erodes all of them at once.

Relative weights, not measurements. The ordering is what transfers between platforms; the magnitudes do not, and this domain never publishes a price.

Relative weights within a single comparison, not measurements, and not transferable to a specific dataset. The teaching is the ordering plus the last row: columnar is a bet that the data is written once, read often, and read narrowly, and it pays out in proportion to how true that is.

How to build it

Most important first.

  • Choose the layout from the access pattern, not from the product category. Scan-shaped, narrow-projection, append-mostly work wants columns; key-shaped, whole-record, update-heavy work wants rows (OLTP vs OLAP).
  • Once columnar, stop writing SELECT *. It is the one construct that defeats the layout's primary advantage, and BI tools emit it constantly unless you stop them (Projection Pushdown).
  • Sort or cluster the data on the column the common predicate uses, because per-block statistics can only skip blocks whose ranges exclude the value — and on randomly ordered data no block's range excludes anything (Clustering and Sort Order).
  • Size the blocks deliberately. Larger row groups amortise metadata and encode better, and they also raise the floor on the smallest possible read. It is a trade with a middle, not a parameter to maximise (File Size and the Small-Files Problem).
  • Batch the writes. Columnar layouts assume write-once-read-many; if the workload genuinely needs row-level mutation, use a table format that implements it explicitly rather than emulating it with rewrites (Open Table Formats, Upserts and Merges).

What this actually promises

Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.

  • A layout guarantees nothing about correctness. Both layouts return identical results for identical queries; this whole lesson is about cost, not about answers.
  • Columnar guarantees that a column the query does not name is not read. That is a physical property of the format, not an optimisation the planner might or might not apply (Projection Pushdown).
  • Block statistics guarantee only that a block *can* be skipped when its range excludes the predicate value. They can prove absence and never presence, so a block that is read may still match nothing (Predicate Pushdown).
  • Nothing guarantees compression helps. Encoding gains depend entirely on the data's cardinality and ordering, and a high-cardinality identifier column may barely shrink at all (Dictionary, Run-Length, Delta and Bit Packing).

Can I trust it?

A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.

The check that would catch this
  • The check that belongs here is structural rather than statistical: assert the file layout you believe you have. Count files per partition, check the row-group count and size distribution, and confirm the sort column is actually sorted (Physical Data Layout).
  • It misses everything about the values. A perfectly laid-out file full of wrong numbers scans beautifully (Data Quality).
  • It also misses drift over time — a table that was well laid out at creation and has since received six months of small incremental writes will pass any one-off inspection made before them (File Compaction).
Freshness
  • Columnar layouts introduce a write-side batching pressure: rows have to accumulate before it is worth encoding a chunk, so the freshest data usually sits in some smaller, less optimised form until it is compacted in.
  • That is why analytical systems often have a two-tier read path — recent unoptimised data plus older optimised data — and why the freshest slice of an analytical table is frequently the slowest to query.
  • The tradeoff is direct and unavoidable: smaller write batches give fresher data and more small files; larger batches give better layout and staler data (Cost vs Freshness, File Size and the Small-Files Problem).
When the schema or meaning changes
  • Adding a column to a columnar table is cheap in a way it is not in a row store: the new column is a new chunk, and existing files simply do not have it. Readers must therefore tolerate a missing column, which is a schema-evolution rule rather than a physical one (Schema Evolution).
  • Changing a column's type generally means rewriting its chunks, since the encoding is type-specific. That is a rewrite of everything, and it is the reason type changes are treated as breaking in analytical stores (Breaking Schema Changes).
  • The layout itself evolves independently of the schema. Re-partitioning or re-sorting a table changes nothing about its columns and everything about its query cost, and it is invisible to every schema check you have (Partitioning).
How to re-run this safely
  • Layout mistakes are fully recoverable and that is the good news in this lesson: the data is unchanged, and re-partitioning, re-sorting or compacting is a rewrite rather than a loss (File Compaction).
  • The rewrite is not free — it reads and writes the whole dataset — so it is bounded by cost and time rather than by risk, and it should be done into a new location and swapped in atomically (Atomic Publish).
  • What is not recoverable is a column the pipeline never landed. Layout can be redone; a value that was never written cannot be reconstructed by rearranging the ones that were (The Raw Landing Zone).

What can go wrong

Failure modes
  • A columnar table queried with SELECT * by a BI tool, so the format's main advantage is never used and the team concludes columnar was oversold (Projection Pushdown).
  • A columnar table receiving single-row updates, generating small files and progressively worse read performance (File Size and the Small-Files Problem).
  • Statistics that never skip anything because the data is randomly ordered on the predicate column (Clustering and Sort Order).
  • A storage estimate extrapolated from one compressible column and wrong for the wide, high-cardinality ones (Dictionary, Run-Length, Delta and Bit Packing).
  • The mitigation failing: compaction scheduled aggressively enough that it competes with the queries it was meant to speed up, and rewrites data that was about to be rewritten again anyway.
Misreads
  • "Parquet is CSV with compression bolted on." Compression is a consequence of the layout, not the point of it. The point is that a reader can decline to open a column chunk at all, which no amount of compressing a row-oriented file will ever provide (Parquet, CSV, JSON and Their Limits).
  • "Columnar is faster." Columnar is faster for scan-shaped, narrow-projection queries on append-mostly data. It is slower for whole-row lookups and much slower for point updates, and saying so without the qualifier is how teams adopt it for the wrong workload (OLTP vs OLAP).
  • "Compression ratios transfer between datasets." They depend on cardinality, ordering and value distribution. A ratio measured on one table predicts almost nothing about another (Dictionary, Run-Length, Delta and Bit Packing).
  • "We can get the same effect with an index." An index gives you a selective path to a small number of rows. Nothing about it lets you avoid reading columns you did not ask for, because the base table is still row-oriented (An Index Scan Is Not Automatically Faster).

Operating it

How you see it in production
  • Bytes read per query against bytes in the table — the single ratio that tells you whether projection and pruning are doing anything (Scan Cost).
  • File count and file size distribution per partition, tracked over time, because both degrade gradually and neither has a threshold anyone notices (File Size and the Small-Files Problem).
  • The fraction of row groups skipped by statistics on the common predicate. Near zero means the sort order does not match the query pattern (Partition Pruning).
  • Column-level access frequency from query logs, which tells you which columns justify their place in the layout and which are never read at all (Metadata: Technical, Operational and Business).
What changes at 10x and 100x
  • At small scale the layout barely matters, because the whole table is resident in memory and reading extra columns costs a memory copy rather than an I/O (Working Set: Why Performance Falls Off a Cliff).
  • At 10x, projection starts to dominate: the difference between reading four columns and ninety is the difference between an interactive query and a coffee break.
  • At 100x, block-level skipping matters as much as projection, because even four columns of a large enough table is too much to read. That is the point where sort order stops being tidiness and becomes the design (Clustering and Sort Order).
What drives cost here
  • Read cost is bytes moved, and bytes moved is set by columns projected, blocks skipped and how well the surviving columns encode (What Actually Drives Data Platform Cost).
  • Write cost is higher for columnar: encoding, statistics and chunk assembly are real work on ingest, paid once to make many reads cheaper.
  • Storage cost usually falls with columnar because of encoding, and by an amount entirely dependent on the data — which is why any specific ratio quoted for it should be treated as an anecdote about someone else's dataset (Why Analytical Data Compresses).
What this approach costs
  • Columnar buys projection, block skipping and strong encoding. It costs whole-row reconstruction, single-row updates, and a write path with real work in it.
  • Row-oriented buys cheap whole-record access and cheap in-place updates. It costs the ability to decline to read a column, which is the entire analytical optimisation surface.
  • Hybrid formats — row groups of column chunks — buy most of both and cost a tuning parameter: the row-group size, which is simultaneously the skipping granularity and the minimum useful read.

Where this applies

Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.

  • GENERALThat storage moves in blocks, and that a block's contents are decided by the layout, is true of every storage device and every cache in the stack. Everything in this lesson is downstream of that one fact rather than of any product.
  • FORMAT-SPECIFICParquet and ORC are hybrids: row groups (or stripes) first, column chunks inside them, with statistics at both levels. A pure column store such as a classic columnar database has no such horizontal boundary, so its skipping granularity and its update story are both different.
  • SCALE-SPECIFICBelow the point where the working set stops fitting in memory, the layout difference is a memory-copy cost rather than an I/O cost, and it is often unmeasurable. The argument becomes decisive exactly when the data stops being resident.

Where the depth lives

This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.

Domains that do not exist yet
  • Computer Architecture owns the layer below this one: why a cache line is the smallest thing that moves between memory and the CPU, and why a layout that fills each line entirely with values a loop will use is the same idea as columnar storage applied a few orders of magnitude smaller.
  • DevOps / Production Engineering owns the rewrite itself — re-partitioning or re-sorting a large table is a migration with a rollout, a verification step and a rollback, not a configuration change.