Skip to main content

MotherDuck Acquires Tower to power Data AgentsRead

What Is Columnar Storage? Column vs Row Databases Explained

13 min read · Last updated BY
What Is Columnar Storage? Column vs Row Databases Explained

Columnar storage is a disk layout that stores every value of one column together, instead of storing each row as a contiguous record. A query touching 3 of 50 columns reads roughly 6% of the bytes. DuckDB, ClickHouse, Snowflake, and BigQuery are columnar; PostgreSQL and MySQL are row stores. On ClickBench's 99.9-million-row benchmark, the same data takes 106 GB in PostgreSQL and 94 GB in MySQL but 20.5 GB in DuckDB, and the median query drops from over four minutes to 0.35 seconds — identical hardware, stock configs.

Key takeaways

  • Columnar storage groups values by column. Row storage groups values by record.
  • A query on 3 of 50 columns reads about 6% of the bytes, plus compression.
  • Columnar engines win at OLAP (scans, aggregates, filters). Row engines win at OLTP (single-row reads and writes).
  • Apache Parquet is the standard columnar file format. A columnar database (DuckDB, ClickHouse, Snowflake) has its own internal layout, indexes, and sometimes updates.
  • Do not use a columnar store as your app database. Point lookups, frequent single-row updates, and SELECT * on tiny tables are the wrong shape.
  • Cassandra, HBase, Bigtable, and ScyllaDB are wide-column stores — row-oriented under the hood, built for high-volume writes and key-based reads — and are not columnar OLAP databases.

What is columnar storage?

Columnar storage is how the bytes sit on disk. All customer_id values are packed together, then all region values, then all amount values, then all ts values. A row store packs customer_id, amount, ts for row 1, then the same three fields for row 2.

row-vs-columnar-diagram.svg

Columnar storage vs a columnar database

  • Columnar storage is the layout. Parquet is columnar storage in a file. A DuckDB table is columnar storage inside the engine.
  • A columnar database is a query engine built around that layout: vectorized execution, zone maps, predicate pushdown, a SQL parser. DuckDB and ClickHouse are columnar databases. Snowflake and BigQuery are columnar warehouses.

You can have columnar storage without a database (a directory of Parquet files). You cannot have a columnar database without columnar storage.

Copeland and Khoshafian described the idea in 1985. CWI began the MonetDB work in 1993 and open-sourced it in 2004; C-Store arrived in 2005 and became Vertica. Parquet, DuckDB, ClickHouse, Snowflake, and BigQuery made it the default for analytics.

Columnar vs row storage: what's the difference?

Row storage keeps one record's fields together on disk; columnar storage keeps one field's values together — the difference decides whether a scan reads the whole row or just the columns a query touches.

DimensionRow storageColumnar storage
LayoutOne record, all fields, then the next recordOne field, all rows, then the next field
Best forOLTP: point reads, inserts, updatesOLAP: scans, GROUP BY, filters on a few columns
I/O for AVG(amount) on a 20-column tableReads every field of every rowReads amount (and any filter columns)
I/O for 3 of 50 columnsReads all 50 fields (100% of the row bytes)Reads roughly 6% of the bytes
Compression1.5–3× typical5–10× typical, up to 30× on low-cardinality columns
Concurrency1000s of point queries per node10s–100s of analytical queries per node
SELECT * of one rowCheapRebuilds the row from many columns
ExamplesPostgreSQL, MySQL, SQLiteDuckDB, ClickHouse, Snowflake, BigQuery

The gap is measurable. On ClickBench (99.9M rows, 43 queries, c6a.4xlarge, stock configs), PostgreSQL stores the dataset in 106 GB with a median query of 4.3 minutes, MySQL in 94 GB at 7.3 minutes, and DuckDB in 20.5 GB at 0.348 seconds — roughly 5× smaller and three orders of magnitude faster on the same machine.

A sales table with 40 columns and a query that averages two of them is a textbook query: the row store eads 40 fields per row, while the column store reads only two.

Why are columnar databases faster?

Columnar databases are faster on analytics because they read fewer bytes, skip unread blocks, decompress less, operate on batches, and assemble rows late.

