Streaming with Apache Kafka
Run Kafka locally in Docker and learn topics, partitions, keys, offsets and consumer groups by building a Python producer and consumer for Brew & Bean order events. Understand ordering and at-least-once delivery.
What you will build: a one-machine Kafka setup in Docker, a topic for Brew & Bean order events, a Python producer that publishes status changes, and a Python consumer in a consumer group that reads them. You will see partitions, keys, offsets and consumer groups working.
Prerequisites: Python, the command line and Docker Desktop (or Docker Engine) installed. No Java install is needed because Kafka runs inside the container. Read *ETL vs ELT and pipeline design* first if batch versus streaming is new to you.
Why Kafka
So far the pipelines in this track have been batch: run once an hour or day on a pile of data. Many things cannot wait: fraud checks, live order screens in a kitchen, delivery tracking, live dashboards. They need to react to events (an order was placed, a payment failed) within seconds.
Apache Kafka is a distributed event log. Programs called producers append events to it. Programs called consumers read them, each at their own pace. Kafka stores events on disk for a configurable time, so a slow or crashed consumer can catch up and a new consumer can replay history. It decouples systems: the ordering app does not need to know that the kitchen screen, the analytics pipeline and the SMS service all want its events. It just publishes them.
Think of a notice board with a numbered sequence of notes. Anyone can pin a note at the end. Each reader keeps their own bookmark and reads forward from it. Notes stay on the board for a while. Nobody removes a note because someone read it.
The core ideas
Kafka vocabulary
| Term | Meaning | Brew & Bean example |
|---|---|---|
| Event / record | A key, a value (usually JSON or Avro), a timestamp | {"order_id": 101, "status": "ready"} |
| Topic | A named stream of events, like a table of an append-only log | orders |
| Partition | A topic is split into partitions; each is an ordered log | orders has 3 partitions |
| Offset | The position of an event in a partition (0, 1, 2, ...) | Partition 1, offset 5 |
| Key | Decides the partition: same key always goes to the same partition | order_id |
| Producer | Writes events | The order service |
| Consumer | Reads events | Kitchen screen, analytics job |
| Consumer group | Consumers sharing one group.id split the partitions between them | Group kitchen |
| Broker | A Kafka server; a cluster has several | One container here |
Two rules explain most Kafka behaviour.
Order is guaranteed only within a partition. Events for different partitions have no global order. That is why we use the order id as the key: all events for order 101 (received, preparing, ready) go to the same partition and are read in that order.
Within a consumer group, each partition is read by exactly one consumer. If the orders topic has 3 partitions and a group has 3 consumers, each reads one partition, so you scale reading by adding consumers (up to the number of partitions). A different group, say analytics, independently reads all the events again. A second consumer beyond the partition count would sit idle.
Kafka has two other pieces you will see later: replication (each partition is copied to other brokers so a broker can fail without data loss) and Kafka Connect (ready-made connectors that copy data between Kafka and databases, S3 and so on). Older Kafka versions needed a separate ZooKeeper service; current versions use a built-in mechanism called KRaft, which is what the Docker image uses.
Start Kafka
- 1Make sure Docker is running (
docker versionshould show a client and a server). - 2Start a single-node Kafka broker:
docker run -d --name kafka -p 9092:9092 apache/kafka:latest. This is the official image, which starts a ready-to-use broker listening on port 9092 with no further configuration. For a repeatable setup, replacelatestwith a specific version tag from the Docker Hub page forapache/kafka. - 3Wait about 10 seconds, then check the logs:
docker logs kafka --tail 5. You should see the broker has started. - 4From now on, the Kafka command-line tools are inside the container in
/opt/kafka/bin.
docker exec kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--create --topic orders --partitions 3 --replication-factor 1
docker exec kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--describe --topic ordersCreates the orders topic with 3 partitions (replication factor 1 because we have one broker) and shows its layout. Lines ending with a backslash continue the command; in PowerShell put the whole command on one line.
Topic: orders TopicId: ... PartitionCount: 3 ReplicationFactor: 1
Topic: orders Partition: 0 Leader: 1 Replicas: 1 Isr: 1
Topic: orders Partition: 1 Leader: 1 Replicas: 1 Isr: 1
Topic: orders Partition: 2 Leader: 1 Replicas: 1 Isr: 1Single broker is for learning only
A replication factor of 1 means a lost broker means lost data. That is fine for learning. Production topics normally use a replication factor of 3 on a cluster of at least 3 brokers.
Try the command-line tools
Open two terminals. In the first, start a consumer that reads from the beginning of the topic. In the second, start a producer and type some lines.
# Terminal 1: consumer
docker exec -it kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic orders --from-beginning
# Terminal 2: producer (type a line and press Enter; Ctrl+C to stop)
docker exec -it kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 --topic ordersAnything you type into the producer appears in the consumer within a moment. Stop both with Ctrl+C.
This works, but the messages have no keys and are just text. In real systems your own programs produce and consume. Let us write them in Python.
Write a producer
- 1Create a folder
kafka-demo, activate a virtual environment and runpip install confluent-kafka. This is the Python client from Confluent, built on the widely used librdkafka library. - 2Create
producer.pywith the code below. - 3Run it with
python producer.pyand watch each message get a partition and an offset.
import json
import time
from confluent_kafka import Producer
producer = Producer({"bootstrap.servers": "localhost:9092"})
def report(err, msg):
"""Called once Kafka confirms (or rejects) each message."""
if err is not None:
print(f"FAILED: {err}")
else:
print(f"sent to partition {msg.partition()} at offset {msg.offset()}")
events = [
{"order_id": 101, "status": "received", "item_id": "cappuccino", "qty": 2},
{"order_id": 102, "status": "received", "item_id": "masala-chai", "qty": 1},
{"order_id": 101, "status": "preparing"},
{"order_id": 101, "status": "ready"},
{"order_id": 102, "status": "preparing"},
{"order_id": 102, "status": "ready"},
]
for event in events:
producer.produce(
"orders",
key=str(event["order_id"]), # same key -> same partition -> in order
value=json.dumps(event),
callback=report,
)
producer.poll(0) # let delivery callbacks run
time.sleep(0.2)
producer.flush() # wait until everything is deliveredPublishes six order status events. The key is the order id, so events of one order always land in the same partition and keep their order. produce() is asynchronous: it queues the message, and the callback tells you when Kafka has accepted it.
sent to partition 2 at offset 0
sent to partition 0 at offset 0
sent to partition 2 at offset 1
sent to partition 2 at offset 2
sent to partition 0 at offset 1
sent to partition 0 at offset 2Notice what the pattern shows: all three events for order 101 share a partition and have increasing offsets, so a consumer sees received, then preparing, then ready. producer.flush() at the end matters: without it the program may exit before queued messages are sent.
Write a consumer
import json
from confluent_kafka import Consumer
consumer = Consumer({
"bootstrap.servers": "localhost:9092",
"group.id": "kitchen",
"auto.offset.reset": "earliest", # a new group starts from the beginning
"enable.auto.commit": False, # we commit only after processing
})
consumer.subscribe(["orders"])
try:
while True:
msg = consumer.poll(1.0) # wait up to 1 second for a message
if msg is None:
continue
if msg.error():
print(f"Consumer error: {msg.error()}")
continue
event = json.loads(msg.value())
print(f"partition={msg.partition()} offset={msg.offset()} "
f"key={msg.key().decode()} -> order {event['order_id']} is {event['status']}")
consumer.commit(message=msg) # record progress: this message is done
except KeyboardInterrupt:
pass
finally:
consumer.close() # leave the group cleanlyReads the orders topic as part of the group kitchen. Stop it with Ctrl+C.
partition=2 offset=0 key=101 -> order 101 is received
partition=2 offset=1 key=101 -> order 101 is preparing
partition=2 offset=2 key=101 -> order 101 is ready
partition=0 offset=0 key=102 -> order 102 is received
partition=0 offset=1 key=102 -> order 102 is preparing
partition=0 offset=2 key=102 -> order 102 is readyWhy does the consumer commit manually? Offsets record how far a group has read. If the consumer crashes and restarts, it resumes from the last committed offset. If you commit before processing and then crash, that message is lost (at-most-once). If you commit after processing and crash before committing, the message is processed again after restart (at-least-once). At-least-once is the usual choice, which means your processing must tolerate duplicates: make it idempotent, for example by updating a row by order_id rather than inserting a new one. This is the same idempotency idea as in the Airflow lesson. Kafka also offers exactly-once processing in some setups (transactions between Kafka topics), but it needs careful configuration, so design for at-least-once first.
See consumer groups at work
- 1Stop the consumer. Run it again in the same terminal: nothing prints, because the group
kitchenalready committed all offsets and there is nothing new. - 2Run
python producer.pyagain, then the consumer. Only the new six events appear. - 3Check the group's progress:
docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group kitchen. The LAG column shows how many events are waiting to be read. Lag that keeps growing means consumers cannot keep up. - 4Change
group.idtoanalyticsand run the consumer. It receives all the events from the start, because a new group has its own offsets. This is how many applications can read the same stream independently. - 5Start two copies of the consumer (two terminals) with the same group id and run the producer. Each copy gets a share of the partitions.
Group behaviour
| Question | Answer |
|---|---|
| Same group, more consumers than partitions | Extra consumers sit idle |
| Different groups | Each group gets every event |
| Consumer crashes | The group reassigns its partitions to the others (a rebalance) and resumes from committed offsets |
| Events deleted after reading? | No. Kafka deletes by age or size (retention), not by consumption |
Clean up and next steps
docker stop kafka
docker rm kafkaStops and removes the container and its data.
In real pipelines Kafka sits between producers and many consumers: an app writes order events, Kafka Connect or a stream processor copies them to a data lake or warehouse, and a processing engine computes live aggregates. The next lesson, *Stream processing: Flink and Spark Structured Streaming*, covers that last part.
Managed Kafka services exist on every major cloud, plus Confluent's platform; alternatives such as Redpanda speak the Kafka protocol too. Managed options save you from running brokers yourself.
How this comes up in interviews: the difference between a topic, partition and offset; why order is guaranteed only within a partition; how consumer groups scale reads; at-most-once, at-least-once and exactly-once; what consumer lag is; how to choose a key and the number of partitions (more partitions allow more parallelism but are hard to reduce later, and a skewed key makes one partition a hot spot); and how Kafka differs from a queue like RabbitMQ (Kafka retains events and lets many groups replay them).
You are a streaming data engineer. I am designing Kafka topics for [describe your system, e.g. a food delivery app with orders, payments and rider locations]. Suggest topic names, the key for each, a sensible partition count with reasoning, the retention period, and the consumer groups that would read each topic. Point out one risk such as hot partitions or ordering problems.What to expect: A good answer picks keys that keep related events together (for example order_id), explains that ordering only holds per key, and warns about skew. Treat partition counts as estimates and verify them against your real throughput.
Try it: Count orders per status
Extend consumer.py into a small program that keeps a Python dictionary counting how many events of each status it has seen, printing the counts after each message. Run it with a new group.id. Then restart it and explain why the counts start from zero again even though offsets were committed.
Check yourself
docker exec kafka ... --describe --topic ordersshows 3 partitions- The producer prints a partition and offset for every message
- Events with the same order id arrive in order at the consumer
- You can explain what a consumer group is and why a second group re-reads everything
Key takeaways
- Kafka is a distributed, durable event log: producers append events to topics, and many consumers read them independently.
- Topics are split into partitions; order is guaranteed only within a partition, and the message key decides the partition.
- Within a consumer group each partition is read by one consumer; different groups each get all events.
- Offsets track progress; committing after processing gives at-least-once delivery, so consumers must handle duplicates.
- Events stay for a retention period regardless of being read, which allows replay and new consumers later.
Check yourself
Finishing the quiz marks the lesson as done.
1. You produce events for orders with the key order_id. What does this guarantee?
2. A topic has 3 partitions and a consumer group has 5 consumers. What happens?
3. Your consumer commits the offset after processing each message and crashes between processing and committing. What happens on restart?
4. A new consumer group with auto.offset.reset=earliest subscribes to a topic with a week of data. What does it read?
5. What does a steadily growing consumer lag tell you?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.