Parquet layout: row groups and statistics

PyArrow · Advanced · 15 min

Knowing the file layout explains why some queries skip 99% of a file and others read it all.

A Parquet file is split horizontally into row groups. Inside each, every column is stored as a column chunk, which is split into pages. The footer holds the schema and, per column chunk, statistics (min, max, null count), plus offsets so readers can jump straight to the chunks they need.

This layout enables two kinds of skipping:

  • Column pruning: only the requested columns' chunks are read.
  • Row-group pruning: a filter like amount > 100 skips any row group whose max is 100 or less. It only helps if data is clustered; sorting by a commonly filtered column before writing makes statistics selective.

Encodings shrink data before compression: dictionary encoding for repeated values (on by default; it falls back to plain encoding if the dictionary grows too large), run-length and bit-packing for small integers and levels, and delta encodings for sorted numbers. Compression (Snappy, Zstd, LZ4, Gzip) is then applied per page.

Tuning on write with pq.write_table:

  • row_group_size: too small adds overhead and footer size; too large hurts parallelism and skipping. A common target is 64 to 512 MB of uncompressed data, or around a million rows.
  • compression="zstd", compression_level=...
  • use_dictionary=[...], write_statistics=True, sorting_columns=

pq.ParquetFile(path).metadata.row_group(i).column(j).statistics lets you inspect all of this.

Example

import pyarrow as pa
import pyarrow.parquet as pq

t = pa.table({"amount": list(range(1000)), "shop": ["A", "B"] * 500})
pq.write_table(t, "sorted.parquet", row_group_size=250)   # 4 row groups

pf = pq.ParquetFile("sorted.parquet")
md = pf.metadata
print(md.num_row_groups)                         # 4
for i in range(md.num_row_groups):
    st = md.row_group(i).column(0).statistics
    print(i, st.min, st.max, end=" | ")
# 0 0 249 | 1 250 499 | 2 500 749 | 3 750 999 |
print()
col = md.row_group(0).column(1)
print(col.compression, "RLE_DICTIONARY" in col.encodings)    # SNAPPY True

# amount > 900 can only match the last row group, so readers skip the other 3
print(pq.read_table("sorted.parquet", filters=[("amount", ">", 900)]).num_rows)   # 99

Remember

  • File = row groups; row group = column chunks; chunk = pages
  • Min/max statistics let readers skip row groups, if data is clustered
  • Sort by common filter columns and choose sensible row-group sizes

Watch out for

  • Writing thousands of tiny row groups (or files), which bloats metadata and kills scan speed.

Check yourself

  1. Why does sorting by 'date' before writing Parquet speed up later queries filtering on date?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: arrow.apache.org/docs/python/parquet.html