AI-220 · Lesson 9 of 12 · 30 min

Processing data at scale

Methods that work on a laptop with a few hundred megabytes of data can fail completely at a few hundred gigabytes: the data no longer fits in memory, a single processor takes days, and a query that scans everything costs real money in a cloud warehouse. Yet most "big data" problems can be handled with a small set of ideas: use compact data types, read only what you need, process in chunks, split work across machines by key, and accept approximate answers where exact ones are too costly. This lesson explains these ideas, the systems that implement them, and how to decide when a problem actually needs them.

How big is big?

A useful first step is to estimate size. A table of \(n\) rows and \(c\) numeric columns of 8 bytes needs about \(8nc\) bytes in memory; strings stored as Python objects take far more. Libraries such as pandas typically need two to five times the data size in free memory to work comfortably, because operations create copies. A laptop with 16 GB of memory therefore handles tables up to a few gigabytes in pandas.

Memory use of a million-row table with default types, and with compact types: 32-bit integers and floats, an 8-bit count, and categories for repeated strings.
import numpy as np
import pandas as pd

rng = np.random.default_rng(0)
n = 1_000_000
df = pd.DataFrame({
    "user_id": rng.integers(0, 50_000, n),
    "country": rng.choice(["CA", "US", "MX", "FR", "DE"], n),
    "device": rng.choice(["mobile", "desktop", "tablet"], n, p=[0.6, 0.3, 0.1]),
    "clicks": rng.integers(0, 40, n),
    "spend": rng.gamma(2.0, 10.0, n),
})

def mb(frame):
    return frame.memory_usage(deep=True).sum() / 1e6

print(f"default types: {mb(df):6.1f} MB")
small = df.assign(
    user_id=df["user_id"].astype("int32"),
    country=df["country"].astype("category"),
    device=df["device"].astype("category"),
    clicks=df["clicks"].astype("int8"),
    spend=df["spend"].astype("float32"),
)
print(f"compact types: {mb(small):6.1f} MB")
print(small.memory_usage(deep=True).div(1e6).round(1).to_string())
Output
default types:  130.3 MB
compact types:   11.0 MB
Index      0.0
user_id    4.0
country    1.0
device     1.0
clicks     1.0
spend      4.0

Choosing types cut memory by more than ten times. Most of the default size was the string columns, stored as Python objects; categories store each distinct value once and use small integer codes for the rows. Before reaching for a cluster, it is worth checking whether compact types and selecting only needed columns make the data fit.

Worked problem

Estimating memory

A clickstream has 400 million rows with a 64-bit user id, a 64-bit timestamp, a page id that fits in 32 bits, and a device type with three values. Estimate memory with compact types, and compare with the 16 GB of a laptop.

Show solution
  1. Bytes per row: \(8 + 8 + 4 + 1 = 21\).
  2. Total: \(400 \times 10^{6} \times 21 = 8.4\) GB.
  3. With working copies, pandas would need two to five times that: 17 to 42 GB.

Answer: About 8.4 GB of data, too large to process comfortably in pandas on a 16 GB laptop. Options: process in chunks, use a columnar engine that streams data, sample, or use a larger machine or cluster.

Columnar storage and partitioning

Analytical data is usually stored in columnar formats such as Parquet and ORC (Lesson 8): the values of each column are stored together, compressed, with statistics (minimum, maximum, count) for each block. Three techniques make reading such data fast:

  • Column pruning: a query that uses 3 of 80 columns reads only those 3.
  • Predicate pushdown: block statistics let the reader skip blocks that cannot match a filter, for example blocks whose maximum date is before the requested range.
  • Partitioning: files are organized into directories by a key such as date (for example, events/date=2026-10-21/), so a query on one day reads one directory.
Diagram of a partitioned columnar data set. On the left, a directory tree shows events split into folders by date, each holding several Parquet files. A query for one day and three columns is shown: only the folder for that day is opened, and inside each file only three column chunks are read, while other columns and other dates are grayed out. A note shows data read as 3 of 80 columns of 1 of 365 days, about 0.01% of the total.
Partitioning by date and column pruning: a query for one day and three columns reads a tiny fraction of the data.

