Deep Dive · Data engineering

Why .parquet Dominates Modern Data Engineering

A byte-level tour of the format that most AI training data is stored in: how rows become columns, why the footer sits at the end, how dictionary and run-length encoding turn 76 MB of payment types into 2.2 MB, and why sorting your data can matter more than your cluster.

why .parquet dominates modern data engineering — video thumbnail
Video: Python In Production 13:04 5 October 2026
Apache Parquet Columnar storage Predicate pushdown DuckDB · Polars · PyArrow

⏱ The seven-millisecond demo 0:00

The chapter opens with a function that computes the average fare of ten million New York taxi trips. Reading a CSV of almost a gigabyte, it takes under a fifth of a second. Change one line to read a Parquet file with the same trips and two things happen: the file is a sixth of the size, and the function takes seven milliseconds — about twenty-seven times faster.

The framing is the point of the whole series: a language model can write both functions in seconds, so the code is no longer the differentiator. How you store the data is. The same aggregation over the same rows differs by more than an order of magnitude purely because of the file format.
What the numbers are, and are not: these are single-machine measurements on one laptop with one dataset, not a benchmark suite. Sized as a demonstration, they are honest — the direction is well understood and reproducible in kind. Treat the multipliers as illustrations, not as a performance claim you can quote for your own workload.

📊 Rows versus columns 0:51

Two families of file format, and the split is about which unit is contiguous:

StorageFormatsWhat is adjacent on disk
Row storageCSV, JSON Lines, AvroAll nineteen values of one trip
Columnar storageParquet, ORC, Feather (Arrow IPC)All ten million fares, then all the times

In the CSV, each line holds one trip and commas separate its values. Because the values have different lengths, the fare is not at a fixed position in the line — so a CSV reader cannot jump to the fares. It reads every character of every line and counts commas. In the Parquet file, the fares sit next to each other, so the reader reads that column and skips the rest.

His second finding is the more useful one: this is not a Python-tooling artefact. Polars has the fastest CSV reader of the three tools tested, DuckDB takes more than three times as long, pandas more than ten times — but even pandas reads the fares from Parquet seven times faster than Polars reads the same column from CSV. The advantage belongs to the format, not the library.

Why columnar wins for analytics: "most aggregations, filters and training jobs read a few columns of many rows." That is the whole argument in one sentence — and it is also why columnar loses for point lookups, which comes up later.

🔤 Types live in the file 2:08

A CSV stores every value as text. A fare of seventeen dollars and seventy cents is four characters — 1, 7, ., 7 — and turning those characters back into a number is parsing, done on every read, by every reader, forever. A CSV also does not record the type of each column, so readers guess from the first values they see: type inference.

He demonstrates the failure mode precisely. PyArrow writes a fee of one dollar as the digit 1, without the decimal point. Polars reads the file, checks the first hundred rows, sees only whole numbers, and decides the column is integers. About eighty thousand rows later it reaches a fee of thirty cents and the read fails.

This is the most practically useful bug in the chapter. Type inference is sampling, and sampling is a guess. It is why the same CSV loads on Monday and fails on Friday when a new export introduces a decimal, a null, or a currency symbol. Parquet removes the guess entirely: the type of every column is written into the file, by the writer, once.

🧱 Anatomy of a file: PAR1 and the footer 3:08

The layout, in order:

BytesContent
First 4PAR1 — the magic number
ThenThe column data, split into row groups, columns and pages
ThenThe footer: every column's name, type and where its data starts
Last 8The footer's length, then PAR1 again

The footer is written last because the writer only knows where each column starts after writing it. A reader therefore starts at the end: read the final eight bytes, learn the footer's length, then read the footer. In his file the footer takes 26 kilobytes of 158 megabytes — about one six-thousandth of the file, and everything the reader needs to plan its work.

The footer is encoded in Thrift, a compact binary serialisation. That is why a Parquet reader can pull every type and offset without parsing a single character of text — the metadata is numbers in a known binary shape.

