FormatsFORMAT-SPECIFICENGINE-SPECIFICSIMPLIFIED

Parquet Internals

Row groups, column chunks, pages and the footer — where statistics come from, why they are only useful when the data is sorted, and how nesting is stored without abandoning columns.

What actually happensHow to build itCan I trust it?

Who needs this, what one row is, and why the obvious build breaks

Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.

The question

A query filters on event_date and my engine still reads every row group. What in the file decides whether a row group can be skipped?

Who needs this

The query planner. Everything in this lesson exists so that a planner can answer "must I read this byte range?" without reading it. Downstream of that, the engineer looking at a query profile that says "row groups read: 4000, skipped: 0" and needing to know whose fault that is.

What one row is

Four nested grains, and confusing them is the most common source of layout mistakes. The file is the unit of listing and scheduling. The row group is the unit of parallelism and of statistics. The column chunk is the unit of byte-range fetching. The page is the unit of encoding, compression and — where an engine supports it — the finest unit of skipping.

The obvious build

Assume that because Parquet stores min/max statistics, a filter on any column will skip data. It is a reasonable inference from the documentation and it is the belief that produces the most disappointed query profiles in this domain.

Why it breaks

On unsorted data, every row group's country range is roughly AD to ZW. Every predicate on country intersects every range, nothing is skipped, and the statistics are perfectly correct while being useless (Clustering and Sort Order).

How it breaks with real data
  • On unsorted data, every row group's country range is roughly AD to ZW. Every predicate on country intersects every range, nothing is skipped, and the statistics are perfectly correct while being useless (Clustering and Sort Order).
  • A row group is set very large to improve compression. Now the finest thing that can be skipped is a large slice, so a selective predicate still reads far more than it needs.
  • A writer is configured with statistics disabled, or writes them for some columns and not others. The reader has no way to prune and no way to tell you why (Data Engineering Anti-Patterns).
  • A predicate on a computed expression — WHERE UPPER(country) = 'DE' or WHERE CAST(event_ts AS DATE) = ... — cannot be compared against stored min/max at all, because the statistics describe the stored values, not the expression's output (Predicate Pushdown).
  • A nested field is filtered on. Whether statistics exist for a leaf inside a struct or a list depends on the writer and the engine, and the pruning that works on a top-level column silently does not work there.
SourceIngestionRawTransformationValidationStorage ModelServingConsumerObservability

What is actually happening

  • A row group is a horizontal cut of the table: a set of complete rows, stored column by column within the cut. Its size is chosen by the writer, and it is the unit at which the file can be split across workers (Partitions: the Unit of Parallelism).
  • Inside a row group, each column's values form a column chunk — a contiguous byte range. The footer records that range's offset and length, which is what lets a reader fetch two columns out of forty with two byte-range requests rather than a full read (Projection Pushdown).
  • A column chunk is divided into pages, and the page is where encoding and compression are actually applied. A dictionary page holds the chunk's dictionary; data pages hold encoded values. Each page carries its own header with its own value count and, in newer page versions, its own statistics.
  • The footer holds the schema and, per column chunk, the offset, the encodings used, compressed and uncompressed sizes, and statistics { min, max, null_count }. Those statistics are written by the writer from the values it saw, which is why they are trustworthy — and why a writer that skips them leaves nothing for the reader to work with.
  • Skipping is a range-intersection test, nothing more sophisticated. The engine takes the predicate's implied range and checks it against each chunk's min/max. Disjoint means skip; overlapping means read. This is why the whole mechanism is a function of sortedness: sorting is what makes the ranges disjoint (Partition Pruning).
  • Nesting is stored with definition and repetition levels: two small integer streams alongside each leaf column that encode, respectively, how deep the non-null path went and where a repeated element started a new list. A nested record is therefore still stored as flat leaf columns, which is how the format keeps columnar benefits for JSON-shaped data (Ingestion: Parsing & Chunking).

The four grains, and which one does what

Most Parquet tuning arguments are two people using the word "chunk" for different things. The grain table below fixes the vocabulary, and once it is fixed most of the arguments answer themselves: parallelism is a row group question, fetching is a column chunk question, and compression is a page question.

