Skip to content

Kafka: Assignments Can Only Be Reset If the Group Is Inactive

Kafka's reset-offsets fails with 'Assignments can only be reset if the group is inactive, but the current state is Stable.' Here's why, and the fix.

Gopi Gorantala
Gopi Gorantala
8 min read
Reading Progress

On This Page

Kafka: Assignments Can Only Be Reset If the Group Is Inactive

You need to replay a topic, or a bad deploy committed garbage offsets, so you reach for kafka-consumer-groups.sh --reset-offsets. Instead of resetting, it refuses:

Error: Assignments can only be reset if the group 'order-service' is inactive, but the current state is Stable.

Your consumers are running, everything looks healthy, and the tool won't touch the offsets. This is not a bug and not a permissions problem. It's a deliberate guard, and understanding why it exists tells you exactly how to work around it safely — and why the "just use AdminClient instead" advice you'll find online is a foot-gun.

1. The Error

The full message, verbatim, as ConsumerGroupCommand prints it:

Error: Assignments can only be reset if the group 'order-service' is inactive, but the current state is Stable.

The trailing state varies with what the group is doing:

... but the current state is Stable.
... but the current state is PreparingRebalance.
... but the current state is CompletingRebalance.

Where it shows up:

  • CLI: kafka-consumer-groups.sh --reset-offsets ... (the ConsumerGroupCommand tool), Kafka 2.x through 4.0, KRaft or ZooKeeper. --zookeeper was removed in 3.0; this is all --bootstrap-server / AdminClient under the hood.
  • Managed Kafka: identical on MSK, Confluent Platform/Cloud CLI, Aiven — the check is broker-behavior-adjacent but enforced in the client tool, so every distribution shipping kafka-consumer-groups.sh behaves the same.

The command that triggers it:

kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --group order-service \
  --reset-offsets --to-earliest \
  --topic orders \
  --execute

2. How to Reproduce It (step-by-step)

Minimal KRaft cluster:

# docker-compose.yml
services:
  kafka:
    image: apache/kafka:3.9.0
    container_name: kafka
    ports:
      - "9092:9092"
      - "29092:29092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_LISTENERS: INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:29092
      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:9092,EXTERNAL://localhost:29092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
docker compose up -d

# Create the topic and push some records
docker exec kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 --create --topic orders --partitions 3

seq 1 100 | docker exec -i kafka /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server localhost:9092 --topic orders

Now start a consumer and leave it running (this is the whole point — an active member):

docker exec -d kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic orders --group order-service

Confirm the group is live:

docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 --describe --group order-service --state
GROUP          COORDINATOR (ID)   ASSIGNMENT-STRATEGY  STATE     #MEMBERS
order-service  localhost:9092 (1) range                Stable    1

Try to reset while it's Stable:

docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --group order-service --reset-offsets --to-earliest --topic orders --execute
Error: Assignments can only be reset if the group 'order-service' is inactive, but the current state is Stable.

Reproduces every time. The trigger is not the reset target (--to-earliest, --to-offset, --shift-by all fail identically) — it's that the group has at least one live member.

3. Why It Happens — Surface Level

A consumer group's committed offsets are owned by its live members. Each active consumer periodically commits its own position to __consumer_offsets. If the CLI let you overwrite those offsets while a member is actively consuming and committing, two writers would be racing for the same (group, topic, partition) key: the tool writes 0, and a few hundred milliseconds later the live consumer's next auto-commit writes 147 right back over it. Your reset would appear to succeed and then silently vanish.

Rather than hand you that race, ConsumerGroupCommand checks the group's state first and refuses unless the group is Empty or Dead — i.e. no live members. Stable, PreparingRebalance, and CompletingRebalance all mean "someone is in this group right now," so the reset is rejected up front.

4. Why It Happens — Under the Hood

The group coordinator runs every consumer group through a state machine backed by the __consumer_offsets topic:

  • Empty — group exists (has committed offsets and/or metadata) but has zero live members. Offset records persist even with nobody consuming.
  • PreparingRebalance — a member joined or left; the coordinator is collecting JoinGroup requests.
  • CompletingRebalance — join phase done; the coordinator is waiting for the leader's SyncGroup assignment.
  • Stable — assignment distributed, members heartbeating, everyone consuming.
  • Dead — group has no members and no metadata; scheduled for removal.

Offset resets are only sound in Empty or Dead, because only then is nothing else writing to those offset keys. The check lives in the tool, not the broker: ConsumerGroupCommand.resetOffsets() calls AdminClient.describeConsumerGroups, inspects ConsumerGroupDescription.state(), and throws the "inactive" error before it ever sends an OffsetCommit. The broker itself will happily accept an alterConsumerGroupOffsets (OffsetCommit) request against an active group — which is precisely why the guard has to live in the tool.

There's a second, subtler reason the coordinator can't safely let you rewrite a live member's offset: under the group protocol, offset commits are tagged with the committing member's generationId and memberId. A live consumer that resumes after your reset will keep fetching from its own in-memory position, not from what you committed — the committed offset only takes effect when a member (re)initializes its position on assignment, i.e. after a rebalance or restart. So even setting aside the write race, a reset applied under a running member frequently wouldn't take effect until the member restarts anyway. Making you stop the members first isn't just about avoiding a race; it's the only point where the committed offset is guaranteed to be the one the group reads on its next poll.

The #MEMBERS count is what "active" means here — not throughput, not lag. A group with one idle consumer that hasn't polled a record in an hour is still Stable with one member, and the reset will still be refused. Conversely, a group that processed millions of records yesterday but whose consumers have all shut down is Empty, and resets freely.

5. The Fix

There is exactly one correct move: make the group inactive, then reset. Stop every consumer in the group so the coordinator transitions it to Empty, run the reset, then start the consumers back up.

Before — reset against a running group (fails):

# consumers still running -> STATE = Stable
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group order-service --reset-offsets --to-earliest \
  --topic orders --execute
# Error: Assignments can only be reset if the group 'order-service'
#        is inactive, but the current state is Stable.

After — stop members, verify Empty, dry-run, then execute:

# 1. Stop ALL consumers in the group (scale the deployment to 0,
#    stop the pods/containers, or kill the processes).
kubectl scale deployment/order-service --replicas=0

# 2. Wait out the session timeout, then confirm the group is Empty.
#    A cleanly-closed consumer sends LeaveGroup and the group goes Empty
#    immediately; a hard kill takes up to session.timeout.ms (45s default).
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group order-service --state
# ... STATE = Empty   #MEMBERS = 0

# 3. DRY RUN first (default when neither --execute nor --dry-run is given,
#    but be explicit). This prints the target offsets without writing them.
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group order-service --reset-offsets --to-earliest \
  --topic orders --dry-run

# 4. Commit the reset.
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group order-service --reset-offsets --to-earliest \
  --topic orders --execute

# 5. Bring the consumers back.
kubectl scale deployment/order-service --replicas=3

The reset target is orthogonal to this error — pick whichever scenario you actually need, all of which require the same Empty precondition:

# Replay everything
--reset-offsets --to-earliest --topic orders

# Skip to the end (drop the backlog)
--reset-offsets --to-latest --topic orders

# Exact offset on a specific partition
--reset-offsets --to-offset 500 --topic orders:2

# Rewind 1000 records per partition (negative shifts allowed)
--reset-offsets --shift-by -1000 --topic orders

# Everything committed since a point in time (ISO-8601)
--reset-offsets --to-datetime 2026-07-13T00:00:00.000 --topic orders

# Rewind by a duration (reprocess the last hour)
--reset-offsets --by-duration PT1H --topic orders

# All topics the group is subscribed to
--reset-offsets --to-earliest --all-topics

The AdminClient "workaround" — and why it's a trap. You'll find advice to skip the CLI and call AdminClient.alterConsumerGroupOffsets from Java, which does not enforce the inactive check. It will "work" against a live group, and then the running members' next auto-commit silently overwrites your reset:

// DON'T do this against an active group — it commits, then gets
// clobbered by the live members' next auto-commit.
try (Admin admin = Admin.create(props)) {
    Map<TopicPartition, OffsetAndMetadata> offsets = Map.of(
        new TopicPartition("orders", 0), new OffsetAndMetadata(0L),
        new TopicPartition("orders", 1), new OffsetAndMetadata(0L),
        new TopicPartition("orders", 2), new OffsetAndMetadata(0L));
    admin.alterConsumerGroupOffsets("order-service", offsets)
         .all().get();
}

The CLI's guard is a feature, not an obstacle. If you must reset programmatically (a controlled runbook, an automated replay job), first stop the members, then verify the group is Empty via describeConsumerGroups before you call alterConsumerGroupOffsets:

