Skip to content

Kafka IllegalStateException: No Current Assignment for Partition

Getting java.lang.IllegalStateException: No current assignment for partition on seek()? Why Kafka's lazy assignment causes it — and the correct fix.

Gopi Gorantala
Gopi Gorantala
8 min read
Reading Progress

On This Page

You called seek() (or seekToBeginning(), position(), pause()) on a KafkaConsumer and got this:

Exception in thread "main" java.lang.IllegalStateException: No current assignment for partition demo-topic-0
	at org.apache.kafka.clients.consumer.internals.SubscriptionState.assignedState(SubscriptionState.java:378)
	at org.apache.kafka.clients.consumer.internals.SubscriptionState.seekUnvalidated(SubscriptionState.java:395)
	at org.apache.kafka.clients.consumer.KafkaConsumer.seek(KafkaConsumer.java:1632)
	at com.example.ReplayConsumer.main(ReplayConsumer.java:24)

In Spring Kafka the same exception surfaces wrapped in listener-container startup or error-handler logs, typically when someone calls seek from the wrong place:

java.lang.IllegalStateException: No current assignment for partition orders-3
	at org.apache.kafka.clients.consumer.internals.SubscriptionState.assignedState(SubscriptionState.java:378)
	at org.apache.kafka.clients.consumer.internals.SubscriptionState.requestOffsetReset(SubscriptionState.java:426)
	at org.apache.kafka.clients.consumer.KafkaConsumer.seekToBeginning(KafkaConsumer.java:1680)

Typical setup where this bites: kafka-clients 3.x (any version — the guard has been there since 0.9, message unchanged), Spring Kafka 3.x, local Docker or managed Kafka. It is a client-side exception. The broker never sees the call. Line numbers vary by version; since kafka-clients 3.7 the frames may show ClassicKafkaConsumer or AsyncKafkaConsumer instead of KafkaConsumer — same bug, same fix.

Two distinct situations produce it:

  1. POC/startup variant — you call seek() right after subscribe(), before the consumer has actually joined the group. This is the overwhelmingly common one.
  2. Rebalance race variant — production consumer, worked for weeks, then a rebalance revoked the partition between polls and some code (an error handler, a stashed TopicPartition, a metrics endpoint calling position()) touched a partition the consumer no longer owns.

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

Single-broker KRaft cluster:

# docker-compose.yml
services:
  kafka:
    image: apache/kafka:3.8.0
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093
      KAFKA_LISTENERS: PLAINTEXT://:29092,CONTROLLER://:29093,EXTERNAL://:9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,EXTERNAL://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
docker compose up -d
docker compose exec kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:29092 --create --topic demo-topic --partitions 3

Now the code every "replay from the beginning" tutorial victim writes (kafka-clients pinned to 3.8.0):

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "replay-poc");
props.put("key.deserializer", StringDeserializer.class.getName());
props.put("value.deserializer", StringDeserializer.class.getName());

try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
    consumer.subscribe(List.of("demo-topic"));

    // BOOM — throws IllegalStateException: No current assignment for partition demo-topic-0
    consumer.seek(new TopicPartition("demo-topic", 0), 0L);

    while (true) {
        consumer.poll(Duration.ofMillis(500))
                .forEach(r -> System.out.println(r.value()));
    }
}

Run it. It fails on the seek() line 100% of the time, on every Kafka version, regardless of broker state. No producer needed, no data needed.

The rebalance-race variant is reproducible too: start two instances of a consumer that caches TopicPartition objects from a previous poll() and later calls consumer.position(cachedTp). When instance #2 joins and the group rebalances, the cached partition may move to the other instance, and the next position() call on it throws the same exception. It only fires under consumer churn — which is why it shows up during deploys and autoscaling events, not in your single-instance dev run.

3. Why It Happens — Surface Level

subscribe() does not assign you anything. It records an intent. Partition assignment for a subscribed consumer only materializes after the consumer joins the group — and joining the group happens inside poll(), not inside subscribe().