The diagram after it shows the read decision as it actually happens — footer first, then eliminate, then fetch. Note that the reader never touches a byte of data before it has read metadata, which is the whole architectural point of putting a map at the end of the file.

What a reader does before it reads data
disjointoverlapsList files in surviving partitionsSeek to end, read footerSchema: which leaf columns does the query need?Per row group: does min/max intersect the predicate?Skip row group — no bytes fetchedFetch byte ranges for needed column chunks onlyDecompress pages, decode encodingsApply the predicate to decoded valuesRows to the next operator
UserLLMAgentToolDataDecisionHumanGuardrail
What one unit is at each level of a Parquet file
StageOne row isBreaks if
FileA complete, self-describing set of rows with its own schema and footer.You treat a directory of files as guaranteed-homogeneous. Each file carries its own schema and nothing enforces that they agree (Schema Evolution).
Row groupA horizontal slice of complete rows — the unit of parallel read and the unit statistics describe.You expect skipping finer than this. On a standard reader a row group is atomic: if one value matches, the whole group is read.
Column chunkAll values of one column within one row group, as one contiguous byte range.You assume selecting fewer columns reduces row groups read. It reduces bytes fetched per surviving group; elimination is a separate mechanism.
PageA bounded run of values within a column chunk — the unit of encoding and compression.You reason about encodings per column. The writer chooses per page, so one column can be dictionary-encoded early in a file and plain later.
ValueOne field of one row, plus its definition and repetition levels if the schema is nested.You forget nulls cost storage. A highly nullable column still records a level per value, which is the overhead people are surprised by.

Read this top to bottom before tuning anything. Almost every disappointing Parquet result comes from optimising at one level and expecting an effect at another.

Statistics, and why sortedness is the whole story

A row group's statistics are a bound, not an index. The reader asks one question — does the predicate range intersect [min, max]? — and the answer is either "possibly" or "definitely not". There is no third answer and no cleverness available.

That makes the value of statistics entirely a property of how disjoint the ranges are, and disjointness is produced by sorting. In data sorted by event_ts, consecutive row groups cover consecutive time ranges and a one-hour predicate touches one or two of them. In the same data in arrival order across many sources, every row group spans the full day and the predicate touches all of them.

The footer sketch below is what this looks like when you actually read one. It is worth doing once with any Parquet library: the moment you have seen a real footer, the difference between "we have statistics" and "our statistics are selective" stops being abstract.

Pushdown looks broken. Which of these is it?
TriggerSymptomCauseResponse
Query profile shows row groups skipped: 0 on a selective time predicate.Full scan cost on a query that should touch a fraction of the data.Data is in arrival order, so every row group's time range spans the whole partition and no range is disjoint.Sort by the time column during compaction and re-check the profile. If skipping does not appear, the sort did not happen (File Compaction).
Predicate is WHERE CAST(event_ts AS DATE) = DATE '2026-08-25'.No pruning at all, even on well-sorted data.Statistics describe stored event_ts values. The engine cannot compare a bound against the output of an arbitrary expression unless it can rewrite the predicate.Filter on the stored column directly, or materialise event_date as its own column and partition or sort on it (Predicate Pushdown).
Filter on a long free-text or URL column.Ranges look implausibly wide in the footer; nothing prunes.Writers truncate min/max for long strings. Truncated bounds are deliberately widened to stay sound, which makes them non-selective.Do not filter on long strings for pruning. Derive a short, low-cardinality column and filter on that instead.
Some files prune, others do not, in the same table.Inconsistent query cost that correlates with partition age.Two writers with different settings — one gathering statistics or sorting, one not. Common after a pipeline migration where the old writer still runs (Data Engineering Anti-Patterns).Audit footers per partition, identify the divergent writer, and rewrite the affected partitions.
Filter on a field inside a struct or array.Pruning works on flat columns and not on this one, in this engine but not another.Statistics and pruning support for nested leaves varies by writer and engine. The file may not carry usable statistics for that leaf at all.Flatten the fields consumers filter on into top-level columns at the modelling layer (Model Layering).
Footer metadata for two files holding the same rows, sorted and unsorted
1SORTED BY event_ts
2 row_group[0] rows=1000000
3 event_ts min=2026-08-25T00:00:00 max=2026-08-25T02:41:07 nulls=0
4 country min=AD max=ZW nulls=0
5 row_group[1] rows=1000000
6 event_ts min=2026-08-25T02:41:07 max=2026-08-25T05:18:22 nulls=0
7 country min=AD max=ZW nulls=0
8
9 WHERE event_ts >= '2026-08-25T06:00' -> both groups DISJOINT -> both skipped
10 WHERE country = 'DE' -> both ranges overlap -> both read
11
12
13ARRIVAL ORDER (many producers interleaved)
14 row_group[0] rows=1000000
15 event_ts min=2026-08-25T00:00:01 max=2026-08-25T23:59:58 nulls=0
16 country min=AD max=ZW nulls=0
17 row_group[1] rows=1000000
18 event_ts min=2026-08-25T00:00:03 max=2026-08-25T23:59:59 nulls=0
19 country min=AD max=ZW nulls=0
20
21 WHERE event_ts >= '2026-08-25T06:00' -> both ranges overlap -> both read
22
23Same rows. Same statistics feature. Same codec.
24The only difference is physical order, and it decides everything.

