Kafka partition assignment: Range, RoundRobin, and why joins care
RangeAssignor looks naive until you join two topics — then its alignment is the whole point. Choosing an assignment strategy is choosing what your consumers are allowed to assume.
🧪 Sample seed note. Placeholder content demonstrating the garden structure — replace with your own text.
A consumer group is a little scheduler: topics on one side, consumers on the other, and an assignor deciding who reads what. Most people meet assignors the day load lands unevenly on their pods. The more interesting encounter is the day a stream-stream join silently produces garbage.
RangeAssignor: skewed, but aligned
RangeAssignor works per topic: sort the partitions, sort the consumers,
carve the partitions into contiguous ranges. With 8 partitions and 3
consumers you get 3-3-2 — the first consumers always take the extra
partitions, for every topic they subscribe to. Subscribe one group to five
8-partition topics and consumer 0 carries five extra partitions while
consumer 2 carries none. That is the famous Range skew.
But look at what the per-topic sorting buys: partition N of every
subscribed topic lands on the same consumer. If impressions and clicks
are both keyed by campaign_id, both have 8 partitions, and both were
written with the same partitioner, then all events for one campaign — from
both topics — arrive at one process. You can join them in local memory with
no shuffle, no external store. This property is called co-partitioning,
and Range-style assignment is the only reason it survives group rebalancing.
RoundRobin and Sticky: even, but blind
RoundRobinAssignor throws every topic-partition into one pool and deals
them out like cards. Load: even. Alignment: destroyed — impressions-3 and
clicks-3 now live on different consumers, and your in-memory join is
quietly wrong.
StickyAssignor and its successor CooperativeStickyAssignor optimize a
different axis: keep the previous assignment as intact as possible across
rebalances. Cooperative rebalancing additionally kills the stop-the-world
pause — consumers only release the partitions that actually move. For a
plain fan-out consumer group this is what you want in 2026.
The actual decision table
| You are… | Pick |
|---|---|
| joining topics on a shared key | Range (and see checklist) |
| running a plain high-volume fan-out | CooperativeSticky |
| on Kafka Streams | nothing — it brings its own |
The co-partitioning checklist, all four or nothing:
- Same partition count on every joined topic.
- Same key and serialization.
- Same partitioner on every producer.
- A Range-family assignor on the consumer group.
Break any one of them — someone “rebalances” partition counts, one producer uses murmur2 and another crc32 — and the join degrades without a single error in any log. The only alarm you’ll get is a business metric drifting.
In Go this is explicit: franz-go takes
kgo.Balancers(kgo.CooperativeStickyBalancer()) (or kgo.RangeBalancer()),
segmentio/kafka-go takes GroupBalancers. Choose consciously — the default
depends on the library, and as shown above, the default is a semantic
decision, not a tuning knob.