Backend / Data engineering / Parquet / 01_parquet_fundamentals.md

Parquet — Columnar Format Fundamentals

Updated 7 interview angles 7 min read source
On this page13
  1. Why columnar matters
  2. Structure of a Parquet file
  3. Predicate pushdown
  4. Projection pushdown
  5. Compression
  6. Encoding
  7. Row group size
  8. Schema evolution
  9. Partitioning on disk
  10. Reading and writing
  11. Parquet vs alternatives
  12. Common gotchas
  13. Interview angle

Parquet — Columnar Format Fundamentals

The dominant columnar file format for analytics. If you do any data work, you write to Parquet by default.

Why columnar matters

Row-oriented (CSV, JSON, traditional RDBMS):

text
row1: [user_id, name, email, amount, ts]
row2: [user_id, name, email, amount, ts]
row3: ...

Column-oriented (Parquet, ORC):

text
user_id column: [1, 2, 3, ...]
name column:    [Alice, Bob, Carol, ...]
amount column:  [10, 20, 30, ...]

For analytics queries (SELECT user_id, SUM(amount) FROM ... GROUP BY user_id), columnar wins because:

  1. Read only the columns you need. Scanning a 10-column row-store file reads everything; columnar reads just user_id and amount.
  2. Compression is much better. Similar values clustered together compress 5-10× better than mixed types in row order.
  3. Vectorized processing. Modern CPUs process columns in SIMD batches.

Typical analytics workload on Parquet vs CSV: 10-100× faster + 5-10× less storage.

Structure of a Parquet file

text
[ Row Group 1 ]
    Column Chunk: user_id
        Page 1 (dictionary)
        Page 2 (data)
    Column Chunk: amount
        Page 1
    Column Chunk: name
        ...
    Footer (statistics: min/max per column chunk)

[ Row Group 2 ]
    ...

[ File Metadata ]
    Schema
    Row group offsets
  • File — split into row groups (default ~128 MB).
  • Row group — a horizontal slice of rows, with all columns for those rows.
  • Column chunk — within a row group, the data for one column.
  • Page — within a chunk, the actual encoded bytes (with stats).
  • Footer — schema, row group offsets, column chunk statistics.

Each column chunk has min / max statistics per row group. Readers use this for predicate pushdown — skip entire row groups whose stats don’t match the filter.

Predicate pushdown

python
df = pl.scan_parquet("orders.parquet").filter(pl.col("year") == 2026).collect()

Polars reads the Parquet metadata; for each row group, checks the min/max of year column; if max < 2026 or min > 2026, skips the entire row group. No data read.

This is why Parquet + filters can be 100× faster than CSV + filters. CSV has to read every byte to find matching rows; Parquet skips whole chunks via metadata.

Projection pushdown

python
df = pl.scan_parquet("orders.parquet").select("user_id", "amount").collect()

Only user_id and amount columns read from disk. If the file has 100 columns, 98 are skipped at the I/O layer.

Combine projection + predicate pushdown for huge wins.

Compression

Parquet compresses each column chunk independently. Codecs:

Codec Speed Ratio Use
snappy (default) fast medium balanced; default
gzip slow best storage-cost-sensitive
zstd medium best modern default for many shops
brotli slow best rare
lz4 fastest weakest speed-sensitive
uncompressed n/a none small files; testing

zstd has become the de facto best balance (compression close to gzip, decompression speed close to snappy). Most production pipelines now write zstd Parquet.

python
df.write_parquet("file.parquet", compression="zstd")

Encoding

Within a column chunk, Parquet picks an encoding per page:

  • Dictionary — for low-cardinality columns (status, country). Stores values once, references via integer.
  • Run-length — for sorted / clustered columns.
  • Delta — for integers with small deltas (timestamps in order).
  • Plain — fallback.

Combined with column-store layout, encodings give Parquet excellent compression for typical structured data.

Row group size

Default: ~128 MB. Tunable per writer.

Row group size Trade-off
Smaller (~16 MB) finer-grained predicate pushdown, more metadata overhead
Larger (~256 MB) better compression, less metadata, but coarser pushdown

For interactive query engines (DuckDB, Trino, Spark): ~128 MB is the sweet spot. For very large datasets where compression matters more than fine-grained skip: ~256 MB.

Schema evolution

Parquet supports adding / removing columns; readers handle missing columns as null.

What works:

  • Adding new optional columns.
  • Removing columns (readers just skip them).
  • Renaming via aliases at the catalog layer (Iceberg, Delta, Hive).

What breaks:

  • Changing a column’s type (int → string).
  • Required to optional or vice versa without care.

For production: layer a table format (Delta Lake, Iceberg, Hudi) on top of Parquet files. The table format handles schema evolution, transactions, time travel. Plain Parquet alone is just files.

Partitioning on disk

text
s3://bucket/orders/
    year=2026/
        month=01/
            file1.parquet
            file2.parquet
        month=02/...
    year=2025/...