The design lesson: putting the index at the end means the writer needs no second pass and no pre-computed offsets, and the reader pays one small read to learn the whole layout. It is why Parquet writers can stream, and why "read the last 8 bytes first" is literally the first thing a reader does.

🗜 Encodings: dictionary, run-length, bit-packing 3:57

The six-times-smaller file is not primarily about compression. It is about encoding — rewriting values in a smaller form that turns back into exactly the same values. In a column, values of the same kind sit together, and many repeat.

The payment-type column is the demonstration. It has only a handful of distinct values, so:

  1. Dictionary encoding — write each distinct value once, in a short list, then store each row's position in that list.
  2. Run-length encoding — when the same position repeats many times in a row, store it once with a count.
  3. Bit-packing — five positions fit in three bits, not sixty-four.
RepresentationSize
Plain 64-bit integers76 MB
After dictionary + RLE + bit-packing2.2 MB
After Zstandard on top1.2 MB
The most honest moment in the video: he points out that for this column the dictionary alone saves nothing — a 64-bit position costs as many bytes as the 64-bit value it replaces. The saving comes from bit-packing and run-length. And he quantifies the fallback: even if each payment type were stored as a 32-bit number, the three encodings together would still save about 94% of the space.

📄 Pages and page headers 5:08

Encoded values are grouped into pages, and pages are the unit of both compression and skipping. In his example one row group holds about a million payment types: the first page holds the dictionary of five values, and the next fifty-three pages hold the positions. PyArrow packs up to twenty thousand positions into a page by default.

Each page starts with a short header saying how it is encoded and how many bytes it takes. When twenty thousand consecutive payment types are identical, the page holds one run and its data takes thirteen bytes before compression.

🌡 Compression, and the network threshold 5:39

Compression happens per page, so a reader can decompress one page without touching the others. Zstandard takes the payment types from 2.2 MB to 1.2 MB. The defaults differ by tool, which is worth knowing before you compare file sizes across ecosystems:

ToolDefault Parquet compression
PolarsZstandard (configurable level, default 3)
PyArrowSnappy
DuckDBSnappy

His measurements: Zstandard writes about as fast as Snappy and produces a file about a fifth smaller. Gzip and Brotli go smaller still, but with PyArrow's defaults Gzip takes almost thirty times as long to write as Zstandard, and Brotli two and a half times as long to read.

The best insight in the chapter is arithmetic, not opinion. In memory, Zstandard costs about 24 milliseconds of decompression per column read — so uncompressed looks faster. But the compressed file is 91 MB smaller, and over any network slower than roughly 25 gigabits per second, those 91 MB take longer to transfer than Zstandard takes to decompress. Compression makes reads faster over a network, because you are trading CPU you have for bandwidth you do not.

🔀 Sorting changes the file size 6:50

Order the rows by pickup time and almost every timestamp is within a few seconds of the one before it — so adjacent values share most of their bytes and compress well. The sorted file is 144 MB; shuffled, the same data is 213 MB.

He also tests the obvious objection: could you just compress the CSV? Yes — Python 3.14 ships Zstandard in the standard library, and the compressed CSV lands at 188 MB, larger than the Parquet file at 158 MB. And the read is far worse: reading the fares from a compressed CSV takes about five times as long as from the plain CSV, because the reader still decompresses every line and parses it. From Parquet the same column takes seven milliseconds.

Practical takeaway: "sort by the column you filter on" is not only a query optimisation, it is a storage optimisation. The same rows, the same schema, the same compressor — a third smaller purely because the data is ordered. If your pipeline appends random-order events into Parquet, you are paying for that twice: bigger files and worse skipping.

🎯 Predicate pushdown and row-group statistics 7:43

Columns can be skipped because they are separate. Rows can be skipped too, because Parquet splits rows into row groups and writes, for each column in each row group, the smallest and largest value into the footer.