Partition keys should match common filters and give partitions of reasonable size, typically hundreds of megabytes to a few gigabytes. Partitioning by a key with millions of values creates millions of tiny files, which is slow; partitioning by a rarely filtered key does not help.

Worked problem

Data scanned and cost

A warehouse charges $6.25 per TB scanned. A table holds 365 days × 80 columns, 12 TB in total, partitioned by day, with columns of roughly equal size. What does a query cost that reads 3 columns for 7 days, with and without pruning and partitioning?

Show solution
  1. Without: full scan of 12 TB costs \(12 \times 6.25 = \$75\).
  2. With: \(12 \times \frac{3}{80} \times \frac{7}{365} = 0.0086\) TB, costing about $0.05.

Answer: $75 against about 5 cents, a factor of about 1,400. Selecting only needed columns and filtering on the partition key are the most important cost controls in a cloud warehouse.

Processing in chunks

When data does not fit in memory but the computation only needs a summary, it can be streamed: read a chunk, update running totals, discard the chunk, repeat. Many statistics combine exactly across chunks: counts, sums, minimums and maximums, and therefore means and variances (keeping sums of squares, or using a numerically stable update). Others, such as exact medians and distinct counts, do not, and need either a second pass, sorting, or an approximation.

Computing per-store means by streaming a file in chunks and combining sums and counts: the result matches the in-memory calculation exactly.
import io
import numpy as np
import pandas as pd

# Build a CSV in memory to stand in for a file too large to load at once
rng = np.random.default_rng(1)
big = pd.DataFrame({"store": rng.integers(1, 6, 200_000), "sales": rng.gamma(3.0, 20.0, 200_000)})
buf = io.StringIO()
big.to_csv(buf, index=False)
buf.seek(0)

# Stream it in chunks, keeping only running sums and counts per store
sums, counts = {}, {}
for chunk in pd.read_csv(buf, chunksize=50_000):
    g = chunk.groupby("store")["sales"].agg(["sum", "count"])
    for store, row in g.iterrows():
        sums[store] = sums.get(store, 0.0) + row["sum"]
        counts[store] = counts.get(store, 0) + int(row["count"])

streamed = pd.Series({s: sums[s] / counts[s] for s in sorted(sums)}).round(3)
direct = big.groupby("store")["sales"].mean().round(3)
print(pd.DataFrame({"streamed_mean": streamed, "in_memory_mean": direct}))
print("identical:", bool((streamed == direct).all()))
Output
   streamed_mean  in_memory_mean
1         59.782          59.782
2         60.135          60.135
3         59.676          59.676
4         59.994          59.994
5         59.826          59.826
identical: True

The key design point is to carry mergeable state (sums and counts) rather than results (means): the mean of chunk means is wrong when chunks have different sizes, but the sum of sums divided by the sum of counts is always right.

Distributed processing

When one machine is too slow, the work is spread over many. Distributed engines such as Apache Spark, Dask, Ray and the query engines inside cloud warehouses all rest on the split-apply-combine pattern popularized by MapReduce:

  • Map: each worker processes its share of the input independently, producing key-value pairs (and usually combining them locally first).
  • Shuffle: pairs are sent over the network so that all pairs with the same key reach the same worker, chosen by hashing the key.
  • Reduce: each worker combines the values for its keys.
A word-count-style job: local counting in each split, a hash shuffle to four reducers, and the skew caused by one very popular key.
import zlib
from collections import Counter, defaultdict

import numpy as np

rng = np.random.default_rng(2)
# Page-view events; one very popular page creates skew
pages = np.where(rng.random(100_000) < 0.4, "home", rng.choice([f"p{i}" for i in range(500)], 100_000))

workers = 4
def partition(key):
    return zlib.crc32(key.encode()) % workers     # stable hash, same on every machine

