Partitioning: the four strategies
What do round-robin, random, hash and range partitioning each buy, and how should the key be chosen?
Partitioning means splitting a stream (or a data set) across N destinations. The strategy is fully described by one function:
destination = f(record)
Four choices of f cover almost everything. (Applied to stored data, partitioning is called sharding, and the destination becomes an owner: choosing the shard key, and moving shards between nodes, are coordination's.)
Round-robin
f = counter++ % N
(The symbol % is the modulo operator: the remainder after division. 7 % 3 = 1. So the counter cycles through destinations 0, 1, 2, …, N−1, 0, 1, …)
- Perfect balance. Nothing beats it.
- Zero locality and zero order. Related records are scattered as widely as possible.
- Zero state. There is no key to extract, no lookup, no skew to analyze.
Round-robin is correct when records are truly independent and nothing downstream groups them: log lines going to a search index, independent task units, any work that is per record and stateless.
The mistake is to use it because it balances well, and then find that downstream code needs to group records per entity. Now every consumer must read every partition. You did not avoid the coordination cost. You moved it downstream and made it worse.
Random
f = random()
This is almost the same as round-robin, with one real difference: there is no shared counter, so it needs no state across producers. Round-robin across 50 producer instances needs either 50 independent counters (fine, and what everyone really does) or coordination between them (which nobody does). Random gets the same long-run result with no state at all.
The balance is slightly worse than round-robin, because random placement has the variance of "balls in bins" (throwing M balls at random into N bins). With M records into N partitions, the fullest partition exceeds the mean by roughly √(2 · (M/N) · ln N).
At high volume this variance disappears. At low volume it is visible. Know it, so that you do not spend a week chasing a "skew" that is only chance.
Kafka's sticky partitioner is an interesting refinement. Assignment is still random, but batched: the producer fills one partition's batch, then switches to another. The long-run balance is the same, but batching is much better. The reason: a producer sends records in batches to each partition, and a batch pays a fixed cost per request (this is the batching lesson of Flow control). If you spray single records across N partitions, you get N tiny batches and lose the benefit. This is routing and flow control interacting: the naive routing choice damages batching, and the fix is to make the routing coarser in time.
Key-based (hash)
f = hash(key) % N
A hash function turns any value (a string, a number) into a large, evenly spread number. The same key always gives the same number. Taking it modulo N gives a destination between 0 and N−1. In Python:
import zlib
def partition_for(key: str, n_partitions: int) -> int:
return zlib.crc32(key.encode()) % n_partitions
partition_for("order-4711", 12) # always the same partition for this keyThis is the default in data engineering, and correctly so, because our records are facts about entities (an order, a seller, a user) and nearly all downstream work is per entity.
What it buys:
- All records for one key land on one partition. So per-key order is preserved (see "Message keys and key-based ordering" below).
- Stateful processing becomes local. A consumer that keeps per-key state (a running total, a join, a session, a set of already-seen IDs for deduplication) needs no coordination with any other consumer, because no other consumer will ever see that key. This is what makes distributed stream processing tractable at all. It is worth saying plainly: key partitioning converts one distributed state problem into N independent local state problems.
- Co-partitioned joins. If two streams are partitioned by the same key with the same N, they can be joined partition by partition, with no shuffle (a shuffle is a network-wide redistribution of records).
What it costs:
- Skew, proportional to your data's real distribution. Covered in the next section. It is the dominant operational concern.
- N is nearly immovable.
hash(key) % Nreassigns almost every key when N changes. For a stateful consumer, state must be moved. For a log, the historical order of each key is broken across the boundary. Kafka forbids decreasing the number of partitions for exactly this reason. - Cross-key operations require a shuffle. Anything global (a total distinct count, a top-K across all keys, a join on a different key) needs a repartition: a full network reshuffle of the data.
Choosing the key
Choosing the key is the highest-leverage decision in this article. Here are the criteria, in order of importance:
- High cardinality relative to N. Cardinality is the number of distinct key values. If cardinality is lower than N, some partitions are idle forever.
- Even distribution in the real data, not in theory. Look at the actual histogram.
- Matches the ordering requirement. If order matters per account, the key must be the account.
- Matches the dominant downstream access pattern. Partition by what you group by.
- Stable over a record's lifetime. If the key value changes (a status, a region), the entity moves to another partition in the middle of its life, and both order and state locality break.
When criteria 3 and 4 disagree, ordering usually wins, because you can repartition for the access pattern downstream, but you cannot recover an order you never had.
Range
f = the range that contains the key
For example, keys A–F go to partition 0, G–M to partition 1, and so on.
- It preserves sort order globally. Range scans are efficient: a scan for keys between X and Y touches the minimum number of partitions.
- It allows split and merge. A hot range can be split in two, and a cold pair can be merged. You rebalance without reassigning everything. This is why range partitioning is practical for storage systems, where hash partitioning would require a full redistribution.
- But sequential keys create a write hotspot. Timestamps, auto-increment IDs and ULIDs (time-ordered unique IDs) all grow over time, so every new write goes to the last range. You have serialized all writes onto one node while N−1 nodes are idle. This is the most common data-modeling failure in HBase, Bigtable, DynamoDB, and Cassandra with a sequential clustering key (the part of the key that orders rows inside a partition).
Standard mitigations, each with a real cost:
- Salting. Prefix the key with
hash(key) % M. Writes spread across M ranges. The cost: a range scan now needs M sub-scans and a merge. You deliberately traded read simplicity for write distribution. - Reverse or hash the high-order part of the sequential component. This distributes writes and destroys range-scan locality completely.
- A composite key with a high-cardinality prefix, such as
(tenant_id, timestamp). Writes spread across tenants, while scans inside one tenant stay sequential. This is usually the right answer, because it keeps locality where you need it and takes distribution from a dimension you already have.
Hash versus range in one line: hash gives you balance and destroys ordering; range gives you ordering and hands you the responsibility for balance. Choose by whether your dominant query is a point lookup (hash) or a range scan (range).