Latest     About     Archive

Parquet Under the Hood: How a Query Reads Less

Parquet Under the Hood: How a Query Reads Less

Parquet cheat sheet: file layout from PAR1 magic through row groups, column chunks, pages and footer; column pruning, encoding and compression, and the data skipping ladder of partition pruning, row group min/max, dictionary filter, bloom filter and page index; how to read PushedFilters, PartitionFilters and post-scan Filter in a Spark plan; what Delta and Iceberg add on top; and the parquet-cli commands to inspect a file

Click to open full size.

Parquet stores a table column by column and writes statistics about every block it writes. A reader uses those statistics to skip whole blocks without decoding them, then reads only the columns the query asks for. Delta Lake and Apache Iceberg both store their data as Parquet files, so everything in this post is also how they read data. They add a layer on top, covered at the end.

This is a prerequisite post for the bootcamp series. Later posts on Delta, Iceberg and liquid clustering build on the terms defined here: row group, column chunk, page, footer, min/max statistics and predicate pushdown.


The Scenario

One query, run against the same data stored in different ways.

SELECT user_id, country, amount
FROM   events
WHERE  event_date = DATE '2026-10-01'
AND    country    = 'IE'
Assumption Value
Table events: 2 million rows over one year (2026), 19 columns
Columns read 3 of 19, plus the filter columns
Matching rows 280 (one day of 365, one country of 20)
Engine Spark 4.2.1 (writes with parquet-java 1.17.0), local mode, Scala spark-shell
Layouts compared CSV; Parquet unsorted; Parquet sorted by event_date, country; Parquet partitioned by event_date
Row group size Lowered to 4 MB (parquet.block.size) so one file has 53 row groups to skip between

The table is small enough to run on a laptop. Every number and plan in this post comes from running the script at the end on that data.

Layout Size on disk Rows Spark decoded Rows returned What did the work
CSV 319 MiB every line (all 319.5 MiB read) 280 Nothing: no columns or blocks to skip
Parquet, unsorted 107 MiB 2,000,000 280 Column pruning only. Every row group spans the whole year
Parquet, sorted, pushdown off 156 MiB 2,000,000 280 Column pruning only (spark.sql.parquet.filterPushdown=false)
Parquet, sorted 156 MiB 38,440 280 Min/max kept 2 of 53 row groups; the page index kept 1 page in each
Parquet, partitioned by date 151 MiB in 365 files 5,522 280 Partition pruning opened 1 file of 365

Measured on a laptop with Spark’s task input metrics (recordsRead). Spark’s input byte counter gave inconsistent numbers for the Parquet runs on a local disk (pushdown off reported fewer bytes than pushdown on), so bytes are only shown for CSV. The sorted file is larger than the unsorted one because it also carries 53 MB of bloom filters; section 3d explains why.

The same 3 columns and the same rows come back each time. What changes is how much of the file the reader has to touch to find them.


What You Get

Benefit What it means in practice Mechanism
Column pruning A query that reads 3 of 19 columns skips the other 16 column chunks entirely Values stored by column, located by byte offset in the footer
Smaller files Repeated and similar values shrink before the codec even runs Dictionary, run-length and delta encodings, then snappy or zstd
Data skipping Blocks whose statistics can’t match the filter are never read Min/max, null counts, dictionaries, bloom filters, page index
Self-describing Any engine can read the file with no external schema Schema and types stored in the footer
Parallel reads One large file is read by many tasks at once Row groups are independent units with known offsets
Fast vectorized decoding Values of one type sit together, so the reader decodes them in batches Columnar pages, Spark’s vectorized reader (on by default)

Writing and reading Parquet from Spark (Scala):

// Write: sort first so related rows share row groups (makes min/max useful)
events
  .sortWithinPartitions("event_date", "country")
  .write
  .option("compression", "zstd")                         // default is snappy
  .option("parquet.bloom.filter.enabled#user_id", "true") // off by default
  .parquet("s3://lake/events")

// Read: select only the columns you need, filter on raw columns
spark.read.parquet("s3://lake/events")
  .where($"event_date" === "2026-10-01" && $"country" === "IE")
  .select("user_id", "country", "amount")

Inside a Parquet File

A Parquet file is a sequence of row groups, followed by a footer. Inside each row group, every column has one column chunk, and each column chunk is split into pages.

PAR1 4-byte magic number Row group 1 (rows 0 – 999,999) event_datecountryamount ← column chunk = pages ■ dictionary page Row group 2 … row group N (same structure) Column index + offset index (page-level min/max and page offsets) · bloom filters Footer (FileMetaData) schema: names, physical and logical types, nesting per row group: row count, and per column chunk: byte offset, size, encodings, codec per column chunk statistics: min, max, null_count · offsets of page index and bloom filter 4 bytesfooter length (little endian) PAR14-byte magic number
Data first, metadata last. The page index and bloom filters sit outside the row groups so readers that don't use them never pay for them.
Term What it is Typical size
Row group A horizontal slice of the table: a range of rows, all columns parquet-java writes 128 MB by default (parquet.block.size); the format docs recommend 512 MB to 1 GB
Column chunk One column’s values for one row group, stored contiguously One per column per row group
Page The unit of encoding and compression inside a column chunk 1 MB or 20,000 rows by default in parquet-java, whichever comes first
Dictionary page The distinct values of the column chunk, if dictionary encoding is used One per column chunk, before the data pages
Data page Repetition levels, definition levels, then the encoded values Many per column chunk
Footer Schema, row group list, byte offsets, statistics Usually KBs

