06 · Working with Parquet/Arrow¶
CSV and JSON are row-oriented, text-based, and untyped — fine for small data,
wasteful for analytics at scale. Parquet is a columnar, binary,
compressed file format built for exactly this. Apache Arrow is the
in-memory columnar format that pandas, Polars, DuckDB, and Spark increasingly
share, making conversions between them nearly free. This module writes,
reads, and reasons about both using pyarrow.
What actually ran
Code uses pyarrow and pyarrow.parquet, reasoned through against
documented APIs (pyarrow>=14). Install with pip install pyarrow.
Row-oriented vs. columnar, concretely¶
import pyarrow as pa
import pyarrow.parquet as pq
table = pa.table({
"user_id": [1, 2, 3, 4],
"country": ["IN", "US", "IN", "DE"],
"amount": [120.5, 89.0, 45.25, 300.0],
})
print(table.schema)
A CSV row is 1,IN,120.5 stored contiguously. Parquet instead stores all
user_id values together, then all country values, then all amount
values — each column compresses far better (repeated "IN" strings compress
to almost nothing) and a query touching only amount never has to read
user_id or country off disk at all.
Writing and reading Parquet¶
pq.write_table(table, "/tmp/users.parquet", compression="snappy")
read_back = pq.read_table("/tmp/users.parquet")
print(read_back.to_pandas())
compression="snappy" is the Parquet default — fast to decompress, good
enough compression ratio for most analytics workloads. gzip compresses
tighter but is slower to read, which matters more than write time for
data that's read many times.
Column pruning and predicate pushdown¶
# Only reads the 'amount' column off disk — user_id and country are
# never touched, because Parquet stores column offsets in its footer.
amounts_only = pq.read_table("/tmp/users.parquet", columns=["amount"])
print(amounts_only.column_names)
# Predicate pushdown: row-group statistics (min/max per column) let the
# reader skip entire row groups that can't match the filter.
import pyarrow.dataset as ds
dataset = ds.dataset("/tmp/users.parquet", format="parquet")
filtered = dataset.to_table(filter=(ds.field("country") == "IN"))
print(filtered.to_pandas())
For a file with many row groups, Parquet stores per-row-group min/max
statistics for each column — if a row group's country min/max can't
possibly contain "IN", the reader skips decompressing it entirely. This is
why Parquet + partition pruning together make cloud data warehouses fast
without indexes.
Schema and types matter¶
schema = pa.schema([
("user_id", pa.int64()),
("country", pa.string()),
("amount", pa.decimal128(10, 2)), # exact decimal, not float
("signup_date", pa.date32()),
])
import datetime
table2 = pa.table({
"user_id": [1],
"country": ["IN"],
"amount": [pa.scalar(120.50, type=pa.decimal128(10, 2))],
"signup_date": [datetime.date(2026, 1, 1)],
}, schema=schema)
pq.write_table(table2, "/tmp/users_typed.parquet")
print(pq.read_schema("/tmp/users_typed.parquet"))
Unlike CSV, Parquet stores an explicit schema — no re-inferring types on
every read, and no silent surprises like "120.50" being read back as a
float that can't represent money exactly. Use decimal128 for currency,
never float64.
Arrow as the pandas/Polars/DuckDB bridge¶
import pandas as pd
import duckdb
df = table.to_pandas() # Parquet -> Arrow -> pandas, zero-copy where possible
back_to_arrow = pa.Table.from_pandas(df)
con = duckdb.connect()
result = con.execute("SELECT country, SUM(amount) FROM read_parquet('/tmp/users.parquet') GROUP BY country").arrow()
print(result.to_pandas())
DuckDB can query a Parquet file directly with SQL and hand results back as an Arrow table — no separate "load into a database" step. This is the basis of most modern local/lakehouse analytics tooling: Arrow is the lingua franca that lets these tools interoperate without serializing to CSV/JSON in between.
Row groups and file layout¶
writer_props = pq.ParquetWriter(
"/tmp/users_rowgroups.parquet", table.schema
)
# Writing in chunks creates multiple row groups — useful for very large
# tables where you want independent, prunable chunks rather than one giant
# block.
writer_props.write_table(table.slice(0, 2))
writer_props.write_table(table.slice(2, 2))
writer_props.close()
meta = pq.read_metadata("/tmp/users_rowgroups.parquet")
print(meta.num_row_groups, meta.num_rows)
A row group is the unit of parallelism and pruning — Spark and other distributed engines assign row groups to different workers. Too few row groups limits parallelism; too many (tiny row groups) adds per-group overhead. A common target is 128MB-1GB of uncompressed data per row group.
Traps¶
- Storing money as
float64. Floats can't represent0.10exactly; repeated arithmetic accumulates rounding errors. Usedecimal128for any currency or exact-precision numeric column. - One giant row group. Writing an entire multi-GB table in a single
write_tablecall with no chunking can produce one row group, killing both parallel reads and pruning granularity. - Schema drift between files in a dataset. If file A has
amountasint64and file B has it asdouble, apyarrow.datasetscan across both can fail or silently upcast — enforce a shared schema when writing. - Assuming Parquet compression always beats CSV+gzip. For very small files (a few KB), Parquet's footer/metadata overhead can make it larger than an equivalent gzipped CSV — the columnar win shows up at scale.
Cheat sheet¶
| Concept | What it gives you |
|---|---|
| Columnar storage | Read only the columns a query needs |
| Row-group statistics | Skip whole chunks via predicate pushdown |
| Schema in the file | No re-inference, explicit types (decimal128 for money) |
| Arrow in-memory format | Zero/low-copy interop across pandas/DuckDB/Spark/Polars |
| Row groups | Unit of parallelism and pruning granularity |
How It Actually Works¶
Parquet's file layout is: a small header, then a sequence of row groups, each row group containing one column chunk per column, each column chunk further split into pages, and finally a footer holding the schema and, per column chunk, min/max statistics, null counts, and byte offsets. A reader that wants columns a and c out of ten seeks directly to those column chunks' byte offsets (found in the footer) and never touches the bytes for the other eight columns — this is column pruning, and it's a physical I/O optimization, not a logical query-planning trick. Predicate pushdown goes further: because the footer stores each column chunk's min/max, a filter like WHERE amount > 1000 lets the reader skip entire row groups whose max amount is below 1000 without decompressing a single byte of them.
Arrow is the missing piece that makes Parquet fast to use, not just fast to store: Arrow defines an in-memory columnar layout that is essentially what Parquet's column chunks decompress into, so a library like DuckDB or Polars can read a Parquet column chunk, decompress it, and hand it to the query engine as an Arrow array with zero row-by-row conversion — the on-disk columnar format and the in-memory columnar format are structurally the same shape, which is why "zero-copy" interchange between pandas/Polars/DuckDB via Arrow is possible at all.
Exercise¶
Take the table from the top of this lesson, write it as Parquet with 4
separate single-row row groups, then use pyarrow.dataset with a filter on
user_id > 2 and print dataset.to_table(filter=...)'s row count. Confirm
via pq.read_metadata that the file really has 4 row groups, and explain in
your own words why an engine could, in principle, skip 2 of them without
decompressing any data.