What to notice: country prunes in neither case. A low-cardinality column has a wide range in any chunk that contains more than a handful of distinct values, which is why clustering — not statistics — is the lever for categorical predicates.

Nesting, nulls and the cost of keeping the source shape

FORMAT-SPECIFICThe definition/repetition-level model is Parquet's, inherited from Dremel. ORC represents nulls with a separate present-bit stream per column and lists with a length stream, which costs differently for sparse data and is not interchangeable when reasoning about footprint.

Parquet stores nested records without abandoning columnar layout, and the mechanism is worth understanding because it explains a cost that otherwise looks like magic. Each leaf of the schema becomes its own column. Alongside it sit two small integer streams: a definition level saying how far down the optional path each value was actually present, and a repetition level saying where a repeated element began a new list.

The consequence people miss is that nulls are not free. A column that is null for most rows still records a level for every row. A wide, sparsely populated struct — the shape you get by landing a third-party API response verbatim — pays levels across every leaf, and its physical footprint is larger than the visible data suggests (The Raw Landing Zone).

The second consequence is analytical rather than physical. A nested array means the file's row and the analytical grain are no longer the same thing: one row holds an order with three line items, and any aggregate over line items requires an explode that changes the grain. That is a modelling decision that the format has quietly deferred to whoever writes the SQL (Grain: What Does One Row Represent?).

SCHEMA
  order_id        int64                       required
  customer        group                       optional
    ├── id        int64                       required
    └── country   binary (utf8)               optional
  items           group (LIST)                repeated
    └── element   group
        ├── sku   binary (utf8)               required
        └── qty   int32                       required

LEAF COLUMNS ACTUALLY STORED
  order_id
  customer.id           + definition levels
  customer.country      + definition levels
  items.element.sku     + definition levels + repetition levels
  items.element.qty     + definition levels + repetition levels

WHAT THIS COSTS
  - one physical column per leaf, not per top-level field
  - level streams per optional/repeated leaf, paid for EVERY row
    including the rows where the value is absent
  - statistics quality on leaves varies by writer and engine
  - the file's "row" is an order; an item-level aggregate needs an
    explode, and the grain changes at that point

How to build it

Most important first.

  • Choose the sort key from the predicate you actually run, then verify with a query profile that row groups are being skipped. A sort order that does not show up as skipped row groups bought nothing (Clustering and Sort Order).
  • Set the row group size against predicate selectivity. If your typical filter selects a small slice, smaller row groups skip better; if you mostly full-scan and aggregate, larger groups compress better and cost less to plan.
  • Confirm statistics are being written, per column, by reading a file's footer. This is a one-line check with a library and it is the first thing to look at when pruning fails.
  • Filter on stored columns rather than on expressions over them. Materialise event_date as a column if you filter on the date of a timestamp, instead of casting at query time (Predicate Pushdown).
  • Keep string columns you filter on reasonably short. Min/max statistics on very long strings are truncated by writers, and truncated bounds are wider than real ones, which weakens pruning without any visible signal.
  • Treat nested structures as a cost. They work, they preserve columnar layout, and they make statistics, pruning and evolution all harder to reason about — flatten what consumers query on (Model Layering).