The footer is written last so the writer can stream the data in one pass and only describe it at the end. A reader works the other way round:

sequenceDiagram
    participant R as Reader
    participant F as Parquet file
    R->>F: Read last 8 bytes
    F-->>R: footer length + PAR1
    R->>F: Read footer
    F-->>R: schema, row groups, offsets, min/max
    Note over R: Decide which row groups
and which columns are needed R->>F: Read page index / dictionary / bloom filter
(only for filter columns) Note over R: Decide which pages are needed R->>F: Ranged reads of the surviving column chunks and pages F-->>R: Encoded, compressed pages Note over R: Decompress, decode, re-check the filter on each row

On object storage, each of those reads is a ranged GET. The reader never downloads the whole file just to find out what is in it.


How It Works Underneath

1. Column pruning: only read the columns you select

A row format such as CSV or Avro stores each row’s values together. To get 3 columns, the reader still has to read and parse all 20. Parquet stores each column’s values together, and the footer records the byte offset and length of every column chunk. The reader seeks straight to the 3 chunks it needs.

Row layout (CSV, Avro) row 1row 2row 3 every byte of every row is read and parsed Columnar layout (Parquet) idtsdateuserctrydevamtattr… only user_id, country, amount chunks are read ReadSchema: struct<user_id:string,country:string,amount:double> ↑ shown in Spark's FileScan node: the columns actually read
Filter columns are read too (event_date here), but only to evaluate the filter.

Two things quietly defeat pruning:

Pattern What happens Fix
SELECT * or df.collect() on the whole frame Every column chunk is read Select columns before the action
A UDF or map over the whole row Spark must hand the UDF every field Select the columns the UDF needs, then apply it

2. Encoding and compression: make each column small

Parquet shrinks a column twice. First an encoding turns the values into a compact representation that depends on the data. Then a general-purpose codec (snappy by default in Spark) compresses each page.

Take 12 values of the country column after sorting:

Raw values (PLAIN)       IE IE IE IE IE IE IE GB GB GB US US        12 strings

Step 1: dictionary       dictionary page:  0 → IE   1 → GB   2 → US
                         data page ids:    0 0 0 0 0 0 0 1 1 1 2 2   12 small integers

Step 2: RLE / bit-pack   run(0, ×7)  run(1, ×3)  run(2, ×2)           3 runs
        (the ids)        ids need only 2 bits each when they don't repeat

Step 3: codec            snappy / zstd over the encoded page bytes

Sorted data turns into long runs, and long runs collapse to almost nothing. Unsorted, the same column becomes a stream of 5-bit ids (20 countries) with no runs, which is still far smaller than repeating the strings.

Here is what that looks like on the scenario data. These are real column sizes from parquet column-size, same 2 million rows, same codec:

Column Unsorted Sorted by event_date, country Why
event_date 2.23 MiB 7.8 KiB 365 values; sorted, each page is one or two long runs
country 1.21 MiB 31.8 KiB Sorted within each day, so 20 long runs per day
device 30.5 KiB 499 KiB The unsorted file kept the generator’s order, where device cycles ios, android, web. Sorting by date broke that pattern
user_id 11.7 MiB 11.7 MiB 500,000 distinct values: written without a dictionary (PLAIN), so sort order doesn’t matter

Sorting helps the columns you sort by and can hurt the others. Pick the sort columns from your filters, not from compression alone.

Encoding Used for How it shrinks data
PLAIN Anything; the fallback Values back to back, no tricks
RLE_DICTIONARY Low and medium cardinality columns Store each distinct value once; data pages hold small integer ids
RLE / bit-packing hybrid Dictionary ids, booleans, repetition and definition levels Runs of the same value become (value, count); others use the minimum bit width
DELTA_BINARY_PACKED INT32, INT64 (ids, timestamps) Stores differences between neighbours, bit-packed
DELTA_BYTE_ARRAY Strings with shared prefixes (URLs, keys) Stores the shared-prefix length plus the suffix
BYTE_STREAM_SPLIT Floats and doubles Regroups bytes by position so the codec compresses them better; doesn’t shrink on its own

Dictionary fallback. If a column chunk’s dictionary grows past parquet.dictionary.page.size (1 MB by default in parquet-java), the writer stops adding to it and writes the rest of the chunk with PLAIN encoding. That matters for skipping too: section 3d only works when the whole chunk is dictionary encoded.

Timestamps in Spark default to INT96. Spark writes TimestampType as the legacy INT96 physical type (spark.sql.parquet.outputTimestampType is INT96 in 4.2.1). In the scenario file, parquet meta shows no min/max for event_ts at all, so a filter on it can’t skip anything. Set the option to TIMESTAMP_MICROS for new tables.

Codec choice in Spark is set with spark.sql.parquet.compression.codec (default snappy) or the compression write option. Accepted values include snappy, gzip, lz4_raw, zstd and none. Iceberg’s default for Parquet is zstd.

3. Data skipping: the main event