Five mechanisms, roughly in execution order:

  1. Column pruning. A query touching 3 of 50 columns reads roughly 6% of the bytes. SELECT avg(trip_distance) FROM trips never opens the other 19 columns.
  2. Data skipping. Unread blocks never get decompressed. Zone maps (min/max), Bloom filters (equality on high-cardinality columns), and a sparse primary index (range on the sort key) do the pruning. In Parquet, those min/max zone maps live in the file footer written at write time. DuckDB's own storage builds the same min/max stats automatically per row group. WHERE trip_distance > 10 skips every block whose max is 8. On our unsorted taxi file no row group qualifies — long trips appear throughout, so nothing is skipped.
  3. Compression. Same-type values encode well (dictionary, RLE, delta). On the taxi file, dictionary encoding plus Zstd turned 1.09 GB of CSV into 164 MB of Parquet (6.8×).
  4. Vectorized execution. The engine processes batches of 1,024–4,096 values with SIMD instead of one row at a time. The remaining bytes process roughly 10× faster per cycle.
  5. Late materialisation. The engine defers stitching columns back into rows until after filters run, so it does not assemble unread fields for rows the WHERE clause is about to throw away.
MechanismWhat it storesBest for
Zone mapsMin/max per block or row groupRange predicates (WHERE ts >= …)
Bloom filtersProbabilistic membershipEquality on high-cardinality columns
Sparse primary indexSampled sort-key valuesRange scans on the sort key

Reading 3 of 50 columns saves roughly 94% of I/O, the remaining bytes process roughly 10× faster per cycle, and skipping indexes prune roughly 90% of surviving blocks — each mechanism multiplies the others.

On our CSV-vs-Parquet benchmark (DuckDB v1.5.5, Apple M3 Max, OS-cached, median of 3, 11,198,026 NYC taxi rows): avg(trip_distance) was 0.008 s on Parquet-Zstd vs 0.490 s on CSV (~60×). The all-columns top-5 query was still 22×. count(*) was ~160× because Parquet answers it from row-group metadata. Treat 160× as a format trick, 22–60× as the working range.

DuckDB can spill sorts and joins to disk and analyze 100 GB on a laptop with 16 GB of RAM. Columns make the working set smaller.

How does columnar compression work?

Encodings run before a general compressor (Snappy, Zstd, Gzip). The engine picks per column.

EncodingWhat it doesBest for
DictionaryReplace repeated values with small integer codesLow/medium cardinality (country, status, vendor)
Run-length (RLE)Store value + repeat countSorted columns, long runs
DeltaStore differences between neighborsTimestamps, sequential IDs
Frame of reference (FOR)Subtract a block minimum, store offsetsIntegers in a tight range
FSSTTokenize common substringsHigh-cardinality strings (URLs, names) where a dictionary fails

Parquet's spec covers dictionary, RLE, and delta encoding; FOR and FSST are DuckDB's internal storage encodings, not part of the Parquet format itself.

Sort or cluster on a common filter column (usually a date) so RLE and zone maps fire. Unsorted high-cardinality strings compress the least. On the taxi data, dictionary encoding plus Zstd turned 1.09 GB of CSV into 164 MB of Parquet (6.8×); Snappy landed at 5.1×.

What are the key columnar file formats?

Apache Parquet is the interchange format: row groups, column chunks, pages, a footer of min/max stats. Spark, DuckDB, pandas, and the cloud warehouses all read it. Files are immutable; an update means rewriting the file.

Parquet is how columnar data rests on disk; Arrow is how it moves and computes in memory; engines read Parquet from object storage and decode into Arrow buffers. ORC is the Hadoop-era sibling of Parquet. DuckDB's internal table format is also columnar, but mutable: built for INSERT / UPDATE / DELETE with ACID inside one file, not for shipping datasets between tools.

FormatUnitTypical sizeWhere it livesRole
ParquetRow group → column chunk → pageSpark/Trino default 128 MB row groups; DuckDB defaults to ~2 MB on taxi-shaped data with Zstd (122,880 rows)Object storage, data lakesOn-disk rest format
ORCStripe → column streamStripes ~200 MBHadoop / HiveOn-disk rest format
ArrowRecord batchBatches of 1,024–65,536 rowsMemory, IPC, FlightIn-memory compute and exchange
DuckDB internalRow group → fixed-size column blockFixed-size column blocks in one fileA DuckDB database fileMutable tables and transactions
PropertyParquetDuckDB internal
JobWrite-once lake filesActive tables inside DuckDB
StructureFile → row groups → column chunks → pagesRow groups → fixed-size column blocks
UpdatesRewrite the fileIn-place / delta-style
UseInterchange, S3, data lakesQuery processing, transactions