# Map: each input split counts locally (a combiner), then sends counts by key to a reducer
splits = np.array_split(pages, 8)
shuffled = defaultdict(Counter)
for split in splits:
    local = Counter(split.tolist())
    for key, c in local.items():
        shuffled[partition(key)][key] += c

# Reduce: each worker owns some keys and sums their counts
totals = Counter()
for w in range(workers):
    totals.update(shuffled[w])
print("top pages:", totals.most_common(3))

# Skew: events handled per reducer
load = [sum(shuffled[w].values()) for w in range(workers)]
print("events per reducer:", load, " max/mean =", round(max(load) / (sum(load) / workers), 2))
Output
top pages: [('home', 39951), ('p137', 148), ('p307', 148)]
events per reducer: [54923, 15082, 15006, 14989]  max/mean = 2.2

The shuffle is usually the most expensive step, because it moves data across the network, and skew is the most common performance problem. In the example, 40% of events have the key "home", so the reducer that owns it does nearly four times the work of the others, and the whole job waits for it. Remedies include combining locally before the shuffle (as the example does), splitting hot keys into several sub-keys ("salting") and recombining, or handling known hot keys separately.

Joins in distributed systems follow the same pattern. A broadcast join sends a small table to every worker so the large table never moves; a shuffle join redistributes both tables by key. Knowing which one the engine chooses, from its query plan, explains most slow joins.

Worked problem

Why a job is slow

A Spark job with 200 tasks finishes 199 of them in 2 minutes; the last one takes 45 minutes. What is the likely cause, and what are two fixes?

Show solution
  1. One task processing far more data than the others indicates key skew: one key (or a null key) dominates.
  2. Fix 1: salt the hot key, adding a random suffix 0 to 9, aggregate, then aggregate again without the suffix.
  3. Fix 2: if the skew is in a join, broadcast the small table, or filter out null keys that all hash to one task.

Answer: Skew. Inspect the key distribution of the slow stage; nulls and default values (such as user id 0 for anonymous users) are frequent culprits.

Approximate algorithms

Some questions are expensive to answer exactly at scale but cheap to answer approximately with small, mergeable summaries called sketches:

  • Distinct counts: HyperLogLog and related sketches estimate the number of distinct values to within about 1% to 2% using a few kilobytes, whatever the data size.
  • Quantiles: t-digest and KLL sketches estimate medians and percentiles with bounded error.
  • Frequencies: Count-Min sketches estimate how often each item appears and find heavy hitters.
  • Sampling: reservoir sampling keeps a uniform random sample of fixed size from a stream of unknown length.
Reservoir sampling and a k-minimum-values distinct-count sketch over a million-event stream: 256 stored values estimate 289,345 distinct users within about 1%.
import hashlib
import random

import numpy as np

random.seed(5)
stream = (f"user{random.randint(1, 300_000)}" for _ in range(1_000_000))

# Reservoir sampling: a uniform sample of fixed size from a stream of unknown length
k, reservoir = 5, []
# Distinct count by k minimum values (KMV): keep the smallest hash values seen
m, mins = 256, set()
seen = set()           # exact answer, only possible here because the demo is small

for i, item in enumerate(stream):
    if i < k:
        reservoir.append(item)
    else:
        j = random.randint(0, i)
        if j < k:
            reservoir[j] = item
    h = int(hashlib.blake2b(item.encode(), digest_size=8).hexdigest(), 16) / 2 ** 64
    if len(mins) < m:
        mins.add(h)
    elif h < max(mins) and h not in mins:
        mins.remove(max(mins))
        mins.add(h)
    seen.add(item)

estimate = (m - 1) / max(mins)
print("reservoir sample:", reservoir)
print(f"exact distinct users {len(seen):,}; KMV estimate {estimate:,.0f}; error {100 * (estimate / len(seen) - 1):+.1f}%")
print(f"memory: exact set holds {len(seen):,} items; sketch holds {m}")
Output
reservoir sample: ['user149127', 'user243468', 'user276187', 'user188861', 'user66191']
exact distinct users 289,345; KMV estimate 286,545; error -1.0%
memory: exact set holds 289,345 items; sketch holds 256