Column pruning decides which columns to read. Data skipping decides which rows’ blocks to read. It works in levels, from coarsest to finest. Each level uses metadata that is much smaller than the data it lets the reader skip.

Level Unit skipped Metadata used Helps with Where it lives
a. Partition pruning Whole directories of files Directory names (event_date=2026-10-01/) Filters on the partition column Not Parquet: the table layout
b. Row group min/max Row groups min, max, null_count per column chunk Ranges and equality on sorted or clustered columns Footer
c. Dictionary filter Row groups The column chunk’s dictionary page Equality and IN on low-cardinality columns Dictionary page
d. Bloom filter Row groups Bit set of hashed values Equality on high-cardinality columns (ids) Bloom filter block, offset in footer
e. Page index Pages inside a row group Per-page min/max and row offsets Selective filters on sorted columns Column index and offset index, near the footer
f. Row filter Individual rows The decoded values Everything; always runs Spark, after the scan

None of a to e is exact. Each one answers “definitely not here” or “maybe here”. Whatever survives is decoded, and Spark checks the filter again on every row.

The slides below walk through each level for the scenario query.

Slide 1 / 7 · Read the footer first WHERE event_date = 2026-10-01 AND country = 'IE' row grouprowsevent_date min … maxcountryuser_id RG 038,4402026-01-01 … 2026-01-08AT … USu-100002 … u-9997 RG 138,4402026-01-08 … 2026-01-15AT … USu-10 … u-99999 … RG 3838,4402026-09-24 … 2026-10-01AT … USu-1 … u-9999 RG 3938,4402026-10-01 … 2026-10-08AT … US… … RG 521,1202026-12-31 … One small read of the file's tail gives the reader all 53 row groups, where each column chunk starts, and the min and max of every column. No data page has been read yet.
Real values from parquet meta on the sorted scenario file. Strings compare byte by byte, which is why u-9997 sorts after u-100002.
Slide 2 / 7 · a. Partition pruning (before Parquet) events/ event_date=2026-09-29/ part-0.parquet event_date=2026-09-30/ part-0.parquet event_date=2026-10-01/ part-0.parquet ← listed and opened event_date=2026-10-02/ part-0.parquet … 361 more directories, never opened Spark matches the filter against directory names, so 364 of 365 files are never opened. This is a table-layout feature, not a Parquet one: the value lives in the path, and the column isn't stored in the files at all. Shows in the plan as PartitionFilters.
Slide 3 / 7 · b. Row group min/max: unsorted file JanDecevent_date RG 0RG 1RG 2RG 3… 2026-10-01 Rows arrived in random date order, so every row group holds some rows from every day. All 53 ranges are 2026-01-01 … 2026-12-31 and contain 2026-10-01. Every row group is "maybe". Nothing is skipped.
Slide 4 / 7 · b. Row group min/max: sorted file JanDecevent_date RG 0RG 1…RG 38RG 39 … each row group covers about one week (38,440 rows) … 2026-10-01 09-24 … 10-01 10-01 … 10-08 The same rows, sorted before writing. For each row group: is max < 2026-10-01 or min > 2026-10-01? Then skip it. The day falls on a boundary, so two match. 2 of 53 row groups survive. The other 51 are never read.
Min/max statistics are only as good as the data's sort order. This is why Z-ordering and liquid clustering exist.
Slide 5 / 7 · c. Dictionary filter Row group 7 · country min/max: AT … US IE is between AT and US → "maybe" dictionary: AT DE FR PL US no IE → "definitely not" Min/max only knows the ends of the range. The dictionary knows every value. The reader decodes one small dictionary page instead of the data pages. Works for =, IN, and their negations, when: · every page of the chunk is dictionary encoded (no fallback to PLAIN), and · parquet.filter.dictionary.enabled is true (the default). High-cardinality columns fall back to PLAIN, so they need a bloom filter instead.
Illustrative row group. In the scenario every row group holds all 20 countries, so this filter can't help there; it pays off when the data is clustered by the filtered column.
Slide 6 / 7 · d. Bloom filter WHERE user_id = 'u-4242' user_id min/max is about u-1 … u-99999 in every row group, so min/max can't help. xxHash64('u-4242') → top 32 bits pick a block, the rest pick one bit in each of 8 words Block k (256 bits = 8 words × 32 bits) 0 7 of the 8 bits are set, but one is 0. If 'u-4242' had been written, all 8 would be set. → "definitely not in this row group": skip it. If all 8 were set: "maybe" (a false positive is possible; a false negative is not). Off by default. Enable per column: parquet.bloom.filter.enabled#user_id = true. Size it with parquet.bloom.filter.expected.ndv#user_id. About 10 bits per distinct value gives roughly a 1% false positive rate.
Scenario result: parquet bloom-filter answers "NOT exists" for 51 of 53 row groups and "maybe" for 2, which both really contain u-4242. Spark decoded 76,880 rows instead of 2,000,000. Parquet uses a split block bloom filter: each value touches only one 32-byte block, so a lookup reads 8 bits from one small, contiguous block.
Slide 7 / 7 · e. Page index: skip pages inside a row group Column index for event_date (min … max per page) RG 38RG 39 page 009-24 … 09-28 page 109-28 … 10-01 page 010-01 … 10-05 page 110-05 … 10-08 Offset index: first row of each page, then the same rows in the other columns rows 0–19,999rows 20,000–38,439rows 0–19,999rows 20,000–38,439 countryuser_idamount Pages here hold up to 20,000 rows (parquet-java's default row limit per page). Only 2 of the 4 event_date pages can match, so the reader decodes 18,440 + 20,000 = 38,440 rows, not the 76,880 in both row groups. In general, page boundaries differ between columns; the offset index keeps every column row-aligned. Only the blue pages are read and decompressed.
Real page ranges from parquet column-index, and the real 38,440 rows Spark decoded. Without the page index, a reader that needs one page has to walk every page header in the chunk.

How the levels combine

flowchart TD
    Q["Query with a filter"] --> P{"Partition column
in the filter?"} P -- yes --> P1["List only matching
directories"] P -- no --> F P1 --> F["For each file: read footer"] F --> RG{"For each row group"} RG --> S{"min/max or null_count
rule it out?"} S -- yes --> X["Skip row group"] S -- no --> D{"Fully dictionary encoded
and value not in dictionary?"} D -- yes --> X D -- no --> B{"Bloom filter present
and says no?"} B -- yes --> X B -- no --> PI["Page index: keep only
matching page ranges"] PI --> R["Read, decompress, decode
surviving pages of needed columns"] R --> RF["Spark re-checks the filter
on every row"]

Bloom filters in practice

A bloom filter answers one question per row group: could this exact value be here? It’s built for the case the other levels miss: equality on a high-cardinality column such as user_id, order_id or trace_id. Its min/max range covers almost everything, and the column is too varied to keep a dictionary.

Setting (parquet-java writer option) Default What it does
parquet.bloom.filter.enabled#<col> false Write a bloom filter for that column
parquet.bloom.filter.expected.ndv#<col> not set Distinct values to size the filter for. If unset, the filter uses the maximum size
parquet.bloom.filter.fpp#<col> 0.01 Target false positive probability
parquet.bloom.filter.max.bytes 1 MB Upper limit per filter
parquet.bloom.filter.adaptive.enabled false Try several sizes and keep the smallest that fits the real distinct count
parquet.filter.bloom.enabled (reader) true Use bloom filters when reading

From Spark, pass them as write options:

events.write
  .option("parquet.bloom.filter.enabled#user_id", "true")
  .option("parquet.bloom.filter.expected.ndv#user_id", "40000")  // per row group, not per table
  .option("parquet.bloom.filter.fpp#user_id", "0.01")
  .parquet(path)

Size it per row group. There is one filter per column chunk, so the distinct count that matters is how many distinct values one row group holds. The scenario run set expected.ndv to 500,000, the table-wide count. Every one of the 53 row groups got a 1 MB filter (bloomFilterLength: 1048594 in parquet footer), although each holds at most 38,440 rows. That is 53 MB of bloom filters on 107 MB of data. They still worked (51 of 53 row groups skipped), but they were more than ten times larger than they needed to be.

What it can’t do. Ranges, LIKE, IS NULL and != gain nothing from a bloom filter. It only says “definitely absent” for one exact value, and it never says “definitely present”.

Null counts

Every column chunk also records null_count. WHERE col IS NULL skips row groups whose null_count is 0. WHERE col IS NOT NULL skips row groups where every value is null.

What can’t be pushed down

The reader can only compare statistics against a raw column and a literal. Anything that changes the column’s value before the comparison stays in Spark.

Filter Pushed to Parquet? Why
country = 'IE' Yes Column compared to a literal
country IN ('IE', 'GB') Yes Becomes a set of equality checks
event_date >= '2026-10-01' Yes Range check against min/max
user_id IS NULL Yes Uses null_count
upper(country) = 'IE' No The statistics are for country, not upper(country)
myUdf(country) No Spark can’t reason about a UDF
year(event_date) = 2026 No Function on the column; rewrite as a date range
cast(amount as int) = 5 No Cast on the column; filter on the column’s own type
country LIKE '%E' No Only prefix patterns (LIKE 'I%') can use a range

The rule of thumb is to keep the column bare on one side of the comparison.

4. Reading the Spark plan: PartitionFilters, PushedFilters, Filter

Spark prints the levels above as three different lines. Here is the real formatted plan for the partitioned table, with one extra filter, upper(device) = 'IOS', that can’t be pushed:

spark.read.parquet("/tmp/parquet-demo/events_by_date")
  .where($"event_date" === "2026-10-01" && $"country" === "IE" && upper($"device") === "IOS")
  .select("user_id", "country", "amount")
  .explain("formatted")
== Physical Plan ==
* Project (4)
+- * Filter (3)
   +- * ColumnarToRow (2)
      +- Scan parquet  (1)

(1) Scan parquet
Output [5]: [user_id#316, country#317, device#318, amount#319, event_date#332]
Batched: true
Location: InMemoryFileIndex [file:/tmp/parquet-demo/events_by_date]
PartitionFilters: [isnotnull(event_date#332), (event_date#332 = 2026-10-01)]
PushedFilters: [IsNotNull(country), IsNotNull(device), EqualTo(country,IE)]
ReadSchema: struct<user_id:string,country:string,device:string,amount:double>

(3) Filter [codegen id : 1]
Condition : (((isnotnull(country#317) AND isnotnull(device#318)) AND (country#317 = IE))
             AND (upper(device#318) = IOS))

Trimmed: the ColumnarToRow and Project details are left out, and the Filter condition is wrapped.

Line in the plan Applied by When Granularity Exact?
PartitionFilters Spark, against directory names While listing files, before any file is opened Whole files Yes
PushedFilters The Parquet reader, against footer stats, dictionaries, bloom filters and the page index Per file, before decoding pages Row groups and pages No: “maybe” survives
Filter (separate node above the scan) Spark, on decoded rows After the scan Rows Yes

Four things to read from it:

  1. PartitionFilters has the date. Only one directory is listed. The date doesn’t appear in PushedFilters because the files don’t contain that column.
  2. PushedFilters has country but not upper(device). Spark pushed IsNotNull(device), which it derived itself, but not the comparison. A predicate missing from this list can’t skip anything.
  3. ReadSchema includes device. It isn’t selected, but the Filter node needs it, so it is read.
  4. The Filter node repeats the pushed filters. That isn’t a mistake. Pushed filters only rule out blocks, so Spark still has to drop the non-matching rows inside the blocks that survived.

PushedFilters lists what Spark handed to the reader. It doesn’t prove that anything was skipped. To see actual skipping, compare bytes and records read, as in the scenario table, or the number of files read, size of files read and scan time metrics on the scan node in the Spark UI’s SQL tab.

5. Other things Parquet gives you

Benefit How
Self-describing files The footer holds the schema with physical types (INT64, BYTE_ARRAY …) and logical types (DATE, TIMESTAMP, DECIMAL, STRING). No separate schema registry is needed to read a file.
Nested data Structs, arrays and maps are stored as flat leaf columns, using repetition levels (which list a value belongs to) and definition levels (how much of an optional path is present). select("address.city") reads only that leaf column.
Parallel reads of one file Spark splits files into chunks of up to spark.sql.files.maxPartitionBytes (128 MB). Each row group is read by the split that contains its midpoint, so a large file spreads over many tasks without any row group being read twice.
Aggregates from metadata With spark.sql.parquet.aggregatePushdown=true (off by default), COUNT, MIN and MAX can be answered from footer statistics without reading data pages.
Vectorized reading Spark’s vectorized reader (spark.sql.parquet.enableVectorizedReader, on by default) decodes 4,096 rows per batch (columnarReaderBatchSize), a column at a time.
Language neutral The format is an open spec with implementations in Java, C++, Rust and others, so the same files are readable by most query engines, not just Spark.
Schema evolution, partly Adding a column is safe: older files return null for it. Spark merges schemas across files only when mergeSchema=true (off by default). Renames and drops need a table format (below).

How the Pieces Fit

flowchart LR
    W["Writer
sort → encode → compress"] --> FILE["Parquet file
row groups · pages · footer"] FILE --> FT["Footer
schema, offsets, min/max"] FT --> CP["Column pruning
ReadSchema"] FT --> RS["Row group skipping
min/max, null_count"] FILE --> DI["Dictionary pages"] --> RS FILE --> BF["Bloom filters"] --> RS FILE --> PIX["Page index"] --> PS["Page skipping"] CP --> RD["Ranged reads of
surviving pages"] RS --> RD PS --> RD RD --> DEC["Decompress + decode
(vectorized)"] --> FIL["Spark Filter
exact rows"]

Inspecting a File with parquet-cli

The best way to learn Parquet is to write a file and look inside it. parquet-cli is the command-line tool that ships with parquet-java, the reference Java implementation that Spark uses. It replaced the older Java parquet-tools, which is deprecated (Homebrew no longer installs it). Several unrelated projects reuse the parquet-tools name, so check which one you have.

brew install parquet-cli          # installs the `parquet` command
Command Shows Use it to learn
parquet meta <file> Every row group: row count, size, and per column the codec, encodings and min/max/nulls Row group statistics, compression ratio per column
parquet pages <file> Every page: type, encoding, size, value count Dictionary pages, dictionary fallback, page sizes
parquet dictionary -c country <file> The dictionary for a column, per row group Exactly what the dictionary filter checks
parquet column-index -c event_date <file> The column index and offset index Page-level min/max and row offsets
parquet bloom-filter -c user_id -v u-4242 <file> Whether a value might be present, per row group Bloom filter answers
parquet footer <file> The whole footer as JSON Every field the reader sees
parquet schema <file> The schema Types and nesting
parquet column-size <file> Bytes per column Which columns cost the most

Run against the files from the scenario:

F=$(ls /tmp/parquet-demo/events_sorted/*.parquet)

parquet meta: one block per row group. This is row group 38 of the sorted file, the one that holds the end of September (some columns removed):

Row group 38:  count: 38440  53.65 B records  start: 78351620  total(compressed): 1.967 MB total(uncompressed):3.054 MB
--------------------------------------------------------------------------------
            type      encodings count     avg size   nulls   min / max
event_id    INT64     S   _     38440     5.06 B     0       "21" / "1999976"
event_ts    INT96     S _ R     38440     8.90 B     0
event_date  INT32     S _ R     38440     0.00 B     0       "2026-09-24" / "2026-10-01"
user_id     BINARY    S   _     38440     6.12 B     0       "u-1" / "u-9999"
country     BINARY    S _ R     38440     0.02 B     0       "AT" / "US"
device      BINARY    S _ R     38440     0.26 B     0       "android" / "web"

Under encodings, S is the snappy codec, R is a dictionary-encoded column and _ is plain. user_id has no R: with mostly distinct ids, the writer didn’t keep a dictionary and wrote the column plain. event_ts has no min/max, because it is INT96. event_date averages 0.00 bytes per value: one week of sorted dates is a handful of runs.

parquet pages -c country: the dictionary page (dict) and the data pages of each column chunk:

  page   type  enc  count   avg size   size       rows     nulls   min / max
  0-D    dict  S _  20      6.00 B     120 B
  0-1    data  S R  20000   0.01 B     232 B
  0-2    data  S R  18440   0.01 B     220 B

20,000 country values fit in 232 bytes, because they’re sorted runs of 20 dictionary ids.

parquet column-index -c event_date: what the page index stores for row group 38 (columns trimmed):

column index for column event_date:
Boundary order: ASCENDING
                      null count  min                                       max
page-0                         0  2026-09-24                                2026-09-28
page-1                         0  2026-09-28                                2026-10-01

offset index for column event_date:
                          offset       compressed size       first row index
page-0                  78888480                    51                     0
page-1                  78888531                    48                 20000

parquet bloom-filter -c user_id -v u-4242: one answer per row group:

Row group 3:
--------------------------------------------------------------------------------
value u-4242 NOT exists.

Row group 4:
--------------------------------------------------------------------------------
value u-4242 maybe exists.

51 row groups answer “NOT exists” and 2 answer “maybe”.

parquet dictionary -c country: the exact values the dictionary filter checks:

Row group 0 dictionary for "country":
     0: "AT"
     1: "AU"
     2: "BE"
     3: "CA"
     ...

Is this the best way to learn? For the internals, yes. Each command matches one mechanism from this post, and the output uses the format’s own names: row groups, encodings, column index, bloom filter. Writing the same data sorted and unsorted, then comparing parquet meta side by side, teaches more about data skipping than any diagram. For everyday checks on production tables, the Spark plan and the SQL tab metrics are quicker, because they show what the query actually did.


What Delta Lake and Iceberg Add on Top

Delta and Iceberg tables are directories of ordinary Parquet files plus a metadata layer. Everything above still happens inside each file. The metadata layer adds a level above the files and fixes the weaknesses of plain Parquet.

flowchart TD
    Q["Query"] --> M["Table metadata
Delta: _delta_log · Iceberg: manifests"] M -- "per-file min/max, null counts
skip whole files without opening them" --> F["Surviving Parquet files"] F --> FT["Parquet footer
row group min/max, dictionary, bloom"] FT --> PG["Page index"] PG --> RF["Row filter in the engine"]
Parquet feature Plain Parquet Delta Lake Apache Iceberg
Data files Parquet Parquet Parquet by default (write.format.default); ORC and Avro also allowed
Min/max at file level Not stored anywhere central; the reader must open every footer minValues, maxValues, nullCount, numRecords per file in the transaction log lower_bounds, upper_bounds, null_value_counts, value_counts per file in manifests
Which columns get stats Every column, in every footer First 32 columns by default (delta.dataSkippingNumIndexedCols) First 100 columns by default (write.metadata.metrics.max-inferred-column-defaults)
String stats truncation parquet-java truncates column index values to 64 bytes Strings cut to a fixed prefix truncate(16) by default (write.metadata.metrics.default)
Partition pruning Directory names (Hive style) Partition values stored in the log Hidden partitioning: transforms such as days(ts), so filters on ts prune without a separate date column
Skipping depends on sort order Yes Yes: Z-order and liquid clustering keep file ranges narrow Yes: sort orders and Z-order in rewrite_data_files
Bloom filters parquet.bloom.filter.enabled#col Not part of the Delta protocol write.parquet.bloom-filter-enabled.column.<col>
Column rename and drop Columns matched by name; a rename breaks old files Column mapping stores a physical name and a Parquet field_id Columns matched by Parquet field_id, always
Updates and deletes Files are immutable; rewrite the file Deletion vectors mark deleted rows; files rewritten later Positional and equality delete files, or deletion vectors in v3
Atomic commits, time travel None Yes, via the log Yes, via snapshots
Default codec snappy (Spark) snappy (Spark) zstd
Row group size 128 MB (parquet-java) 128 MB (parquet-java) 128 MB (write.parquet.row-group-size-bytes), with 512 MB target files

The biggest change is the first data row. With plain Parquet on object storage, a query over 10,000 files must issue at least 10,000 footer reads before it skips anything. Delta and Iceberg keep the same min/max values in a few metadata files, so the planner drops most files before opening any of them. Inside the files that remain, the Parquet mechanisms in this post still do the rest.


Where It Hurts

Watch-out Why it matters What to do
Unsorted data Every row group spans the full range, so min/max skips nothing Sort within partitions on the main filter columns before writing
Many small files One footer read and one task per file; row groups too small to compress well Compact into larger files; Delta and Iceberg have OPTIMIZE and rewrite_data_files
Functions on filter columns Not pushed, so nothing is skipped Rewrite as a range on the raw column
High-cardinality equality filters Dictionary falls back to PLAIN; min/max too wide Add a bloom filter on that column, or sort by it
Long string columns Statistics are truncated, so ranges become fuzzy Filter on shorter keys; don’t rely on skipping for long strings
Too many partition values One directory per value means tiny files Partition only on low-cardinality columns, usually dates
SELECT * everywhere Column pruning does nothing Select columns as early as possible
Bloom filter sized for the whole table One filter per row group; an oversized NDV gives every row group a 1 MB filter Set expected.ndv to the distinct count of one row group, or enable adaptive sizing
INT96 timestamps (Spark’s default) No min/max is written, so timestamp filters skip nothing Set spark.sql.parquet.outputTimestampType=TIMESTAMP_MICROS
Updating rows Parquet files can’t be changed in place Use Delta or Iceberg, which rewrite or mark rows for you
Schema drift across files Spark reads one file’s schema unless mergeSchema=true Use a table format, or merge schemas explicitly

When Something Else Fits Better

Situation Better fit Why
Writing one record at a time (Kafka, CDC streams) Avro Row format: cheap to append a record, schema per message
Reading whole rows by key A database or key-value store Parquet is built for scanning columns, not point lookups
Small files exchanged with people or other tools CSV or JSON Human-readable; size doesn’t matter
Hive-centric stacks ORC Similar columnar design with its own stats and indexes
Updates, deletes, concurrent writers, time travel Delta Lake or Iceberg (on Parquet) Adds a transaction log and file-level statistics
flowchart TD
    A["New dataset"] --> B{"Mostly analytical scans
over many rows?"} B -- no --> B1["Row format (Avro) or a database"] B -- yes --> C{"Need updates, deletes,
or concurrent writers?"} C -- yes --> C1["Delta Lake or Iceberg
(Parquet underneath)"] C -- no --> D["Plain Parquet"] C1 --> E["Sort or cluster on the
main filter columns"] D --> E E --> F{"Equality filters on
high-cardinality ids?"} F -- yes --> F1["Add bloom filters"] F -- no --> G["Done"] F1 --> G

Checking It Works

  1. Read the plan. Run df.explain("formatted"). Check that ReadSchema lists only the columns you need, and that your filter columns appear in PartitionFilters or PushedFilters.
  2. Compare bytes read. On the Spark UI’s SQL tab, compare the scan node’s size of files read and output rows against a full scan. Skipping works if a selective filter reads a small share of the bytes.
  3. Inspect the file. Run parquet meta and check that the filter column’s min/max ranges are narrow and don’t overlap across row groups. Wide, overlapping ranges mean the data needs sorting.
  4. Check special cases. For bloom filters, parquet bloom-filter -c <col> -v <value> should say “no” for most row groups. For dictionaries, parquet pages should show no PLAIN data pages in the filter column.

Reproduce It

The script that produced every number above. Save it as parquet-demo.scala and run it with spark-shell --master "local[4]" --driver-memory 4g -i parquet-demo.scala.

// spark-shell --master "local[4]" --driver-memory 4g -i parquet-demo.scala
import org.apache.spark.sql.functions._
import org.apache.spark.scheduler._
import java.util.concurrent.atomic.AtomicLong

def runDemo(): Unit = {
val out = "/tmp/parquet-demo"   // about 750 MB in total
spark.sparkContext.setLogLevel("ERROR")

val countries = Array("IE","GB","US","DE","FR","ES","IT","NL","SE","PL","PT","BE","AT","DK","FI","NO","CH","CA","AU","JP")
val countryCol = element_at(typedLit(countries), (pmod(xxhash64(col("id"), lit(7)), lit(countries.length)) + 1).cast("int"))

// 2M events across 2026, plus 12 filler columns so the table is wide (19 columns)
val base = spark.range(0, 2000000L, 1, 8)
  .withColumn("event_ts", timestamp_seconds(lit(1767225600L) + pmod(xxhash64(col("id"), lit(1)), lit(365L * 86400))))
  .withColumn("event_date", to_date(col("event_ts")))
  .withColumn("user_id", concat(lit("u-"), pmod(xxhash64(col("id"), lit(2)), lit(500000L)).cast("string")))
  .withColumn("country", countryCol)
  .withColumn("device", element_at(typedLit(Array("ios","android","web")), (pmod(col("id"), lit(3)) + 1).cast("int")))
  .withColumn("amount", round(pmod(xxhash64(col("id"), lit(3)), lit(100000L)) / 100.0, 2))
  .withColumnRenamed("id", "event_id")
val wide = (1 to 12).foldLeft(base)((df, i) => df.withColumn(s"attr_$i", concat(lit(s"a$i-"), pmod(xxhash64(col("event_id"), lit(10 + i)), lit(1000L * i)).cast("string"))))

// Small row groups so one file has many of them (default is 128 MB)
spark.sparkContext.hadoopConfiguration.setInt("parquet.block.size", 4 * 1024 * 1024)

wide.repartition(1).write.mode("overwrite").parquet(s"$out/events_unsorted")
wide.repartition(1).sortWithinPartitions("event_date", "country").write.mode("overwrite")
  .option("parquet.bloom.filter.enabled#user_id", "true")
  .option("parquet.bloom.filter.expected.ndv#user_id", "500000")
  .parquet(s"$out/events_sorted")
wide.repartition(col("event_date")).sortWithinPartitions("country").write.mode("overwrite")
  .partitionBy("event_date").parquet(s"$out/events_by_date")
wide.repartition(1).write.mode("overwrite").option("header", "true").csv(s"$out/events_csv")

// Sum input bytes and records (rows decoded) over every task of the next action
val bytes = new AtomicLong; val recs = new AtomicLong
spark.sparkContext.addSparkListener(new SparkListener {
  override def onTaskEnd(e: SparkListenerTaskEnd): Unit = if (e.taskMetrics != null) {
    bytes.addAndGet(e.taskMetrics.inputMetrics.bytesRead); recs.addAndGet(e.taskMetrics.inputMetrics.recordsRead)
  }
})
def measure(label: String)(f: => Long): Unit = {
  bytes.set(0); recs.set(0)
  val n = f
  Thread.sleep(1500)
  println(f"RESULT | $label%-38s | rows=$n%,9d | bytesRead=${bytes.get / 1024.0 / 1024}%,8.1f MiB | recordsRead=${recs.get}%,10d")
}

val ieOct1 = col("event_date") === "2026-10-01" && col("country") === "IE"
val cols = Seq("user_id", "country", "amount").map(col)

measure("csv: 3 cols, date+country") { spark.read.option("header", "true").csv(s"$out/events_csv").where(ieOct1).select(cols: _*).count() }
measure("parquet unsorted: 3 cols") { spark.read.parquet(s"$out/events_unsorted").where(ieOct1).select(cols: _*).collect().length.toLong }
measure("parquet sorted: 3 cols") { spark.read.parquet(s"$out/events_sorted").where(ieOct1).select(cols: _*).collect().length.toLong }
measure("parquet sorted: pushdown off") {
  spark.conf.set("spark.sql.parquet.filterPushdown", "false")
  val n = spark.read.parquet(s"$out/events_sorted").where(ieOct1).select(cols: _*).collect().length.toLong
  spark.conf.set("spark.sql.parquet.filterPushdown", "true"); n
}
measure("parquet partitioned: 3 cols") { spark.read.parquet(s"$out/events_by_date").where(ieOct1).select(cols: _*).collect().length.toLong }
measure("sorted: upper(country) = 'IE'") { spark.read.parquet(s"$out/events_sorted").where(col("event_date") === "2026-10-01" && upper(col("country")) === "IE").select(cols: _*).collect().length.toLong }
measure("unsorted: user_id = u-4242") { spark.read.parquet(s"$out/events_unsorted").where(col("user_id") === "u-4242").select(cols: _*).collect().length.toLong }
measure("sorted+bloom: user_id = u-4242") { spark.read.parquet(s"$out/events_sorted").where(col("user_id") === "u-4242").select(cols: _*).collect().length.toLong }

println("PLAN-PARTITIONED")
spark.read.parquet(s"$out/events_by_date").where(ieOct1 && upper(col("device")) === "IOS").select(cols: _*).explain("formatted")
println("PLAN-END")

}
runDemo()
System.exit(0)

Then run the parquet-cli commands above on the files under /tmp/parquet-demo. The whole run needs about 750 MB of free disk.


What’s Next

Topic What it builds on from this post
How Iceberg achieves all-or-nothing writes Immutable Parquet files, plus a metadata layer that commits them atomically
Liquid clustering under the hood Keeping per-file min/max ranges narrow as data keeps arriving

Sources

Checked on 2026-10-29.

Source Used for
Parquet file format File layout, PAR1 magic, footer after the data for single-pass writing
Parquet configurations Recommended row group size (512 MB – 1 GB)
Parquet data pages Page contents: repetition levels, definition levels, values
Parquet encodings PLAIN, RLE_DICTIONARY, RLE/bit-packing, delta encodings, BYTE_STREAM_SPLIT, dictionary fallback
Parquet page index ColumnIndex and OffsetIndex, storage near the footer, why page headers alone weren’t enough
Parquet bloom filter Split block bloom filter, xxHash64, 256-bit blocks, false positive rates, location in file
parquet-hadoop README parquet.block.size, page size, row count limit, dictionary page size, bloom filter options, column index truncation, filter level switches
parquet-cli README Commands and their descriptions
DictionaryFilter.java Dictionary filter only applies when the chunk has no non-dictionary pages
ParquetMetadataConverter.java Row groups assigned to splits by midpoint
Spark Parquet files filterPushdown, compression.codec, vectorized reader, batch size, mergeSchema, aggregatePushdown
Spark performance tuning spark.sql.files.maxPartitionBytes
SPARK-26345, SPARK-34859 Column index (page) filtering in Spark’s Parquet readers
Delta Lake protocol Per-file statistics in the log, string truncation, column mapping and field_id, deletion vectors
Delta table properties delta.dataSkippingNumIndexedCols default 32
Iceberg table spec Manifest bounds and counts, Parquet field IDs
Iceberg configuration Parquet defaults (zstd, 128 MB row groups, bloom filters), metrics mode truncate(16), 100-column metrics default, 512 MB target file size