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.
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.
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?
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.
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.
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.
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).
- On unsorted data, every row group's
countryrange is roughlyADtoZW. Every predicate oncountryintersects 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'orWHERE 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.
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.
| Stage | One row is | Breaks if |
|---|---|---|
| File | A 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 group | A 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 chunk | All 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. |
| Page | A 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. |
| Value | One 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| 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). |
1SORTED BY event_ts2 row_group[0] rows=10000003 event_ts min=2026-08-25T00:00:00 max=2026-08-25T02:41:07 nulls=04 country min=AD max=ZW nulls=05 row_group[1] rows=10000006 event_ts min=2026-08-25T02:41:07 max=2026-08-25T05:18:22 nulls=07 country min=AD max=ZW nulls=08 9 WHERE event_ts >= '2026-08-25T06:00' -> both groups DISJOINT -> both skipped10 WHERE country = 'DE' -> both ranges overlap -> both read11 12 13ARRIVAL ORDER (many producers interleaved)14 row_group[0] rows=100000015 event_ts min=2026-08-25T00:00:01 max=2026-08-25T23:59:58 nulls=016 country min=AD max=ZW nulls=017 row_group[1] rows=100000018 event_ts min=2026-08-25T00:00:03 max=2026-08-25T23:59:59 nulls=019 country min=AD max=ZW nulls=020 21 WHERE event_ts >= '2026-08-25T06:00' -> both ranges overlap -> both read22 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
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 pointHow 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_dateas 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 NULLandIS NOT NULLpredicates 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 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.).
- 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).
- 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).
- 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
- 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.
- "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
- 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).
- 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.
- 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.
- 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.
- — 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.