The sketch works because hashes are spread uniformly between 0 and 1: if \(n\) distinct values are hashed, the \(k\)-th smallest hash is about \(k/n\), so \(n \approx (k-1)/h_{(k)}\). Sketches from different partitions can be merged by keeping the smallest values of their union, which is why warehouses offer approximate distinct counts as a fast built-in function.

Choosing tools

More infrastructure is not always better. A practical progression is:

  • Small (up to a few GB): pandas or similar on one machine, with compact types.
  • Medium (tens to hundreds of GB): a columnar, multi-threaded engine on one machine (for example DuckDB or Polars), or a cloud warehouse, reading Parquet with pruning and pushdown.
  • Large (terabytes and up, or heavy computation): a distributed engine (Spark, Dask, Ray) or the warehouse's own distributed execution.

A single modern server with hundreds of gigabytes of memory and dozens of cores handles workloads that once needed clusters, with less complexity. Distributed systems add overhead for scheduling, shuffles and failures, and are worth it when the data or computation genuinely exceeds one machine, or when the organization already runs them.

Sampling is the other underused tool. Exploration, feature design and model selection can usually be done on a well-designed sample (stratified where needed, Lesson 1), with only the final pipeline run on all the data.

Common mistakes

  • MistakeReaching for a cluster before checking data types and columns.

    InsteadUse compact types, select needed columns and sample; many problems then fit on one machine.

  • MistakeAveraging chunk averages.

    InsteadCarry sums and counts (mergeable state) and divide at the end.

  • MistakePartitioning by a high-cardinality key.

    InsteadPartition by commonly filtered keys giving files of hundreds of MB.

  • MistakeIgnoring skew in distributed jobs.

    InsteadInspect key distributions; salt hot keys or broadcast small tables.

  • MistakeRunning full scans in a pay-per-scan warehouse during development.

    InsteadFilter on partitions, select columns, and develop on samples.

Summary and key terms

  • Estimate size first; compact types and column selection often make data fit.
  • Columnar formats enable column pruning, predicate pushdown and partition skipping.
  • Stream data in chunks using mergeable state such as sums and counts.
  • Distributed engines map, shuffle by key and reduce; skew is the common bottleneck.
  • Sketches give approximate distinct counts, quantiles and frequencies in tiny memory.
  • Choose the simplest tool that fits: one machine, columnar engine, then cluster; sample for exploration.
Column pruning
Reading only the columns a query uses.
Predicate pushdown
Skipping data blocks using their statistics.
Partitioning
Organizing files into directories by a key such as date.
Chunked processing
Processing data in pieces that fit in memory.
Mergeable state
Partial results that combine exactly, such as sums and counts.
Shuffle
Moving data between workers so each key reaches one worker.
Skew
Uneven distribution of work across workers.
Broadcast join
A join that copies a small table to every worker.
Sketch
A small summary giving approximate answers with bounded error.
Reservoir sampling
Keeping a uniform fixed-size sample of a stream.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Memory for 50 million rows of five float64 columns?

    Show answer

    2 GB.

  2. Why do categories save memory for repeated strings?

    Show answer

    Each value is stored once; rows hold small integer codes.

  3. Fraction read for 4 of 100 columns over 30 of 365 days?

    Show answer

    0.04 × 0.082 ≈ 0.33%.

  4. What does predicate pushdown use to skip blocks?

    Show answer

    Block statistics such as min and max.

  5. Chunk means 10 (100 rows) and 20 (300 rows). Overall mean?

    Show answer

    17.5.

  6. Can an exact median be combined from chunk medians?

    Show answer

    No.

  7. What is the most expensive step of most distributed jobs?

    Show answer

    The shuffle.

  8. One reducer gets 40% of events among four. Max/mean load?

    Show answer

    1.6 at least; more if it also gets other keys.

  9. KMV with k = 100 and 100th smallest hash 0.0005. Estimate?

    Show answer

    99/0.0005 = 198,000.

  10. When is a cluster worth its overhead?

    Show answer

    When data or computation genuinely exceeds one machine.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz