Week 11 · Priority: Medium

Kafka + Streaming

Learn the core concepts, not deep administration. You need to understand how streaming data moves through Kafka well enough to build and reason about a pipeline — not to operate a production cluster.

Why streaming, not just batch

Batch processing (Week 4) waits and processes data in scheduled chunks. Streaming processes each event as it arrives, continuously.

What
A system for handling data as a continuous stream of events, instead of scheduled batches.
Where
Anywhere data needs to be acted on within seconds, not hours.
When
When “check again tomorrow” (or even “in 5 minutes”) genuinely isn't fast enough.
How
Producers publish events to Kafka; consumers read and react to them in near real time.
  • Kafka sits between producers (apps generating events) and consumers (apps/pipelines processing them) as a durable, ordered, replayable buffer — a "message bus" purpose-built for high-throughput event data.
  • Use cases where batch falls short: fraud detection, live dashboards, clickstream analytics, order status updates — anywhere "find out an hour later" isn't good enough.
  • You don't need to reach for Kafka by default — most DE workloads are batch. Recognize the signal (low-latency requirement, continuous event volume) that says "this needs streaming."

Why not just check the database more often? A natural first idea is "run the batch job every minute instead of every day." That breaks down for two reasons. First, checking a database every few seconds puts real, repeated load on it, just to ask "did anything change?" Second, checking on a timer always has a built-in delay — you're only ever as fast as your check interval. Kafka instead pushes events out the instant they happen. It also fully separates producers from consumers: an order-placing service doesn't need to know or care whether zero, one, or ten different systems are reading its events. It just publishes to a topic and moves on.

Producer

What
The part of your system that publishes (writes) events into Kafka.
Where
Wherever an event happens — an app placing an order, a sensor reading.
When
Any time something happens that other systems need to know about immediately.
How
producer.send(topic, message), optionally with a key to control ordering.
from kafka import KafkaProducer
import json

producer = KafkaProducer(
    bootstrap_servers="localhost:9092",
    value_serializer=lambda v: json.dumps(v).encode("utf-8"),
)

producer.send("orders", {"order_id": 1042, "customer_id": 7, "total_amount": 59.99})
producer.flush()
  • The application that publishes (writes) events to a Kafka topic — e.g. your e-commerce app emitting an event every time an order is placed.
  • Producers choose which partition a message goes to (by key, or round-robin) — messages with the same key (e.g. customer_id) always land on the same partition, preserving order for that key.
  • flush() ensures buffered messages are actually sent before your script exits — a common gotcha when testing.

Why keying matters, concretely — sending without a key lets Kafka spread messages round-robin across partitions for maximum throughput, but that scrambles per-entity ordering:

# no key: order_id 1042's status updates could land on different partitions,
# and partitions are consumed independently — a consumer might process
# "shipped" before "paid" simply because they took different paths
producer.send("order_events", {"order_id": 1042, "status": "paid"})
producer.send("order_events", {"order_id": 1042, "status": "shipped"})

# keyed by order_id: EVERY event for order 1042 always goes to the same partition,
# guaranteeing "paid" is read before "shipped" by whichever consumer processes it
producer.send("order_events", key=str(1042).encode(), value={"order_id": 1042, "status": "paid"})
producer.send("order_events", key=str(1042).encode(), value={"order_id": 1042, "status": "shipped"})

Delivery guarantees are another setting worth knowing by name. acks=0 means "fire and forget" — fastest, but messages can silently get lost. acks=1 waits for the partition leader to confirm — this is the default, a reasonable middle ground. acks='all' waits for every in-sync replica to confirm — the slowest option, but a message only counts as sent once it's safely copied in multiple places, not just accepted by one server that could crash a second later.

Consumer

What
The part of your system that reads events from Kafka and does something with them.
Where
Anywhere downstream that needs to react to events — a database loader, a dashboard.
When
Whenever you need to process a stream of events as they arrive.
How
Subscribe to a topic, loop over incoming messages, and process each one.
from kafka import KafkaConsumer
import json

consumer = KafkaConsumer(
    "orders",
    bootstrap_servers="localhost:9092",
    group_id="orders-processing-group",
    value_deserializer=lambda v: json.loads(v.decode("utf-8")),
    auto_offset_reset="earliest",
)

for message in consumer:
    order = message.value
    print(f"Processing order {order['order_id']}")
  • The application that reads (subscribes to) events from a topic and does something with them — validate, transform, write to a database, trigger an alert.
  • Unlike a queue, consuming a message doesn't remove it — Kafka retains messages for a configured retention period, so multiple independent consumers can read the same data.
  • auto_offset_reset="earliest" vs "latest" controls whether a new consumer starts from the beginning of the topic or only sees new messages.

Committing offsets by hand is often a better idea. The example at the top auto-commits by default, which can quietly lose messages if your process crashes right after reading, but before it's actually finished the work. For anything writing to a database, only committing after a successful write is the safer, idempotency-friendly approach (same principle as Week 4):

consumer = KafkaConsumer(
    "orders",
    bootstrap_servers="localhost:9092",
    group_id="orders-processing-group",
    value_deserializer=lambda v: json.loads(v.decode("utf-8")),
    enable_auto_commit=False,   # take manual control of when an offset counts as "processed"
)

for message in consumer:
    order = message.value
    try:
        write_to_postgres(order)              # do the actual work first
        consumer.commit()                      # only THEN mark this offset as processed
    except Exception:
        logger.exception("Failed to process order %s, will retry on next poll", order["order_id"])
        # not committing means this message will be redelivered — the consumer just re-reads it

This is Kafka's version of "at-least-once" delivery — a crash between the write and the commit just means the message gets processed again. That's exactly why the downstream write (write_to_postgres) needs to be idempotent, same as any Airflow task.

Topic

A named stream of events — the Kafka equivalent of a table or a channel (e.g. orders, page_views, payment_events).

What
A named stream of events — Kafka's version of a table or a channel.
Where
The thing producers write to and consumers read from.
When
Create one topic per type of event, e.g. "orders", "page_views".
How
Give it a clear name and a sensible number of partitions.
  • Producers write to a topic; consumers read from it. Many producers and many consumers can share one topic.
  • Topics are append-only logs — new events are always added at the end, never modified in place.
  • Good topic design mirrors good table design: one topic per logical event type, with a clear, stable message schema.

Retention, not permanence — unlike a database table, a Kafka topic doesn't keep data forever by default. Each topic has a retention policy, most commonly time-based:

# create a topic with a 7-day retention window
kafka-topics.sh --create --topic orders \
  --partitions 3 --replication-factor 3 \
  --config retention.ms=604800000

Messages older than the retention window get deleted automatically, whether or not a consumer ever read them. That's why "replaying old events" only works within limits — a consumer group that's been offline for 8 days on a topic with 7-day retention has permanently lost that first day's events. Picking a retention period is a real decision: it's a trade-off between storage cost and how much history a consumer might genuinely need to replay.

Schema evolution is another thing worth planning for. Once producers and consumers are live, changing a message's shape — renaming a field, changing its type — can break every single consumer at once. Real Kafka setups usually use a schema registry to enforce rules about what changes are safe: adding a new field is fine, but removing or retyping an existing one needs a coordinated rollout across every producer and consumer.

Partition

What
A slice of a topic — the unit Kafka uses to parallelize reading and writing.
Where
Every topic is split into one or more partitions.
When
More partitions means more parallelism, but order is only guaranteed within one partition.
How
Choose a partition count when creating a topic; key messages so related events land together.
Topic: orders +-- Partition 0: [msg0, msg1, msg2, msg3, ...] +-- Partition 1: [msg0, msg1, msg2, ...] +-- Partition 2: [msg0, msg1, msg2, msg3, msg4, ...]
  • A topic is split into partitions — this is Kafka's unit of parallelism, directly analogous to Spark partitions (Week 9-10).
  • Order is only guaranteed within a partition, not across the whole topic — this is why keying matters (e.g. all events for one customer_id should hit the same partition if order matters for that customer).
  • More partitions = more parallel throughput, since different consumers can process different partitions simultaneously.

Watching key-based partitioning happen — this is the mechanism behind "same key, same partition" from the Producer section, made concrete:

# conceptually, Kafka's default partitioner does roughly:
partition = hash(key) % number_of_partitions

# so every message keyed by customer_id=101 always hashes to the SAME partition number,
# for the life of the topic (as long as partition count doesn't change) —
# this is what guarantees per-customer ordering without needing a global order

The catch: you can only add partitions, never remove them. And adding partitions changes which keys map to which partition (since the math above depends on the total count). That means adding partitions to an existing topic can briefly break ordering for any keys that get reassigned. It's worth choosing a partition count thoughtfully up front — usually based on how much consumer parallelism you'll want — rather than treating it as something to casually change later.

Offset

A sequential ID for each message within a partition — like a row number that only ever increases.

What
A running number marking each message's position within a partition.
Where
Tracked per consumer, per partition.
When
Used any time a consumer needs to know (or reset) how far it's read.
How
Kafka increments it automatically; your consumer commits it after processing a message.
  • Consumers track "how far they've read" by committing offsets — this is how Kafka supports resuming exactly where a consumer left off after a restart or crash.
  • Because Kafka retains history, a consumer can also intentionally rewind (reset to an earlier offset) to reprocess data — Kafka's version of a backfill.
  • Offsets are per-partition, not per-topic — "offset 42" only makes sense in the context of a specific partition.

Rewinding on purpose — the "backfill" use case mentioned above, made concrete. Say a bug in a consumer mis-processed the last day's worth of events; once fixed, replay just that window:

consumer = KafkaConsumer(
    "orders",
    bootstrap_servers="localhost:9092",
    group_id="orders-processing-group",
    enable_auto_commit=False,
)
consumer.poll(timeout_ms=1000)   # trigger partition assignment before seeking

for tp in consumer.assignment():
    consumer.seek_to_beginning(tp)   # or consumer.seek(tp, specific_offset) for a precise rewind

for message in consumer:
    reprocess(message.value)   # now re-reads from the beginning with the fixed logic

This is the streaming equivalent of Airflow's backfill (Week 7-8) — same core idea, fix the bug then re-run history against it — just triggered by manually resetting a consumer's position instead of picking a date range.

Consumer Group

What
A team of consumers sharing the work of reading one topic.
Where
Any time more than one process needs to read the same topic together.
When
To scale up how fast you can process a topic — add more consumers, up to the partition count.
How
Give consumers the same group_id; Kafka splits partitions between them automatically.
Topic: orders (3 partitions) Consumer Group "orders-processors" +-- Consumer A -- reads Partition 0 +-- Consumer B -- reads Partition 1 +-- Consumer C -- reads Partition 2 -> add a Consumer D: Kafka rebalances partitions across the group -> each partition is read by exactly one consumer within the group at a time
  • A consumer group is a set of consumers cooperating to read a topic — Kafka automatically splits partitions across the group members, so each partition is processed by exactly one consumer in the group at a time.
  • This is how Kafka scales consumption horizontally: add more consumers (up to the partition count) to increase throughput.
  • Two separate consumer groups reading the same topic each get their own full copy of the stream — groups are independent of each other, which is how one topic can feed both a real-time dashboard and a batch-loading pipeline simultaneously.
  • If consumers > partitions, the extra consumers sit idle — partition count is the hard ceiling on parallelism within one group.

The two independent groups pattern, worth seeing explicitly since it's a common source of confusion — one topic, two totally separate consumer groups, each getting the full stream:

# consumer group 1: loads every order into the warehouse
KafkaConsumer("orders", group_id="warehouse-loader", ...)

# consumer group 2: powers a live "orders today" dashboard counter
KafkaConsumer("orders", group_id="live-dashboard", ...)

# both groups read ALL messages on the "orders" topic, completely independently —
# group_id is what defines a group's boundary; group 1's progress has zero
# effect on group 2's progress, each tracks its own offsets per partition

This is exactly the fan-out pattern from the capstone diagrams elsewhere on this site: one event stream, several independent consumers downstream — a warehouse loader, a real-time dashboard, maybe a fraud-detection service — each reading through the same topic at its own pace, without slowing down or blocking any of the others.

Replication

What
Keeping copies of each partition on multiple servers, so no data is lost if one fails.
Where
Configured per topic.
When
Always in production — this is what makes Kafka durable.
How
Set a replication factor (commonly 3) when creating a topic.
Partition 0 +-- Broker 1: Leader (handles all reads/writes for this partition) +-- Broker 2: Replica (follower, kept in sync) +-- Broker 3: Replica (follower, kept in sync) If Broker 1 fails -> one replica is promoted to Leader automatically
  • Each partition is copied across multiple brokers (Kafka servers) for fault tolerance — controlled by the topic's replication factor (commonly 3 in production).
  • One replica is the leader (handles all reads/writes); the others are followers that replicate the leader's data. If the leader's broker dies, a follower is automatically promoted.
  • You don't need to administer this yourself early on — just understand that replication is what makes Kafka durable against individual server failures, the same guarantee replication gives you in Postgres or S3.

In-sync replicas (ISR), and why acks='all' is actually safe: a replica only counts as "in sync" if it's genuinely caught up with the leader's latest messages. A replica that's lagging or disconnected automatically drops out of that set. This connects directly back to acks='all' from earlier — it really means "wait until every currently in-sync replica has the message," not "wait for literally every replica that was ever created." That way, one slow replica doesn't bring every write to a halt.

# min.insync.replicas: the other half of durability config, set per topic
kafka-topics.sh --alter --topic orders --config min.insync.replicas=2

With replication.factor=3 and min.insync.replicas=2, a write using acks='all' only succeeds once at least 2 of the 3 replicas confirm it. That means Kafka can survive one broker going down without losing any acknowledged data, and without writes grinding to a halt. Together, these three settings — replication factor, min in-sync replicas, and producer acks — are what actually guarantee durability in a real Kafka setup. Worth understanding, even if you won't configure it by hand this early.

Practice project

Build: Application → Kafka → Spark → PostgreSQL

What
A small pipeline — an app produces events, Kafka carries them, Spark reads and writes them to Postgres.
Where
Locally, using Docker.
When
After you're comfortable with the concepts above.
How
Producer sends test events, Spark Structured Streaming reads the topic, then writes to Postgres.
Application (produces order events) | v Kafka (topic: orders, buffers & distributes the stream) | v Spark (Structured Streaming: reads the topic, transforms in near-real-time) | v PostgreSQL (sink: transformed events land in a table)
# Spark Structured Streaming reading from Kafka
df = (
    spark.readStream
         .format("kafka")
         .option("kafka.bootstrap.servers", "localhost:9092")
         .option("subscribe", "orders")
         .load()
)

orders = df.selectExpr("CAST(value AS STRING) as json") \
           .select(from_json("json", order_schema).alias("data")) \
           .select("data.*")

query = (
    orders.writeStream
          .format("jdbc")  # or foreachBatch to upsert into Postgres
          .start()
)

Let's fill in the missing pieces: a minimal producer to generate test data, and the foreachBatch sink that actually upserts into Postgres. (The "jdbc" streaming format mentioned above can't do upserts on its own, so foreachBatch is the pattern people actually use in practice.)

# producer.py — simulates order events landing in Kafka
import json, time, random
from kafka import KafkaProducer

producer = KafkaProducer(
    bootstrap_servers="localhost:9092",
    value_serializer=lambda v: json.dumps(v).encode("utf-8"),
)

order_id = 1000
while True:
    order = {
        "order_id": order_id,
        "customer_id": random.randint(1, 5),
        "total_amount": round(random.uniform(10, 500), 2),
    }
    producer.send("orders", key=str(order["customer_id"]).encode(), value=order)
    print("sent", order)
    order_id += 1
    time.sleep(1)
# the missing piece: writing each micro-batch to Postgres with an idempotent upsert
def write_batch_to_postgres(batch_df, batch_id):
    (
        batch_df.write
        .format("jdbc")
        .option("url", "jdbc:postgresql://localhost:5432/ecommerce")
        .option("dbtable", "orders_stream")
        .option("user", "postgres").option("password", "postgres")
        .mode("append")   # combine with an ON CONFLICT upsert at the DB level for true idempotency
        .save()
    )

query = (
    orders.writeStream
          .foreachBatch(write_batch_to_postgres)   # gives you a plain DataFrame per micro-batch,
          .outputMode("append")                     # so any batch-style write logic (Week 3) applies directly
          .start()
)
query.awaitTermination()

foreachBatch is the bridge between streaming and everything else on this site. Spark hands you a plain, regular DataFrame for each micro-batch, so the exact same SQLAlchemy/psycopg2 upsert patterns from Week 3 and Week 4's idempotency section work unchanged. Streaming doesn't need a whole new toolkit just for "write to a database" — only a different trigger for when that step runs.

Target: Write a small Python producer that simulates order events into a local Kafka topic (via Docker, extending your Week 6 docker-compose stack), consume it with a simple Python consumer first to confirm messages flow end to end, then read the same topic with Spark Structured Streaming and write results into your Postgres e-commerce schema. You don't need Kafka cluster administration — the goal is understanding how a streaming pipeline differs from the batch pipelines you've built through Week 10.