Iceberg, Delta Lake, Hudi, and DuckLake are table formats on top of Parquet. They are not a third storage layout.

When should you use a columnar database?

Reach for a columnar database when the workload scans wide tables narrowly, favors reads over single-row writes, cares about storage cost, and benefits from running SQL directly on files.

  • The workload is analytical: SUM, AVG, GROUP BY, filters over many rows.
  • Tables are wide and queries are narrow.
  • Reads matter more than single-row writes.
  • Storage cost matters. Columnar files are usually several times smaller than CSV.
  • You want SQL on files. DuckDB queries Parquet in place; no load step.

The engines split by architecture, and the architecture decides which limit you hit first. Deep evaluation lives on /learn/best-columnar-databases-2026/.

EngineArchitectureBest forKey limitDeploymentLicense
SQLite (row-store contrast)Embedded row storeApp-local transactions, point lookupsNo column pruning: an analytical scan reads every field of every rowEmbedded libraryPublic domain
DuckDBEmbedded (in-process) columnarNotebooks, local ETL, SQL on ParquetSingle process on one machine; no shared storage or concurrent writers across usersLibrary / CLIMIT
MotherDuckServerless DuckDB with Dual ExecutionShared DuckDB from laptop to cloudScales up on a single node per query, so there is a memory ceiling where MPP clusters spill and keep goingCloud + local DuckDBCommercial
SnowflakeCloud MPP columnar warehouseMulti-petabyte batch, high concurrencyBills a 60-second minimum every time a warehouse resumes; micro-partitions are 50–500 MB uncompressedSaaSCommercial
BigQueryCloud MPP columnar warehouseMulti-petabyte batch, GCP ad-hoc SQLOn-demand bills per byte scannedSaaSCommercial
ClickHouseMPP columnarEvent logs, sub-second dashboardsSelf-hosted ops; historically weaker ANSI joins, though this has improvedSelf-host or CloudApache 2.0
RedshiftCloud MPP columnar warehouseAWS-native BICluster sizing is manual; Serverless bills per second in RPU-hours with a 60-second minimum chargeManaged / ServerlessCommercial
DatabricksLakehouse SQL (Photon on Delta)BI on the same lake as MLBilled in DBUs; you adopt the lakehouse stackCloudCommercial
DruidReal-time OLAPHigh-ingest event streamsCluster ops; event streams over ad-hoc warehouse SQLSelf-host or ImplyApache 2.0
PinotReal-time OLAPExtreme QPS in product analyticsUser-facing serving-cluster opsSelf-host or StarTreeApache 2.0

Logistics company Trunkrs moved operational reporting off Redshift onto MotherDuck for snappier drill-downs in daily meetings.

Implementation that actually matters: sort on ingest by the filter you always use, load in batches of thousands of rows, size Parquet row groups for your writer — Spark and Trino default to 128 MB row groups; DuckDB defaults to 122,880 rows per row group (~2 MB on taxi-shaped data with Zstd) — set ROW_GROUP_SIZE if you need larger, expect vectorized batches of 1,024–4,096 values, partition large facts by date, and never SELECT * unless you need every column. Columnar starts to pay from roughly ten million rows up — our taxi benchmark ran at 11.2 million and measured 22-60x.

Columnar storage is the ideal substrate for agent and LLM analytics: agents need sub-second structured context, and embeddings sit in the same files as nested Parquet lists or in Lance, where FP16/FP8 instead of FP32 cuts 50–75% of the bytes.

When should you not use a columnar database?

Skip a columnar database for OLTP-style single-row writes, whole-row lookups, tiny tables, trickle ingest, and workloads that run into a specific engine's own limits.

  • OLTP. Frequent single-row inserts, updates, deletes. Updating one logical row means touching many column segments. Use Postgres.
  • Write amplification. Columnar systems write large immutable blocks. A one-row update often rewrites a block.
  • SELECT * of whole rows, especially one row by primary key. Reconstructing a row from N columns is the slow path.
  • Tiny tables. A few thousand rows plus columnar metadata can lose to SQLite.
  • Trickle ingest. One event at a time fragments the layout and wrecks compression. Buffer, then flush a batch.
  • Named engine limits. SQLite's single-writer lock model makes even light concurrent writes a bottleneck. Snowflake bills a 60-second minimum on every warehouse resume. MotherDuck scales one node per query and hits a memory ceiling where MPP clusters keep going.

A columnar warehouse does not replace the application database. Keep both.