So between subscribe() and the first successful poll() that completes the group join, the consumer's assignment set is empty. seek(), seekToBeginning(), seekToEnd(), position(), pause(), and resume() all operate on the current assignment, and each of them throws IllegalStateException: No current assignment for partition X if you pass a partition that isn't in that set. Note the asymmetry: committed() does not require assignment (it's a coordinator lookup), which is why half your offset code works and the other half explodes.

4. Why It Happens — Under the Hood

Every KafkaConsumer holds a SubscriptionState object — the client-side source of truth for what you subscribed to, what you're assigned, and the fetch position per partition. Position-mutating APIs funnel through SubscriptionState.assignedState(tp):

// kafka-clients internals (abridged)
private TopicPartitionState assignedState(TopicPartition tp) {
    TopicPartitionState state = this.assignment.stateValue(tp);
    if (state == null)
        throw new IllegalStateException("No current assignment for partition " + tp);
    return state;
}

For a subscribe()-mode consumer, this.assignment is populated only when the group protocol completes: poll() drives ConsumerCoordinator, which sends JoinGroup, waits for the leader to run the partition assignor, receives its share via SyncGroup, and then installs the assignment and fires onPartitionsAssigned. Until that handshake finishes, the assignment map is empty and assignedState() throws.

Two details make this nastier than it looks:

poll() completing is not a synchronization point you can rely on. Since KIP-266, poll(Duration) respects the timeout: poll(Duration.ZERO) returns immediately, possibly before the join completes. The old "call poll(0) once, then seek" hack is a race condition wearing a trench coat — it worked with the deprecated poll(long) (which blocked indefinitely on coordinator operations) and silently broke when people migrated to poll(Duration).

Assignment is revocable at any poll boundary. With the default RangeAssignor/eager rebalancing, every rebalance revokes all partitions before reassigning; with CooperativeStickyAssignor only migrating partitions are revoked, but revocations still happen. Any TopicPartition reference you cached is a claim ticket that can expire between two poll() calls. Code that seeks/pauses partitions from a stale snapshot — a common pattern in retry/backoff handlers that pause partitions and resume them from a scheduled task on another data structure — throws exactly this exception the first time a rebalance lands mid-backoff. (If your logs show this error clustered around rebalance log lines, this is your variant.)

The consumer group protocol is being rewritten by KIP-848 (broker-driven assignment, default in Kafka 4.0's new consumer), but the client contract is unchanged: assignment still only materializes through poll(), and position APIs still throw on unassigned partitions.

5. The Fix

Fix 1: seek inside onPartitionsAssigned (subscribe mode — the right way)

The ConsumerRebalanceListener callback runs at the only moment assignment is guaranteed current:

- consumer.subscribe(List.of("demo-topic"));
- consumer.seek(new TopicPartition("demo-topic", 0), 0L);   // throws
+ consumer.subscribe(List.of("demo-topic"), new ConsumerRebalanceListener() {
+     @Override
+     public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
+         consumer.seekToBeginning(partitions);   // assignment is live here
+     }
+     @Override
+     public void onPartitionsRevoked(Collection<TopicPartition> partitions) { }
+ });

  while (true) {
      consumer.poll(Duration.ofMillis(500)).forEach(...);
  }

The callback executes on the polling thread inside poll(), after assignment is installed — no race, and it re-runs after every rebalance, so your seek logic also survives partition migration. If you only want to rewind on first assignment (not on every rebalance), keep a Set<TopicPartition> of already-seeked partitions and skip them.

Fix 2: use assign() when you don't want group management

If you're building a replay tool, a backfill job, or a consumer with an external offset store, you don't want a consumer group deciding your assignment at all:

- consumer.subscribe(List.of("demo-topic"));
+ List<TopicPartition> parts = consumer.partitionsFor("demo-topic").stream()
+         .map(pi -> new TopicPartition(pi.topic(), pi.partition()))
+         .toList();
+ consumer.assign(parts);
  consumer.seekToBeginning(parts);   // legal immediately — assign() is synchronous

assign() populates SubscriptionState directly, client-side, with no group join. Seek works on the very next line. Use this for tooling and one-off jobs; use subscribe mode for services that need scaling and failover. Never mix the two on one consumer instance — that throws its own IllegalStateException ("Subscription to topics, partitions and pattern are mutually exclusive").

Fix 3: Spring Kafka — AbstractConsumerSeekAware

Don't touch the Consumer from outside the container thread. Spring routes the same rebalance callback for you:

@Component
public class ReplayListener extends AbstractConsumerSeekAware {

    @Override
    public void onPartitionsAssigned(Map<TopicPartition, Long> assignments,
                                     ConsumerSeekCallback callback) {
        // runs on the consumer thread, assignment guaranteed current
        assignments.keySet().forEach(tp -> callback.seekToBeginning(tp.topic(), tp.partition()));
    }

    @KafkaListener(topics = "demo-topic", groupId = "replay-poc")
    public void listen(String value) {
        // ...
    }
}

For ad-hoc rewinds at runtime (ops endpoint, admin trigger), AbstractConsumerSeekAware exposes seekToBeginning() / seekToTimestamp(long) on the listener bean itself; Spring queues the seek and applies it safely on the consumer thread. Calling consumer.seek() from an HTTP handler thread will get you either this exception or a ConcurrentModificationException — both are the same design error.

Fix for the rebalance-race variant

Never cache TopicPartition sets across polls for position operations. Re-derive from consumer.assignment() at the point of use, and treat the operation as best-effort:

- consumer.pause(stashedPartitions);                    // stale snapshot → throws
+ Set<TopicPartition> owned = new HashSet<>(consumer.assignment());
+ stashedPartitions.retainAll(owned);
+ consumer.pause(stashedPartitions);                    // only what we still own

If a partition was revoked, whoever owns it now is responsible for it — pausing or seeking it from the old owner was never meaningful anyway.

6. Best Practices & The Better Design

The class of bug here is treating assignment as a static fact instead of a leased, revocable state. The design that avoids it:

// The pattern: all position state derives from the rebalance listener, nothing cached elsewhere
public class ReplayableConsumer implements ConsumerRebalanceListener, AutoCloseable {

    private final KafkaConsumer<String, String> consumer;
    private final long replayFromTimestamp;

    public ReplayableConsumer(Properties props, long replayFromTimestamp) {
        props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
                  CooperativeStickyAssignor.class.getName());
        this.consumer = new KafkaConsumer<>(props);
        this.replayFromTimestamp = replayFromTimestamp;
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        Map<TopicPartition, Long> query = partitions.stream()
                .collect(Collectors.toMap(tp -> tp, tp -> replayFromTimestamp));
        consumer.offsetsForTimes(query).forEach((tp, oat) -> {
            if (oat != null) consumer.seek(tp, oat.offset());   // else: no data past ts, keep committed position
        });
    }

    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        // commit or flush per-partition state here, then forget these partitions
    }

    public void run(String topic) {
        consumer.subscribe(List.of(topic), this);
        while (true) {
            consumer.poll(Duration.ofMillis(500)).forEach(this::process);
        }
    }
    // ...
}

