04 · Working with Big Data¶
pandas loads everything into memory — great up to a few million rows, painful beyond that. This module covers what to do when a dataset stops fitting comfortably in RAM: smarter pandas usage, chunked processing, and an introduction to distributed tools.
Know your memory footprint first¶
import pandas as pd
import numpy as np
np.random.seed(0)
n = 2_000_000
df = pd.DataFrame({
"user_id": np.random.randint(1, 100_000, n),
"category": np.random.choice(["A", "B", "C", "D"], n),
"amount": np.random.gamma(2, 20, n),
})
print(df.memory_usage(deep=True).sum() / 1e6, "MB")
memory_usage(deep=True) is the honest number (the shallow default
undercounts object/string columns badly). Before reaching for a
"big data" tool, check whether the dataset actually needs one — 2 million
rows of a few numeric columns is well within pandas' comfort zone.
Reduce memory with dtypes¶
before = df.memory_usage(deep=True).sum()
df["category"] = df["category"].astype("category")
df["user_id"] = pd.to_numeric(df["user_id"], downcast="unsigned")
df["amount"] = pd.to_numeric(df["amount"], downcast="float")
after = df.memory_usage(deep=True).sum()
print(f"{before/1e6:.1f} MB -> {after/1e6:.1f} MB")
category dtype stores each unique string once and repeats integer codes
— dramatic savings for low-cardinality text columns. downcast picks the
smallest numeric type that fits the data's actual range
(uint32 instead of default int64, float32 instead of float64).
This alone often turns a "doesn't fit in memory" dataset into one that
does.
Reading in chunks¶
When a file genuinely doesn't fit in memory, process it incrementally instead of loading it all at once.
chunk_totals = []
for chunk in pd.read_csv("big_transactions.csv", chunksize=200_000):
chunk_totals.append(chunk.groupby("category")["amount"].sum())
total_by_category = pd.concat(chunk_totals).groupby(level=0).sum()
print(total_by_category)
chunksize turns read_csv into an iterator of smaller DataFrames — each
chunk is processed and its intermediate result (here, per-category sums)
kept; the raw chunk is discarded, keeping peak memory bounded regardless
of total file size. This pattern (aggregate per chunk, combine aggregates)
works for sums, counts, and means; it's harder for things like exact
medians that need to see all values at once.
Query-pushdown formats: Parquet over CSV¶
df.to_parquet("transactions.parquet", index=False)
# Only reads the columns you actually need, and only matching row groups
subset = pd.read_parquet("transactions.parquet", columns=["category", "amount"])
print(subset.shape)
Parquet is a columnar, compressed binary format: reading only the columns
you need (columns=[...]) skips the rest of the file entirely on disk,
unlike CSV which must be parsed row by row regardless of which columns you
want. For repeated analysis on the same large dataset, converting once to
Parquet pays for itself quickly in both file size and read speed.
Scaling out: a taste of Dask¶
When a dataset is too large even for chunked pandas — or you want to use multiple cores without rewriting your logic — Dask provides a pandas-compatible API that operates on the data lazily, in parallel.
import dask.dataframe as dd
ddf = dd.read_csv("big_transactions.csv")
result = ddf.groupby("category")["amount"].mean().compute()
print(result)
Dask builds a task graph from pandas-like calls (groupby, merge, etc.)
and only executes it when you call .compute() — it partitions the file
into chunks automatically and can spread work across cores or even a
cluster, all while you write nearly the same code you already know from
pandas.
When to reach for what¶
- Pandas with better dtypes: usually the first and best fix — most "big data" problems are actually "wasteful data types" problems.
- Chunked pandas: file doesn't fit in memory, but the aggregation you need can be computed incrementally.
- Parquet: repeated reads of the same large dataset, especially when you only need some columns.
- Dask / Spark: dataset genuinely exceeds a single machine's memory even chunked, or you need to parallelize heavy computation across cores or a cluster.
Cheat sheet¶
| Technique | Fixes |
|---|---|
astype("category"), downcast= |
Wasteful default dtypes |
pd.read_csv(..., chunksize=N) |
File doesn't fit in memory |
to_parquet / read_parquet(columns=...) |
Slow repeated reads, unneeded columns |
dask.dataframe |
Needs parallelism or exceeds single-machine memory |
How It Actually Works¶
Every pandas dtype has a fixed per-element byte cost: default int64 and
float64 use 8 bytes per value regardless of the actual magnitude stored,
and a default object column of strings stores a Python string object per
cell — each with its own header overhead (~50 bytes even for short
strings) plus pointer indirection, not a compact contiguous buffer the way
numeric NumPy arrays are. downcast="unsigned" or "float" picks the
smallest fixed-width type that can represent the column's actual range
(e.g. uint32 covers 0 to ~4 billion, more than enough for a user_id
under 100,000, at a quarter the bytes of int64). The category dtype
attacks the object-column problem differently: it stores the small set of
distinct strings once in a lookup table and replaces every cell with a
small integer code referencing that table — for a 4-value column repeated
2 million times, this converts 2 million string objects into 2 million
single-byte codes plus one 4-entry table, which is why the savings are so
large specifically for low-cardinality text.
Chunked reading works because chunksize turns read_csv from "parse
the whole file, then hand it to me" into a generator that parses and yields
one bounded-size DataFrame at a time, discarding the buffer for the
previous chunk before reading the next. This bounds peak memory to one
chunk's size regardless of total file size — but it only works cleanly for
aggregations that are decomposable: a sum, count, or per-category total
can be computed chunk-by-chunk and combined (sum of sums = total sum), but
an exact median or exact set of distinct values generally cannot, because
they require comparing every value against every other value at once.
Parquet's speed advantage over CSV comes from two structural choices.
It's columnar (values for one column are stored contiguously on disk,
rather than CSV's row-by-row layout), so reading columns=["amount"]
literally means seeking to and reading only that column's bytes — CSV has
no way to skip columns since every row must be parsed character-by-character
to even find where one column ends and the next begins. Parquet is also
statically typed and compressed per column (integers, categories, and
repeated strings compress far better when grouped with same-type
neighbors than when interleaved row-by-row), which is why file size drops
substantially on top of the read-speed gain.
Dask builds a task graph — a DAG of "read this partition," "apply
this groupby to that partition," "combine these partial results" — and does
no actual computation until .compute() is called (lazy evaluation). This
matters mechanically because it lets Dask's scheduler see the entire
pipeline before running anything, so it can decide how to partition work
across cores (or machines) and only materialize small final results in the
caller's memory, while each worker only ever holds one partition's worth of
data — the same "process a bounded piece, combine the summaries" principle
as chunked pandas, just automated and parallelized across a task graph
instead of a manual Python loop.
Exercise¶
Take the df DataFrame from the first example (2M rows). Measure its
memory footprint before and after converting category to a category
dtype and downcasting the numeric columns. Then write it to both CSV and
Parquet, compare file sizes on disk, and time how long it takes to read
back just the amount column from each format.