> ## 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: Assignments Can Only Be Reset If the Group Is Inactive
- URL: https://www.ggorantala.dev/kafka-reset-offsets-group-inactive-fix/
- Published: 2026-04-06T13:21:00.000Z
- Updated: 2026-08-29T08:25:50.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: kafka-errors, kafka-consumer-groups, reset-offsets, offset-management, kafka-consumer, kafka-docker, Java

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:

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

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

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

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

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

```

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

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

```bash
docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 --describe --group order-service --state

```

```text
GROUP          COORDINATOR (ID)   ASSIGNMENT-STRATEGY  STATE     #MEMBERS
order-service  localhost:9092 (1) range                Stable    1

```

Try to reset while it's Stable:

```bash
docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --group order-service --reset-offsets --to-earliest --topic orders --execute

```

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

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

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

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

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

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