Hive-style partitioning. Filters on year / month skip whole partitions without reading metadata. Combines with predicate pushdown within the read files.

Best practice: partition by columns commonly filtered. Don’t over-partition (millions of tiny files is worse than few big ones).

python
df.write_parquet("orders/", use_pyarrow=True, partition_cols=["year", "month"])

Reading and writing

Pandas

python
df.to_parquet("file.parquet", engine="pyarrow", compression="zstd")
df = pd.read_parquet("file.parquet", columns=["user_id", "amount"])
df = pd.read_parquet("file.parquet", filters=[("status", "=", "completed")])

filters enables predicate pushdown at the read level.

Polars

python
df.write_parquet("file.parquet", compression="zstd")
df = pl.read_parquet("file.parquet")
ldf = pl.scan_parquet("file.parquet").filter(...).select(...)

Lazy scan + filter is the canonical efficient pattern.

PyArrow directly

python
import pyarrow.parquet as pq
import pyarrow.dataset as ds

table = pq.read_table("file.parquet")
table = pq.read_table("file.parquet", columns=["user_id"], filters=[("status", "=", "x")])

# Partitioned dataset
dataset = ds.dataset("orders/", format="parquet", partitioning="hive")
scanner = dataset.scanner(columns=["amount"], filter=ds.field("year") == 2026)
for batch in scanner.to_batches():
    process(batch.to_pandas())

PyArrow is the underlying library most others use. For streaming / iterative processing, PyArrow’s batch API is cleaner than reading the whole table.

DuckDB

sql
-- SQL on Parquet files
SELECT user_id, SUM(amount) FROM 'orders/*.parquet' GROUP BY user_id;
SELECT * FROM 'orders/year=2026/*.parquet' WHERE amount > 100;

DuckDB reads Parquet directly via read_parquet() (also a glob). Predicate + projection pushdown via the optimizer.

For “ad-hoc analytics on data files” — DuckDB on Parquet is often faster than Pandas + Parquet.

Parquet vs alternatives

Format Row vs col Speed Schema Streaming-friendly
Parquet columnar fast for analytics rich, evolvable OK
CSV row slow, text none yes
JSON / NDJSON row slow, text weak yes
ORC columnar similar to Parquet rich OK
Avro row fast, binary strong, evolvable yes (streaming-first)
Arrow IPC columnar (in-memory) fastest strong for IPC, not long-term storage
  • Parquet for analytics warehouse storage.
  • Avro for streaming (Kafka, schema registry).
  • Arrow for in-process data exchange (not for long-term file storage).
  • ORC is comparable to Parquet, more common in Hive / Hadoop ecosystem.

Common gotchas

  • Small files problem. Writing tons of tiny Parquet files (per-event) destroys query performance — per-file overhead dominates. Batch writes; use OPTIMIZE / compact passes.
  • object dtype roundtrip. Pandas writes mixed-type object columns as bytes or fails. Cast to a real dtype before writing.
  • Schema drift across files. Two files with different schemas in the same folder → reader confused. Either enforce schema in writer or use a table format (Delta / Iceberg) that tracks schemas centrally.
  • Predicate pushdown not happening. Some filters don’t push down (string regex, complex expressions). Inspect: .explain() in Polars; check query times.
  • Compression mismatch. Some readers can’t read zstd-compressed Parquet on older versions. Default to snappy if compatibility matters; zstd if speed/ratio matters more.

Interview angle 7

  • “Why is Parquet faster than CSV for analytics?” — columnar layout: only read needed columns (projection pushdown). Min/max statistics per row group enable skipping entire chunks (predicate pushdown). Compression is 5-10× better. Vectorized processing on CPU. Typical: 10-100× faster end-to-end.
  • “What’s predicate pushdown?” — readers check row-group min/max stats against the query’s filter; skip row groups whose stats can’t match. No data read for skipped groups. Works in Spark, Polars, DuckDB, PyArrow.
  • “How does Parquet handle schema evolution?” — supports adding / removing columns (readers tolerate missing as null). Doesn’t support type changes safely. Production: layer Delta Lake / Iceberg on top for proper schema management.
  • “Parquet vs Avro — when each?” — Parquet for analytics (columnar, optimized for column scans + aggregations, large files). Avro for streaming (row-oriented, schema-evolvable, message-by-message). Different use cases; common to use both in one architecture (Avro for Kafka, Parquet for the warehouse).
  • “How do you handle the ‘small files problem’ in a Parquet pipeline?” — batch writes (collect events before writing), or schedule a compaction job. In Delta Lake: OPTIMIZE my_table. In raw Parquet: rewrite small files into bigger ones.
  • “What compression codec do you pick?”zstd is the modern default (close to gzip ratio, close to snappy speed). snappy if compatibility matters; gzip if storage cost dominates. lz4 for speed-critical.
  • “Why partition by date?” — query workloads typically filter by date (recent data, this month, etc.). Partition pruning (skipping whole partition directories) is even cheaper than predicate pushdown. Don’t over-partition; millions of tiny files hurts.