Partitioning¶
14:20. Design review. Someone proposes partitioning the 10 TB SaaS events table by region, because "that's how the business thinks about it." You have 20 workers, each holds about 500 GB, and product wants p95 latency per customer for yesterday.
Before you weigh in: does partitioning by region help the BI scan that filters WHERE region = 'eu-west-1', the GROUP BY customer_id rollup, both, or neither? And what happens to the one worker holding eu-west-1 if that region is 60% of traffic?
That's the whole question this page answers: how you assign work so twenty machines don't all read the same 10 TB, and so GROUP BY customer_id is even possible. Kafka topics, Spark shuffles, Iceberg files, ClickHouse parts, Cassandra vnodes: different files, same idea. If you only remember one idea from Phase 0, remember this one.
Start with the situation¶
Events:
Two jobs share the lake:
- BI scan:
WHERE date = yesterday AND region = 'eu-west-1'— wants to skip data. - Per-customer rollup:
GROUP BY customer_id— wants all rows for one customer together.
Those jobs want different keys. Partitioning is the compromise you write down, not a property of the data.
The same tension appears elsewhere:
- Observability: partition by time for “last hour,” but a noisy
cluster_idis a hot shard. - E-commerce CDC: partition Kafka by
order_idso per-order history is ordered; a flash-sale SKU still hotspots inventory topics. - IoT: partition by
device_idfor per-device state; a warehouse full of devices in onesite_idwrecks site-level partitions. - Fraud: partition by
user_idis useless for “accounts sharing this device.”
Why the obvious approach breaks at scale¶
On one machine, “split the file into 8 chunks” is enough. On a cluster:
- Every record must belong to exactly one partition of a given scheme (or you double-count).
- Independent partitions can run in parallel; dependent ones (all rows for
cust_0042) must meet — that meeting is a shuffle. - The slowest partition is the job. See Distributed Execution.
- Changing the scheme later rewrites the world. Iceberg/Hudi rewrites files; Kafka needs a new topic; Spark pays a full shuffle.
Cardinality is a load-bearing number
Partition by status_code and you have ~20 buckets, two of which (200, 500) hold almost everything. Partition by event_id (UUID) and you have perfect balance and zero pruning for every business query.
Build the mental picture¶
A partition is a slice that a worker can own.
graph LR
subgraph "10 TB dataset"
P0["P0 500 GB"]
P1["P1 500 GB"]
P2["P2 500 GB"]
P3["P3 500 GB"]
Px["… 20 slices"]
end
W1[Worker 1] --> P0
W2[Worker 2] --> P1
W3[Worker 3] --> P2
W4[Worker 4] --> P3 Two different “partitions” get confused in reviews. Keep them separate:
| Kind | Example | Job |
|---|---|---|
| Storage layout | s3://events/date=2024-01-15/region=eu-west-1/ | Skip I/O (pruning) |
| Compute slices | Spark partition / Kafka partition / Flink key group | Parallelism and ordering |
A well-laid-out lake still repartitions in memory when the compute key disagrees with the folder key. That is normal. Paying that shuffle and scanning 10 TB because you forgot date= is not.
"Partition" is one of the most overloaded words in this academy — the same term names at least seven different mechanisms across the systems you will touch:
| System | "Partition" means | Why it partitions |
|---|---|---|
| Kafka | A topic's ordered, append-only shard | Ordering per key + parallel consumption |
| Spark | A slice of an RDD/DataFrame held by one task | Unit of parallel execution |
| Flink | A key group assigned to a parallel subtask | Keyed state locality + parallelism |
| Iceberg / Hive-style lake | A directory or partition spec value (date=, region=) | Pruning — skip files without reading them |
| Parquet row group | A horizontal slice within one file | Skip via min/max stats without opening the file's other row groups |
ClickHouse PARTITION BY | A coarse, time-oriented grouping of parts | Lifecycle — drop/move/TTL a whole partition cheaply |
ClickHouse ORDER BY | The physical sort order and sparse primary index within a part | Skip granules — a different mechanism from PARTITION BY, easy to conflate; see ClickHouse |
| Cassandra / DynamoDB | The unit that determines which node(s) own a row | Placement, distribution, and hot-partition throttling limits |
Two of these are worth calling out because they are routinely confused inside the same system: ClickHouse's PARTITION BY decides which coarse-grained parts exist (and can be dropped/TTL'd together); ORDER BY decides the physical row order and sparse index inside each part. A query can prune partitions and still scan every granule in the surviving ones if ORDER BY doesn't match the filter — the two mechanisms answer different questions and neither substitutes for the other.
Mental test: If I delete one partition, did I delete a coherent business slice (a day, a tenant, a Kafka shard) or a random 128 MB? Coherent slices are how you retry and how you expire data.
Under the hood¶
Hash partitioning¶
| customer_id | hash % 4 | partition |
|---|---|---|
| customer-001 | 1 | 1 |
| customer-002 | 2 | 2 |
| customer-001 | 1 | 1 |
All events for customer-001 land together. Per-customer aggregations and keyed Flink state become local. Hashing does not preserve range locality: “customers A–C” is not a folder you can skip.
Spark’s default shuffle is hash (or hash + sort in the sort-shuffle writer). Kafka’s default partitioner hashes the message key.
Range partitioning¶
- P0:
timestamp2024-01-01 → 2024-03-31 - P1: 2024-04-01 → 2024-06-30
Natural for time series and for Iceberg hidden partitioning on days. Queries with BETWEEN skip files. Writes concentrate on “now” — every producer hits today’s range.
Time / identity partitioning (Hive-style)¶
This is range partitioning with a path convention. Combined with Parquet, it is the default lake layout for the SaaS table.
Composite keys¶
(date, customer_id) in storage: prune days, then hash customers inside the day. (customer_id, hour) as a compute key: splits a whale tenant across hours.
Partition pruning¶
-- Without date layout: 10 TB scan
SELECT count(*) FROM events WHERE date = '2024-01-15';
-- With date identity partitions: ~27 GB if volume is flat over a year
SELECT count(*) FROM events WHERE date = '2024-01-15';
The engine reads metadata (Hive metastore, Iceberg manifests, Spark InMemoryFileIndex), not the data, to drop files. Filters that do not match the partition column (WHERE endpoint = '/v2/events') do not prune.
Filter on the column you partitioned
WHERE timestamp >= '2024-01-15' AND timestamp < '2024-01-16' may not prune if the partition column is date and you never derived it. Iceberg hidden partitioning can extract days(timestamp); Hive-style tables cannot.
How many partitions?¶
| Too few | Too many |
|---|---|
| One task owns tens of GB → spill/OOM | Scheduling overhead, millions of tiny files, S3 LIST storms |
| Cores idle | Metastore / driver OOM listing files |
Rules of thumb (compute): 2–4× cores at the shuffle, 100–300 MB per Spark partition after compression. Storage: one day (or hour) per partition, files 128–512 MB inside it after compaction.
Kafka: throughput ≈ min(partitions, consumer threads) for a group; ordering is per partition. See Kafka partitions.
Put it to work¶
Storage layout for the SaaS lake¶
from pyspark.sql import functions as F
events = spark.read.json("s3://raw/events/") # landing zone, not the lake
curated = (
events
.withColumn("date", F.to_date("timestamp"))
.withColumn("hour", F.hour("timestamp"))
)
# Identity partitions for prune-friendly BI. Compact files, don't explode.
(
curated
.repartition(200, "date") # control file count; don't use 200 × 365
.write
.mode("append")
.partitionBy("date")
.parquet("s3://analytics/events/")
)
partitionBy is a directory bomb
partitionBy("date", "region", "service") with 365 × 12 × 80 values and 200 Spark partitions can write millions of tiny files. Partition the lake on low-cardinality time, cluster/sort on the rest (Iceberg sort_order, Parquet row-group clustering).
Compute: size the shuffle¶
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", str(128 * 1024 * 1024))
spark.conf.set("spark.sql.shuffle.partitions", "400") # starting point, AQE coalesces
p95 = events.groupBy("customer_id").agg(F.expr("percentile_approx(latency_ms, 0.95)"))
Detect a hot key before it pages you¶
from pyspark.sql import functions as F
# Cheap sketch: top tenants by rows (and bytes if you have them)
events.groupBy("customer_id").count().orderBy(F.desc("count")).show(20)
# Distribution of partition sizes after a hash split
hashed = events.withColumn("p", F.pmod(F.hash("customer_id"), 200))
hashed.groupBy("p").count().agg(
F.min("count"), F.expr("percentile_approx(count, 0.5)"), F.max("count")
).show()
If max / median is > 5, you have a reducer problem, not a “need more executors” problem.
Salting a whale tenant¶
from pyspark.sql.functions import col, concat, lit, floor, rand, split, sum as Fsum, count
SALTS = 16
salted = events.withColumn(
"k",
concat(col("customer_id"), lit("#"), floor(rand() * SALTS).cast("int").cast("string")),
)
partial = salted.groupBy("k").agg(count("*").alias("n"), Fsum("bytes").alias("bytes"))
final = (
partial
.withColumn("customer_id", split("k", "#")[0])
.groupBy("customer_id")
.agg(Fsum("n").alias("n"), Fsum("bytes").alias("bytes"))
)
Two-phase aggregation: first shuffle is balanced; second shuffle is tiny. Percentiles need a different trick (sample, t-digest merge, or isolate the whale into its own job).
Production gotchas¶
The key choice table¶
| Key | Why it fails |
|---|---|
customer_id | One enterprise is 40% of writes and of every shuffle |
country / region | US/India/us-east-1 dwarf the rest |
status (ACTIVE/INACTIVE) | Cardinality 2 |
timestamp alone as Kafka key | All producers hit “now”; also destroys per-entity order |
user_id | Often balanced, but BI filters date and customer_id, so you still shuffle |
UUID event_id | Perfect balance, no locality, no prune, no per-entity order |
The right key is a checklist¶
- Write distribution — will ingest be uniform across values this hour?
- Query filters — do they match the storage key so pruning works?
- Cardinality — enough distinct values for the parallelism you need, not 10× more files than cores.
- Hotspot risk — can one value dominate? If yes, plan salt / isolated pipeline now.
- Ordering — Kafka/Flink: order is per partition. If you need per-order FIFO, the key is
order_id, notcountry.
Today is always the hot range
Time partitioning is correct for lakes and TSDBs and still hotspots ingest. Split writers by a secondary hash (device_id, customer_id) inside the hour, or you DDoS one Iceberg partition / one ClickHouse part.
How it fails¶
graph LR
subgraph "Hash by customer_id"
P0["P0 BigCorp 4 TB"]
P1["P1 SmallCo 50 GB"]
P2["P2 MidCorp 200 GB"]
end
subgraph "Wall time"
W1["Worker 1: 4 hours"]
W2["Worker 2: 3 min"]
W3["Worker 3: 12 min"]
end
P0 --> W1
P1 --> W2
P2 --> W3 | Failure | Cause | Blast radius |
|---|---|---|
| Straggler stage | Hot hash bucket | Whole job waits |
| Kafka consumer lag on 1 of 60 partitions | Hot key | That partition’s lag; others look healthy |
| Driver OOM listing files | Too many storage partitions | Job never starts |
| Wrong counts after “adding partitions” | Kafka: new partitions do not rebalance old keys | Per-key ordering and sticky consumers surprise you |
| Empty prune | Filter on timestamp not date | Full scan, surprise bill |
Fixes, in order: measure the histogram → change storage key if pruning is wrong → salt / isolate whales if compute is skewed → compact files if you have a million 2 MB objects.
How to investigate¶
| System | What to open | Healthy vs sick |
|---|---|---|
| Spark | Stage → task duration, input size, shuffle read | max/median ≲ 3; one task at 50× is skew |
| Spark SQL | Exchange hash partitioning, partition count | 200 tasks on 8 GB is too many; 200 on 8 TB is too few |
| Kafka | records-lag per partition, bytes-in per partition | One partition at 80% of bytes-in |
| Iceberg / Hive | files / partitions metadata tables, file size histogram | Thousands of < 8 MB files per day |
| ClickHouse | system.parts, partition column | One part much larger than siblings |
# Spark: confirm prune happened
events.filter("date = '2024-01-15'").explain("formatted")
# FileScan should list only that path / partition filters
Simulation: Kafka partitions if you want to feel consumer parallelism before you pay for a cluster.
Scale¶
| Factor | What happens to a bad key | What you do |
|---|---|---|
| 10× volume, same tenants | Hot customer is 10× hotter | Histogram + AQE skew join; maybe 8-way salt |
| 100× | File count and Kafka partition count both wrong | Increase Kafka partitions with a migration plan; compact lake hourly; consider a dedicated pipeline for the top-N tenants |
| 1000× | Metadata and name-nodes / manifests dominate | Hidden partitioning, hierarchical namespaces, tenant isolation, maybe shuffle service + more AZs only after keys are sane |
Partition count should grow with bytes, not with “number of microservices.” Doubling workers without doubling useful partitions just means each worker is idle waiting on the same fat tasks.
Trade-offs¶
| Scheme | Good for | Bad for |
|---|---|---|
| Hash(entity) | Balanced compute, keyed state, Kafka order per entity | Range queries, prune, “all of US-EAST” |
| Time range | Scans, retention, CDC windows | Ingest hotspots, “this user across 3 years” |
| Composite (day + hash) | Most lakes | Easy to over-partition into tiny files |
| Static (country) | Simple mental model | GDP-weighted skew |
| Random / no key | Max ingest spread | No locality, no order |
Repartitioning cost: Spark shuffle; Kafka new topic + dual-write or messy partition increase; Iceberg rewrite. Choose the storage key as if you will live with it for two years.
Alternatives¶
- Clustering / Z-order / sort_order inside a coarse partition (Iceberg, Delta, ClickHouse
ORDER BY) when you need extra skip without extra directories. - Separate tables for whales (
events_whale,events_longtail) instead of heroic salting forever. - Serving stores (KV, search, graph) when the access pattern is point-get or multi-hop — partitioning a lake will never make “friends of friends” cheap. See Fraud architecture.
- Single-node with table partitions (
PARTITION BY RANGE (day)in Postgres) until scale says otherwise.
How to apply at work¶
In every design review, force these answers onto the wiki:
- What is the storage partition key? The compute key?
- Histogram of that key (p50 / p95 / max bytes) — from production, not intuition.
- Cardinality, and file/partition count after a day of writes.
- Can one value dominate? Which tenant/device/SKU?
- Do query
WHEREclauses match the storage key? - What happens when workers double — do partitions double, or do fat tasks just move?
- What is the rewrite plan if we change the key in 18 months?
If nobody can paste a histogram, you do not have a partitioning strategy. You have a hope.
Check your understanding¶
You are designing a Kafka topic for e-commerce order events. Orders come from 50 countries. Volume is roughly proportional to GDP (US + EU + India dominate). Primary consumer today is “orders per country per hour.” A second consumer in Q3 wants per-customer order history in event order.
- Trade-offs of partitioning by
country? - By
order_id? - By
customer_id? - What key would you choose for consumer 1 only?
- What do you choose if both consumers must be first-class, and what extra piece (topic, table, or job) do you add?
- How do you stop a Black Friday SKU from hot-spotting a related
inventory_updatestopic?
Worked answer
- Country: consumer 1 is a single-partition aggregation per country (nice), but US may be 30–40% of the topic — one partition / one consumer thread owns Black Friday. Low cardinality (50) caps parallelism. GDP skew is structural.
- Order id: excellent ingest balance and per-order FIFO. Consumer 1 must shuffle (Flink keyed by country, or Spark
groupBy). History-by-customer is not ordered on one partition. - Customer id: per-customer FIFO for consumer 2. Balance depends on whale retailers. Consumer 1 still shuffles. Hot customers = hot partitions.
- Consumer 1 only:
countryis acceptable at small scale; better ishash(order_id)plus a streaming agg keyed by country — more moving parts, no US hotspot. - Both first-class: partition by
customer_id(history is the hard constraint: order per customer). Build country-hour as a derived stream or table (Flink window or Spark job) — do not make the raw topic serve both locality needs. Alternatively two topics:orders_by_customer(log of record) and a compactedorders_by_country_hoursink. - Inventory: do not key by SKU. Key by
warehouse_id + skusalt, or isolate top SKUs to a dedicated topic/consumer, or buffer and coalesce updates. Same whale pattern ascust_0042in SaaS.