ZooKeeper vs KRaft in Apache Kafka
ZooKeeper vs KRaft is about where a Kafka cluster keeps its metadata and who manages it. In ZooKeeper mode, the metadata lives in a separate Apache ZooKeeper service. In KRaft mode, a small group of Kafka controllers keeps it in Kafka's own replicated log, using the Raft consensus protocol. Since Kafka 4.0, KRaft is the only mode.
You still need to know both: many production clusters were built on ZooKeeper and must be migrated, and the difference explains why KRaft clusters recover from failures so much faster.
1. What Is Cluster Metadata?
Besides messages, every Kafka cluster tracks facts about itself, called metadata:
- which brokers (Kafka servers) are alive
- which topics and partitions exist, and where their replicas live
- the leader of each partition and its ISR (in-sync replicas, the copies that are fully caught up)
- topic configs, quotas and access rules
One node, the controller, changes this metadata, for example by picking new leaders when a broker dies. So "ZooKeeper or KRaft" really asks: where does the metadata live, and how does the controller get it?
2. How ZooKeeper Mode Works
ZooKeeper is a separate coordination service with its own cluster of servers, called an ensemble. It stores small pieces of data called znodes in a tree, like files in folders.
- Each broker registers by creating an ephemeral znode under
/brokers/ids. ZooKeeper deletes an ephemeral znode automatically when the broker's session expires. - Brokers race to create the
/controllerznode. The winner becomes the controller. - The controller keeps a copy of all metadata in memory and pushes changes to every broker with
LeaderAndIsrandUpdateMetadatarequests.
The weak spot is failover. A new controller starts with empty memory, so it must read every partition's state from ZooKeeper before electing a single leader. With hundreds of thousands of partitions, this could take minutes.
3. How KRaft Mode Works
KRaft (Kafka Raft) removes ZooKeeper. A few nodes run with process.roles=controller and form a controller quorum (a group that decides by majority vote).
- Every metadata change is appended as a record to an internal topic,
__cluster_metadata. It has a single partition, so all changes have one strict order. - The controllers replicate this log with Raft. The Raft leader is the active controller. The others are hot standbys that apply each record as it arrives.
- Brokers pull new records from the active controller and send it a heartbeat every 2 seconds (
broker.heartbeat.interval.ms). If no heartbeat arrives for 9 seconds (broker.session.timeout.ms), the controller fences the broker, which means it stops counting it as a partition leader or an in-sync replica. - Controllers and brokers write periodic snapshots of the metadata so the log does not grow forever.
Because standbys already hold the metadata in memory, a new active controller only needs the few records it was missing.
3.1 Configuration Side by Side
# ZooKeeper mode (Kafka 3.x and older)
broker.id=4
zookeeper.connect=zk1:2181,zk2:2181,zk3:2181
# KRaft mode: dedicated controller
process.roles=controller
node.id=1
controller.quorum.voters=1@c1:9093,2@c2:9093,3@c3:9093
listeners=CONTROLLER://c1:9093
controller.listener.names=CONTROLLER
listener.security.protocol.map=CONTROLLER:PLAINTEXT
# KRaft mode: broker
process.roles=broker
node.id=4
controller.quorum.voters=1@c1:9093,2@c2:9093,3@c3:9093
listeners=PLAINTEXT://b4:9092
controller.listener.names=CONTROLLER
listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
Before a KRaft node starts for the first time, its storage must be formatted with the cluster ID using kafka-storage.sh format. Kafka 3.9 added controller.quorum.bootstrap.servers for a dynamic quorum, where controllers can be added and removed later.
4. Code Examples
Example 1 is a plain Java model. Examples 2 and 3 use kafka-clients 4.1.0 against a real Kafka 4.1.0 cluster (Docker image apache/kafka:4.1.0) with three controllers, c1 to c3 (node IDs 1–3), and two brokers, b4 and b5. All run on Java 21.
4.1 Example 1: What a New Controller Must Load
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class ControllerFailoverModel {
// Metadata changes, in the form KRaft appends them to __cluster_metadata
sealed interface MetadataRecord permits BrokerRegistered, TopicCreated, LeaderChanged {}
record BrokerRegistered(int brokerId) implements MetadataRecord {}
record TopicCreated(String topic, int partitions) implements MetadataRecord {}
record LeaderChanged(String topic, int partition, int newLeader) implements MetadataRecord {}
// In-memory metadata that a controller builds by replaying the log
static final class MetadataImage {
final List<Integer> brokers = new ArrayList<>();
final Map<String, Integer> leaders = new TreeMap<>(); // "topic-partition" -> leader
int appliedRecords = 0;
void apply(MetadataRecord rec) {
switch (rec) {
case BrokerRegistered b -> brokers.add(b.brokerId());
case TopicCreated t -> {
for (int p = 0; p < t.partitions(); p++) {
leaders.put(t.topic() + "-" + p, brokers.get(p % brokers.size()));
}
}
case LeaderChanged c -> leaders.put(c.topic() + "-" + c.partition(), c.newLeader());
}
appliedRecords++;
}
}
// ZooKeeper mode: a new controller reads the broker list, every broker znode,
// the topic list, every topic znode and every partition state znode
static long zooKeeperReads(int brokers, int topics, long partitions) {
return 1 + brokers + 1 + topics + partitions;
}
public static void main(String[] args) {
List<MetadataRecord> log = List.of(
new BrokerRegistered(101), new BrokerRegistered(102), new BrokerRegistered(103),
new TopicCreated("orders", 6), new TopicCreated("payments", 4),
new TopicCreated("audit", 2),
new LeaderChanged("orders", 0, 102), new LeaderChanged("payments", 3, 103));
// A standby controller follows the leader; here it is 2 records behind
MetadataImage standby = new MetadataImage();
log.subList(0, log.size() - 2).forEach(standby::apply);
System.out.println("Standby has applied " + standby.appliedRecords + " of " + log.size() + " records");
// Active controller fails: the standby only replays the missing tail
List<MetadataRecord> tail = log.subList(standby.appliedRecords, log.size());
tail.forEach(standby::apply);
System.out.println("KRaft failover: replayed " + tail.size() + " records -> "
+ standby.leaders.size() + " partitions ready");
System.out.println(" orders-0 leader = " + standby.leaders.get("orders-0")
+ ", payments-3 leader = " + standby.leaders.get("payments-3"));
System.out.println("ZooKeeper failover: " + zooKeeperReads(3, 3, 12)
+ " znode reads before the new controller can act");
// Scale up: ZooKeeper work grows with partitions, KRaft work does not
System.out.println("At 200,000 partitions: ZooKeeper reads = "
+ zooKeeperReads(30, 2_000, 200_000) + ", KRaft replay = " + tail.size() + " records");
}
}
Standby has applied 6 of 8 records
KRaft failover: replayed 2 records -> 12 partitions ready
orders-0 leader = 102, payments-3 leader = 103
ZooKeeper failover: 20 znode reads before the new controller can act
At 200,000 partitions: ZooKeeper reads = 202032, KRaft replay = 2 records
The ZooKeeper count (broker list, each broker, topic list, each topic, each partition's state) grows with every partition. The KRaft count only depends on how far behind the standby was, which is rarely more than a few records.
4.2 Example 2: Finding the Real Active Controller
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.kafka.clients.admin.Admin;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.DescribeClusterResult;
import org.apache.kafka.common.Node;
public class ControllerIdCheck {
public static void main(String[] args) throws Exception {
// Normal clients talk to brokers
Map<String, Object> viaBrokers = Map.of(
AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "b4:9092,b5:9092");
// Admin clients can also talk to the controllers directly (Kafka 3.7+)
Map<String, Object> viaControllers = Map.of(
AdminClientConfig.BOOTSTRAP_CONTROLLERS_CONFIG, "c1:9093,c2:9093,c3:9093");
try (Admin brokers = Admin.create(viaBrokers);
Admin controllers = Admin.create(viaControllers)) {
int raftLeader = brokers.describeMetadataQuorum().quorumInfo().get().leaderId();
System.out.println("Raft leader (real active controller): " + raftLeader);
print("bootstrap.servers ", brokers.describeCluster());
print("bootstrap.controllers", controllers.describeCluster());
}
}
private static void print(String label, DescribeClusterResult cluster) throws Exception {
Node controller = cluster.controller().get();
String nodes = cluster.nodes().get().stream()
.map(Node::id).sorted().map(String::valueOf)
.collect(Collectors.joining(",", "[", "]"));
System.out.println(label + " -> controller()=" + controller.id() + ", nodes()=" + nodes);
}
}
Raft leader (real active controller): 2
bootstrap.servers -> controller()=5, nodes()=[4,5]
bootstrap.controllers -> controller()=2, nodes()=[1,2,3]
Asked through the brokers, describeCluster().controller() returns broker 5, which is not a controller at all. In KRaft mode, brokers report an alive broker here and forward "controller" requests to the real one. Only describeMetadataQuorum() or a client using bootstrap.controllers (Kafka 3.7+) reports node 2.
4.3 Example 3: Watching a KRaft Failover
import java.util.Map;
import org.apache.kafka.clients.admin.Admin;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.QuorumInfo;
public class FailoverWatch {
public static void main(String[] args) throws Exception {
Map<String, Object> config = Map.of(
AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "b4:9092,b5:9092",
AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, 2000,
AdminClientConfig.DEFAULT_API_TIMEOUT_MS_CONFIG, 2000);
try (Admin admin = Admin.create(config)) {
long start = System.currentTimeMillis();
int lastLeader = -2;
// Poll the quorum for 20 seconds and print every leader change
while (System.currentTimeMillis() - start < 20_000) {
long t = System.currentTimeMillis() - start;
try {
QuorumInfo q = admin.describeMetadataQuorum().quorumInfo().get();
if (q.leaderId() != lastLeader) {
System.out.printf("t=%5d ms leader=%d epoch=%d highWatermark=%d%n",
t, q.leaderId(), q.leaderEpoch(), q.highWatermark());
lastLeader = q.leaderId();
}
} catch (Exception e) {
System.out.printf("t=%5d ms no answer: %s%n", t, e.getCause().getClass().getSimpleName());
lastLeader = -1;
}
Thread.sleep(200);
}
}
}
}
t= 0 ms leader=2 epoch=3 highWatermark=248
t= 6958 ms no answer: TimeoutException
t= 9180 ms leader=3 epoch=5 highWatermark=262
During the run, controller 2 was killed with docker kill, a hard crash. The others noticed when the leader went silent for controller.quorum.fetch.timeout.ms (default 2 seconds) and held an election. The epoch jumped from 3 to 5 because, as the controller logs showed, the round at epoch 4 split the votes and a second round elected node 3. Within seconds there was a new active controller. Your timings will differ.
5. ZooKeeper vs KRaft Comparison
| ZooKeeper mode | KRaft mode | |
|---|---|---|
| Extra system | A ZooKeeper ensemble to run, secure and monitor | None |
| Where metadata lives | Znodes in ZooKeeper, plus a copy in the controller's memory | __cluster_metadata log on the controllers |
| Controller | One broker, elected through the /controller znode | Raft leader of the controller quorum |
| Controller failover | Reloads all metadata from ZooKeeper | Standby is already up to date |
| How brokers learn changes | Controller pushes requests | Brokers fetch the log |
| Broker liveness | ZooKeeper session, default timeout 18 s | Heartbeats to the controller, default timeout 9 s |
| Kafka versions | Up to 3.9 (deprecated since 3.5) | Production-ready since 3.3, only mode in 4.0+ |
6. Common Mistakes and Pitfalls
- Trusting
describeCluster().controller()in KRaft. As Example 2 shows, it returns a broker, so old dashboards quietly report the wrong node. - Upgrading straight from a ZooKeeper cluster to 4.x. Kafka 4.x cannot read ZooKeeper metadata. Migrate to KRaft on 3.x first. Version 3.9 is the recommended bridge release.
- Pointing clients at controllers.
bootstrap.serversmust list brokers. Controllers do not serve produce or fetch requests. - Reusing a
node.id. Every node in the cluster, broker or controller, needs a unique ID. Plan separate ranges for controllers and brokers. - Formatting nodes with different cluster IDs. A node whose storage was formatted with another ID refuses to start.
- Running combined mode in production. With
process.roles=broker,controller, heavy broker traffic can delay heartbeats and elections. It is fine for development.
7. Best Practices
- Run three controllers (five for critical clusters) on separate machines or racks. Three survive one failure; five survive two.
- Use dedicated controller nodes in production, so busy brokers cannot slow the quorum.
- Monitor the quorum with
kafka-metadata-quorum.sh describe --statusand watch broker metadata lag. - Put the metadata log on fast, reliable disks, separate from data if needed (
metadata.log.dir). - Rehearse migrations on a test cluster, including a rollback, before touching production.
8. Summary
- ZooKeeper vs KRaft is a choice of where Kafka keeps its metadata: in an external ZooKeeper ensemble, or in Kafka's own Raft log.
- ZooKeeper mode uses one broker as the controller, and it must reload every partition's state after a failover.
- KRaft uses a controller quorum, a single-partition
__cluster_metadatalog and hot standbys, so failover takes seconds. - Use
describeMetadataQuorum()orbootstrap.controllers, notdescribeCluster().controller(), to find the active controller. - Kafka 4.0 removed ZooKeeper, so migrate on 3.x (ideally 3.9) before upgrading.
ZooKeeper vs KRaft: Interview Questions
Common interview questions on ZooKeeper vs KRaft, from basic to scenario-based, with short answers.
Basic questions
Q. What replaces broker.id in KRaft mode?
Answer: node.id identifies every KRaft node, brokers and controllers alike, and it must be unique across the whole cluster. If you start a broker with node.id=1 while 1 is listed in controller.quorum.voters, Kafka refuses to start it with "the node id 1 must not be included in the set of voters". Plan one ID range for controllers (for example 1–3) and another for brokers.
Q. Why does __cluster_metadata have only one partition?
Answer: Metadata changes depend on each other: a partition cannot get a leader before its topic exists. A single partition gives every change one global order, so any node that replays the log from the start ends up with exactly the same state. Metadata is small, so one partition is never a throughput limit.
Q. Can you read the __cluster_metadata topic with a normal consumer?
Answer: No. The topic is managed by the Raft layer, not as a normal topic, so consumers cannot fetch it. The brokers answer with UNKNOWN_TOPIC_OR_PARTITION. To inspect it, run kafka-dump-log.sh --cluster-metadata-decoder --files <segment>.log on a controller, or open a log or snapshot file with kafka-metadata-shell.sh --snapshot <file>.
Intermediate questions
Q. What is a metadata snapshot in KRaft and when is one written?
Answer: A snapshot is a compact copy of the whole metadata state at a given log offset. A new or restarted node loads the latest snapshot and then replays only the records after it. By default, a snapshot is written after about 20 MB of new metadata records (metadata.log.max.record.bytes.between.snapshots) or after one hour (metadata.log.max.snapshot.interval.ms), whichever comes first.
Q. How did ZooKeeper mode stop an old controller from making changes after a new one was elected?
Answer: Each new controller incremented a controller epoch stored in ZooKeeper, and brokers ignored requests carrying an older epoch. This protected against a "zombie" controller, for example one that paused for a long garbage collection and came back still believing it was in charge. KRaft does the same with the Raft leader epoch, which you saw jump from 3 to 5 in Example 3.
Q. What is migration "dual-write" mode?
Answer: During a ZooKeeper-to-KRaft migration on Kafka 3.6 to 3.9, the new KRaft controllers become active while the brokers still run in ZooKeeper mode. The controllers write every metadata change to their log and also copy it back to ZooKeeper. Because ZooKeeper stays up to date, you can roll back to ZooKeeper mode at any point until you finalize the migration.
Q. What happens to a broker that cannot reach the active controller?
Answer: Its heartbeats stop arriving, so after broker.session.timeout.ms (9 seconds by default) the controller fences it. The controller then moves leadership of the broker's partitions to other in-sync replicas. When the broker reconnects and catches up with the metadata log, it is unfenced and can lead partitions again.
Tricky and scenario questions
Q. Two of your three controllers are down. What still works?
Answer: The quorum has lost its majority, so no metadata can change. You cannot create topics, elect new leaders or shrink an ISR. Brokers keep serving produce and fetch requests for existing partitions, using the metadata they already have. But if a follower falls behind, the leader cannot remove it from the ISR, so acks=all writes to that partition may start timing out.
Q. Why is a 4-controller quorum no better than a 3-controller quorum?
Answer: Raft needs a majority. With 4 voters the majority is 3, so the quorum still survives only one failure, the same as with 3 voters. The extra controller only raises the number of votes each metadata write needs, from 2 to 3, without adding any fault tolerance. Use an odd number: 3 or 5.
Q. After moving to KRaft, a monitoring alert that fires "when the controller changes" goes off after broker restarts, but not when a controller fails. Why?
Answer: The alert probably reads describeCluster().controller() through the brokers. In KRaft, that call returns an alive broker, so the reported ID can change when brokers restart, even though the real controller did not. Read the leader ID from describeMetadataQuorum() instead:
int leader = admin.describeMetadataQuorum().quorumInfo().get().leaderId();