Home › Databases & storage › Data layout
algorithm medium

Columnar storage

Problem Analytics workloads (reporting, event and log analysis, feature stores) that scan a few columns across many rows waste I/O and cache when data is stored row by row.

Store each column contiguously so analytic scans read only the columns they need, compress well and run as tight SIMD loops.

scan I/O ∝ rows × compressed bytes of the referenced columns; reconstructing one row costs one access per column
analyticsolapcompressionlayoutscan
Overview

Analytic queries touch a few columns of many rows. A row store drags every column through I/O and cache. A column store keeps each column in its own contiguous chunks, so a 3-of-100-column query reads about 3 % of the bytes. Adjacent values share a type and often a range, so they compress 3-10× with lightweight encodings, and a filter becomes a tight loop over an array. Analytics databases, data-lake files (Parquet), time-series stores and ML dataset pipelines all store data this way.

Key facts

Use when

  • OLAP, reporting, log and event analytics, feature stores, data lakes.
  • Append-mostly data with scans and aggregations over few columns.
  • Data-lake files (Parquet, ORC), time-series and observability stores, and ML dataset pipelines that read a few columns of many rows.

Avoid when

  • OLTP with point lookups and small updates by key: use a row store (B+ tree).
  • Queries need whole rows of very wide tables (SELECT * by ID).

Deep dive

How it works

Parts:

  • row group — about 10⁵-10⁶ rows written together
  • column chunk — one column's values for a row group, split into pages
  • page encoding — dictionary, RLE, delta, frame-of-reference, bit-packing, then optional LZ4 or zstd
  • statistics — min/max/null counts per chunk (zone maps)
  • footer — schema, chunk offsets and statistics, read first
  • delete vector or file — marks removed rows without rewriting chunks
row group 0 (1M rows)                     footer
 ├─ col a: [page][page]... min=3 max=97    schema, chunk offsets,
 ├─ col b: [page][page]... min=0 max=12    min/max/null counts per chunk
 └─ col c: ...
row group 1 ...
  1. Partition rows into groups (about 10⁵-10⁶ rows). Within a group, write each column as a chunk of pages.
  2. Encode each page: dictionary if the number of distinct values is small, otherwise plain, delta or frame-of-reference with bit-packing. Store nulls as a bitmap (Arrow) or definition levels (Parquet).
  3. Footer: schema, chunk offsets and statistics, read first (one small I/O).
  4. Scan: prune row groups by statistics and partition keys, read only the referenced column chunks, decode in batches of about 1-2k values (vectorized execution), evaluate predicates into a selection vector, then fetch the other columns only for the selected rows (late materialization).
  5. Updates: write new files plus delete vectors or bitmaps, then merge in the background (C-Store's write store and read store; Delta Lake and Iceberg delete files).

Code

Frame-of-reference + bit-packing per chunk, zone-map pruning and late materialization:

pub struct Chunk { pub min: i64, pub max: i64, bits: u32, pub n: usize, packed: Vec<u64> }

impl Chunk {
    pub fn encode(vals: &[i64]) -> Chunk { // vals non-empty
        let (min, max) = (*vals.iter().min().unwrap(), *vals.iter().max().unwrap());
        let range = (max as i128 - min as i128) as u64;
        let bits = 64 - range.leading_zeros(); // 0 bits when all values are equal
        let mut packed = vec![0u64; (vals.len() * bits as usize + 63) / 64];
        if bits > 0 {
            for (i, &v) in vals.iter().enumerate() {
                let d = v.wrapping_sub(min) as u64; // offset from the frame of reference
                let (w, o) = ((i * bits as usize) / 64, (i * bits as usize) % 64);
                packed[w] |= d << o;
                if o + bits as usize > 64 { packed[w + 1] |= d >> (64 - o); } // straddles two words
            }
        }
        Chunk { min, max, bits, n: vals.len(), packed }
    }

    #[inline]
    pub fn get(&self, i: usize) -> i64 {
        if self.bits == 0 { return self.min; }
        let b = self.bits as usize;
        let (w, o) = ((i * b) / 64, (i * b) % 64);
        let mask = if b == 64 { u64::MAX } else { (1u64 << b) - 1 };
        let mut d = self.packed[w] >> o;
        if o + b > 64 { d |= self.packed[w + 1] << (64 - o); }
        self.min.wrapping_add((d & mask) as i64)
    }
}

/// Two columns cut into chunks over the same row ranges.
pub struct Table { pub a: Vec<Chunk>, pub b: Vec<Chunk> }

/// SELECT sum(b) FROM t WHERE a BETWEEN lo AND hi
pub fn sum_b_where_a_between(t: &Table, lo: i64, hi: i64) -> i64 {
    let mut sel: Vec<u32> = Vec::new();
    let mut sum = 0i64;
    for (ca, cb) in t.a.iter().zip(&t.b) {
        if ca.max < lo || ca.min > hi { continue; } // zone map: chunk skipped, nothing decoded
        sel.clear();
        for i in 0..ca.n {
            let v = ca.get(i);
            sel.push(i as u32);
            sel.truncate(sel.len() - (!(v >= lo && v <= hi)) as usize); // branch-free keep/drop
        }
        for &i in &sel { sum = sum.wrapping_add(cb.get(i as usize)); } // b decoded only where a matched
    }
    sum
}

Predicates can also run on the encoded values: compare the packed offsets with lo − min and hi − min without adding min back. On dictionary columns, compare dictionary codes after translating the constant once.

Complexity and constants

  • I/O reduction = (bytes of referenced columns) / (bytes of all columns), multiplied by the compression ratio. 3 of 50 similar columns at 5× compression means about 1 % of the row-store bytes.
  • In-memory scans run near memory bandwidth: order of 1-10 GB/s per core for simple filters on bit-packed integers. SIMD unpacking decodes billions of values per second.
  • A selective filter with zone maps on a sorted column reads O(matching chunks), not O(table).
  • Point lookup of a full row = one random read per column chunk plus a decode of the page around it.

Efficiency

  • Cost: a scan reads (bytes of referenced columns) / (bytes of all columns) times the compression ratio: 3 of 50 similar columns at 5× compression is about 1 % of the row-store bytes (computed). In memory, scans run order of 1-10 GB/s per core on bit-packed integers. Bound: I/O bandwidth when cold, memory bandwidth and SIMD when cached.
  • Levers:
    • column pruning — read only referenced columns (computed above) — any query touching few columns
    • dictionary, RLE, delta, bit-packing — smaller columns, filter without full decompression (Abadi et al., 2006) — low-cardinality or sorted columns
    • sort by the common filter column — zone maps and Bloom filters exclude whole chunks — repeated filters on one column
    • vectorized loops, LZ4 or plain for hot data — high-level zstd can bottleneck fast NVMe (order of) — cached data
    • large row groups, compact tiny files — sequential reads — data lakes
  • Naive → standard → state of the art:
    • row store scan · all columns through I/O and cache
    • columnar chunks with light encodings and zone maps · about 1 % of the bytes in the example
    • sorted layout, late materialization, vectorized decode · scans near memory bandwidth

Implementation notes

  • Sort key: sorting by a common filter column makes zone maps effective and runs long (RLE). ClickHouse ORDER BY, Snowflake clustering, Parquet files written sorted.
  • Dictionary fallback: Parquet writers fall back from dictionary to plain encoding when the dictionary grows too big (1 MiB page by default). High-cardinality strings then lose their compression.
  • Row group size trades I/O efficiency (large sequential reads, good compression) against memory for writers and parallelism granularity.
  • Strings: offsets array + contiguous bytes (Arrow layout), or dictionary codes. Avoid one heap allocation per value.
  • Nested data: Dremel repetition and definition levels (Parquet), or Arrow's list offsets.
  • Bloom filters per chunk help equality predicates on unsorted high-cardinality columns where min/max are useless.

Pitfalls

  • The small-files problem: thousands of tiny Parquet files mean metadata and open costs dominate, and compression is poor. Compact them.
  • Row-at-a-time inserts into a column store: each one touches N files or segments. Batch inserts (ClickHouse recommends ≥ 1000s of rows per insert).
  • Wide SELECT * over many columns erases the advantage.
  • Unsorted data makes every zone map span the full range, so nothing is skipped.
  • Decompressing heavy codecs (zstd at high levels) can become the bottleneck on fast NVMe. Use LZ4 or plain encodings for hot data.

Variants and alternatives

PAXcolumnar layout inside each page of a row store, for cache efficiency without separate files.
Hybrid / HTAProw store for writes plus a columnar replica (SQL Server columnstore with delta store, TiFlash, SingleStore).
Apache Arrowthe in-memory columnar format for exchanging data without serialization.
Row store with covering indexeswhen the query set is small and known.

In production

  • Analytics databases: ClickHouse MergeTree, DuckDB, Vertica (C-Store), Snowflake, BigQuery (Capacitor), Redshift, Druid, Pinot.
  • Data lakes and pipelines: Apache Parquet and ORC files; Apache Arrow as the in-memory form.
  • Time-series and observability: InfluxDB 3 stores data as Parquet; ClickHouse is widely used for event and log analytics.
  • Machine learning: Hugging Face Datasets stores data in Arrow and reads Parquet.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
parquet (arrow-rs)Rustapache/arrow-rscargo add parquet————Parquet reader and writer on Arrow arrays
DataFusionRustapache/datafusioncargo add datafusion————vectorized query engine over Arrow and Parquet
LanceRustlance-format/lancecargo add lance————columnar file format aimed at random access
DuckDBC++duckdb/duckdbbrew install duckdb————embedded columnar OLAP database
ClickHouseC++ClickHouse/ClickHousebrew install --cask clickhouse————MergeTree column store, server

Sources

  • M. Stonebraker et al., C-Store: A Column-oriented DBMS, VLDB, 2005. paper
  • D. Abadi, S. Madden, M. Ferreira, Integrating Compression and Execution in Column-Oriented Database Systems, SIGMOD, 2006. paper
  • D. Abadi et al., The Design and Implementation of Modern Column-Oriented Database Systems, Foundations and Trends in Databases, 2013. paper
  • S. Melnik et al., Dremel: Interactive Analysis of Web-Scale Datasets, VLDB, 2010. paper
  • A. Ailamaki, D. DeWitt, M. Hill, M. Skounakis, Weaving Relations for Cache Performance (PAX), VLDB, 2001. paper
  • Apache Parquet, File format documentation. docs

Builds on

Used by

delta main storage · plannedWrite-optimized delta store merged into a read-optimized columnar main (HTAP)
Distributed tracingEngineers whose single request crosses several services need to see where that one request spent its time or failed, but each service only holds its own logs and metrics.
star tree index · plannedPre-aggregated tree over dimension combinations with a split threshold for fast group-bys

Systems (51):

Alternatives and related