What this actually promises

Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.

  • Statistics, when present, are a sound bound: every value in the chunk lies within the recorded min and max. A reader may therefore skip on disjointness without risk of dropping a matching row. They are not tight — a chunk with one outlier has a range as wide as that outlier.
  • Null counts are exact when written, which makes IS NULL and IS NOT NULL predicates prunable in a way that value predicates on unsorted data are not.
  • Statistics are optional. A file with none is a valid Parquet file, and its absence is silent — there is no error, only a query that reads everything.
  • Row group boundaries guarantee row completeness: a row is never split across row groups, so a worker handed a row group has whole records and needs nothing from its neighbours.
  • Nothing guarantees that two files in the same dataset used the same row group size, the same encodings or the same statistics settings. Physical uniformity is a property of your writer, not of the format.

Can I trust it?

A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.

The check that would catch this
  • The operational check here is a layout audit: sample files per partition and assert row group count, row group size distribution, presence of statistics per column, and schema fingerprint. Run it after every compaction.
  • Pair it with an engine-level check on skipped-versus-read row groups for the table's top queries, because layout that looks healthy in the footer can still prune nothing if the sort key and the predicate disagree.
  • Both are physical checks and neither says anything about correctness. A beautifully laid out table can be missing a day of data, and the layout audit will report it as healthy (The Pipeline Succeeded. The Data Is Wrong.).
Freshness
  • Row group size is a direct freshness lever on a streaming writer: the writer must accumulate a row group before flushing, so larger groups mean data becomes visible later.
  • The common resolution is asymmetric layout — small row groups and small files on the recent partition, rewritten to large well-sorted ones during compaction. Consumers then see recent data prune poorly and historical data prune well, which is worth documenting rather than leaving as a mystery (File Compaction).
  • Statistics on a partially-written partition describe only the files that exist. A reader can prune correctly and still see an incomplete answer, because pruning is about bytes and completeness is about publication (Atomic Publish).
When the schema or meaning changes
  • Row group size, page size, encodings and codec are writer settings with no consumer-visible schema effect, so they can change between runs without breaking anything except your own size charts.
  • Adding a column adds a column chunk to future row groups and leaves existing files alone. Readers project null for files that predate it, which is why additive change is the safe kind (Backward Compatibility).
  • Changing a column from a scalar to a nested type is a rewrite in practice, because the leaf structure and its definition levels change entirely. Engines will not merge those two shapes (Breaking Schema Changes).
  • A string column whose typical length grows past the writer's statistics truncation threshold quietly loses pruning quality without any schema change at all — an evolution with no schema signature (Semantic Changes).
How to re-run this safely
  • Re-laying-out a table (new row group size, new sort order) is a full rewrite of the affected partitions, validated then swapped. It is physical and idempotent if the output path is derived from the input (Reprocessing vs Retrying).
  • A file whose footer is missing or corrupt is a total loss for that file: without the map there is no way to locate the column chunks. Re-derive it from raw (Keeping Raw History: The Recovery Position and the Liability).
  • Because a row is never split across row groups, a partially-read file yields whole rows rather than fragments — which makes a re-read after a transient failure straightforward, provided the reader restarts at a row group boundary.

What can go wrong

Failure modes
  • Statistics present, correct, and useless because the data is unsorted on the predicate column — the single most common cause of "pushdown does not work".
  • Truncated min/max bounds on long string columns widening every range and quietly disabling pruning.
  • Row group size tuned for compression on a table whose queries are highly selective, so the finest skippable unit is far larger than the result set.
  • A writer with statistics disabled, or an older writer whose statistics are known-unreliable and are therefore ignored by newer readers.
  • Predicates written as expressions over columns, which no stored statistic can be compared against.
  • Nested leaf columns where the engine does not evaluate statistics, so a filter on a struct field behaves completely differently from the same filter on a flat column.
