Apache Kafka: A Working Introduction

Kafka is a log, not a queue. Once that lands, partitions, consumer groups, offsets and every delivery guarantee stop being trivia and start being consequences.

producer p0 p1 p2 consumer A consumer B offset increases → lag
1
Consumer per partition, per group
7d
Default topic retention
1GB
Default log segment size
0
ZooKeeper nodes in Kafka 4.x

Apache Kafka is a distributed, append-only log. Producers append records to the end of a topic; consumers read forward from a position they control, and nothing is removed when they read it. A topic is split into partitions, and the partition is the unit of ordering, storage and parallelism: records inside one partition are strictly ordered by an increasing offset, and records in different partitions have no defined order relative to each other. A consumer group divides a topic's partitions among its members, with each partition going to at most one member, so the partition count sets a hard ceiling on how many consumers can work at once. Kafka keeps records for a configured retention period (seven days by default) whether or not anyone has read them, which is what makes replay possible and what separates a log from a queue. Every other behavior in this guide follows from those four facts.

1
Consumer per partition inside a group. That is the ceiling on parallelism
7d
Default retention.ms. Records age out on a clock, not on acknowledgement
4.0
The release that removed ZooKeeper. KRaft is now the only mode

Key Takeaways

What follows is the model, a broker you can run in five minutes, working code, and an honest account of when a queue or a database table serves better. Defaults are those of Kafka 4.x.

01

What Kafka Actually Is: An Append-Only Log

A Kafka topic is a durable, ordered, replayable file that many readers scan independently. Writes only go to the end, reads are sequential scans from a position the reader owns, and there is no per-message acknowledgement.

Most confusion about Kafka comes from importing queue vocabulary. In a queue a message has a lifecycle: enqueued, delivered, acknowledged, gone. In Kafka a record has a location, partition 3 offset 918,442, and it stays there until retention expires. Any number of unrelated consumer groups read it at their own pace without affecting each other.

That buys sequential disk writes, free replay, and the ability to add a downstream service by starting a new group at the earliest offset with no coordination from the producer. It costs the queue features: no per-message retry, no built-in dead-letter routing, and head-of-line blocking whenever one slow record sits at the front of a partition.

The Physical Layout

02

Topics, Partitions, and the Partition Key

The record key decides the partition. The default partitioner hashes the key modulo the partition count, so every record with the same key lands on the same partition and stays ordered. Keyless records are spread across partitions in batches.

This is the most useful lever in Kafka. If ordering matters per bank account, key on the account id. You get ordered processing per entity and full parallelism across entities, a far better trade than a single-partition topic that orders everything and scales to one consumer.

Two consequences catch people. Partition counts only increase. And increasing them changes the hash result for existing keys, so a key that lived on partition 2 may start landing on partition 5 while its older records sit on partition 2. Plan the count once, at creation.

Creating a topic with durability settings that mean something
/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 \ --create --topic payments.events \ --partitions 12 \ --replication-factor 3 \ --config min.insync.replicas=2 \ --config retention.ms=1209600000 \ --config compression.type=zstd # inspect it: leader, replicas, and which replicas are in sync /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 \ --describe --topic payments.events # partitions can be raised, never lowered /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 \ --alter --topic payments.events --partitions 24

Real durability is replication factor 3, min.insync.replicas=2, and a producer using acks=all: a write is confirmed only once the leader and at least one follower hold it. Setting acks=all while leaving min.insync.replicas at its default of 1 is the most common false sense of safety in a new cluster, because the leader alone satisfies "all in-sync replicas" when it is the only one.

03

Run a Kafka Broker Locally with KRaft

Kafka 4.0 removed ZooKeeper. Metadata lives in a Raft-replicated internal log managed by controller nodes, and 3.9 was the last release supporting the old arrangement. A single-node broker that plays both roles is one Compose file.