Why this shape: offsetsForTimes + seek inside the callback gives you timestamp-based replay that is correct across rebalances; CooperativeStickyAssignor minimizes how often partitions actually move; revocation is the flush point for any per-partition state. Add group.instance.id (static membership) on Kubernetes so restarts within session.timeout.ms don't trigger rebalances at all — fewer assignment changes, fewer chances for stale-partition bugs.

And a team convention worth enforcing in review: seek/pause/resume/position may only appear inside a ConsumerRebalanceListener callback, immediately after assign(), or guarded by consumer.assignment().contains(tp). Anywhere else is a latent production incident.

7. How to Prevent It Long-Term

Test with rebalances, not against them. A Testcontainers integration test that starts one consumer, lets it stabilize, then starts a second instance of the same group will flush out every stale-TopicPartition bug in your seek/pause logic. Single-consumer tests never rebalance and therefore never catch this.

Monitor rebalance rate. The client-side metrics rebalance-rate-per-hour and failed-rebalance-rate-per-hour (consumer-coordinator-metrics group) tell you how often assignment is churning. If this exception appears in logs, correlate timestamps with rebalances — the race variant always co-occurs.

Grep your codebase. consumer.seek, .pause(, .position( outside a rebalance listener or assign() block is a five-minute audit. In Spring services, any injected/stashed Consumer reference used from a non-listener thread is a finding.

Lint the poll(0) hack away. If you find poll(Duration.ZERO) followed by seek, it's the legacy workaround and it's racy. Replace with a rebalance listener.

8. Key Takeaways

  • subscribe() assigns nothing — assignment materializes inside poll() after the group join, so seek() right after subscribe() always throws IllegalStateException: No current assignment for partition.
  • seek, seekToBeginning/End, position, pause, resume require current assignment; committed() doesn't — that asymmetry is why the bug looks intermittent.
  • Do seeks inside ConsumerRebalanceListener.onPartitionsAssigned (or Spring's AbstractConsumerSeekAware); use assign() for group-less replay/backfill tools.
  • Never cache TopicPartition sets across polls — assignment is a lease that any rebalance can revoke; re-check consumer.assignment() at the point of use.
  • The poll(0)-then-seek hack is a race since KIP-266's poll(Duration) — if you see it, replace it.
apache-kafkakafka-errorsIllegalStateExceptionkafka-consumerkafka-seekconsumer-rebalancespring-kafkaJava

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