His file has ten row groups. Ask for the average fare on February 15th: for each row group the footer holds the earliest and latest pickup time, and when February 15th falls outside that range the reader skips the group entirely without reading a byte of it. Passing the filter down to the reader like this is predicate pushdown.

Data orderRow groups readBytes readTime
Sorted by pickup time1 of 10—baseline
Shuffled10 of 10~20× morealmost 2× longer

Then the data-quality twist, which is the chapter's second-best moment. In the real New York dataset, nine trips have pickup times in 2002, 2008 and 2009 — published out of order. Because the file keeps the order New York published, two of those nine land in a row group of March trips. The footer then records a range from December 2002 to March 2024 for that group, so the reader cannot skip it and reads a whole row group of March trips to find one day in February.

Nine bad rows can cost you a row group. "A few wrong values may not matter for a machine learning model, but they can keep a row group from being skipped." Data cleaning is not hygiene here — it is a query optimisation, and the cost is paid on every read until someone fixes the timestamps.

📦 Row group size is a trade-off 9:10

Row group size is a writer setting. Smaller groups let a reader skip more precisely; larger groups keep the footer small — and the reader reads the whole footer first, before anything else.

Row group sizeNumber of groupsFooter sizeConsequence
10,000 rows95626 KB → 2.2 MBFine skipping, but the footer itself becomes a read
Default1026 KBThe balanced case he measured with
All rows in one group1minimalReads 27 MB to find one day — 55 ms

Writer defaults differ, and this is where the video has a factual slip worth correcting. PyArrow's default of about a million rows matches his description. DuckDB's default is indeed 122,880 rows. But Polars defaults to 512² — 262,144 rows, roughly twice what he states, so "DuckDB and Polars put about a hundred twenty thousand" is right for one and wrong for the other.

The rule he ends on is the right one: understand your access pattern, then choose. Defaults of popular writers are tuned for general use and are usually fine — but "usually fine" is not "measured for your query".

📜 A short history, and two meanings of "version two" 9:57

YearEvent
2010Google describes Dremel, storing records column by column
2013Engineers at Twitter and Cloudera build Parquet on that idea for Hadoop; Parquet 1.0 ships in July
2015Parquet graduates from the Apache Incubator to a top-level project (27 April 2015)
2017Zstandard compression and a page-level index for skipping are added

The Hadoop context explains the design constraint: Hadoop read from local disks across many machines, disk was the bottleneck, so a reader that touches only the columns it needs reads less from disk. The name comes from parquet floors — small blocks of wood laid in a pattern, like row groups and columns.

A genuinely subtle point most explainers get wrong: "Parquet version 2" means two different things. The format version sets which types a file can hold (nanosecond timestamps, for instance); the page version sets how a page is laid out, and version-2 pages keep the definition and repetition levels outside the compressed part. Files of both format versions still start and end with PAR1. His file uses format version 2.6 with version-1 pages — the PyArrow defaults.

⚖️ When columnar loses 10:59

The comparison here is cleaner than the usual "Parquet is faster" claim: two embedded databases, one row-oriented (SQLite) and one columnar (DuckDB), each holding a whole database in one file.

OperationRow storage (SQLite)Columnar (DuckDB)
Fetch one trip by its number~6 microseconds~29× slower
Append one trip~0.2 millisecondsNo append — rewrite the file, ~2 seconds
Average fare per monthbaseline~49× faster
Average fare per month, single corebaseline~9× faster
File sizeCSV-sizedA sixth of the CSV, smaller than the compressed CSV

Two structural facts matter as much as the timings. First, PyArrow, Polars and DuckDB cannot append rows to a finished Parquet file — adding one trip means writing the whole file again, and that takes almost two seconds against SQLite's 0.2 milliseconds. Second, DuckDB uses every core while SQLite uses one, and it is still nine times faster on a single core, so the win is architectural rather than a matter of parallelism.