The official apache/kafka image ships the standard scripts under /opt/kafka/bin, so what you learn locally transfers to a real cluster. This configuration combines the broker and controller roles, which is fine for development and wrong for production, where three dedicated controllers are the norm.

compose.yaml: a single-node KRaft broker
services: kafka: image: apache/kafka:4.0.0 container_name: kafka ports: - "9092:9092" environment: KAFKA_NODE_ID: 1 KAFKA_PROCESS_ROLES: broker,controller KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT" KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093" # single node: internal topics cannot replicate further than this KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 KAFKA_NUM_PARTITIONS: 3
Start it, then prove the round trip works
docker compose up -d # produce a few keyed records; type key:value lines, Ctrl-D to end docker exec -it kafka /opt/kafka/bin/kafka-console-producer.sh \ --bootstrap-server localhost:9092 --topic payments.events \ --property parse.key=true --property key.separator=: # read the whole log from the beginning, showing key and partition docker exec -it kafka /opt/kafka/bin/kafka-console-consumer.sh \ --bootstrap-server localhost:9092 --topic payments.events \ --from-beginning --property print.key=true \ --property print.partition=true # the single most useful operational command in Kafka docker exec -it kafka /opt/kafka/bin/kafka-consumer-groups.sh \ --bootstrap-server localhost:9092 --describe --group billing-service

That last command prints current offset, log end offset and lag for every partition the group owns. Lag is the metric that tells you whether a consumer is healthy: steady growth means it cannot keep up, while a sawtooth usually means rebalances. If you run Kafka in containers, the Docker fundamentals guide covers the volume and networking details worth settling before you put state in one.

04

Producing and Consuming in Python

The confluent-kafka package wraps the C library and is the fastest and most complete Python client. A producer is asynchronous by default: produce() puts a record in an internal buffer, and only a delivery callback tells you whether the broker accepted it.

Forgetting that asynchrony is the first bug in most Kafka programs. A script that produces a thousand records and exits without calling flush() sends some fraction of them and drops the rest with no error at all.

