> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Kafka InconsistentGroupProtocolException: Cause and Fix
- URL: https://www.ggorantala.dev/kafka-inconsistentgroupprotocolexception-fix/
- Published: 2026-04-22T14:57:00.000Z
- Updated: 2026-08-29T08:32:44.000Z
- Description: Kafka InconsistentGroupProtocolException: the group member's supported protocols are incompatible. Why it hits mid-upgrade and how to fix the assignor mismatch.
- Author: Gopi Gorantala
- Tags: #kafka-consumer-groups, kafka-errors, InconsistentGroupProtocolException, kafka-consumer, cooperative-sticky, spring-kafka, partition-assignment, Java

A consumer joins a group it has joined a thousand times before, and this time the broker rejects it at the door:

```text
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):

```text
[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:

```text
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) to `CooperativeStickyAssignor` and 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 `ConsumerPartitionAssignor` deployed to some instances but not others.
- Mixed client stacks on one group: a Java `kafka-clients` consumer 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:

```yaml
# 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

```

```bash
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:

```java
// 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:

```bash
java AssignorDemo org.apache.kafka.clients.consumer.RangeAssignor

```

Now start a second consumer in the **same group** with only the cooperative assignor:

```bash
java AssignorDemo org.apache.kafka.clients.consumer.CooperativeStickyAssignor

```

The second consumer throws almost immediately:

```text
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.

```diff
- 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):**

```diff
- 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):**

```java
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):**

```java
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:

```java
@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:

```yaml
# 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.id` per application, never shared.** Two apps on one group is the root cause of the protocol-*type* variant and a dozen other pathologies. Treat `group.id` as 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:

```java
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:

```bash
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 on `JoinGroup`, 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-sticky` alongside 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 (prefers `range`) 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.