The rule to keep: rows for applications that read and write one row at a time; columns for analysis and training, which read many rows at once. Parquet's immutability is not a bug — it is the same property that makes its encoding and statistics cheap to compute. That is why the answer at scale is a table format on top (Iceberg, Delta), which the next chapter covers.

🤖 Why this matters for AI data 12:26

Many of the datasets models learn from are Parquet files. Hugging Face converts its public datasets to Parquet, and the New York taxi data used throughout is published by the city as Parquet too. Big tech uses the format heavily for the same reasons the video measures: smaller files, and reads that touch only what a query needs.

The training-loop implication is worth spelling out, because the video only gestures at it: a data loader that reads Parquet can fetch a column, or a row group, without materialising the rest of the table. On a shuffled CSV, every epoch re-parses every value of every row — including the nineteen columns your model does not use.

🧐 Where the video is imprecise 13:04

  • Polars' default row group size is 262,144 rows (512²), not ~120,000. His pairing of "DuckDB and Polars" is correct for DuckDB (122,880) and wrong for Polars. Everything else he says about the trade-off stands.
  • "Only five different values" in the payment-type column is a description of his filtered sample. The TLC schema defines six codes (credit card, cash, no charge, dispute, unknown, voided trip); the compression figures are unaffected either way, but the dictionary is larger than five entries in a full-year file.
  • All timings are one machine, one dataset, one run. The 27×, 49× and 29× multipliers are the shape of the effect, not its magnitude on your hardware — and DuckDB's 49× partly reflects using every core.
  • The 25 Gbit/s threshold is arithmetic from his own measured numbers (91 MB saved against 24 ms of decompression). It is a good way to think, and it moves with your CPU and network — not a constant to memorise.

✅ Claims checked

ClaimVerdictEvidence
Parquet files start and end with the magic bytes PAR1VerifiedParquet file format specification
The footer holds the schema and column offsets, and is written lastVerifiedSpec: file metadata at the end, followed by 4-byte footer length and the magic
Footer metadata is encoded with ThriftVerifiedSpec: Thrift compact protocol
Encodings include dictionary, run-length and bit-packingVerifiedSpec encoding list (plain, RLE/dictionary, delta variants, byte-stream-split)
Compression is applied per pageVerifiedPages are independently compressed and decompressed
Polars defaults to Zstandard; PyArrow and DuckDB default to SnappyVerifiedPolars `write_parquet(compression='zstd')`; PyArrow and DuckDB default to snappy
PyArrow packs about a million rows per row group by defaultVerifiedDefault `row_group_size` of 1,048,576 rows
DuckDB and Polars both default to about 120,000 rows per row groupHalf wrongDuckDB: 122,880 ✓. Polars: 512² = 262,144 rows ✗
Data pages default to 1 MB, which is why ~20,000 positions fit a pageConsistentPolars/PyArrow default `data_page_size` of 1024² bytes
Python 3.14 includes Zstandard in the standard libraryVerified`compression.zstd` module added in Python 3.14
Google described Dremel, column-oriented, in 2010VerifiedDremel paper, 2010
Parquet was built in 2013 by engineers at Twitter and Cloudera for HadoopVerifiedProject history; Parquet 1.0 released July 2013
Parquet became a top-level Apache project in 2015VerifiedASF announcement, 27 April 2015
Zstandard and the page index were added in 2017VerifiedFormat history: zstd codec and page-level indexing
"Version two" means two different things — format version and page versionVerified, and rarely explained this clearlyFormat version governs the type set; page version governs page layout and level encoding
Format 2.6 with version-1 pages is PyArrow's defaultVerifiedPyArrow writes format version "2.6" with `data_page_version="1.0"` unless told otherwise
Parquet cannot be appended to; adding a row rewrites the fileVerifiedImmutable-by-design; all three writers behave this way
Hugging Face converts public datasets to Parquet; NYC publishes taxi data as ParquetVerifiedDatasets Server exposes Parquet; TLC trip data page offers Parquet downloads
All speed and size multipliers (27×, 49×, 29×, 20×, ~2×)Single-run measurementsDemonstrations on one machine and one dataset — direction reliable, magnitude not
Network threshold of ~25 Gbit/s for compression to pay offDerived, not universalArithmetic from his own 91 MB and 24 ms figures