try (Admin admin = Admin.create(props)) {
    ConsumerGroupDescription desc = admin
        .describeConsumerGroups(List.of("order-service"))
        .describedGroups().get("order-service").get();

    if (desc.state() != ConsumerGroupState.EMPTY
            && desc.state() != ConsumerGroupState.DEAD) {
        throw new IllegalStateException(
            "Refusing to reset offsets: group is " + desc.state()
            + " with " + desc.members().size() + " live member(s)."
            + " Stop all consumers first.");
    }
    admin.alterConsumerGroupOffsets("order-service", offsets).all().get();
}

That reproduces the CLI's safety in your own code instead of defeating it.

6. Best Practices & The Better Design

Stop the consumers cleanly, not with kill -9. A consumer that exits via close() sends a LeaveGroup and the coordinator marks the group Empty in milliseconds. A hard kill leaves the member "alive" in the coordinator's view until session.timeout.ms (default 45000 ms since 3.0) expires — so you sit staring at STATE = Stable for up to 45 seconds after the process is gone, then it finally flips to Empty. In Spring Kafka, server.shutdown=graceful plus a terminationGracePeriodSeconds longer than your longest in-flight batch gets you the clean LeaveGroup.

Always dry-run. --dry-run prints the exact NEW-OFFSET per partition it would commit. On a topic with retention, --to-earliest is the current log-start offset (which may be well above 0 if old segments were deleted), not literally 0. Eyeball the numbers before --execute; there is no undo.

Prefer a new group id over a reset for one-off replays. If you just need to reprocess a topic once — backfilling a new downstream, testing a consumer change — starting a throwaway consumer with a fresh group.id and auto.offset.reset=earliest reads from the beginning without touching the production group's committed offsets at all. No stop, no reset, no window where production is down. Resets are for correcting the production group's position; replays into a side system don't need them.

Design consumers to make resets rare. Idempotent processing (dedup by a business key, or upsert-by-primary-key downstream) means an accidental over-replay is a no-op rather than a data-corruption incident, which lowers the stakes of every reset you ever run. Externalizing "where am I" into the sink (transactional outbox / consume-transform-produce with read_committed) removes whole classes of "the committed offset is wrong" situations that send you reaching for --reset-offsets in the first place.

7. How to Prevent It Long-Term

Runbook, not tribal knowledge. Codify the reset as a four-step procedure — scale to zero, wait for Empty, dry-run, execute, scale back up — in the repo next to the deployment manifests. The number-one cause of this error in an incident is someone under pressure running --execute against a live group and burning ten minutes confused before they read the message.

Guard automated resets. Any script or job that resets offsets must call describeConsumerGroups and assert state == EMPTY before committing, exactly as in the Java snippet above. CI/lint should reject alterConsumerGroupOffsets calls that aren't preceded by a state check — that's the difference between a safe runbook and a silent clobber.

Alert on the group state you actually depend on. Monitor consumer lag and assigned-partitions; a group that unexpectedly sits at Empty in production (members all gone) is its own incident. Watching state also tells you at a glance, mid-reset, whether your consumers have actually stopped.

Test the reset path. A Testcontainers test that produces records, starts a consumer, stops it, resets to earliest, and asserts full re-consumption verifies your runbook actually works — and catches the case where a stray sidecar consumer keeps the group Stable and blocks every reset.

8. Key Takeaways

  • "Group is inactive" means zero live members — state Empty or Dead. Not "low lag," not "idle." One running consumer is enough to block the reset.
  • The guard prevents a write race. Live members commit their own offsets; letting the tool write concurrently would let the consumer clobber your reset milliseconds later.
  • Fix: stop all consumers, wait for Empty (up to session.timeout.ms after a hard kill), dry-run, then --execute, then restart. Clean shutdown (LeaveGroup) makes the group Empty instantly.
  • AdminClient.alterConsumerGroupOffsets skips the check — that's a foot-gun, not a shortcut. It commits against a live group and gets silently overwritten. Replicate the state check in code.
  • For one-off replays, a fresh group.id with auto.offset.reset=earliest beats a reset — no downtime, no touching production offsets.
apache-kafkakafka-errorskafka-consumer-groupsreset-offsetsoffset-managementkafka-consumerkafka-dockerJava

Gopi Gorantala Twitter

I'm Gopi — 15+ years in Java, building Kafka and Flink platforms for banks, where one lost event is a financial discrepancy. I write javahandbook.com because the guides I needed didn't exist. Everything here is tested against a real cluster first.

Comments