How do you query columnar data in DuckDB?

Column pruning is just naming the columns you need:

Copy code

SELECT avg(trip_distance) FROM 'trips.parquet' WHERE passenger_count = 1;

DuckDB reads trip_distance and passenger_count. The other 18 columns are skipped entirely.

Write a columnar file from anything DuckDB can see:

Copy code

COPY ( SELECT * FROM 'trips.csv' ) TO 'trips.parquet' ( FORMAT PARQUET, COMPRESSION ZSTD );

An all-columns query on the same Parquet file is the fair lower bound (22× vs CSV on the taxi data), not the headline case. Name your columns.

Try this on your own data

Point DuckDB at a Parquet file you already have. EXPLAIN ANALYZE shows which columns were scanned; parquet_metadata() shows the row-group min/max the skipper used.

Copy code

EXPLAIN ANALYZE SELECT avg(trip_distance) FROM 'trips.parquet' WHERE passenger_count = 1; SELECT path_in_schema, row_group_id, stats_min_value, stats_max_value, row_group_num_rows FROM parquet_metadata('trips.parquet') WHERE path_in_schema IN ('trip_distance', 'passenger_count') ORDER BY row_group_id, path_in_schema;

If Projections in the plan names only the columns you asked for, pruning worked. On the taxi data, trip_distance row-group maxima range from 64 to over 320,000, so WHERE trip_distance > 200 lets DuckDB skip 39 of the 92 row groups without reading them.

Is SQL the standard for columnar databases?

Yes. DuckDB, ClickHouse, Snowflake, BigQuery, and Redshift all speak SQL: SELECT, JOIN, GROUP BY, window functions. Dialects differ (ClickHouse is case-sensitive and picky about joins), but the interface is SQL.

Start using MotherDuck now!

FAQS

Row storage packs every field of a record together. It is the right layout for OLTP: fetch or update one row. Column storage packs every value of a field together. It is the right layout for OLAP: scan or aggregate a few columns across many rows.

Yes. Apache Parquet is a columnar file format: column chunks, page encodings, and a footer of min/max stats. It is not a database. Iceberg, Delta Lake, and DuckLake are table formats that usually store their data as Parquet files on object storage.

Yes. DuckDB is an in-process columnar SQL engine. It stores tables in its own columnar format and reads Parquet, CSV, and JSON in place. MotherDuck runs the same engine in the cloud with shared storage and Dual Execution across laptop and cloud.

Postgres is a row store. It is built for transactions, not wide analytical scans: an 8 KB heap page stores every column of each row together. Extensions such as pg_duckdb can run DuckDB inside Postgres for OLAP, but vanilla Postgres remains row-oriented end to end.

Skip it for OLTP: frequent single-row writes, point lookups by primary key, and trickle ingest. A single-row update touches many column segments, and a few thousand rows plus per-file metadata can make DuckDB or Snowflake slower than SQLite for a lookup. Keep the application database on Postgres or MySQL; columnar pays off on large, wide, read-mostly tables.

No. Cassandra — like HBase, Bigtable, and ScyllaDB — is a wide-column store: rows are partitioned by key and hold many dynamic columns, but the on-disk layout is row-oriented and tuned for high-volume writes and key-based reads. A columnar OLAP database (DuckDB, ClickHouse, Snowflake, BigQuery) stores each field as a contiguous stream for analytical scans.

Yes. The usual 2026 pattern is Postgres or MySQL for the application database and a columnar engine for analytics. Keep recent operational rows in the row store, then batch them into Parquet, DuckDB, MotherDuck, or a warehouse. Hybrid Postgres extensions can age rows into columnar storage inside one table.

They support UPDATE and DELETE, but the write is expensive. Immutable files such as Parquet must be rewritten. Engines with mutable internal storage (DuckDB, ClickHouse) mark or rewrite column segments rather than patching one field in place. Frequent single-row updates belong in a row store; batch the changes and merge them.

Parquet is how columnar data rests on disk. Arrow is how it moves and computes in memory. Engines read Parquet from object storage and decode it into Arrow buffers for vectorized execution. DuckDB speaks both: it writes Parquet for interchange and uses Arrow-shaped batches while a query runs.

No. Iceberg, Delta Lake, Hudi, and DuckLake are table formats: they add snapshots, schema evolution, and ACID over files. The data files underneath are usually Parquet. They are not a third storage layout and they do not replace Parquet, ORC, or Arrow.