On This Page
A consumer joins a group it has joined a thousand times before, and this time the broker rejects it at the door:
org.apache.kafka.common.errors.InconsistentGroupProtocolException: The group
member's supported protocols are incompatible with those of existing members.
No rebalance loop, no lag climbing, no slow degradation. The consumer never even gets an assignment — it fails on JoinGroup and dies. If you are seeing this, you almost certainly changed partition.assignment.strategy on some but not all instances of a group, or you have two different applications sharing one group.id with different assignor configs. This is not a tuning problem. It is a group-membership contract violation, and the coordinator is doing exactly what it is supposed to do.
This article covers the exact mechanics, why it shows up during the eager → cooperative migration specifically, and the two-bounce upgrade procedure that avoids it.
1. The Error
The full exception as it surfaces on the consumer (kafka-clients 2.4+ through 3.x, same wording):
[Consumer clientId=consumer-payments-1, groupId=payments] User provided
listener org.apache.kafka.clients.consumer.internals.ConsumerCoordinator
failed on invocation of onJoinPrepare
org.apache.kafka.common.errors.InconsistentGroupProtocolException: The group
member's supported protocols are incompatible with those of existing members.
There is a second, closely related message from the same error code that people paste into search engines just as often — it appears when a member sends a malformed or empty protocol list:
org.apache.kafka.common.errors.InconsistentGroupProtocolException: The group
member tried to join with an empty protocol type or empty protocol list.
Both map to INCONSISTENT_GROUP_PROTOCOL, error code 23, returned by the GroupCoordinator in the JoinGroup response. It is a fatal, non-retriable error for the consumer — InconsistentGroupProtocolException extends ApiException, not RetriableException. The client will not spin; the poll() call throws and the consumer thread exits.
Where it typically occurs:
- Mid-rolling-upgrade when you switch a group from
RangeAssignor/RoundRobinAssignor(eager) toCooperativeStickyAssignorand the two halves of the group no longer share a common protocol. - Two distinct applications accidentally sharing one
group.id(copy-pasted config) where one uses the Streams protocol type (stream) and the other the plain consumer protocol type (consumer) — the "empty/incompatible protocol type" variant. - A custom
ConsumerPartitionAssignordeployed to some instances but not others. - Mixed client stacks on one group: a Java
kafka-clientsconsumer and a librdkafka/KafkaJS consumer advertising different assignor names.
Kafka version note: this predates KRaft and is unchanged by it — group membership is a coordinator concern, not a metadata-quorum one. With the new KIP-848 consumer group protocol (group.protocol=consumer, GA in 4.0) assignment moves broker-side and this specific client-negotiated failure mode goes away, but every classic-protocol consumer still hits it.
2. How to Reproduce It
You need a broker and two consumers in one group with different assignor lists. Minimal KRaft compose:
# docker-compose.yml
services:
kafka:
image: apache/kafka:3.9.0
container_name: kafka
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
docker compose up -d
docker exec -it kafka /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server localhost:9092 \
--create --topic orders --partitions 6 --replication-factor 1
A tiny consumer parameterized by assignor:
// AssignorDemo.java — kafka-clients 3.9.0
import org.apache.kafka.clients.consumer.*;
import java.time.Duration;
import java.util.*;
public class AssignorDemo {
public static void main(String[] args) {
String assignor = args[0]; // fully-qualified class name
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
p.put(ConsumerConfig.GROUP_ID_CONFIG, "orders-group");
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
"org.apache.kafka.common.serialization.StringDeserializer");
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
"org.apache.kafka.common.serialization.StringDeserializer");
p.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, assignor);
try (KafkaConsumer<String, String> c = new KafkaConsumer<>(p)) {
c.subscribe(List.of("orders"));
while (true) {
c.poll(Duration.ofMillis(1000));
}
}
}
}
Start the first consumer with the eager range assignor and let it own all 6 partitions:
java AssignorDemo org.apache.kafka.clients.consumer.RangeAssignor
Now start a second consumer in the same group with only the cooperative assignor:
java AssignorDemo org.apache.kafka.clients.consumer.CooperativeStickyAssignor
The second consumer throws almost immediately:
Exception in thread "main"
org.apache.kafka.common.errors.InconsistentGroupProtocolException: The group
member's supported protocols are incompatible with those of existing members.
The trigger is exact and reproducible: the existing member advertises {range}, the newcomer advertises {cooperative-sticky}, the intersection is empty, and the coordinator rejects the join. There is no timing dependency and no way to "retry past" it — the sets simply do not overlap.
3. Why It Happens — Surface Level
Every consumer, when it sends JoinGroup, includes the list of partition-assignment protocols it supports (the names come from partition.assignment.strategy). The group coordinator's job during a rebalance is to pick one protocol that every member in the group supports. It computes the intersection of all members' supported-protocol sets.
If that intersection is empty — because one member offers only range and another offers only cooperative-sticky — there is no protocol the whole group can agree on. The coordinator cannot proceed, so it fails the join with INCONSISTENT_GROUP_PROTOCOL. The mechanical cause is always the same: the members of one group do not share at least one common assignor name.
4. Why It Happens — Under the Hood
The consumer group protocol negotiates two things on every join: a protocol type and, within that type, an ordered list of protocol names.
The protocol type is a coarse label: plain consumers use "consumer", Kafka Streams uses "stream". If two members join one group with different protocol types, you get the "empty protocol type or incompatible" variant — the coordinator can't even get to assignor selection. This is the tell-tale sign that two genuinely different applications are colliding on one group.id.
Within a matching protocol type, each member submits its supported assignors as JoinGroupRequestProtocol entries, in preference order. Concretely, RangeAssignor reports the name "range", CooperativeStickyAssignor reports "cooperative-sticky", RoundRobinAssignor reports "roundrobin", and StickyAssignor reports "sticky". The coordinator collects these across all members and selects the assignor most preferred by the most members that is common to all (the vote is tallied across members' preference lists). If no name is common to all members, the intersection is empty → error code 23.
Two subtleties matter here and are the root of most real incidents:
Eager vs. cooperative is not just a name — it changes the revocation contract. Under the eager protocol (range, roundrobin, sticky), every member revokes all its partitions before rejoining. Under cooperative (cooperative-sticky), members keep partitions across the rebalance and revoke only what must move. The group as a whole must run one or the other. You cannot have half the group revoking everything and half holding on — the assignment bookkeeping would double-assign partitions. That is precisely why Kafka forbids a mixed group and why the migration requires care rather than a single flip.
The default in Kafka 3.0+ is a list, and it prefers eager. Since KAFKA-13023, the default partition.assignment.strategy is [RangeAssignor, CooperativeStickyAssignor] — two entries. Because range is listed first and every member supports it, the coordinator selects range, and the group stays eager by default. This default exists specifically so you can migrate safely: every member already advertises both protocols, so adding or removing one, one instance at a time, always leaves a non-empty intersection. The failures happen when someone replaces the whole list with a single entry (cooperative-sticky only) and rolls it out incrementally, breaking the intersection while old instances still advertise only range.
5. The Fix
There are two distinct situations. Diagnose which one you have from the message variant and the group's membership.
Case A: incompatible protocol type — two apps sharing a group.id
If the message mentions an empty/incompatible protocol type, you have a Streams app and a plain consumer (or two different apps) on one group.id. The fix is not an assignor change — it is giving them separate group IDs. group.id is the coordinator's primary key for a group; it is never something two unrelated applications should share.
- spring.kafka.consumer.group-id=shared-group # both apps had this
+ spring.kafka.consumer.group-id=orders-processor # app A
+ # application.id=orders-aggregator # app B (Streams)
Case B: assignor mismatch — the eager → cooperative migration
Never flip the strategy to a single new value across a running group. Migrate with two rolling bounces so the intersection is never empty.
Before (the broken single-flip):
- props.put(PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
- "org.apache.kafka.clients.consumer.RangeAssignor");
+ props.put(PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
+ "org.apache.kafka.clients.consumer.CooperativeStickyAssignor");
Rolling this out one pod at a time means, for the duration of the deploy, some members advertise {range} and some advertise {cooperative-sticky} — empty intersection, error code 23 on every newly-bounced pod.
After — bounce 1: add cooperative alongside the old one (both advertised):
props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, List.of(
"org.apache.kafka.clients.consumer.RangeAssignor",
"org.apache.kafka.clients.consumer.CooperativeStickyAssignor"
));
Roll this to every instance. Throughout this bounce, range is common to all members (old pods have only range; new pods have both), so the group keeps running on range — still eager, still safe.
After — bounce 2: remove the old one (cooperative only):
props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, List.of(
"org.apache.kafka.clients.consumer.CooperativeStickyAssignor"
));
Only start bounce 2 once every member is on the bounce-1 config. Now cooperative-sticky is common to all, so as the group re-forms it selects cooperative and switches protocols cleanly.
Why two bounces and not one is not optional: the group leader performs the assignment. If the leader is still an old-bytecode/eager-only member while newer members have already stopped revoking (cooperative behavior), the leader would happily assign partitions that the new members never gave up — double assignment and duplicate processing. The intermediate state where all members advertise both protocols guarantees the leader can always fall back to range until the whole group is ready.
Spring Kafka is configured the same way — set the list, not a single class:
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, List.of(
RangeAssignor.class.getName(),
CooperativeStickyAssignor.class.getName() // bounce 1
));
// ... bootstrap, deserializers, group id
return new DefaultKafkaConsumerFactory<>(props);
}
One more Spring-specific gotcha: if you use CooperativeStickyAssignor you must also make sure your container's ack/commit path is cooperative-aware — but that is a separate concern from the join failure and does not affect protocol negotiation.
6. Best Practices & The Better Design
The whole class of problem disappears if every member of a group advertises the same, superset assignor list, applied everywhere at once — via config, not code. Externalize the strategy so a single change lands on all instances together rather than baking a single class name into the artifact:
# application.yml — one source of truth, rolled as a unit
spring:
kafka:
consumer:
group-id: orders-processor
properties:
partition.assignment.strategy: >-
org.apache.kafka.clients.consumer.CooperativeStickyAssignor,
org.apache.kafka.clients.consumer.RangeAssignor
Design rules that keep you out of trouble:
- One
group.idper application, never shared. Two apps on one group is the root cause of the protocol-type variant and a dozen other pathologies. Treatgroup.idas owned by exactly one deployable. - Change assignors in two bounces, always, even for trivial groups. It costs one extra deploy and removes any chance of an empty intersection or a double-assignment window.
- Pin the assignor list explicitly rather than relying on the default. The 3.0 default (
range, cooperative-sticky) is convenient but implicit; a client library upgrade that changes defaults can silently alter which protocol your group negotiates. Declaring the list makes upgrades boring. - Consider KIP-848 (
group.protocol=consumer) for new deployments on 4.0+ brokers. Assignment moves to the broker, members no longer negotiate a client-side assignor at all, and this failure mode is designed out. It is opt-in per consumer and interoperates with classic-protocol members in the same group.
The right-way baseline for a cooperative group, post-migration:
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka:9092");
p.put(ConsumerConfig.GROUP_ID_CONFIG, "orders-processor"); // unique per app
p.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, List.of(
"org.apache.kafka.clients.consumer.CooperativeStickyAssignor"
));
p.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG,
System.getenv("POD_NAME")); // static membership, suppresses needless rebalances
7. How to Prevent It Long-Term
Config as code, deployed atomically. Keep partition.assignment.strategy in one config file or config-map that rolls to all instances of a group together. The failure mode requires disagreement between instances, so anything that guarantees instances agree kills it.
A CI/lint check that fails the build if partition.assignment.strategy is set to a single cooperative entry without a documented migration ticket — catches the exact single-flip mistake before it ships.
A pre-deploy assertion in the migration runbook: before starting bounce 2, verify every live member already carries the bounce-1 config. kafka-consumer-groups.sh shows the assignor negotiated by the group:
kafka-consumer-groups.sh --bootstrap-server kafka:9092 \
--describe --group orders-processor --state
# Look at ASSIGNMENT-STRATEGY — it must read the expected protocol
# before you proceed to the next bounce.
Alerting. InconsistentGroupProtocolException is fatal, so the loud signal is consumers exiting: alert on consumer-process restarts and on assigned-partition count dropping to zero for a group that should be live. A spike of these during a deploy is the migration going wrong — roll back to the both-protocols config immediately.
Integration test the migration, not just the steady state. A two-instance Testcontainers test that starts instance A on {range, cooperative-sticky}, joins instance B on the same list, then bounces both to {cooperative-sticky} and asserts no InconsistentGroupProtocolException — reproduces the whole path in CI and locks the procedure into the codebase.
8. Key Takeaways
InconsistentGroupProtocolException(INCONSISTENT_GROUP_PROTOCOL, code 23) means the members of one group share no common assignor — it is fatal and non-retriable, thrown onJoinGroup, not a rebalance loop.- Two variants: incompatible protocol type = two apps wrongly sharing one
group.id(fix: separate group IDs); incompatible assignor list = an eager→cooperative migration done as a single flip (fix: two rolling bounces). - Migrate assignors with two bounces — bounce 1 adds
cooperative-stickyalongside the old assignor everywhere; bounce 2 removes the old one — so the intersection is never empty and the leader can never double-assign. - The 3.0+ default
[RangeAssignor, CooperativeStickyAssignor]stays eager (prefersrange) and exists to make this migration safe; declare the list explicitly so client upgrades don't silently change it. - On 4.0+ brokers,
group.protocol=consumer(KIP-848) moves assignment broker-side and designs this failure mode out entirely.
Gopi Gorantala Newsletter
Join the newsletter to receive the latest updates in your inbox.