System Design for Data Engineers: A Framework, the Building Blocks, and Worked Designs
A practitioner's guide to the data engineering system design round: a seven-step framework, capacity estimation, the building blocks in data-flow order, delivery semantics and replay, a runnable Kafka to Iceberg pipeline that survives a crash, ten worked designs, production failures, tuning knobs, and interview questions with model answers.
- How to use this guide
- The core problem and architecture: a framework for any design prompt
- Why the data design round is different
- The seven-step framework
- Step 1 in practice: the questions to ask
- Step 2 in practice: freshness, completeness and correctness are three targets
- Step 2 in practice: capacity estimation
- Step 3 in practice: the data contract
- Step 4 in practice: the reference architecture
- Step 5 in practice: correctness and recovery
- Step 7 in practice: trade-off sentences
- What interviewers grade, and the mistakes that cost the round
- Framework interview questions
- Under the hood: the building blocks, in data-flow order
- Ingestion: how data enters the platform
- The log: Kafka as the system’s buffer and replay store
- Processing: batch, micro-batch and continuous
- Delivery semantics: where exactly-once actually lives
- Storage: lake, warehouse and lakehouse tables
- Serving: pick the store by the query
- Orchestration and backfills
- Data quality and observability
- Architecture patterns in one table
- Building-block interview questions
- Hands-on demo: an exactly-once Kafka to Iceberg pipeline
- Real-world scenarios: ten worked designs
- Design 1: clickstream analytics for a large website
- Design 2: CDC from an OLTP database to the lakehouse in under five minutes
- Design 3: a real-time operations dashboard with a 10-second SLA
- Design 4: log aggregation for 10,000 servers
- Design 5: a fraud feature pipeline with a 100 ms decision
- Design 6: IoT telemetry from one million devices
- Design 7: a multi-tenant file ingestion service
- Design 8: an e-commerce warehouse for daily reporting
- Design 9: a data quality platform for 500 tables
- Design 10: GDPR right to erasure across the lake
- Production failures: where designs break
- Scenario interview questions
- Optimisation, tuning knobs and best practices
- Summary and key takeaways
- References
- Trademarks
TL;DR
- Start from the consumer and the decision the data supports. Tools come last. An answer that opens with “Kafka, Spark, Snowflake” has skipped the part being graded.
- State freshness, completeness and correctness as separate targets. “Real time” is not a requirement until it has a number and a consumer.
- Estimate before you draw: events per second at peak, bytes per day, log disk with replication, lake storage after compression, and the partition count those numbers imply.
- Exactly-once is a property of a boundary, not of a pipeline. Name the boundary, then make the sink idempotent so that replay is safe.
- Every design needs a replay story: where the raw data lives, how far back it goes, and how a backfill runs without starving the live path.
- Monitor the data, not just the jobs. A job that succeeds with zero rows is the most expensive failure in data engineering because nobody gets paged.
- Close every choice with its cost: “X buys A at the cost of B, and we rejected Y because C”.
How to use this guide
A pipeline that double-counts revenue after a node restart is a system design failure, not a Spark bug. The data engineering design round tests whether you can see that failure coming: whether you ask what one row means, where the data can be replayed from, and which write is allowed to happen twice. This guide gives you a repeatable way to answer that round and the mechanics you need to defend each answer.
It follows the order you would use in the room:
- A framework for any design prompt, with a capacity estimate you can do in two minutes.
- The building blocks in the order data flows through them: ingestion, the log, processing, delivery guarantees, storage, serving, orchestration and quality.
- A runnable demo of the property interviewers probe hardest: a Kafka to Iceberg stream that is replayed after a simulated crash, with one sink that duplicates and one that does not.
- Ten worked designs of the prompts that come up most often, from clickstream analytics to GDPR erasure.
- Production failures and tuning knobs, so the trade-offs you name are ones you can back with a config key or an error message.
Interview questions with model answers close the framework, building-block, demo, design and tuning sections. Each answer is written the way an interviewer wants to hear it: the decision, the mechanism, and what it costs.
If you have not yet read them, the companion guides cover the ground this one assumes: the data modeling guide for grain, SCD history, CDC patterns and Lambda versus Kappa in depth, and the Structured Streaming internals post for the micro-batch engine the demo runs on.
Running the examples
The demo scripts run on the public datalake-playground stack, which ships Spark 3.5.9 with the Iceberg runtime on disk. Start it from its root:
git clone https://github.com/rangareddy/datalake-playground.git
cd datalake-playground
SPARK_VERSION=3.5.9 sh run_datalake.sh start
sh run_datalake.sh status
The demo reads from Kafka, which only the all profile starts (the broker is
kafka:29092), so start the stack with that profile instead:
PROFILE=all SPARK_VERSION=3.5.9 sh run_datalake.sh start
The PySpark scripts use an Iceberg catalog named sd of type hadoop, whose
warehouse is file:///tmp/sd_blog/warehouse inside the spark-master
container, and one Kafka topic, sd_blog_orders. They do not touch the shared
Hive Metastore or MinIO buckets. Spark reads Kafka on this stack without extra
--jars. The capacity estimate in the next section is plain Python and runs
anywhere.
The core problem and architecture: a framework for any design prompt
Why the data design round is different
A backend design round asks whether a service stays up and answers fast. A data design round asks whether the numbers are still right after the system has failed, been replayed, and been changed by three teams over a year. The state that matters is not a request in flight; it is every row ever written.
| Concern | Backend system design | Data system design |
|---|---|---|
| Unit of work | A request | A record, a batch, or a table commit |
| Main failure | Latency and availability | Wrong, missing or duplicated data, often silent |
| Time | Request time | Event time, processing time, and the gap between them |
| Recovery | Retry the request | Replay a range of history without double-counting |
| Change | Deploy a new version | Evolve a schema that years of stored data already use |
| Success metric | p99 latency, error rate | Freshness, completeness, correctness, cost per TB |
That difference is why the framework below spends two of its seven steps on contracts and recovery, which a backend round barely touches.
The seven-step framework
45-minute round
┌────────┬──────────┬────────┬──────────┬────────────┬────────┬─────────┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
│Consumer│Numbers & │Data │End-to-end│Correctness │Ops & │Trade- │
│and │non-funct.│contract│flow │and recovery│govern- │offs and │
│decision│targets │ │(diagram) │ │ance │stress │
├────────┼──────────┼────────┼──────────┼────────────┼────────┼─────────┤
│ ~4 min │ ~5 min │ ~4 min │ ~10 min │ ~10 min │ ~5 min │ ~7 min │
└────────┴──────────┴────────┴──────────┴────────────┴────────┴─────────┘
ask estimate define draw defend operate attack
The timings are a budget, not a script. What matters is the order: an architecture drawn before step 3 has no grain to be correct about, and a diagram defended before step 5 has never been replayed.
Step 1, the consumer and the decision. Ask who reads the output and what they do with it. “The fraud team blocks a card within 200 ms” and “finance closes the month on day 3” lead to different systems from the same events. Write the consumer and the decision at the top of the whiteboard.
Step 2, numbers and non-functional targets. Turn adjectives into numbers: volume, peak rate, record size, key cardinality, retention, growth. Then set the targets in the next subsection.
Step 3, the data contract. Define the key, the grain (what one row means), the event timestamp, the schema format and its compatibility rule, and who owns it. Most double-counting starts with a grain nobody wrote down.
Step 4, the end-to-end flow. Producers, ingestion, durable log, processing, storage layers, serving, and the control plane around them. Draw it left to right and walk one record through it.
Step 5, correctness and recovery. Delivery semantics at each boundary, deduplication, late data, dead-letter handling, replay and backfill. This is where most candidates lose the round.
Step 6, operations, security and governance. Freshness and volume alerts, lineage, access control, PII masking, retention and deletion, ownership.
Step 7, trade-offs and stress tests. Attack your own design: peak traffic at 10x, a hot key, a source outage of six hours, a breaking schema change, a backfill of a year. Say what bends and what breaks.
Step 1 in practice: the questions to ask
| Area | Question | Why it changes the design |
|---|---|---|
| Consumer | Who reads it: a dashboard, a model, an API, a regulator? | Sets the serving store and the latency target |
| Decision | What action does the data trigger? | Separates “nice to be fresh” from “must be fresh” |
| Sources | Databases, events, files, third-party APIs? | Push or pull, CDC or extract, rate limits |
| Volume | Events per day, peak hour, average and largest record? | Partition counts, cluster size, cost |
| Freshness | How old may the newest data be when read? | Batch, micro-batch or continuous |
| Correctness | Is approximate acceptable? Can a number change after publish? | Exactly-once effort, restatement policy |
| History | How far back must it be queryable, and replayable? | Log retention, lake retention, tiering |
| Change | Do source records update or delete? | Append-only versus upsert and CDC |
| Compliance | PII? Right to erasure? Residency? | Encryption, masking, deletion path |
| Team | Who operates it, and what do they already run? | Managed versus self-hosted |
Ask five or six of these, not all ten. Then confirm the one constraint that dominates: “So the binding requirement is fraud decisions under 200 ms, and analytics can be an hour behind. Correct?”
Step 2 in practice: freshness, completeness and correctness are three targets
“Real time” fuses three properties that a design trades against each other:
| Target | Definition | Example SLO |
|---|---|---|
| Freshness | Age of the newest data a consumer can read | Dashboard data at most 2 minutes old at p95 |
| Completeness | Share of events for a period that have arrived | 99.9% of an hour’s events present 15 minutes after the hour |
| Correctness | Each event counted once, at the right grain and time | Daily revenue matches the payment ledger to the cent |
| Latency | Time from event to decision | Fraud score returned in under 100 ms |
| Durability | Data that may be lost | Zero acknowledged events lost |
| Replayability | How far back the raw input can be reprocessed | 7 days from the log, 3 years from the lake |
A dashboard can be fresh and incomplete at the same time: at 10:02 the 10:00-10:01 bucket is visible but late mobile events have not arrived yet. Stating both targets forces the late-data question that step 5 answers.
Step 2 in practice: capacity estimation
Estimation earns its time when it changes a decision. Do it in this order: average rate, peak rate, bytes per day, log disk, lake storage, and the partition count. Every input is an assumption to say out loud.
"""Back-of-envelope sizing for an event pipeline, as done in an interview.
Every input is an assumption to state out loud. The per-partition budget and
the compression ratio in particular must be measured on your own data.
"""
import math
events_per_day = 2_000_000_000 # stated by the interviewer
avg_event_bytes = 1_000 # JSON, measured from a sample
peak_to_avg = 3 # evening peak vs daily average
kafka_retention_days = 7
kafka_replication = 3
partition_budget_mb_s = 5 # per-partition write budget, measured
parquet_ratio = 5 # JSON -> zstd Parquet, measured
lake_retention_days = 365
SECONDS_PER_DAY = 86_400
MB, GB, TB = 10**6, 10**9, 10**12
avg_eps = events_per_day / SECONDS_PER_DAY
avg_mb_s = avg_eps * avg_event_bytes / MB
peak_mb_s = avg_mb_s * peak_to_avg
raw_per_day = events_per_day * avg_event_bytes
kafka_disk = raw_per_day * kafka_retention_days * kafka_replication
lake_per_day = raw_per_day / parquet_ratio
partitions = math.ceil(peak_mb_s / partition_budget_mb_s)
print(f"average rate : {avg_eps:,.0f} events/s, {avg_mb_s:.1f} MB/s")
print(f"peak rate : {avg_eps * peak_to_avg:,.0f} events/s, "
f"{peak_mb_s:.1f} MB/s")
print(f"raw per day : {raw_per_day / TB:.1f} TB")
print(f"kafka disk : {kafka_disk / TB:.0f} TB "
f"({kafka_retention_days} days x RF {kafka_replication})")
print(f"min partitions : {partitions} at {partition_budget_mb_s} MB/s each")
print(f"lake per day : {lake_per_day / GB:,.0f} GB Parquet")
print(f"lake per year : {lake_per_day * lake_retention_days / TB:,.0f} TB")
Run with python3 capacity_estimate.py:
average rate : 23,148 events/s, 23.1 MB/s
peak rate : 69,444 events/s, 69.4 MB/s
raw per day : 2.0 TB
kafka disk : 42 TB (7 days x RF 3)
min partitions : 14 at 5 MB/s each
lake per day : 400 GB Parquet
lake per year : 146 TB
Read the numbers back as decisions. 69 MB/s at peak is a modest Kafka load, so the cluster is sized by retention disk, 42 TB, not by throughput. Fourteen partitions is the floor from throughput; the real count is set by consumer parallelism and headroom, so 24 or 48 is a defensible answer if you say why. 400 GB of Parquet a day means daily partitions hold hundreds of 512 MB files, which is healthy; hourly partitions would hold about 17 GB each, also fine. 146 TB a year puts a lifecycle policy on the table.
The two inputs marked “measured” are the ones an interviewer may challenge. Say that the per-partition budget comes from a load test on your brokers and message sizes, and the compression ratio from writing a day of real data. A number you can explain beats a number that sounds precise.
Step 3 in practice: the data contract
event: order_status_changed owner: orders-service team
┌──────────────┬───────────┬──────────────────────────────────────────┐
│ field │ type │ contract │
├──────────────┼───────────┼──────────────────────────────────────────┤
│ event_id │ string │ unique per emitted event; dedup key │
│ order_id │ string │ entity key; Kafka message key │
│ status │ enum │ PLACED, PAID, SHIPPED, CANCELLED │
│ amount │ decimal │ order total in minor units, never float │
│ order_ts │ timestamp │ business time the order was placed (UTC) │
│ updated_ts │ timestamp │ event time of this change; orders events │
│ schema_ver │ int │ registry id; BACKWARD compatible only │
└──────────────┴───────────┴──────────────────────────────────────────┘
grain of the sink table: one row per order_id, latest status wins
Three fields carry most of the correctness: a dedup key (event_id), an
entity key that becomes the Kafka message key and the upsert key
(order_id), and an event time that orders versions of the same entity
(updated_ts). Without the last one, “latest wins” means “last to arrive
wins”, and a retried old event silently overwrites a newer one. The demo in
this guide feeds exactly that stale event into the pipeline.
Step 4 in practice: the reference architecture
SOURCES INGEST LOG PROCESS STORE / SERVE
┌────────┐ ┌───────────┐ ┌───────────┐ ┌────────────┐ ┌──────────────┐
│OLTP DB │──►│CDC │──►│ │──►│stream │──►│bronze (raw) │
│(WAL) │ │(Debezium) │ │ Kafka │ │(Spark / │ │silver (clean)│
├────────┤ ├───────────┤ │ topics, │ │ Flink) │ │gold (agg) │
│apps, │──►│SDK / │──►│ keyed, │ ├────────────┤ │ Iceberg/Hudi/│
│devices │ │collector │ │ RF = 3 │ │batch │──►│ Delta on S3 │
├────────┤ ├───────────┤ └───────────┘ │(Spark, │ ├──────────────┤
│files, │──►│landing │──────────────────►│ dbt) │──►│OLAP / KV / │
│SaaS API│ │zone + pull│ └────────────┘ │search / APIs │
└────────┘ └───────────┘ └──────────────┘
─────────────────────────────── CONTROL PLANE ────────────────────────────────
schema registry · catalog & lineage · orchestration (Airflow) · data quality
checks · freshness / volume / lag alerts · access control & PII masking · cost
Walk one record through it. An order row changes in PostgreSQL; the change lands in the write-ahead log; Debezium reads the log and publishes a keyed event to Kafka, which replicates it to three brokers before acknowledging. A stream job reads it, validates it against the registered schema, and appends it to bronze. A second step deduplicates and upserts it into silver at the order grain. Batch jobs build gold aggregates from silver, and a serving store answers dashboards and APIs. Files and SaaS APIs skip the log and land in an object-store landing zone, from which the batch path picks them up.
The bottom band is not decoration. A design without a schema registry, a catalog, quality checks and freshness alerts works on the day it ships and degrades silently afterwards.
Step 5 in practice: correctness and recovery
Answer five questions for every boundary in the diagram:
- What are the delivery semantics here? At-most-once, at-least-once, or effectively-once, and what makes it so.
- Where is the dedup key, and who applies it? Usually the sink, through a
MERGEon the entity key or a unique constraint. - What happens to late data? Updated in place, written to a correction partition, or dropped behind a watermark and counted.
- Where do bad records go? A dead-letter topic or table with the error, the raw payload and the source offset, plus an alert on its rate.
- How do we replay? From which store, how far back, at what rate, and what stops the replay from overwriting newer data.
Step 7 in practice: trade-off sentences
Interviewers grade the trade-off, not the tool. Use one sentence shape:
“Micro-batches every 30 seconds buy us idempotent, transactional table commits at the cost of about a minute of freshness. We rejected per-record streaming into the warehouse because each record would be a commit.”
Then stress-test: “At 10x the peak, the Kafka cluster holds because it is
sized by disk; the stream job falls behind, lag grows, and maxOffsetsPerTrigger
keeps each batch bounded while autoscaling catches up.”
What interviewers grade, and the mistakes that cost the round
| Graded dimension | Strong signal | Common mistake |
|---|---|---|
| Requirements and SLOs | Names consumer, volume, freshness and completeness with numbers | Picks tools in the first minute |
| Contracts and modeling | Defines keys, grain, event time, schema owner | Treats “the data” as one undifferentiated blob |
| Correctness | Scopes guarantees to boundaries; idempotent sinks; reconciliation | Claims end-to-end exactly-once with no boundary named |
| Scale and reliability | Partitioning, skew, backpressure, checkpoints, replay | Forgets replay and backfill entirely |
| Operations and quality | Freshness, volume, lag, quality assertions, cost | Monitors jobs, not data |
| Trade-offs and communication | Every choice has a cost and a rejected alternative | Lists features of a tool instead of reasons |
Framework interview questions
1. You are asked to "design a real-time analytics pipeline". What do you do in the first five minutes?
I do not draw anything yet. I ask who consumes the output and what decision it drives, the event volume at peak, how fresh the data must be and how complete, whether source records change after they are emitted, and how far back we must be able to reprocess. Then I restate the binding constraint, for example “dashboards two minutes fresh, finance numbers final after one hour”, and get agreement on it. Those answers decide batch versus streaming and how much exactly-once machinery is justified; the tool names come after.
2. How do you choose between batch and streaming?
From the freshness target and the decision it serves, not from preference. If
the consumer acts on data within seconds or minutes (fraud, alerting,
operational dashboards), stream. If the consumer reads daily or hourly
(finance, most BI), batch is cheaper to build, test, backfill and operate. A
common middle ground is micro-batch on a schedule, such as Spark Structured
Streaming with trigger(availableNow=True) every 15 minutes, which keeps one
code path and gets incremental processing without an always-on cluster. I
also say what I give up: streaming costs state management, late-data handling
and on-call load; batch costs freshness.
3. Walk me through a capacity estimate for 2 billion events a day.
Two billion over 86,400 seconds is about 23,000 events per second on average. At 1 KB an event that is 23 MB/s, and with a 3x evening peak about 70 MB/s. Raw volume is 2 TB a day; seven days in Kafka at replication 3 is 42 TB of broker disk, so retention, not throughput, sizes the cluster. If Parquet compresses the JSON 5x, the lake grows 400 GB a day, roughly 146 TB a year, which justifies lifecycle tiering. Fourteen partitions is the floor at a 5 MB/s per-partition budget, and I would pick 24 or 48 for consumer parallelism and growth. I would flag the compression ratio and partition budget as numbers to measure.
4. What is a data contract, and what goes in one?
A data contract is the producer’s promise about a dataset that consumers can build on. It names the owner, the schema and its compatibility rule (for example backward compatible in the schema registry), the grain, the keys (a unique event id for dedup and an entity key for ordering and upserts), the event-time field and its time zone, allowed values, nullability, and the freshness and volume a consumer can expect. It turns a breaking change into a failed CI check or a rejected registration instead of a broken dashboard.
5. Freshness and completeness: why state both?
Because a pipeline can meet one and fail the other. A dashboard can show data from 30 seconds ago (fresh) while 2% of the last hour’s mobile events are still buffered on phones (incomplete). If I only promise freshness, consumers treat the latest hour as final and make decisions on partial numbers. Stating “fresh within 2 minutes, 99.9% complete 15 minutes after the hour, final at T+1 day” tells consumers when a number can still move, and it forces the design to decide how late data updates published results.
Under the hood: the building blocks, in data-flow order
Ingestion: how data enters the platform
| Pattern | How it works | Captures deletes | Source load | Typical use |
|---|---|---|---|---|
| Full extract | SELECT * on a schedule, replace the target |
Yes, by absence | High | Small reference tables |
| Incremental query | WHERE updated_at > :high_water_mark |
No | Medium | Sources without log access |
| Log-based CDC | Read the WAL or binlog (Debezium) | Yes | Low | OLTP replication to the lake |
| Event push | Apps send events through an SDK or collector | Not applicable | None | Clickstream, telemetry |
| File drop | Partners write files to object storage or SFTP | By convention | None | B2B feeds, exports |
| API pull | Poll a SaaS API with pagination and rate limits | Rarely | External | CRM, ads, payments data |
The incremental-query row hides a race that interviewers like: a transaction
that started before the high-water mark was read but commits after it gets an
updated_at below the mark and is skipped forever. Log-based CDC reads
committed changes in commit order, so it has no such gap. The data modeling
guide covers CDC mechanics
in detail, including replication-slot retention.
For files and APIs, land the raw bytes first, unchanged, in an object-store prefix keyed by source and arrival time. Parsing comes after. If the parser has a bug, you re-parse from the landing zone instead of asking a partner to resend last month.
The log: Kafka as the system’s buffer and replay store
A durable log decouples producers from consumers in time. Producers write at their rate, consumers read at theirs, and the log holds the difference. Most data platform designs put one in the middle for three reasons: it absorbs bursts, it lets several consumers read the same events independently, and it is the first replay store.
topic: orders (3 partitions, replication factor 3)
producer: key = order_id ──► partition = hash(key) % 3
(Kafka's default partitioner uses murmur2)
partition 0 [o1 o4 o7 ...] leader broker 1, followers 2, 3
partition 1 [o2 o5 o8 ...] leader broker 2, followers 3, 1
partition 2 [o3 o6 o9 ...] leader broker 3, followers 1, 2
offset 0 1 2 ... append-only, ordered within a partition only
consumer group "lake-writer" consumer group "fraud"
consumer A ◄─ partition 0, 1 consumer X ◄─ partition 0
consumer B ◄─ partition 2 consumer Y ◄─ partition 1
consumer Z ◄─ partition 2
each group tracks its own committed offsets in __consumer_offsets
Read it from the top. The producer hashes the message key to pick a
partition, so every event for o1 lands in partition 0 and stays in order
relative to other o1 events. Ordering holds within a partition only; there
is no global order across the topic. Each partition has a leader that takes
writes and followers that copy it. A consumer group splits the partitions
among its members, so a group can have at most as many active consumers as
there are partitions, and the extra members sit idle. Each group commits its
own offsets, which is why the fraud team and the lake writer can read the same
topic at different speeds.
The settings below decide whether an acknowledged event can be lost:
| Setting | Scope | Default | Design choice |
|---|---|---|---|
replication.factor |
Topic | 1 (default.replication.factor) |
3 in production |
min.insync.replicas |
Topic / broker | 1 | 2 with RF 3: a write needs two copies |
acks |
Producer | all since Kafka 3.0 |
all; 1 loses data if the leader dies before followers copy |
enable.idempotence |
Producer | true since Kafka 3.0 |
Keep it; retries no longer create duplicates in the log |
retention.ms |
Topic | 604800000 (7 days) | Sized from the replay window and disk |
isolation.level |
Consumer | read_uncommitted |
read_committed when producers use transactions |
acks=all with min.insync.replicas=2 and RF 3 means an acknowledged write
exists on at least two brokers, and the cluster tolerates one broker loss
without refusing writes. The cost is write latency: the leader waits for a
follower. The idempotent producer attaches a producer id and a sequence
number per partition, so a retried batch that the broker already has is
discarded rather than appended twice. It removes duplicates caused by producer
retries; it does nothing for an application that sends the same business event
twice, which is why the contract still carries an event_id.
Choosing the key. The key decides ordering and load. Key by the entity
whose events must stay ordered (order_id, account_id, device_id). If one
key dominates, such as one huge merchant, its partition becomes hot. The fixes
are a composite key (merchant_id plus a bucket) when per-merchant order does
not matter, or a dedicated topic for the outlier. Increasing the partition
count later changes hash(key) % n, so keys move partitions and per-key
ordering breaks across the change. Over-provision partitions up front instead.
Processing: batch, micro-batch and continuous
| Model | Unit | Latency | State | Failure recovery | Engines |
|---|---|---|---|---|---|
| Batch | A bounded input, often a date partition | Minutes to hours | Recomputed each run | Re-run the partition | Spark, dbt, Trino |
| Micro-batch | A range of offsets per trigger | Seconds to minutes | Checkpointed state store | Replay the last uncommitted batch | Spark Structured Streaming |
| Continuous | One record at a time | Milliseconds to seconds | Keyed state with periodic snapshots | Restore snapshot, rewind sources | Flink, Kafka Streams |
The micro-batch engine is worth knowing in detail, because its recovery rule is what the demo exercises:
Spark Structured Streaming, one micro-batch (batch N)
1. plan ── read latest Kafka offsets ──► write offsets/N to checkpoint
2. execute ── read offsets(N-1)..offsets(N) ──► transform ──► sink
3. commit ── write commits/N to checkpoint
▲
└─ crash here: offsets/N exists, commits/N does not.
On restart, Spark re-runs batch N with the SAME offsets.
The offsets log is written before the batch runs (a write-ahead log) and the commit log after the sink returns. If the process dies between steps 2 and 3, the restart finds an offset entry without a commit entry and re-executes batch N over exactly the same offset range. The engine guarantees the replay is the same input. Whether the output is duplicated depends entirely on the sink. A sink that appends writes the batch twice; a sink that upserts on a key writes the same final state twice, which is the same as writing it once.
Event time and watermarks. For aggregations by event time, the engine has
to decide when a window is complete. A watermark is that decision: “no event
older than the maximum event time seen minus 10 minutes is expected”.
withWatermark("event_ts", "10 minutes") lets Spark finalise windows and
drop state behind the watermark. Events older than the watermark are dropped
by stateful operators. That is a correctness trade-off you should state:
a larger delay keeps more state and publishes later; a smaller one drops more
late data. Count the late events you drop, or route them to a correction path,
rather than losing them silently.
State. Windowed aggregations, stream-stream joins and streaming
deduplication keep state per key. Spark’s default state store provider is
HDFSBackedStateStoreProvider, which holds state in executor JVM memory and
checkpoints it to files. For large state, switch
spark.sql.streaming.stateStore.providerClass to
org.apache.spark.sql.execution.streaming.state.RocksDBStateStoreProvider,
which keeps it in native RocksDB instances outside the JVM heap. Spark 3.5
also added dropDuplicatesWithinWatermark, which deduplicates on a key while
bounding state with the watermark.
Delivery semantics: where exactly-once actually lives
| Guarantee | Meaning | How you get it | Cost |
|---|---|---|---|
| At-most-once | Each event is processed zero or one times | Commit offsets before processing | Data loss on crash |
| At-least-once | Each event is processed one or more times | Commit offsets after the sink succeeds | Duplicates on replay |
| Effectively-once | Each event affects the output once | At-least-once delivery plus an idempotent or transactional sink | Sink design and keys |
“Exactly-once” is always scoped to a boundary. The mechanisms differ by boundary:
| Boundary | Mechanism |
|---|---|
| Producer to Kafka | Idempotent producer (sequence numbers per partition) |
| Kafka to Kafka | Kafka transactions: consume, produce and commit offsets atomically; readers use read_committed |
| Kafka to a table format | Replayable source, deterministic batch, idempotent MERGE or a sink that records the batch id it committed |
| Kafka to a database | Write the rows and the consumed offset in one database transaction |
| Anything to an external API | Idempotency key on the request; the API must deduplicate |
| Flink to Kafka or files | Two-phase commit tied to checkpoints (KafkaSink with DeliveryGuarantee.EXACTLY_ONCE) |
The sentence interviewers want: “Delivery is at-least-once everywhere; I make the effect exactly-once at the sink by upserting on a deterministic key, so a replay rewrites the same rows.”
Storage: lake, warehouse and lakehouse tables
A plain data lake is a directory of Parquet files: cheap, open, and with no transactions. Two writers can interleave, a reader can see half a job’s output, and an update means rewriting a partition by hand. A warehouse gives transactions and fast SQL but stores data in its own format, so every other engine pays to copy it out. An open table format (Iceberg, Hudi, Delta Lake) adds a transaction log over Parquet files in object storage, which is what a lakehouse is.
Iceberg table commit: an atomic swap of one pointer
catalog: shop.orders ──► metadata/v12.metadata.json (current)
│ snapshots: [..., s11, s12]
▼
snap-s12 manifest list (.avro)
├─► manifest A (.avro) ──► day=2026-10-28/00001.parquet
│ ──► day=2026-10-28/00002.parquet
└─► manifest B (.avro) ──► day=2026-10-27/00417.parquet
writer: write new data files ──► write manifests ──► write v13.metadata.json
──► ask catalog: "swap v12 → v13 if current is still v12"
success: readers see s13 atomically
conflict: re-read, re-validate, retry (commit.retry.num-retries)
Writers never modify files in place. A commit writes new files and then
compare-and-swaps the catalog pointer to a new metadata file. Readers resolve
the pointer once and read a consistent snapshot, so they never see a half
write. Concurrent writers use optimistic concurrency: the loser of the swap
re-reads, checks that its change does not conflict, and retries up to
commit.retry.num-retries times (default 4). Because each commit is a
metadata file and a manifest, a design that commits every second creates
thousands of snapshots and tiny files an hour. That is why streaming into a
table format uses micro-batches of tens of seconds or more, plus scheduled
compaction and snapshot expiry.
| Choice | Option A | Option B | Rule of thumb |
|---|---|---|---|
| Format of record | Warehouse-native | Open table format | Open format when several engines read it or cost of copies matters |
| Write mode for upserts | Copy-on-write: rewrite affected files | Merge-on-read: write delete files, merge at read | MOR for frequent small updates, COW for read-heavy tables |
| Partitioning | By event date | By ingestion date | Event date for queries; handle late data by rewriting old partitions |
| File size | Small, frequent commits | Fewer, larger files | Target hundreds of MB; Iceberg’s write.target-file-size-bytes defaults to 512 MB |
The open table formats comparison goes deeper on how Iceberg, Hudi and Delta Lake differ.
Serving: pick the store by the query
| Query pattern | Store | Why |
|---|---|---|
| Ad hoc SQL over months of history | Trino, Spark SQL, or a warehouse over the lakehouse | Scans columnar files, prunes partitions |
| Sub-second aggregates for dashboards | ClickHouse, Apache Pinot, Apache Druid | Pre-aggregation and columnar indexes |
| Point lookup by key in milliseconds | Redis, DynamoDB, Cassandra | Key-value access, no scans |
| Full-text and log search | Elasticsearch or OpenSearch | Inverted indexes |
| Time series per device | TimescaleDB, InfluxDB | Time-partitioned storage and downsampling |
| Operational tools need warehouse data | Reverse ETL to the SaaS tool | Pushes rows to where the user works |
Most designs have two or three serving stores fed from one lakehouse. Say which is the source of truth (usually silver or gold in the lakehouse) and which are disposable projections you can rebuild by replay.
Orchestration and backfills
An orchestrator (Airflow, Dagster, or a managed equivalent) runs batch steps in dependency order, retries them and records what ran. Two properties make a DAG safe to retry and to backfill:
Tasks are idempotent per partition. Each run writes one logical partition,
for example order_date = 2026-10-27, with an overwrite or a MERGE, never a
blind append. Re-running the same date gives the same result.
Inputs are pinned. A run for 2026-10-27 reads the inputs for that date,
not “everything new since the last run”. Then a backfill of last March is the
same code with a different date, and it can run in parallel with a separate
compute pool so it does not starve the live schedule.
For streaming jobs, the backfill is a replay: a second instance of the job with its own checkpoint reads a historical range (from the lake if the log has expired) and writes through the same idempotent sink.
Data quality and observability
Monitor five signals per dataset, not just job status:
| Signal | Check | Example alert |
|---|---|---|
| Freshness | Max event time versus now | orders silver is 45 minutes behind a 10-minute SLO |
| Volume | Row count versus same hour last week | Rows dropped 50% day over day, or zero rows |
| Schema | Columns and types versus the contract | A new nullable column, or a type change |
| Validity | Null rates, ranges, enums, uniqueness | amount < 0 in 0.3% of rows |
| Reconciliation | Totals against the source of record | Lakehouse revenue differs from the ledger by more than 0.01% |
Place the checks as gates. Bronze warns and continues, because blocking raw ingestion loses data. Silver and gold halt the publish step, so consumers keep the last good version instead of reading a bad one. Tie each alert to the dataset’s owner, and record lineage (OpenLineage, a catalog such as DataHub or OpenMetadata) so the alert can say which dashboards are affected.
Architecture patterns in one table
| Pattern | Idea | Choose when | Cost |
|---|---|---|---|
| Lambda | Batch layer for correctness, speed layer for freshness, merged at serving | A legacy batch path must stay and fresh numbers are also needed | Two code paths that drift |
| Kappa | One streaming path; reprocess by replaying the log | Logic is the same for live and history, and replay is affordable | Long log retention or lake replay |
| Medallion | Bronze raw, silver cleaned and conformed, gold business-ready | Almost every lakehouse | Extra copies; layers without contracts become sprawl |
| CDC | Stream database changes from the log | Database is the source of truth | Slot and binlog retention, schema changes |
| Outbox | Service writes business row and event in one transaction; CDC publishes the event | A service must update its DB and emit an event atomically | One more table and relay |
| Event sourcing | Store events; derive state | Audit, ledgers, undo | Snapshots, ever-growing logs, schema evolution of history |
| Data mesh | Domains own data products on a shared platform | Many domains, a central team is the bottleneck | Needs strong standards and platform investment |
The data modeling guide’s Lambda versus Kappa section walks both architectures step by step.
Building-block interview questions
6. Why put Kafka between the database and the lake instead of writing straight to the lake?
Three reasons. It decouples rates: a slow lake commit or a compaction does not push back on the source. It allows several independent consumers, such as the lake writer, a search indexer and a fraud service, to read the same changes with their own offsets. And it is a replay buffer: if a bug corrupts silver, I can rewind a consumer group and reprocess the retained days. The cost is another system to operate and size, mostly by retention disk. For a single consumer with hourly freshness I might skip it and land CDC files directly.
7. How does Kafka guarantee ordering, and when does it not?
Ordering is per partition. The producer picks the partition from the message key, so all events with one key go to one partition in send order, and a consumer reads a partition in offset order. There is no order across partitions. Ordering breaks if producers use no key, if the partition count is increased (keys rehash to different partitions), or if a non-idempotent producer with several in-flight requests retries a failed batch after a later one succeeded. The idempotent producer, the default since Kafka 3.0, preserves order for up to five in-flight requests.
8. Explain at-least-once versus exactly-once in a Spark streaming job writing to a table.
Spark’s source and checkpoint give replayable, deterministic input: the
offsets for batch N are written before it runs, and on restart an uncommitted
batch N is re-run over the same offsets. So delivery to the sink is
at-least-once. The effect is exactly-once only if the sink tolerates the
replay. An Iceberg MERGE on the entity key does: replaying the batch writes
the same final rows. A plain append does not: it adds the batch twice. I would
show this with a deleted commit file, which is what the demo below does.
9. What is a watermark, and what does it cost?
A watermark is the engine’s rule for when event-time results are final: the maximum event time seen minus an allowed delay. It lets the engine emit windows and discard state. The cost is late data: events older than the watermark are dropped by stateful operators. A long delay keeps more state and publishes later; a short one drops more. I size it from the observed lateness distribution of the slowest source, count what is dropped, and route very late events to a batch correction job if the numbers must be exact.
10. How does an open table format make a commit atomic on object storage?
Files are immutable. A writer writes its new data files and manifests, then a new metadata file, and finally asks the catalog to swap the table’s pointer from the old metadata file to the new one, only if the pointer has not moved. That compare-and-swap is the commit. Readers resolve the pointer once and see a full snapshot. If another writer moved the pointer first, the loser re-reads, checks for conflicts with what changed, and retries. Object storage only has to store files; the catalog provides the atomic step.
11. How do you make a batch pipeline safe to re-run and to backfill?
Each task writes one logical partition with overwrite or MERGE, never
append, so a re-run replaces rather than adds. Each run reads inputs pinned to
its logical date, not “since last run”, so a backfill is the same code with a
different date. Backfills run on separate compute, throttled, so the live
schedule keeps its SLO. And I check the result with the same quality gates,
including a reconciliation against the source for the backfilled range.
12. What would you monitor on a new pipeline on day one?
Freshness (max event time against now), volume against the same period last week, schema drift against the contract, a handful of validity checks on keys and amounts, and consumer lag for streaming jobs. Job success alone is not enough: the worst incidents are jobs that succeed with zero or half the rows. Each alert has an owner and lineage, so it says which consumers are affected. P1 alerts page someone; data-quality warnings open tickets.
Hands-on demo: an exactly-once Kafka to Iceberg pipeline
The claim to prove: a replayed micro-batch duplicates rows in an append sink
and leaves an idempotent MERGE sink unchanged. The demo also shows two
contract rules from step 3: duplicates from producer retries are removed, and
a stale event that arrives late does not overwrite newer state.
produce_orders.py ──► Kafka sd_blog_orders (3 partitions, key = order_id)
│ maxOffsetsPerTrigger = 400
▼
orders_stream.py (Structured Streaming, availableNow)
foreachBatch(batch_df, batch_id)
├─► append ──► sd.shop.orders_append (naive)
└─► dedup in batch, MERGE on order_id
──► sd.shop.orders (idempotent)
checkpoint: /tmp/sd_blog/checkpoints/orders
offsets/N commits/N
Step 1: create the topic
docker exec kafka kafka-topics --bootstrap-server kafka:29092 \
--create --topic sd_blog_orders --partitions 3 --replication-factor 1
Replication factor 1 is only because the playground runs one broker.
Step 2: the producer
Run 1 publishes 1,000 orders plus 50 exact copies, the duplicates a producer
retry creates when an acknowledgement is lost after the broker stored the
write. Run 2 publishes 500 new orders, 30 SHIPPED updates for earlier
orders, and 10 PENDING events whose updated_ts is older than the stored
PLACED version.
"""Publish order events to Kafka for the system design demo.
Run 1 sends 1,000 new orders plus 50 exact duplicates, the way a producer
retry after a lost ack would. Run 2 sends 500 new orders, 30 status updates
for run-1 orders, and 10 stale updates that arrive out of order.
"""
import sys
from datetime import datetime, timedelta
from pyspark.sql import SparkSession
TOPIC = "sd_blog_orders"
BOOTSTRAP = "kafka:29092"
BASE = datetime(2026, 10, 28, 10, 0, 0)
def order(i, status, updated):
return {
"event_id": f"o{i:05d}-{status}",
"order_id": f"o{i:05d}",
"customer_id": f"c{i % 97:03d}",
"amount": round(10 + (i * 7.31) % 490, 2),
"status": status,
"order_ts": (BASE + timedelta(seconds=i * 2)).isoformat(),
"updated_ts": updated.isoformat(),
}
def run_1():
rows = [order(i, "PLACED", BASE + timedelta(seconds=i * 2))
for i in range(1, 1001)]
return rows + rows[:50] # retried sends
def run_2():
rows = [order(i, "PLACED", BASE + timedelta(seconds=i * 2))
for i in range(1001, 1501)]
shipped_at = BASE + timedelta(hours=2)
rows += [order(i, "SHIPPED", shipped_at) for i in range(1, 31)]
# Older than the PLACED event already stored: must not win.
rows += [order(i, "PENDING", BASE - timedelta(hours=1))
for i in range(31, 41)]
return rows
if __name__ == "__main__":
spark = SparkSession.builder.appName("sd-produce-orders").getOrCreate()
rows = run_1() if sys.argv[1] == "1" else run_2()
df = spark.createDataFrame(rows)
(df.selectExpr("order_id AS key", "to_json(struct(*)) AS value")
.write.format("kafka")
.option("kafka.bootstrap.servers", BOOTSTRAP)
.option("topic", TOPIC)
.save())
print(f"published {len(rows)} events to {TOPIC}")
spark.stop()
Step 3: the streaming job
Inside foreachBatch, each micro-batch goes to both sinks. Before the
MERGE, a window keeps one row per order_id within the batch, the newest by
updated_ts. That step is required, not a nicety: a MERGE whose source has
two rows for one target row fails, as the last step shows. The MERGE itself
only updates when the incoming version is newer, which is what rejects the
stale events.
"""Kafka -> Spark Structured Streaming -> Iceberg, with an idempotent sink.
Each micro-batch is written twice: a naive append to orders_append, and a
deduplicating MERGE into orders keyed on order_id. Replaying a batch shows
which of the two sinks survives a crash between write and checkpoint commit.
"""
from pyspark.sql import SparkSession, Window
from pyspark.sql import functions as F
from pyspark.sql.types import (DoubleType, StringType, StructField,
StructType, TimestampType)
WAREHOUSE = "file:///tmp/sd_blog/warehouse"
CHECKPOINT = "file:///tmp/sd_blog/checkpoints/orders"
spark = (
SparkSession.builder.appName("sd-orders-stream")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions."
"IcebergSparkSessionExtensions")
.config("spark.serializer",
"org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.sd", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.sd.type", "hadoop")
.config("spark.sql.catalog.sd.warehouse", WAREHOUSE)
.config("spark.sql.shuffle.partitions", "4")
.getOrCreate()
)
spark.sql("CREATE NAMESPACE IF NOT EXISTS sd.shop")
for table in ("orders", "orders_append"):
spark.sql(f"""
CREATE TABLE IF NOT EXISTS sd.shop.{table} (
order_id STRING, customer_id STRING, amount DOUBLE,
status STRING, order_ts TIMESTAMP, updated_ts TIMESTAMP,
kafka_partition INT, kafka_offset BIGINT)
USING iceberg
PARTITIONED BY (days(order_ts))""")
schema = StructType([
StructField("event_id", StringType()),
StructField("order_id", StringType()),
StructField("customer_id", StringType()),
StructField("amount", DoubleType()),
StructField("status", StringType()),
StructField("order_ts", TimestampType()),
StructField("updated_ts", TimestampType()),
])
events = (
spark.readStream.format("kafka")
.option("kafka.bootstrap.servers", "kafka:29092")
.option("subscribe", "sd_blog_orders")
.option("startingOffsets", "earliest")
.option("maxOffsetsPerTrigger", "400")
.load()
.select(F.from_json(F.col("value").cast("string"), schema).alias("e"),
F.col("partition").alias("kafka_partition"),
F.col("offset").alias("kafka_offset"))
.select("e.*", "kafka_partition", "kafka_offset")
.drop("event_id")
)
def write_batch(batch_df, batch_id):
batch_df.persist()
batch_df.writeTo("sd.shop.orders_append").append()
# Keep the newest version of each order inside the batch, so MERGE
# never sees two source rows for one target row.
latest = Window.partitionBy("order_id").orderBy(
F.col("updated_ts").desc(), F.col("kafka_offset").desc())
(batch_df.withColumn("rn", F.row_number().over(latest))
.where("rn = 1").drop("rn")
.createOrReplaceTempView("batch_orders"))
batch_df.sparkSession.sql("""
MERGE INTO sd.shop.orders t
USING batch_orders s
ON t.order_id = s.order_id
WHEN MATCHED AND s.updated_ts > t.updated_ts THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *""")
print(f"batch {batch_id}: {batch_df.count()} events")
batch_df.unpersist()
query = (
events.writeStream
.foreachBatch(write_batch)
.option("checkpointLocation", CHECKPOINT)
.trigger(availableNow=True)
.start()
)
query.awaitTermination()
for table in ("orders", "orders_append"):
# foreachBatch writes through a cloned session with its own catalog
# cache; without REFRESH this session still sees the empty table.
spark.sql(f"REFRESH TABLE sd.shop.{table}")
n = spark.table(f"sd.shop.{table}").count()
print(f"{table}: {n} rows")
spark.sql("""SELECT status, count(*) AS orders FROM sd.shop.orders
GROUP BY status ORDER BY status""").show()
spark.stop()
Step 4: run, crash, replay
Copy both scripts into the container and publish run 1:
docker cp produce_orders.py spark-master:/tmp/
docker cp orders_stream.py spark-master:/tmp/
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit \
--master "local[2]" \
--conf spark.eventLog.enabled=false --conf spark.ui.enabled=false \
/tmp/produce_orders.py 1'
published 1050 events to sd_blog_orders
Run the streaming job:
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit \
--master "local[2]" \
--conf spark.eventLog.enabled=false --conf spark.ui.enabled=false \
--jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/orders_stream.py'
batch 0: 398 events
batch 1: 398 events
batch 2: 254 events
orders: 1000 rows
orders_append: 1050 rows
+------+------+
|status|orders|
+------+------+
|PLACED| 1000|
+------+------+
maxOffsetsPerTrigger = 400 split the 1,050 events into three batches. The
append table holds all 1,050 events, including the 50 duplicates. The MERGE
table holds 1,000 orders, one per order_id.
Now simulate a crash after the sink wrote batch 2 but before Spark recorded
the commit. Delete the commit entry (and its checksum file, or the next commit
fails with FileAlreadyExistsException ... .2.crc already exists) and run the
same job again with no new data:
docker exec spark-master rm /tmp/sd_blog/checkpoints/orders/commits/2 \
/tmp/sd_blog/checkpoints/orders/commits/.2.crc
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit \
--master "local[2]" \
--conf spark.eventLog.enabled=false --conf spark.ui.enabled=false \
--jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/orders_stream.py'
batch 2: 254 events
orders: 1000 rows
orders_append: 1304 rows
+------+------+
|status|orders|
+------+------+
|PLACED| 1000|
+------+------+
Spark found offsets/2 without commits/2 and re-ran batch 2 over the same
254 events. The append table grew by exactly 254 rows, to 1,304, while the
source still holds 1,050 events. The MERGE table did not change.
Finally, publish run 2 and process it:
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit \
--master "local[2]" \
--conf spark.eventLog.enabled=false --conf spark.ui.enabled=false \
/tmp/produce_orders.py 2'
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit \
--master "local[2]" \
--conf spark.eventLog.enabled=false --conf spark.ui.enabled=false \
--jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/orders_stream.py'
batch 3: 399 events
batch 4: 141 events
orders: 1500 rows
orders_append: 1844 rows
+-------+------+
| status|orders|
+-------+------+
| PLACED| 1470|
|SHIPPED| 30|
+-------+------+
The 540 new events became batches 3 and 4. The MERGE table now has 1,500
orders: 30 moved to SHIPPED, and the 10 stale PENDING events were rejected
by s.updated_ts > t.updated_ts, so no order shows PENDING. The append
table holds 1,844 rows for 1,590 published events: the 254-row replay, plus
every duplicate and every superseded version.
Step 5: why the in-batch dedup matters
Remove the window and feed a MERGE two copies of one key, and Spark refuses
the whole statement:
"""Show what MERGE does when one micro-batch carries the same key twice."""
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("sd-merge-dup")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions."
"IcebergSparkSessionExtensions")
.config("spark.serializer",
"org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.sd", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.sd.type", "hadoop")
.config("spark.sql.catalog.sd.warehouse", "file:///tmp/sd_blog/warehouse")
.getOrCreate()
)
spark.sql("CREATE NAMESPACE IF NOT EXISTS sd.shop")
spark.sql("DROP TABLE IF EXISTS sd.shop.merge_dup PURGE")
spark.sql("CREATE TABLE sd.shop.merge_dup (order_id STRING, status STRING) "
"USING iceberg")
spark.sql("INSERT INTO sd.shop.merge_dup VALUES ('o00001', 'PLACED')")
spark.createDataFrame(
[("o00001", "SHIPPED"), ("o00001", "SHIPPED")],
"order_id STRING, status STRING",
).createOrReplaceTempView("retried_batch")
try:
spark.sql("""
MERGE INTO sd.shop.merge_dup t USING retried_batch s
ON t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *""")
except Exception as err: # the failure is the point
code = "MERGE_CARDINALITY_VIOLATION"
print(f"MERGE failed: {code}" if code in str(err) else err)
spark.sql("DROP TABLE sd.shop.merge_dup PURGE")
spark.stop()
MERGE failed: MERGE_CARDINALITY_VIOLATION
The full error is SparkRuntimeException: [MERGE_CARDINALITY_VIOLATION] The ON
search condition of the MERGE statement matched a single row from the target
table with multiple rows of the source table. In a streaming job this fails
the batch, and every restart replays the same batch and fails again, so the
query is stuck until the code changes. Deduplicate inside each batch before
merging.
What the demo proves, and a pitfall it found
Replay is the engine’s job; idempotency is yours. Spark re-ran batch 2 with identical input, as its checkpoint protocol promises. Only the sink design decided whether that produced duplicates.
The catalog cache pitfall. The first version of orders_stream.py
counted the tables at the end without REFRESH TABLE and printed 0 rows for
both, although the data was there. Structured Streaming runs each query in a
cloned SparkSession, and foreachBatch hands you a DataFrame bound to that
clone, whose Iceberg catalog has its own table cache. The driver session still
held the table it loaded at creation time, before any batch committed.
REFRESH TABLE invalidates the cached entry. A monitoring job that reads row
counts from a long-lived session can report stale numbers in the same way.
Clean up
docker exec spark-master rm -rf /tmp/sd_blog
docker exec kafka kafka-topics --bootstrap-server kafka:29092 \
--delete --topic sd_blog_orders
Demo interview questions
13. Your streaming job writes to a table with foreachBatch. How do you make it exactly-once?
foreachBatch gives at-least-once: an uncommitted batch is re-run with the
same offsets and the same batch_id. I make the write idempotent. Either I
MERGE on the entity key after deduplicating within the batch, so a replay
rewrites the same rows, or I use a sink that records the batch id it
committed and skips a repeat (Delta Lake supports this with the txnAppId and
txnVersion writer options). A plain append inside foreachBatch duplicates
the batch on replay.
14. An old event arrives after a newer one for the same order. What protects the table?
The event’s own time, not the arrival order. The contract carries
updated_ts, and the MERGE updates only WHEN MATCHED AND s.updated_ts >
t.updated_ts. Within a batch, a window keeps the newest version per key, with
the Kafka offset as a tie-breaker. Without the guard, “latest wins” means “last
to arrive wins”, and a retried or delayed event rolls the order back.
Real-world scenarios: ten worked designs
Each design follows the framework in compressed form: assumptions, an estimate, the flow, the decisions that matter, and the failure modes an interviewer will push on. The numbers are assumptions to state, not facts about any company.
Design 1: clickstream analytics for a large website
Prompt. Collect page views and clicks from web and mobile, power product dashboards within five minutes, and keep history for analysis.
Assumptions and estimate. 10 TB a day of JSON events at about 1 KB each is 10 billion events a day, about 116,000 events a second on average and roughly 350,000 at a 3x peak, around 350 MB/s.
web / mobile SDK ──► collector API (stateless, autoscaled, batches events)
│ key = session_id or user_id
▼
Kafka clicks.raw ── 7 days ──► stream job:
│ parse, validate, enrich
│ (geo, user agent), dedup
▼ on event_id
bronze (Iceberg, raw JSON + parsed) ──► silver sessions
partitioned by event hour (30-min gap)
│ │
▼ ▼
gold: hourly metrics ──────────────► OLAP store (Pinot /
per page, funnel, cohort ClickHouse) for
5-minute dashboards
Key decisions. The collector batches events and returns quickly; mobile SDKs buffer offline, so expect events hours or days late and partition bronze by event hour while accepting writes to old partitions. Sessionisation is a stateful operation keyed by user with an inactivity gap, typically 30 minutes; run it in the stream with a watermark, or hourly in batch if five-minute freshness only applies to page-level counts. Bot traffic is filtered in silver with a flag, not dropped, so the rule can change later.
Failure modes. A viral page makes one key hot if you key by page; key by
session instead. A mobile release that floods duplicate events is absorbed by
event_id dedup. Identity resolution (anonymous id to logged-in user) changes
history, so keep it as a mapping table joined at read time rather than
rewriting events.
Design 2: CDC from an OLTP database to the lakehouse in under five minutes
Prompt. Replicate 300 PostgreSQL tables into the lakehouse with under five minutes of lag, including deletes, for analytics.
PostgreSQL ── logical replication slot ──► Debezium (Kafka Connect)
│ snapshot first, then WAL changes │ one topic per table
│ │ key = primary key
▼ ▼
outbox / heartbeat table Kafka + Schema Registry (Avro)
│
▼
stream job per table group, 1-min trigger
bronze: append change events (op, lsn)
silver: MERGE on PK, ordered by lsn;
op = 'd' ──► DELETE
Key decisions. Debezium takes a consistent snapshot, then streams from the
replication slot, so the lake starts from a known point. Order changes for a
row by the source log position (the LSN for PostgreSQL), not by arrival time.
Keep bronze append-only with every change event; silver is the MERGE result.
Schema changes go through the registry with backward compatibility, and a
column drop at the source becomes a nullable column in the lake rather than a
failure. Merge-on-read tables suit high-update tables; compact them on a
schedule.
Failure modes. If Debezium stops, the replication slot keeps WAL on the
primary until the disk fills; cap it with max_slot_wal_keep_size and alert on
slot lag. A large table’s initial snapshot can take hours; incremental
snapshots let streaming continue during it. Reconcile row counts per table per
hour against the source.
Design 3: a real-time operations dashboard with a 10-second SLA
Prompt. Show orders per minute, revenue and error rates per region with data at most 10 seconds old.
Key decisions. Ten seconds rules out lakehouse commits as the serving path, so the stream job aggregates in tumbling or sliding windows and writes the results to a low-latency store: an OLAP engine that ingests from Kafka directly (Pinot, Druid, ClickHouse with a Kafka engine) or a key-value store holding the current window counts with a TTL. The same events still land in the lakehouse for history and for correcting late data. The dashboard reads the fast store; anything older than a day reads gold.
Failure modes. Late events update a window already shown; decide whether the dashboard corrects old buckets or rolls forward, and say which. A burst that causes consumer lag makes the dashboard stale silently; show the data’s age on the dashboard itself.
Design 4: log aggregation for 10,000 servers
Assumptions and estimate. 10,000 servers at 5,000 lines a second each is 50 million lines a second; at 300 bytes a line that is 15 GB/s, about 1.3 PB a day before compression. The estimate is the design: at this volume, sampling and tiering are not optional.
agents (Fluent Bit / Vector) ── batch, compress, local buffer on disk
│ drop DEBUG at source; sample INFO; keep WARN+ at 100%
▼
Kafka (per-service topics) ──► stream: parse, add metadata, route
│ ├─► search cluster: 7 days hot
│ └─► object storage: Parquet, zstd,
│ partitioned by service/hour,
▼ queried with Trino; 90 days
metrics extracted from logs ──► time-series store for alerting
Key decisions. Agents buffer to local disk so a Kafka outage does not lose logs or block applications. Filtering and sampling happen at the edge, because shipping and then dropping costs network and broker disk. Hot search holds days, the lake holds months, and retention is set per log level and service.
Failure modes. A noisy service floods the pipeline; enforce per-service quotas in Kafka. A parsing change breaks a format; keep the raw line in the lake so it can be re-parsed.
Design 5: a fraud feature pipeline with a 100 ms decision
Prompt. Score card transactions at 5,000 a second with features such as “transactions for this card in the last 10 minutes” and “distance from the last transaction”.
payment API ──► Kafka txns (key = card_id) ──► Flink keyed state
per card: counts in
sliding windows, last
location, amount stats
│ upsert features
▼
payment API ── sync call ──► scoring service ──► online feature store
(decision < 100 ms) │ model (Redis / DynamoDB,
│ single-digit ms reads)
▼
decision event ──► Kafka ──► lakehouse (training data,
point-in-time correct)
Key decisions. The decision path is synchronous and reads precomputed features; nothing heavy runs inside the 100 ms. Features are computed by a stateful stream keyed by card, so all of one card’s events go to one task. The same feature definitions feed the offline store with point-in-time joins, so training sees what the model would have seen at decision time; mismatched logic between the two is training-serving skew.
Failure modes. If the feature store is slow, the scoring service needs a fallback rule set and a timeout, decided with the business: allow, block, or review. Feature freshness lag shows up as missed fraud, so alert on it.
Design 6: IoT telemetry from one million devices
Assumptions and estimate. One million devices reporting every 5 seconds is 200,000 messages a second; at 200 bytes each, 40 MB/s.
Key decisions. Devices speak MQTT to a broker built for many long-lived
connections, which bridges to Kafka keyed by device_id. The stream computes
per-device rolling statistics and threshold alerts with keyed state (RocksDB
for a million keys). Recent raw data goes to a time-series store with
downsampling; everything lands in the lakehouse partitioned by date, sorted or
clustered by device for per-device queries.
Failure modes. Devices reconnect after an outage and send hours of buffered data at once, so the watermark must tolerate it or the late data must go through a batch path. Device clocks drift; record both device time and ingestion time.
Design 7: a multi-tenant file ingestion service
Prompt. Customers upload CSV files of up to 5 GB; parse, validate, store and report errors back to them.
customer ──► pre-signed upload URL ──► s3://landing/<tenant>/<upload_id>/
│ object-created event
▼
queue ──► validation job (per file)
│ schema + row rules per tenant
├─► valid rows ──► bronze, tagged
│ tenant, upload_id
└─► rejected rows ─► errors table +
report to tenant
status table: upload_id, state (RECEIVED → VALIDATING → LOADED | FAILED)
Key decisions. Uploads go straight to object storage through pre-signed
URLs, so the API never carries file bytes. Processing is keyed by upload_id,
which makes a re-run of the same file a replace, not an append. Each tenant
gets quotas and a separate prefix, and tenant id is a column in every table
for isolation and deletion.
Failure modes. A malformed 5 GB file should fail fast on a sampled header check, not after an hour. One large tenant must not starve others; use per-tenant concurrency limits.
Design 8: an e-commerce warehouse for daily reporting
Prompt. 2 million orders a day across web and stores; finance, marketing and operations dashboards refreshed daily, with history kept as it was.
Key decisions. This is a batch design, and saying so early is a strength:
2 million orders a day is about 23 a second. Ingest with CDC or daily
extracts into bronze, build silver with dbt or Spark, and model gold as a star
schema: fact_order_line at one row per order line, conformed dim_customer
and dim_product with SCD Type 2 history, and a date dimension. Partition the
facts by order date. Separate compute for loading and for BI queries so a
heavy dashboard does not delay the load. The data modeling guide
covers this model in full.
Failure modes. Returns and cancellations arrive days later and change past totals; decide whether gold restates history or records adjustments. A fact joined to the current dimension version instead of the version valid at order time rewrites history.
Design 9: a data quality platform for 500 tables
Key decisions. Checks are declared next to the data, as YAML or dbt tests in version control, owned by the dataset owner. The orchestrator runs them as a stage after each load. Gold checks block publishing; bronze checks warn. Results go to a metrics store with history, so the platform can detect drift (row counts, null rates) against the past instead of fixed thresholds alone. Lineage connects a failed check to downstream consumers, and the catalog shows each table’s health.
Failure modes. Alert fatigue: hundreds of low-value checks get ignored. Start with freshness, volume and key uniqueness on the most-used tables, and add checks after incidents.
Design 10: GDPR right to erasure across the lake
Prompt. Delete a user’s personal data within 30 days of a request, across Kafka, the lakehouse, backups and derived tables.
Key decisions. Keep PII in as few tables as possible, keyed by a user id,
and refer to users elsewhere by a pseudonymous key. Then erasure is a delete
in those tables plus a rewrite of affected files: a table format DELETE
writes new files or delete files, and only snapshot expiry and orphan-file
cleanup physically remove the old bytes. Old snapshots still contain the data
until they expire, so the expiry schedule is part of the compliance window.
Kafka retention shorter than the erasure window avoids rewriting topics. An
alternative is crypto-shredding: encrypt each user’s PII with a per-user key
and delete the key.
Failure modes. Time travel and backups resurrect deleted data; document how long each retains it. Derived aggregates that cannot identify a person can stay; derived tables with user-level rows must be included.
Production failures: where designs break
| Symptom | Cause | Design fix |
|---|---|---|
| Revenue doubles after a node restart | Append sink replayed an uncommitted batch | Idempotent MERGE on a key, or a batch-id-aware sink |
| Streaming job fails on every restart | MERGE_CARDINALITY_VIOLATION from duplicate keys in one batch |
Deduplicate within the batch before MERGE |
| An order shows an old status | Stale event overwrote a newer one: last to arrive won | Version guard on event time or log position |
| Primary database disk fills overnight | CDC connector stopped; replication slot retained WAL | max_slot_wal_keep_size, slot-lag alert |
| Consumer lag climbs while CPU is idle | Rebalance storms: processing exceeds max.poll.interval.ms (default 300000 ms) |
Smaller polls, faster processing, or more partitions |
| One task runs for hours, others finish | Hot key or skewed partition | Salting, AQE skew join, a separate path for the outlier |
| Queries slow down week by week | Thousands of small files and snapshots | Compaction, larger commits, snapshot expiry |
| Backfill makes the live pipeline miss its SLO | Shared compute and source | Separate pool, throttled backfill, read from the lake |
| Dashboard flat at zero, no alert | Job succeeded with zero rows | Volume checks against history |
| Monitoring reports stale counts | Cached table in a long-lived session | REFRESH TABLE, or read through a fresh session |
Scenario interview questions
15. Design a pipeline that ingests clickstream data and serves 5-minute dashboards. What are the hardest parts?
Late and duplicate events, sessionisation state, and keeping two serving
paths consistent. Mobile clients send events hours late, so bronze is
partitioned by event time and accepts writes to old partitions, and gold
numbers for recent hours are marked provisional. Duplicates from SDK retries
are removed by event_id at the silver MERGE. Sessions need keyed state
with an inactivity gap and a watermark. The dashboards read an OLAP store fed
by the stream; history reads gold, and a daily reconciliation compares the two.
16. Your CDC connector was down for six hours. What happens, and how do you recover?
On PostgreSQL the replication slot holds WAL since the connector’s last
confirmed position, so nothing is lost as long as the primary has disk; that is
why slot lag is a P1 alert and max_slot_wal_keep_size is set, accepting that
an invalidated slot means a re-snapshot. When the connector restarts it
resumes from the slot, Kafka absorbs the burst, and the stream job catches up,
bounded by maxOffsetsPerTrigger so batches stay a manageable size. The
silver MERGE is ordered by LSN, so replayed changes apply correctly. Then I
reconcile counts for the window.
17. How would you handle a request to delete one user's data from the lakehouse?
I would have designed for it: PII lives in a few tables keyed by user id, and
everything else uses a pseudonymous key. The deletion job runs a table-format
DELETE on those tables, which writes new files or delete files. The old
bytes remain in earlier snapshots, so snapshot expiry and orphan-file removal
must run within the compliance window, and time travel beyond it is not
allowed for those tables. Kafka retention is shorter than the window. For
data that cannot be rewritten, per-user encryption keys allow crypto-shredding.
18. When would you not use Kafka?
When there is one consumer, freshness is hourly or daily, and the source can be re-read. A daily extract or CDC files landed in object storage and loaded in batch are simpler and cheaper. Kafka earns its operating cost when several consumers need the same events, when bursts must be absorbed, or when sub- minute freshness and replay from a log are requirements.
Optimisation, tuning knobs and best practices
Partitioning and skew
Partition storage by the column most queries filter on, usually a date or hour of event time, and keep partitions large enough to hold files of hundreds of megabytes. Over-partitioning (by date and user id, say) creates millions of tiny files. Within partitions, sort or cluster by a second frequent filter so file-level min and max statistics can skip files.
For skewed joins, Spark’s adaptive query execution splits oversized shuffle
partitions. It is on by default (spark.sql.adaptive.enabled = true,
spark.sql.adaptive.skewJoin.enabled = true). A partition is treated as skewed
when it is larger than spark.sql.adaptive.skewJoin.skewedPartitionFactor
(5.0) times the median and larger than
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes (256 MB). When
the skew is a known hot key, salting it or handling it in a separate branch is
more predictable. The data skew post
covers both.
Backpressure and rate limits
When consumers fall behind, the log absorbs it, and consumer lag is the
signal. Act on it in order: scale the bottleneck stage, bound the batch size
so a backlog does not create one enormous batch (maxOffsetsPerTrigger for
Spark’s Kafka source), and shed or defer low-priority work. A backlog larger
than retention is data loss, so alert on lag as time (“40 minutes behind”)
against the retention window, not as a message count.
Small files and table maintenance
Each streaming commit adds files and a snapshot. Schedule maintenance per table:
| Task | Iceberg procedure | Why |
|---|---|---|
| Compact small files | CALL sd.system.rewrite_data_files('shop.orders') |
Fewer, larger files; faster scans |
| Expire snapshots | CALL sd.system.expire_snapshots('shop.orders', TIMESTAMP '2026-10-21 00:00:00') |
Bounds metadata and frees storage |
| Remove orphan files | CALL sd.system.remove_orphan_files(table => 'shop.orders') |
Deletes files left by failed writes |
| Rewrite manifests | CALL sd.system.rewrite_manifests('shop.orders') |
Faster planning on tables with many commits |
Iceberg’s default snapshot age for expiry is 5 days
(history.expire.max-snapshot-age-ms = 432000000), which also bounds how far
back time travel works after expiry runs.
Cost
Name the cost drivers with the design, because the interviewer will ask:
| Driver | Lever |
|---|---|
| Always-on streaming compute | Micro-batch on a schedule (availableNow) when freshness allows |
| Log storage | Retention sized to the replay need; tiered storage for long retention |
| Object storage | Columnar formats with zstd, lifecycle rules to colder tiers |
| Query compute | Partition pruning, clustering, pre-aggregated gold tables, auto-suspend |
| Cross-zone and egress traffic | Keep producers, brokers and consumers in-region; rack-aware consumers |
| Duplicate copies | Fewer layers with clear contracts; views where a copy adds nothing |
Tuning checklist
| Area | Knob | Default | Guidance |
|---|---|---|---|
| Kafka producer | acks |
all (Kafka 3.0+) |
Keep all for data you cannot lose |
| Kafka producer | enable.idempotence |
true (Kafka 3.0+) |
Keep enabled |
| Kafka topic | min.insync.replicas |
1 | 2 with replication factor 3 |
| Kafka topic | retention.ms |
604800000 (7 days) | Replay window plus margin |
| Kafka consumer | max.poll.interval.ms |
300000 | Raise only with a reason; prefer smaller polls |
| Spark | spark.sql.shuffle.partitions |
200 | AQE coalesces; set for the largest stage |
| Spark | spark.sql.adaptive.advisoryPartitionSizeInBytes |
64 MB | Target shuffle partition size |
| Spark | spark.sql.autoBroadcastJoinThreshold |
10 MB | Raise for medium dimensions if memory allows |
| Spark | spark.sql.files.maxPartitionBytes |
128 MB | Input split size for file scans |
| Spark streaming | maxOffsetsPerTrigger (Kafka source) |
None | Bound batches during catch-up |
| Spark streaming | spark.sql.streaming.stateStore.providerClass |
HDFS-backed | RocksDB provider for large state |
| Iceberg | write.target-file-size-bytes |
536870912 (512 MB) | Keep large; compact streaming tables |
| Iceberg | commit.retry.num-retries |
4 | Raise for many concurrent writers |
Tuning interview questions
19. Your streaming table has 200,000 small files. How did that happen, and what do you do?
Each micro-batch commit writes at least one file per written partition per
task, so a short trigger interval, many shuffle partitions and fine-grained
partitioning multiply into many small files. I would lengthen the trigger
interval if freshness allows, reduce the number of writing tasks or use a
hash distribution so each partition gets fewer files, and schedule
rewrite_data_files for compaction plus expire_snapshots and orphan-file
cleanup. I would also check whether partitioning is too fine for the volume.
20. Consumer lag keeps growing. Walk me through the diagnosis.
First, is it all partitions or a few? A few means skew: a hot key or an
uneven partition assignment. All partitions means throughput: check whether
the job is CPU-bound, waiting on the sink (slow commits, a throttled
database), or rebalancing because processing exceeds max.poll.interval.ms.
Then act: scale the bottleneck, bound batch sizes, fix the hot key, or add
partitions with a plan for the key remapping. Throughout, I compare lag in time
with the retention window, because a backlog beyond retention loses data.
21. How would you cut the cost of a 50 TB-a-day platform?
Measure first: compute, storage, log retention and data transfer each need a number. Then the usual levers: run streams that only need 15-minute freshness as scheduled micro-batches instead of always-on clusters; use spot or preemptible capacity for batch with retries; store columnar with zstd and lifecycle old partitions to colder tiers; compact and cluster so queries scan less; pre-aggregate the most expensive dashboard queries into gold tables; and keep traffic in one region. Each change gets a before and after number.
Summary and key takeaways
A data system design answer is judged on what happens after the happy path: the restart, the replay, the late event, the schema change, the bill. The framework puts those questions in the order an interviewer expects. Consumer and decision first, then numbers, then the contract, then the diagram, then recovery, operations and trade-offs.
The mental model to carry into the room:
- The log and the lake are your replay stores. Know how far back each goes, and design every sink so that replaying into it is safe.
- Delivery is at-least-once; effects are made exactly-once at the sink.
The demo showed it: Spark replayed batch 2 with identical input, the append
table grew from 1,050 to 1,304 rows, and the
MERGEtable stayed at 1,000. - Event time orders versions; arrival time does not. A version guard in
the
MERGErejected all 10 stale events. - Commits are the unit of cost in a lakehouse. Batch them, compact them, and expire them.
- Quality is monitored on data. Freshness, volume, schema, validity and reconciliation, with owners and lineage.
Practise each worked design out loud against a 40-minute timer. For each one, say the binding requirement, the estimate, the replay story and one rejected alternative. If you can do that for all ten, the specific prompt matters much less.
References
- DataVidhya: data engineering system design interview questions for the question bank and pattern list this guide builds on
- PracHub: data engineering system design interview guide for the seven-step framework and scoring dimensions
- Apache Kafka documentation: design for partitions, replication, the idempotent producer and transactions
- Apache Kafka producer configs, topic configs and consumer configs for
acks,enable.idempotence,retention.ms,min.insync.replicas,max.poll.interval.msandisolation.level - KIP-679: producer enables the strongest delivery guarantee by default for the Kafka 3.0 defaults
- Spark 3.5.9 Structured Streaming programming guide for checkpoints,
foreachBatch, watermarks,dropDuplicatesWithinWatermarkand the RocksDB state store - Spark 3.5.9 Structured Streaming and Kafka integration for
maxOffsetsPerTriggerand source options - Spark 3.5.9 SQL performance tuning for adaptive query execution and skew join settings
- Iceberg 1.11.0 Spark writes, maintenance, procedures and table properties for
MERGE INTO, compaction, snapshot expiry and commit retries - Iceberg table spec for metadata files, manifest lists, manifests and atomic commits
- Delta Lake: idempotent writes in foreachBatch for
txnAppIdandtxnVersion - Apache Flink: fault tolerance guarantees of data sources and sinks and the Kafka connector for checkpoint-based exactly-once sinks
- Debezium PostgreSQL connector for snapshots, replication slots and change event structure
- PostgreSQL replication settings for
max_slot_wal_keep_size - Questioning the Lambda Architecture for the Kappa alternative
- The world beyond batch: Streaming 101 for event time, processing time and watermarks
- OpenLineage for lineage metadata
- Run every demo on this blog locally for the public Spark and lakehouse playground setup
Trademarks
Apache Spark, Apache Kafka, Apache Iceberg, Apache Hudi, Apache Parquet, Apache Flink, Apache Pinot, Apache Druid, Apache Airflow and Apache are either registered trademarks or trademarks of The Apache Software Foundation in the United States and other countries. Delta Lake is a trademark of LF Projects, LLC. Debezium is a trademark of Red Hat, Inc. PostgreSQL is a registered trademark of the PostgreSQL Community Association of Canada. ClickHouse is a registered trademark of ClickHouse, Inc. Redis is a registered trademark of Redis Ltd. Elasticsearch is a trademark of Elasticsearch B.V. Other names are the property of their respective owners. Logos are shown unmodified to identify each project and do not imply endorsement.
Found this useful?
These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.
Comments
Questions, corrections and reviews are welcome. Sign in with GitHub to post; comments are stored in this site's public GitHub Discussions.