Transcript notes. Auto-captions render "Zstandard" correctly but "Parquet" occasionally as "parquet"/"par kit", "PyArrow" as "PyArrow"/"Py Airrow", and "Iceberg" as "Iceberg". The dataset is the NYC Taxi & Limousine Commission trip records, which has nineteen columns in the yellow-taxi schema — matching his "nineteen values" per row.

🔑 Key takeaways

  • The format is the lever, not the library: even pandas reading Parquet beats the fastest CSV reader on the same column, so the win belongs to columnar storage itself.
  • Columnar wins because analytics reads a few columns of many rows — and loses for point lookups, which is why SQLite still answers one row in microseconds.
  • Types are stored, not inferred. That removes an entire class of production failure: the CSV that loads fine until a decimal appears eighty thousand rows in.
  • The footer is at the end by necessity, written in Thrift, and it is what makes skipping possible — a reader's first act is to read the last eight bytes.
  • Encoding beats compression. Dictionary, run-length and bit-packing take 76 MB to 2.2 MB; Zstandard then takes it to 1.2 MB. And the dictionary alone saves nothing — bit-packing does the work.
  • Compression can make reads faster, not just smaller: below about 25 Gbit/s, transferring the 91 MB you saved costs more time than decompressing.
  • Sort by the column you filter on. It shrinks the file (144 vs 213 MB) and it enables row-group skipping — the same rows, ordered differently, are a different storage system.
  • Nine bad timestamps can defeat a whole row group. Outlier values inflate min/max ranges in the footer, so data cleaning is literally a query optimisation.
  • Row group size trades skipping against footer size: 956 small groups grew the footer from 26 KB to 2.2 MB; one giant group made a one-day lookup read 27 MB.
  • "Version two" means two independent things — format version (which types you can store) and page version (how a page is laid out). A file can be format 2.6 with version-1 pages.
  • Immutability is the price of all of this: no appends, so a single-row write costs a full rewrite — which is exactly why table formats like Iceberg exist on top.
  • Check the defaults you inherit: Polars writes Zstandard at 262,144 rows per group; PyArrow and DuckDB write Snappy at 1,048,576 and 122,880 — the same query can hit very different layouts.

🔗 Sources

Basis note. Built from the video's 13:04 transcript, then every format and default value was checked against the Apache Parquet specification and the writers' own documentation. The performance and file-size figures are the presenter's single-run measurements on his machine and are labelled as such. One factual correction is applied in the body (Polars' row-group default) alongside a clarification about payment-type codes.

⏱ Timestamp index

0:00The 27× demo: CSV versus Parquet
0:33Why data storage is the choice you still own
0:51Row storage versus columnar storage
1:17Why a CSV reader cannot jump to a column
1:41Polars vs DuckDB vs pandas on CSV
2:08Parsing: every value stored as text
2:27Type inference and the 80,000-row failure
3:08PAR1, the footer, and why it is written last
3:48Thrift: metadata as binary, not text
3:57Encoding: dictionary, run-length, bit-packing
4:4676 MB to 2.2 MB, and why the dictionary alone is not enough
5:08Pages and page headers
5:39Zstandard per page; codec defaults per tool
6:28The 25 Gbit/s network argument
6:50Sorting: 144 MB versus 213 MB
7:13Compressed CSV still loses on read
7:43Row groups and min/max statistics
8:17The nine bad timestamps that defeat skipping
9:10Row group size: footer versus skipping
9:57Dremel, Twitter, Cloudera, the parquet-floor name
10:34Two meanings of "version two"
10:59SQLite versus DuckDB: rows against columns
11:40Append cost and file size
12:26Hugging Face and NYC publish Parquet
12:40Next chapter: how Iceberg holds thousands of files together
☰ View all