What you'll learn
Quick Answer
Columnar storage keeps every value of a single column next to each other on disk, instead of keeping every field of a single record next to each other the way row storage does. That layout lets an analytics query read only the columns it actually needs and compresses far better, because similar values sit side by side. We measured this for real: a 2-million-row dataset shrank from 59.07 MB as CSV to 21.84 MB as Parquet, and a single-column SUM query ran faster against the Parquet file.
Row-Oriented vs Column-Oriented: The Core Idea
Picture a table of sales records: order_id, region, product, quantity, price. A row-oriented format — a CSV file, or the heap pages Postgres writes to disk — stores record 1's five values next to each other, then record 2's five values next to each other, and so on. That's the natural way to think about a "row" of data, and it's the right layout when your workload mostly reads or writes one whole record at a time, which is what a typical OLTP app does: fetch this one order, update this one user's profile.
Columnar storage flips the axis. Instead of storing record-by-record, it stores column-by-column: every order_id value contiguously, then every region value contiguously, then every price value contiguously. Physically on disk, a column is one long run of same-typed values instead of five different-typed values interleaved with four others. This is a completely different decision from how an engine indexes or structures writes internally — that's the B-tree-vs-LSM-tree question (covered in our B-Trees and LSM-Trees Actually Store Your Data post), which is about how an OLTP engine organizes its index structure. Columnar vs row is a layer below that: it's about which bytes sit next to which other bytes on disk in the first place, and it applies whether or not you're using a tree index at all.
Why This Layout Wins for Analytical Queries
An OLAP (analytical) query typically looks like SUM(price) FROM sales WHERE region = 'North' — it touches one or two columns, but scans potentially every row. If the table is stored row-wise, the engine still has to read every byte of every row off disk to get at the price and region fields, because those bytes are physically interleaved with order_id, product, and quantity bytes you don't care about for this query. You pay the I/O cost of the whole table to answer a question about two of its five columns.
Stored column-wise, the engine can open the file, jump straight to the price column's contiguous byte range, and never touch order_id, region, product, or quantity at all unless the query actually needs them. For a wide table — real analytics tables commonly have dozens or hundreds of columns — and a query that needs three of them, this is the difference between reading 3% of the file and reading 100% of it. That's the whole case for columnar storage in one sentence: analytical queries are column-selective but row-exhaustive, so storing by column lets the engine skip exactly the bytes the query doesn't need.
Why Columnar Storage Also Compresses Better
There's a second, independent win: compression. A row of mixed data — an integer ID, a short region string, a product code, a quantity, a price — has high entropy, because five different kinds of values are packed next to each other with little in common. A column of 2 million region values, on the other hand, might contain only four or five distinct strings repeated over and over. A column of quantity values might all fall between 1 and 20. That kind of repetition and narrow range is exactly what compression algorithms exploit well.
Columnar formats go further than generic compression and use type-aware, column-specific encodings before the general-purpose compressor even runs: dictionary encoding replaces a low-cardinality string column with small integer codes pointing into a lookup table, run-length encoding collapses long runs of an identical repeated value into a single (value, count) pair, and delta encoding stores the difference between consecutive values (useful for sorted or near-sorted numeric columns like timestamps or IDs) instead of the full value each time. Only after these column-aware transforms does a general compressor like Snappy or Zstd run on what's left. Parquet's own format documentation and ClickHouse's architecture writeups both describe exactly this layering — it's why columnar files routinely come out several times smaller than row-oriented equivalents of the same data, on top of whatever a generic compressor would have achieved on its own.
Real Measurement: CSV vs Parquet, by the Numbers
Claims about compression ratios are cheap; actual files on disk aren't. We built a synthetic 2,000,000-row, 5-column sales dataset with pandas and pyarrow (pyarrow 25.0.1, pandas 3.0.3) and wrote it both ways:
df.to_csv(CSV_PATH, index=False)
table = pa.Table.from_pandas(df, preserve_index=False)
pq.write_table(table, PARQUET_PATH, compression="snappy")The measured, actual file sizes on disk:
- CSV (row-oriented): 61,937,847 bytes (59.07 MB)
- Parquet (columnar, Snappy-compressed): 22,901,152 bytes (21.84 MB)
- Parquet came out 2.70x smaller than the CSV, on identical data
Then we timed a column-scan-style query — summing the price column across all 2 million rows — using real wall-clock timing (time.perf_counter()), reading only the one needed column in each case:
csv_df = pd.read_csv(CSV_PATH, usecols=["price"])
csv_sum = csv_df["price"].sum()
# measured: 2157.0 ms
price_table = pq.read_table(PARQUET_PATH, columns=["price"])
parquet_sum = price_table.column("price").to_pandas().sum()
# measured: 1298.9 msParquet's column-scan was about 1.7x faster than CSV for this single-column sum, and both produced the identical sum (5,050,331,111.64), confirmed by an assertion in the script. That 1.7x is real but modest, and it's worth being honest about why it isn't more dramatic: this was a single run on a small, locally-cached 2-million-row file with no predicate pushdown or row-group skipping actually exercised, and pandas' CSV parser with usecols still has to tokenize every field of every row before it can discard the ones it doesn't want — it doesn't get to skip bytes the way the Parquet reader does. For reference, reading all 5 columns of the same CSV (which is what a parser has no choice but to effectively do, field by field) took 5,615.7 ms — more than four times the single-column Parquet read. At production OLAP scale (billions of rows, dozens of columns, data spread across many files with per-file statistics the engine can use to skip whole files), the columnar advantage compounds a lot further than a 1.7x on one synthetic benchmark run on a laptop.
How Real OLAP Engines Push This Further
Our benchmark above used plain pyarrow reading a single local file, which already shows the core effect. Production columnar engines like ClickHouse, Redshift, BigQuery, and Snowflake add several more layers on top. Vectorized execution processes a column's values in batches using CPU SIMD instructions instead of one row at a time, which is only efficient because the values are already contiguous in memory — you can't vectorize cleanly over interleaved row data the same way. Predicate pushdown with row-group statistics means a file format like Parquet stores the min/max value of each column for each row-group chunk, so a query filtering WHERE price > 4000 can skip entire chunks without decompressing or reading them at all, just by checking the stored statistics first. Stack vectorization, statistics-based skipping, and column pruning together, and the gap between row and columnar storage on a real multi-billion-row warehouse table is routinely an order of magnitude or more, not the 1.7x we measured on one small local file.
When Row Storage Still Wins
None of this makes columnar storage universally better — it makes it better for a specific access pattern. If your workload is "fetch this one order and all twenty of its fields" or "update this one user row," row storage is the right tool: all the fields you need are sitting together in one contiguous chunk, one disk read away. Reconstructing a full row from a columnar layout means reading from potentially dozens of separate column locations and stitching them back together, which is overhead a row store simply doesn't have. This is exactly why Postgres (row-oriented heap pages with B-tree indexes) and Cassandra (LSM-tree based) remain the right default for OLTP workloads — the tradeoffs of those storage engines are their own topic, not this one. The practical rule of thumb: if your queries ask "give me everything about this one thing," reach for row storage; if they ask "give me one or two facts about millions of things," reach for columnar.