Misreads
  • "Parquet has statistics, so predicates are pushed down." Statistics enable pruning; sortedness makes pruning effective. Correct statistics over random data eliminate nothing, and the query profile will show it plainly.
  • "Bigger row groups are better." Better for compression and planning, worse for skipping. The right answer follows your predicate selectivity, and a table with two workloads may want two copies (Data Marts).
  • "The row group is the unit of everything." It is the unit of parallelism and statistics. The column chunk is the unit of fetching and the page is the unit of encoding, and mixing them up leads to tuning the wrong number.
  • "Statistics tell me about my data." They tell you about the values in each chunk, bounded and possibly truncated. They are not a profile of the column and are a poor substitute for actually querying it (Distribution Tests).

Operating it

How you see it in production
  • Row groups read versus skipped, per query, from the engine's query profile. The definitive signal, and the one that settles arguments (Query Engines).
  • Row group count and size distribution per file, from a periodic footer scan — a distribution with a long tail of tiny groups usually means a writer flushing on time rather than on size.
  • Statistics coverage per column: which columns have min/max recorded and which do not.
  • Bytes fetched versus bytes stored per query, which reveals whether column pruning is happening even when row group pruning is not (Scan Cost).
What changes at 10x and 100x
  • At 10x rows the structure is unchanged; the number of row groups grows, and footer-reading during planning starts to be measurable.
  • At 100x, planning becomes a first-class cost and metadata itself needs management — which is one of the concrete problems table formats solve by keeping their own manifests instead of listing and opening files (Open Table Formats).
  • At high cardinality, min/max statistics degrade in usefulness faster than anything else in the file, because wider domains mean wider and more overlapping ranges.
What drives cost here
  • Bytes fetched is decided at the column-chunk level and is the largest lever; row group skipping multiplies it down further, but only when ranges are disjoint.
  • Planning cost scales with file count and row group count, because the planner reads every footer it might need. A table with many tiny row groups is expensive to plan even before a byte of data is read (File Size and the Small-Files Problem).
  • Write-side CPU covers encoding, statistics gathering and compression per page; the shuffle to produce sorted input is the dominant write cost when clustering is used (The Shuffle).
  • Storage overhead per file — footer, page headers, dictionaries — is fixed per unit and therefore proportionally worse on small files.
What this approach costs
  • Larger row groups: better compression, cheaper planning, coarser skipping. Smaller row groups: finer skipping, more metadata, more planning work. There is no setting that is right for both a selective lookup and a full aggregation.
  • Sorting for pruning costs a shuffle and permits exactly one ordering. Two important predicate columns means choosing which one prunes well and accepting that the other does not (Bucketing).
  • Nested storage keeps the source shape and costs clarity in statistics, pruning behaviour and evolution safety. Flattening costs a transformation step and a decision about grain (Grain: What Does One Row Represent?).

Where this applies

Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.

  • FORMAT-SPECIFICRow group, column chunk, page and tail footer are Parquet's names and Parquet's arrangement. ORC divides into stripes with row-index entries every few thousand rows, giving finer default skipping granularity than Parquet's row group and a different tuning conversation.
  • ENGINE-SPECIFICRow group statistics are used by essentially every reader; page-level statistics, column indexes and bloom filters are used by some engines and ignored by others, so identical files can prune very differently in Spark, Trino and DuckDB.
  • SIMPLIFIEDDefinition and repetition levels are described here as two integer streams per leaf, which is enough to reason about nesting cost. The actual level computation for deeply nested repeated groups is more involved and rarely needs to be reasoned about by hand.

Where the depth lives

This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.

Domains that do not exist yet
  • DevOps / Production Engineering owns the writer-configuration drift problem this lesson keeps running into: two pipelines writing the same table with different settings is a configuration-management failure that shows up as a physical-layout symptom.