producer.py: durable settings and a delivery callback
import json from confluent_kafka import Producer conf = { "bootstrap.servers": "localhost:9092", # defaults since Kafka 3.0, stated here because they matter "enable.idempotence": True, # dedupes producer retries "acks": "all", # wait for the in-sync replicas "compression.type": "zstd", "linger.ms": 20, # batch for 20 ms; big throughput win "batch.size": 131072, } producer = Producer(conf) def on_delivery(err, msg): if err is not None: # the ONLY place a produce failure surfaces print(f"failed: {err}") else: print(f"{msg.topic()}[{msg.partition()}]@{msg.offset()}") for account_id in ("acct-1001", "acct-1002", "acct-1001"): producer.produce( topic="payments.events", key=account_id.encode(), # key decides the partition value=json.dumps({"account": account_id, "amount": 42}).encode(), on_delivery=on_delivery, ) producer.poll(0) # serve delivery callbacks producer.flush(10) # block until the buffer drains. Never skip this.
consumer.py: manual commits, which is the only safe default
from confluent_kafka import Consumer, KafkaError consumer = Consumer({ "bootstrap.servers": "localhost:9092", "group.id": "billing-service", "enable.auto.commit": False, # default is True. Turn it off. "auto.offset.reset": "earliest", # default "latest" skips history "max.poll.interval.ms": 300000, }) consumer.subscribe(["payments.events"]) try: while True: msg = consumer.poll(1.0) if msg is None: continue if msg.error(): if msg.error().code() != KafkaError._PARTITION_EOF: print(f"consume error: {msg.error()}") continue handle(msg.key(), msg.value()) # must be idempotent consumer.commit(msg, asynchronous=False) # commit AFTER work finally: consumer.close() # leaves the group cleanly, avoids a stall

Committing after the work is what makes this at-least-once. If the process dies between handle() and commit(), the record is redelivered, so handle() has to tolerate seeing it twice. Committing first would be at-most-once: faster, and it loses records on every crash.

05

Consumer Groups, Offsets, and Rebalancing

A consumer group is a set of processes sharing a group.id. Kafka assigns each partition to exactly one member and keeps the group's committed offsets in the internal __consumer_offsets topic. Any membership change triggers a rebalance.

Three rules cover most of it. A consumer beyond the partition count sits idle. Two groups reading one topic are fully independent, each with its own offsets. And a rebalance fires on joins, leaves, crashes, and on a member that took too long between calls to poll().

That last trigger causes the classic rebalance storm. If processing exceeds max.poll.interval.ms, five minutes by default, the broker declares the member dead and reassigns its partitions. The member finishes, tries to commit, is told it no longer owns the partition, and rejoins, starting another rebalance. The fix is not a longer timeout: lower max.poll.records from its default of 500, or move slow work off the polling thread.

Setting Default What it controls
max.poll.records 500 Batch size per poll. Lower this first when processing is slow
max.poll.interval.ms 300000 Time allowed between polls before eviction
session.timeout.ms 45000 Heartbeat deadline. Detects crashes
auto.offset.reset latest Where a new group starts. latest skips history
enable.auto.commit true Commits on a timer regardless of work

The Rebalance Protocol Changed

Kafka 4.0 made the broker-coordinated consumer group protocol generally available. Instead of one member computing an assignment for everyone during a stop-the-world pause, the broker's coordinator assigns incrementally and members keep the partitions they retain. Opt in with group.protocol=consumer; on the older protocol the cooperative sticky assignor is close.

06

Delivery Semantics: At-Most-Once to Exactly-Once

Kafka offers three levels, and the choice comes down to where you commit the offset. At-most-once commits before processing. At-least-once commits after. Exactly-once uses transactions to make the write and the offset commit one atomic operation, and works only when both ends are Kafka.

Exactly-once in Kafka means exactly-once processing, not exactly-once delivery to arbitrary systems. The idempotent producer gives each producer a session id and a per-partition sequence number, so the broker discards duplicates from retries. Transactions extend that: the producer writes to one or more topics and commits the consumed offsets in the same transaction. Consumers set to read_committed never see aborted records.

A transactional read-process-write loop
from confluent_kafka import Producer, Consumer producer = Producer({ "bootstrap.servers": "localhost:9092", "transactional.id": "enricher-1", # stable per instance }) producer.init_transactions() consumer = Consumer({ "bootstrap.servers": "localhost:9092", "group.id": "enricher", "enable.auto.commit": False, "isolation.level": "read_committed", # skip aborted records }) consumer.subscribe(["payments.events"]) while True: batch = consumer.consume(num_messages=200, timeout=1.0) if not batch: continue producer.begin_transaction() for msg in batch: producer.produce("payments.enriched", key=msg.key(), value=enrich(msg.value())) # offsets are committed INSIDE the transaction: all or nothing producer.send_offsets_to_transaction( consumer.position(consumer.assignment()), consumer.consumer_group_metadata(), ) producer.commit_transaction()
Where it stops

The Kafka boundary

The moment a consumer writes to Postgres, charges a card or sends an email, the transactional guarantee ends. Kafka cannot roll back an HTTP call.

What to do instead

Idempotent writes

Give each record a stable business key and make the sink upsert. Duplicates become harmless no-ops, which is cheaper and simpler than transactions.

Kafka Streams packages that loop behind processing.guarantee=exactly_once_v2, the right choice when a job reads Kafka and writes Kafka. Everywhere else, at-least-once with idempotent handlers survives production. The distributed systems guide covers why the stronger guarantee is hard to extend past one system.

07

Retention, Log Compaction, and Tiered Storage

Two cleanup policies exist. delete removes whole segments once they exceed retention.ms or retention.bytes. compact keeps the most recent value for every key forever, turning the topic into a durable changelog of current state.

Time-based deletion is the default and applies per segment, not per record, so a record survives until its segment ages out. Both limits are active and whichever fires first wins. retention.bytes is unlimited by default; setting it is the simplest protection against a topic filling a disk.

Compaction is a different tool. Send a key and a value to set state; send the same key with a null value, a tombstone, to delete it. The cleaner collapses the log so only the latest value per key remains, so a consumer reading from offset zero rebuilds current state without the full history. This is how Kafka stores its own consumer offsets and how change-data-capture topics are configured.

A compacted state topic and a bounded event topic
# current state per customer, kept indefinitely /opt/kafka/bin/kafka-configs.sh --bootstrap-server localhost:9092 \ --entity-type topics --entity-name customers.state --alter \ --add-config cleanup.policy=compact,min.cleanable.dirty.ratio=0.1,delete.retention.ms=86400000 # raw events, 14 days or 500 GB per partition, whichever comes first /opt/kafka/bin/kafka-configs.sh --bootstrap-server localhost:9092 \ --entity-type topics --entity-name payments.events --alter \ --add-config retention.ms=1209600000,retention.bytes=536870912000,segment.bytes=536870912 # check what a topic is really running with /opt/kafka/bin/kafka-configs.sh --bootstrap-server localhost:9092 \ --entity-type topics --entity-name payments.events --describe

Long retention used to force a choice between broker disk and lost history. Tiered storage, production-ready in the 3.9 line, moves closed segments to object storage while recent data stays local, so a topic can hold months of history at object-storage prices. Cold reads are slower: right for backfills, wrong for a latency-sensitive consumer.

08

Schema Registry: The Contract Between Services

A schema registry stores versioned schemas, hands out an integer id for each, and enforces a compatibility rule before accepting a new version. Serializers prefix every payload with a magic byte and that four-byte id, so a consumer fetches the exact writer schema.

Kafka moves opaque bytes and has no opinion about their shape. That works until a producer adds a required field and three downstream consumers fail at once, at three in the morning, with a deserialization error that names no owner. A registry turns that outage into a rejected deploy.

Compatibility modes are the actual product. BACKWARD, the default, means a consumer on the new schema can read data written with the previous one, which lets you upgrade consumers first. FORWARD means old consumers can read new data, so producers move first. FULL requires both, and the _TRANSITIVE variants check every earlier version rather than only the last. Under BACKWARD, adding an optional field with a default is allowed; removing a required field is not.

Avro production against a registry with confluent-kafka
from confluent_kafka import Producer from confluent_kafka.schema_registry import SchemaRegistryClient from confluent_kafka.schema_registry.avro import AvroSerializer from confluent_kafka.serialization import SerializationContext, MessageField SCHEMA = """ {"type": "record", "name": "Payment", "fields": [ {"name": "account", "type": "string"}, {"name": "amount_cents", "type": "long"}, {"name": "currency", "type": "string", "default": "USD"} ]} """ sr = SchemaRegistryClient({"url": "http://localhost:8081"}) serializer = AvroSerializer(sr, SCHEMA) producer = Producer({"bootstrap.servers": "localhost:9092"}) record = {"account": "acct-1001", "amount_cents": 4200, "currency": "USD"} producer.produce( topic="payments.events", key=record["account"].encode(), value=serializer(record, SerializationContext("payments.events", MessageField.VALUE)), ) producer.flush() # the schema is registered under subject "payments.events-value". # a later version that drops a field is REJECTED under BACKWARD.

Confluent Schema Registry is the reference implementation; Apicurio Registry and AWS Glue Schema Registry offer the same service under different licenses. Whichever you run, register schemas from CI, so an incompatible change fails a pull request rather than a deployment.

09

What Breaks First in a New Kafka Deployment

New clusters fail in a small, predictable set of ways, and almost none of them are broker problems. They are defaults chosen for compatibility rather than for correctness.

The Recurring Nine

Three numbers cover most of the operational risk: consumer group lag per partition, under-replicated partition count, and broker request handler idle ratio. Rising lag is a capacity or code problem. Under-replicated partitions above zero for more than a moment means a broker or a disk is failing.

10

Kafka vs RabbitMQ vs SQS vs a Postgres Table

The honest comparison is not about throughput, since all four handle more than most applications produce. It is about replay, per-message acknowledgement, and operational surface.

Capability Kafka RabbitMQ Amazon SQS Postgres table
Replay after consumption Any offset Gone on ack Gone on delete Only if rows are kept
Many independent readers One group each Fanout exchange, copies SNS fanout to queues Cursor per reader
Ack a single message No, offsets only Yes Yes Yes, one row
Dead-letter handling Build it yourself Built in Built in A status column
Ordering Total per partition Lost on redelivery FIFO queues, per group Whatever you order by
Consumer scaling limit Partition count Unbounded Unbounded Row lock contention
Operational cost Brokers and rebalances One clustered service None, it is managed A database you already run
Best fit Streams several teams read Task routing with retries Background jobs on AWS Jobs that share your data

The Postgres column deserves more credit than it usually gets. SELECT ... FOR UPDATE SKIP LOCKED turns an ordinary table into a competing-consumer queue with per-row acknowledgement, retries, dead-lettering and SQL visibility, inside the same transaction as your business writes. That last property is the one Kafka cannot offer: no dual-write problem, no outbox.

A working queue in one Postgres table
CREATE TABLE jobs ( id bigserial PRIMARY KEY, payload jsonb NOT NULL, status text NOT NULL DEFAULT 'pending', attempts int NOT NULL DEFAULT 0, run_after timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ON jobs (run_after) WHERE status = 'pending'; -- each worker claims rows nobody else has locked WITH claimed AS ( SELECT id FROM jobs WHERE status = 'pending' AND run_after <= now() ORDER BY run_after LIMIT 20 FOR UPDATE SKIP LOCKED ) UPDATE jobs j SET status = 'running', attempts = attempts + 1 FROM claimed c WHERE j.id = c.id RETURNING j.id, j.payload;

Throughput depends on row size, index count and how long each worker holds its transaction. The PostgreSQL performance guide covers the index and vacuum behavior that decides where the ceiling falls, since a queue table generates dead tuples faster than almost anything else.

Redis Streams sits in the middle: consumer groups and per-message acknowledgement at low latency, bounded by memory rather than disk. Kafka 4.0 also shipped share groups as an early-access feature, queue-style competing consumers on top of the log; watch it rather than design around it.

11

When Not to Use Kafka

Kafka is the wrong tool when you need per-message retry semantics, when one team is both the only producer and the only consumer, or when the data must stay transactionally consistent with a database.

The clearest signal is the shape of failure handling. If a single bad record should be retried with backoff and eventually parked without holding up anything behind it, that is queue behavior, and building it on Kafka means retry topics, a dead-letter topic, and code to move records between them.

The second signal is ownership. A service publishing to itself gains nothing from a log and gains a cluster to patch, monitor and upgrade. Kafka earns its cost when several teams read the same stream at different speeds, when replay has real value, or when retention is itself the feature, as in an audit trail. If none of those apply, use a table, a managed queue, or a scheduled batch pipeline.

The Order to Work In

A Sane Path to a First Production Topic

The bottom line: Kafka is a log with a partition-based concurrency model attached, and every guarantee it offers or withholds comes from that. Get the key and the partition count right, commit offsets after the work, put a schema contract in front of the topic, and it runs quietly for years. Reach for it because you need replay and independent readers.

Frequently Asked Questions

What is the difference between Kafka and a message queue like RabbitMQ?

A queue removes a message once a consumer acknowledges it. Kafka removes nothing on read: it keeps every record for the retention period, and each consumer group tracks its own position. Kafka fits when you need replay, several independent readers, or a consumer added six months later. A queue fits when you need per-message acknowledgement, redelivery of one failed message without blocking the records behind it, and a dead-letter queue, none of which Kafka provides.

How many partitions should a Kafka topic have?

Start from the parallelism you need, not the throughput you hope for. Each partition goes to at most one consumer inside a group, so the partition count caps concurrent consumer instances. Pick slightly above expected peak, commonly 6 to 24 for an application topic. You can add partitions but never remove them, and adding them changes which partition a key hashes to, breaking per-key ordering across the change.

Does Kafka guarantee message ordering?

Kafka guarantees order within a single partition and nothing across partitions. Records in one partition are read exactly in write order, identified by an increasing offset. To order a business entity, set a record key such as the account or device id: the default partitioner hashes the key, so those records share a partition. Different keys may be processed in any relative order.

Can Kafka really do exactly-once delivery?

Kafka supports exactly-once processing inside Kafka, which is narrower than exactly-once delivery to anything else. The idempotent producer, on by default since Kafka 3.0, drops duplicates from producer retries. Transactions commit records and consumer offsets atomically, so a read-process-write loop is exactly-once when both ends are Kafka and readers set isolation.level=read_committed. As soon as a consumer writes to a database or calls an API, the guarantee stops; use at-least-once plus an idempotent write.

How long does Kafka keep messages?

By default a topic keeps records for seven days, set by retention.ms, and deletes them whether or not anyone read them. retention.bytes caps by size and is unlimited by default; whichever fires first wins. Deletion is per log segment, so a record survives until its whole segment ages out. Log compaction, cleanup.policy=compact, instead keeps the latest value per key indefinitely.

Do I still need ZooKeeper to run Kafka?

No. Kafka 4.0 removed ZooKeeper and runs only in KRaft mode, where controller nodes store cluster metadata in an internal Kafka log. Version 3.9 was the last release supporting ZooKeeper and is the bridge release for migrating an older cluster. Format a storage directory with a cluster id, set the controller quorum, and start. Production clusters run three dedicated controllers.

References: Apache Kafka documentation, Kafka design notes, Broker, producer and consumer configuration reference, Confluent Schema Registry documentation

Explore More Guides

The Bottom Line
Kafka is a log with a partition-shaped concurrency model attached. Every guarantee it gives you, and every one it refuses, falls out of that single design choice.
PA
Our Take

Most Kafka problems are schema problems wearing a broker costume.

Teams adopting Kafka spend their first month on cluster sizing and their next year on data contracts. The cluster is the easy part now. KRaft removed an entire external dependency, managed offerings removed the rest, and a three-broker cluster with sensible replication settings is genuinely boring to run. What stays hard is that a topic is a public interface with no compiler behind it, consumed by teams who will not tell you before they deploy.

The second recurring theme is that partition count is treated as a performance knob when it is really a concurrency decision. Choosing 3 because it looked reasonable, then discovering that peak load needs 20 consumers, is a migration rather than a config change, because the fix reshuffles key placement. Deciding the partition count from the consumer side, and doing it before the first record is written, removes a class of problem that is otherwise very expensive to unwind.

Where we would push back on common practice: reach for exactly-once far less often. The transactional machinery is well built and genuinely correct within Kafka, and it also adds coordinator state, a new class of timeout, and a guarantee that quietly evaporates at the first external write. An idempotent handler with a unique business key gets the same practical outcome, and a new engineer can reason about it on their first day.

PA

About the Publisher

Precision AI Academy

Practitioner-focused AI education · tech news, guides, and 145 free courses

Precision AI Academy publishes deep-dives on applied AI engineering for working professionals. Founded by Bo Peng — Kaggle Top 200 data scientist and former university instructor.

Kaggle Top 200 Federal AI Practitioner 5 U.S. Cities Thu–Fri Cohorts