depthfirst home   /   Tools & prototypes

Field guides / Updated August 2026 / Written for engineers who get paged

Six things in data engineering that actually break in production

Not another tool tour. These are the six failure surfaces I keep meeting across payments, governance and AI platform work: reruns that corrupt, models that rot, storage layers that lie, streams that silently drop data, checks that catch nothing, and privacy debt that becomes a legal problem.

Start anywhere

Chapter 01 Correctness

Reruns that do not corrupt

Almost every data incident I have investigated collapses into one question: what happens when this job runs twice? Get that answer right and half of your on-call disappears.

A pipeline is idempotent when running it again with the same inputs leaves the world in the same state. Run it once, run it fifty times, the table looks identical.

This sounds academic until 3am, when a task failed halfway through writing, the retry kicked in, and your revenue table now has 1.4x the real revenue because the first attempt committed 40% of its rows before dying.

Here is the deal

You do not get idempotency from a framework setting. You get it from three decisions: how you partition, how you write, and whether your logic has hidden inputs. Airflow retries, Spark speculation and Kubernetes restarts all assume you already solved it.

The only three write modes, ranked by how much they will hurt you

Write mode Idempotent? When it is right How it bites
Append No Immutable event logs where duplicates are handled downstream Partial failure plus retry equals duplicates. Forever. Nothing cleans it up for you.
Insert overwrite partition Yes Batch tables partitioned on a deterministic key, usually event date Only safe if the partition key is derived from the data, not from the clock at runtime.
Merge / upsert Yes, conditionally Slowly changing dimensions, CDC targets, late-arriving corrections Breaks when the merge key is not truly unique, or when two source rows for the same key arrive in one batch.

The default should be insert overwrite by partition. It is the cheapest form of correctness available: the write is a full replacement of a bounded slice, so the state after the run does not depend on the state before it.

Spark / dynamic partition overwrite
# Without this, Spark's "overwrite" nukes the ENTIRE table, not just
# the partitions present in your dataframe. This one line has caused
# more data loss than any bug I have seen.
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")

(df
  .repartition("event_date")          # one writer per partition, fewer small files
  .write
  .mode("overwrite")
  .partitionBy("event_date")
  .saveAsTable("analytics.fct_payment"))

Hidden inputs: the real cause of non-determinism

A pipeline is deterministic when output equals f(input partition). Anything else your code reads is a hidden input, and every hidden input is a future incident.

  • current_timestamp(), now(), current_date() in the transformation body. If a backfill for March runs in August, every "days since" column is wrong.
  • rand(), uuid(), monotonically_increasing_id(). Rerun, different values, downstream joins silently lose rows.
  • Mutable dimension tables read as of now. Joining yesterday's facts to today's customer table gives you a different answer every day you rerun it.
  • API or service calls inside the transform. The remote system moved on.
  • Ordering assumptions. collect_list without an explicit sort returns whatever the shuffle gives you.

The most expensive one-liner in data engineering

Partitioning on date(current_timestamp()) instead of date(event_time). It works perfectly for months. Then you backfill, and every historic day lands in today's partition. Now the fix is not a rerun, it is a manual archaeology project across every partition you touched.

Late data: pick a lookback window, and pick it with evidence

Events arrive late. Mobile clients buffer offline, upstream services replay, a broker had a bad hour. So "compute yesterday, once" is wrong on every real system.

The pattern that works: every run reprocesses a rolling window, not just the newest day. Because the write is an overwrite by partition, reprocessing is free of side effects.

SQL / rolling lookback with deterministic partitions
-- Runs every day. Rewrites the last 3 event-days from source truth.
-- Rerunning any date range is safe: same input, same output.
INSERT OVERWRITE TABLE analytics.fct_payment PARTITION (event_date)
SELECT
    payment_id,
    TO_DATE(event_time)          AS event_date,   -- from the DATA
    amount_usd,
    status
FROM raw.payment_events
WHERE TO_DATE(event_time)
      BETWEEN DATE_SUB('${run_date}', 2) AND '${run_date}';

How do you choose the window size? Measure it. Log ingest_time - event_time for a month and take the p99. If p99 is 26 hours, a 3 day lookback covers you with margin. If p99 is 9 days, you have an upstream problem to fix before you widen the window to 10 days and pay for it every single night.

Deduplication that actually holds

At-least-once delivery is the norm in every message system worth using. So dedup is not optional, it is a layer.

SQL / the dedup pattern you will use a thousand times
WITH ranked AS (
  SELECT *,
    ROW_NUMBER() OVER (
      PARTITION BY payment_id                -- the true business key
      ORDER BY     source_updated_at DESC,   -- newest wins
                   ingest_sequence   DESC    -- tiebreak, never leave ties
    ) AS rn
  FROM raw.payment_events
  WHERE event_date BETWEEN '${start}' AND '${end}'
)
SELECT * EXCEPT (rn) FROM ranked WHERE rn = 1;

Three details separate this from the version that fails in six months:

  1. Always include a tiebreaker. If two rows share the same key and same timestamp, ROW_NUMBER picks arbitrarily, and "arbitrarily" changes between runs. That alone destroys idempotency.
  2. Order by the source's clock, not yours. Ingest time tells you when you saw it. Source updated time tells you which version is truer. For CDC use the log position (LSN, SCN, binlog offset), which is monotonic by construction.
  3. Dedup inside the reprocessing window, not globally. A global dedup scans the whole table on every run and gets slower forever.

Backfills: one code path or none

The single strongest predictor of backfill pain is having a separate backfill script. It drifts from the production job within two sprints, and then your history and your present are computed by different logic.

ANTIPATTERN daily_job.py v14, tested backfill_v2_FINAL.py v6, last run Q1 one table two definitions PATTERN job.py --start --end one table one definition Backfill is the same job with a wider date range.
Fig 1.1 / A backfill is not a separate program. It is the same program with different parameters.

Three operational rules that make large backfills survivable:

  • Chunk by partition and run with bounded parallelism. Fifty concurrent partition rewrites will evict your production queries from the cluster and page someone else.
  • Write to a shadow table first for anything above a week. Compare row counts and key aggregates against production, then swap. This is the write, audit, publish pattern from Chapter 5.
  • Keep a backfill ledger. One row per partition with status and run id. When someone asks in November whether March 14 was reprocessed after the fix, you want an answer, not a Slack search.

Exactly-once, said honestly

There is no such thing as exactly-once delivery over a network. What exists is at-least-once delivery plus an idempotent sink, which produces exactly-once effects. Every system that markets exactly-once is doing one of these:

Mechanism How it works Cost
Idempotent sink Write is an upsert on a stable key, so replays overwrite rather than duplicate Cheapest and most robust. Needs a real primary key.
Transactional two-phase commit Sink participates in the checkpoint, commits only when the checkpoint completes Adds latency equal to your checkpoint interval. Breaks if the transaction timeout is shorter than a slow checkpoint.
Dedup on a stored key set Keep seen event ids in state or a store, drop repeats State grows without bound unless you TTL it, and the TTL is a correctness window.

Interview answer worth memorizing

"I do not rely on exactly-once delivery. I make the sink idempotent with a deterministic key, so at-least-once delivery converges to the correct state. Then I only pay for transactional commits where the sink genuinely cannot be keyed, for example appending to an external file drop."

Rerun safety check

Interactive

Tick everything that is true about your pipeline. The verdict tells you what happens on the second run.

VerdictSafe to rerun. Output is a function of the input partition only.

The checklist

Before you merge any pipeline

  • Partition key is derived from the event payload, never from the clock.
  • Write mode is overwrite by partition or a keyed merge. Append only for raw immutable landing zones.
  • No clock, no randomness, no network calls inside the transform.
  • Dimension joins are as-of the event date, not as-of now.
  • Dedup ranking has a deterministic tiebreaker.
  • The job takes a start and end date. There is no second script.
  • You have run it twice in staging and diffed the output. Byte for byte, or at least row count plus checksum per partition.
Next: dimensional modeling

Chapter 02 Modeling

Dimensional modeling that survives contact

Kimball is thirty years old and people keep announcing its death. Meanwhile every warehouse that scales still has facts, dimensions and a grain. What changed is the cost of a join, not the value of a definition.

Write the grain as a sentence before you write DDL

The grain is what one row means. If you cannot say it in one sentence with no "and also", the model is wrong and no amount of indexing will save it.

  • Good: "One row per payment attempt."
  • Good: "One row per customer per calendar day."
  • Bad: "One row per payment, and for refunds one row per refund line, and sometimes a summary row."

Mixed grain is the single most common modeling defect I see in code review. It shows up later as double counting that nobody can explain, because the SUM is technically correct and the table is not.

The tell

If your fact table has a column called record_type or row_level that changes what the other columns mean, you have two tables pretending to be one. Split them.

Four fact table shapes and when each is correct

Shape Grain Use it for Watch out for
Transaction One row per event, inserted once Payments, clicks, orders, API calls Grows forever. Needs partition plus retention from day one.
Periodic snapshot One row per entity per period Daily balances, subscriber counts, inventory on hand Storage. A 100 GB table snapshotted daily is 36 TB a year.
Accumulating snapshot One row per process instance, updated as it progresses Order fulfilment, loan application, claim lifecycle It is mutable, so it fights immutable storage. Needs merge and a clear final state.
Factless One row per occurrence, no measures Attendance, eligibility, coverage, "did this happen" People try to add measures to it. Then it is a transaction fact with a bad name.

Additivity is a property you must record

Every measure is one of three things, and the model should tell you which:

  • Additive: sums across every dimension. Revenue, quantity, cost.
  • Semi-additive: sums across some dimensions but not time. Account balance, headcount, inventory level. Summing 30 daily balances gives a meaningless number.
  • Non-additive: ratios, percentages, averages, distinct counts. Never store a ratio. Store the numerator and denominator and let the ratio be computed at query time, otherwise every rollup is wrong.

Slowly changing dimensions, decided in 30 seconds

A customer changes address. Do the old orders belong to the old address or the new one? That question, and only that question, decides your SCD type.

Type Behaviour Pick it when
0 Never changes after insert Original signup date, birth country. Immutable by definition.
1 Overwrite in place, history lost Correcting a typo, or the attribute has no analytical history value.
2 New row per change, with valid_from, valid_to, is_current The default for anything a report might slice by historically. Territory, tier, segment, plan.
3 Extra column holds the previous value Exactly one lookback is needed, usually during a reorg. Rarely worth it.
4 Current row in the dim, history in a separate mini-dimension Rapidly changing attributes on a huge dimension, for example behaviour scores.
6 Type 2 rows plus current-value columns on every row Users want both "as it was" and "as it is now" without a second join. Very common in practice.
SQL / SCD Type 2 merge with hash change detection
-- Step 1: hash only the columns whose change should create a new version.
-- Include a noisy column like last_login_at and you will version every day.
WITH src AS (
  SELECT customer_id, segment, country, plan_tier,
         SHA2(CONCAT_WS('|', segment, country, plan_tier), 256) AS row_hash,
         source_updated_at
  FROM staging.customer
)
MERGE INTO dim.customer AS t
USING src AS s
  ON t.customer_id = s.customer_id AND t.is_current = true

-- Step 2: close the old version. Do NOT overwrite its attributes.
WHEN MATCHED AND t.row_hash <> s.row_hash THEN UPDATE SET
  t.valid_to = s.source_updated_at,
  t.is_current = false

-- Step 3: insert the new version, open ended.
WHEN NOT MATCHED THEN INSERT (
  customer_sk, customer_id, segment, country, plan_tier,
  row_hash, valid_from, valid_to, is_current
) VALUES (
  SHA2(CONCAT_WS('|', s.customer_id, s.source_updated_at), 256),
  s.customer_id, s.segment, s.country, s.plan_tier,
  s.row_hash, s.source_updated_at, '9999-12-31', true
);

The MERGE trap nobody warns you about

A standard MERGE cannot close a row and insert its replacement in the same statement, because a target row can only be acted on once. Most real implementations run two passes: close changed versions, then insert new ones. If your engine has multi-clause MERGE support, read the docs on match ordering carefully before trusting it.

Surrogate keys: hash beats sequence in a distributed system

An auto-increment surrogate key requires a global counter, which means coordination, which means a bottleneck and a non-deterministic value on rerun. A hash key (sha256 of the natural key plus the version timestamp) is computable independently on every executor, is identical on every rerun, and lets you build fact and dimension tables in parallel without waiting for the dimension load to publish keys.

The costs are real and worth naming: 32 bytes instead of 8, no ordering, and no visual debuggability. On columnar storage with dictionary encoding the size cost is usually noise. Take the determinism.

Late-arriving facts and the as-of join

Here is where most SCD2 implementations are quietly wrong. Your fact arrives with an event time. Your dimension has versions. The join must select the dimension version that was in effect at the event time, not the current one.

SQL / the as-of join, correctly
SELECT f.payment_id, f.amount_usd, d.plan_tier
FROM   fct_payment f
JOIN   dim_customer d
  ON   f.customer_id = d.customer_id
 AND   f.event_time  >= d.valid_from
 AND   f.event_time  <  d.valid_to;   -- half-open interval, never BETWEEN

Use a half-open interval. If you use BETWEEN valid_from AND valid_to and the boundaries touch, an event at the exact boundary matches two versions and you have silently doubled that row.

The dimension that has not arrived yet

A fact references customer 8812. The customer dimension has never seen 8812. Options, in order of how much I like them:

  1. Inferred member. Insert a placeholder dimension row with the key and "Unknown" attributes, then update it when the real record lands. The fact joins successfully and nothing is lost.
  2. Route to a quarantine table and reprocess after the dimension load. Correct, but it puts business data in a place nobody queries, so it needs an owner and an alarm.
  3. Drop the row. Never. This is silent data loss and it will be discovered by finance, not by you.

Star schema or one big table? Both, on purpose

The argument is not really about joins. On columnar engines with predicate pushdown, broadcast joins to small dimensions are close to free. The argument is about where the definition lives.

fct_payment grain: attempt dim_customer dim_date dim_method dim_merchant dim_geo wide mart 180 cols flatten
Fig 2.1 / The star is the source of definitions. The wide table is a materialized cache of one common join path.

My rule, and it has held across a payments warehouse and a petabyte-scale analytics estate:

  • Star schema is the semantic layer. Business logic, SCD history and conformed dimensions live here. This is what an auditor reads.
  • Wide denormalized tables are a cache. Built from the star, for high-traffic dashboards, for ML feature pipelines, and for anyone who is going to write their own SQL anyway. Rebuild them, do not hand-edit them.
  • If a metric is defined in the wide table and nowhere else, you have lost. Two teams will define it twice, differently, and both will be defensible.

From the field

At very large scale the daily snapshot wide table wins for a reason people rarely state: it is immutable and reproducible. Yesterday's partition never changes, so any query against it is deterministic forever, and a bad transform is fixed by rewriting one day. SCD2 tables are mutable by design, which makes them correct but harder to reason about under reruns. The storage cost buys you time-travel semantics for free.

The snapshot storage math, so you go in with your eyes open

A 100 GB dimension snapshotted daily costs about 36 TB per year before compression gains. Mitigations, in order of effort: shorten retention on old partitions, snapshot weekly beyond 90 days, or replace snapshots with SCD2 plus a view that reconstructs any as-of date. Do the arithmetic before you commit, not in the FinOps review.

SCD type picker

Interactive
RecommendationPick your answers above.

Modeling review checklist

  • The grain is written in the table description as one sentence.
  • No ratios stored. Numerator and denominator only.
  • Semi-additive measures are documented as such.
  • SCD2 tables use half-open intervals and a change hash that excludes noisy columns.
  • Fact to dimension joins are as-of event time wherever history matters.
  • Unknown dimension members are inferred, never dropped.
  • Every wide table names the star tables it is derived from.
Next: table formats

Chapter 03 Storage

Table formats and what the lakehouse really bought you

Iceberg, Delta and Hudi are not file formats. They are transaction logs that sit on top of Parquet and turn a directory of files into a table with an actual commit protocol. Everything good about them follows from that one idea.

What was broken about a Hive table

A Hive table was a convention: a directory, some subdirectories named dt=2026-08-01, and Parquet files inside. The metastore stored the schema and the partition list. That convention breaks in four specific ways.

  • No atomic commit. A writer produces files, then the reader sees them mid-write. There is no moment where the table flips from old state to new state.
  • Planning means listing. To find files, the engine lists object storage prefixes. On S3 that is a paginated API call per prefix, and a table with 200,000 partitions spends minutes just deciding what to read.
  • Schema evolution by position. Rename a column and old files no longer match. Reorder columns and you get silently wrong data, not an error.
  • Partitioning leaks into every query. The user has to know that dt exists and write WHERE dt = '2026-08-01' AND event_time >= .... Forget the first predicate and you scan five years.

Here is the deal

All three modern table formats solve the same problem the same way: keep an explicit, versioned list of which files belong to the table, and make changing that list an atomic operation. Snapshot isolation, time travel, safe schema evolution and fast planning are all consequences of that single change.

How Iceberg is put together

CATALOG METADATA MANIFESTS DATA catalog one pointer metadata.json schema, specs, snapshots manifest list (snap 41) partition ranges manifest list (snap 40) previous version manifest file path + column stats part-0001.parquet part-0002.parquet part-0003.parquet part-0004.parquet part-0005.parquet A commit is an atomic swap of this one pointer.
Fig 3.1 / Query planning walks metadata and prunes with column statistics. It never lists a directory.

Read that diagram right to left and the properties fall out:

  1. Manifest files record every data file plus per-column min, max and null counts. The planner can skip a file without opening it.
  2. A manifest list is one snapshot: the exact set of manifests, and therefore files, that make up the table at a point in time.
  3. metadata.json holds the schema, partition specs, sort orders and the history of snapshots.
  4. The catalog holds one pointer to the current metadata file. A commit is a compare-and-swap on that pointer.

Because the commit is optimistic, two concurrent writers can conflict. The loser re-reads the new snapshot and retries. This is fine for a handful of writers and turns into a livelock at high concurrency on the same partitions, which is why streaming writers should target distinct partitions or use a single writer per table.

Hidden partitioning is the feature people underrate

In Hive you materialize a dt column and pray users filter on it. In Iceberg you declare a transform, and the engine derives the partition value from a real column.

SQL / Iceberg partitioning and evolution
CREATE TABLE analytics.fct_payment (
  payment_id   STRING,
  event_time   TIMESTAMP,
  merchant_id  STRING,
  amount_usd   DECIMAL(18,2)
)
PARTITIONED BY (days(event_time), bucket(32, merchant_id));

-- Users just write this. No dt column, no leaked implementation detail.
SELECT * FROM analytics.fct_payment
WHERE event_time >= '2026-08-01';

-- Volume grew 10x. Change the layout without rewriting history.
-- Old files keep the old spec, new files use the new one.
ALTER TABLE analytics.fct_payment
  REPLACE PARTITION FIELD days(event_time) WITH hours(event_time);

Partition evolution is the part with no Hive equivalent. Changing a Hive partition scheme means rewriting the entire table. Iceberg stores the spec id per data file, so old and new layouts coexist and the planner handles both.

Copy on write versus merge on read

Updating a row in an immutable file format means one of two strategies, and the choice is a pure read-versus-write trade.

Copy on write Merge on read
Update mechanics Rewrite every data file containing an affected row Write a small delete file or delta log, leave the data file alone
Write cost High. One changed row can rewrite a 256 MB file Low. Proportional to the change
Read cost None. Files are clean Reader merges deletes at query time
Right for Batch updates, read-heavy tables, daily merges Frequent small upserts, CDC targets, streaming sinks
Operational duty Watch write amplification Compaction is not optional. Skip it and reads degrade weekly

The silent decay

Merge on read tables that nobody compacts do not fail. They just get slower every week until a dashboard times out and someone declares "the lakehouse is slow". Schedule compaction on day one, alarm on delete-file count per partition, and treat it as production work, not housekeeping.

The small file problem, with the arithmetic

Say a streaming job writes every minute. That is 1,440 files a day per partition. Over a year, half a million files in one table. Each file costs a metadata entry, a task slot, and at least one object storage request.

Target file size is 128 MB to 512 MB for analytics scans. Below about 32 MB, per-file overhead dominates and your job spends more time scheduling than reading.

Compaction planner

Interactive
Files written / day0
Avg file size0
Ideal file count0
Overhead factor0x
PlanAdjust the inputs.

Delta and Hudi, fairly

Delta Lake keeps a _delta_log directory of ordered JSON commits, with a Parquet checkpoint every ten commits so readers do not replay the whole history. Its strongest cards are deletion vectors (merge on read without rewriting files), OPTIMIZE with Z-ordering for multi-column locality, liquid clustering as the newer alternative to fixed partitioning, and the deepest Spark integration in existence. Its weakness has historically been the tightness of that Databricks coupling, though the open protocol has improved a lot.

Hudi was built for a specific job: high-frequency upserts keyed by a record id, with fast incremental pulls. Its record-level index means it can find the file holding a key without a full scan, which is exactly what a CDC sink needs. It is the most operationally complex of the three and repays that complexity only if your workload really is upsert-dominated.

Choose When Because
Iceberg Multi-engine estate, open catalog, large batch analytics, long retention Cleanest spec, real partition evolution, strongest engine-neutral adoption
Delta Databricks-centric platform, Spark-heavy teams, BI on the lake Best in-platform tooling, deletion vectors, liquid clustering, mature governance via Unity Catalog
Hudi Continuous CDC ingestion keyed by primary key, near-real-time upserts Record index and incremental queries are first class rather than bolted on

How to answer this in an interview

Do not rank them. Say: "They converge on the same commit-log idea, so I choose on ecosystem and workload shape. Iceberg if I need engine neutrality and partition evolution. Delta if the platform is Databricks and I want deletion vectors and liquid clustering. Hudi if the dominant workload is keyed upserts at high frequency." Then name a maintenance job, because that is what shows you have run one in production.

The four maintenance jobs everyone forgets

SQL / Iceberg maintenance, run these on a schedule
-- 1. Compact small files into target-sized ones.
CALL catalog.system.rewrite_data_files(
  table => 'analytics.fct_payment',
  options => map('target-file-size-bytes', '268435456')
);

-- 2. Merge tiny manifests. Planning time is proportional to manifest count.
CALL catalog.system.rewrite_manifests('analytics.fct_payment');

-- 3. Expire old snapshots. This is what actually frees storage.
--    It also sets your time-travel window. Coordinate with compliance.
CALL catalog.system.expire_snapshots(
  table => 'analytics.fct_payment',
  older_than => TIMESTAMP '2026-08-01 00:00:00',
  retain_last => 10
);

-- 4. Delete files no snapshot references (failed writes leave these behind).
CALL catalog.system.remove_orphan_files(
  table => 'analytics.fct_payment',
  older_than => TIMESTAMP '2026-08-20 00:00:00'
);

Two traps in that snippet

Expiring snapshots is the only thing that reclaims storage, and it is also the thing that destroys your ability to time travel or roll back a bad write. Pick the retention deliberately and write it in the table's contract. And never run remove_orphan_files with a recent cutoff: a job that is still writing has files no snapshot references yet, and you will delete data out from under it.

Next: streaming

Chapter 04 Streaming

Three clocks, one watermark, and the state you forgot

Streaming is not batch that runs more often. It is a different correctness model, where the hard part is deciding when a result is final. Almost every streaming bug is really a disagreement between two clocks.

The three clocks

  • Event time. When the thing happened, stamped by the producer. This is the only clock that defines correctness.
  • Ingestion time. When the broker received it. Monotonic and cheap, useful for measuring lag, useless for business logic.
  • Processing time. When your operator touched it. Fast, non-deterministic, and different on every replay.

If you window by processing time, a replay of yesterday's data produces different results than the original run. That is not a streaming system, that is a random number generator with a Kafka dependency.

A watermark is a claim, not a fact

A watermark of time W is the system asserting: I do not expect any more events with event time earlier than W. It is a heuristic. You choose how aggressive it is, and you are choosing between latency and completeness.

The usual construction is watermark = max_observed_event_time - allowed_out_of_orderness. Set the bound to 5 seconds and results are fast but drop stragglers. Set it to 6 hours and results are complete but nothing emits for 6 hours.

The idle partition stall

A watermark is tracked per source partition, and the operator's watermark is the minimum across its inputs. So one quiet Kafka partition holds the entire job's watermark back, no windows fire, and to a dashboard it looks exactly like the pipeline is dead. The fix is idleness detection: mark a source idle after N seconds of no data so it stops holding the minimum. Almost every team learns this at 2am.

WINDOW 10:00 - 10:05 WATERMARK lateness on time, counted late but inside lateness, window re-fires too late, dropped (alarm on this metric) EVENT TIME →
Fig 4.1 / Allowed lateness buys a second chance. Anything past it is silently discarded unless you route it to a side output.

Where late data goes

Three destinations, and you must pick one explicitly:

  1. Re-fire the window. The window emits an updated result. Downstream must handle retractions or upserts, not blind appends.
  2. Side output. Late events go to a separate stream or table, get counted, and are reconciled by a nightly batch job. This is the lambda-flavoured answer and it is usually the pragmatic one.
  3. Drop. Fine, if you emit a metric and alarm on it. Not fine as an accident.

Watermark simulator

Interactive
Counted on first fire0
Counted on re-fire0
Dropped0
Emit delay0s
ReadingMove the sliders.

Sample of 1,000 events with a realistic long-tail arrival delay.

State is the real operational limit

Any stateful operator (window, join, aggregate, dedup) holds data between events. That state is the thing that will page you, not CPU.

  • Key skew. State is partitioned by key. One hot key, say a merchant that is 40% of traffic, puts 40% of the state on one task manager. That task lags, alignment stalls, checkpoints time out. Detect it by plotting state size and lag per subtask, not per job.
  • Unbounded growth. A dedup keyed on event id with no TTL grows forever. Setting a TTL is not a memory optimization, it is a declaration that duplicates arriving after the TTL will not be caught. That is a correctness statement and it belongs in the contract.
  • Restore compatibility. If you do not assign stable operator UIDs, changing the job graph makes your savepoint unrestorable, and your only option is starting from a fresh offset. Assign UIDs on day one.
  • Backend choice. Heap state is fast until GC pauses; RocksDB spills to disk and handles state larger than memory at the cost of serialization on every access. Above a few GB per task, RocksDB.

Checkpoints in one paragraph

The system injects barriers into the stream. Each operator snapshots its state when barriers from all inputs arrive, and the checkpoint completes when every operator has done so. With aligned checkpoints, a fast input waits for a slow one, so backpressure directly inflates checkpoint duration. Unaligned checkpoints let barriers overtake in-flight data and snapshot that data too: much better under backpressure, at the cost of larger checkpoints. Treat checkpoint duration and failure count as first-class health metrics; they degrade before throughput does.

Kafka details that decide your design

Fact Consequence
Ordering exists only within a partition If you need per-customer ordering, customer id must be the partition key. There is no global order and asking for one costs you all parallelism.
Partition count is effectively one-way Adding partitions rehashes keys, so a key can move and its history is split across the old and new partition. Size for two years of growth.
Consumer parallelism caps at partition count Ten partitions means at most ten useful consumers in a group. Scaling the deployment past that does nothing.
Rebalances stop the world Slow processing trips the poll interval, triggering a rebalance, which slows processing further. Use static membership and a cooperative sticky assignor, and keep per-record work small.
Consumer lag is your real SLO Alarm on lag in time, not messages. "Ten thousand messages behind" means nothing; "four minutes behind" is something a business owner can agree to.

Change data capture: the pattern behind most real streams

Most "streaming" in enterprises is CDC: replicating an operational database into analytics in near real time. Query-based CDC (polling WHERE updated_at > last_seen) is easy and wrong in three ways: it misses deletes, it misses intermediate states, and it misses rows committed out of order relative to their timestamp. Log-based CDC reads the database's own write-ahead log, so it catches every change including deletes, in commit order.

The part that bites is the handoff. You need a consistent snapshot of the existing table, plus the stream of changes since, with no gap and no unhandled overlap. Modern connectors do incremental snapshotting with a watermark trick so they never lock the source table, but you should still verify the boundary yourself with row counts and a checksum on a stable key range.

Two CDC things that will bite you

Deletes. A tombstone in the log must become something in the warehouse: a soft-delete flag or a real removal. Ignore it and your analytics keeps counting cancelled accounts forever. Schema changes. An ALTER TABLE upstream flows into your stream mid-topic. Decide in advance whether the pipeline halts, quarantines, or auto-evolves, and register the schema so consumers cannot be surprised.

The uncomfortable part: you probably do not need streaming

Streaming costs roughly three to five times more engineering time than the batch equivalent, forever. State management, checkpoint tuning, exactly-once semantics, replay procedures, on-call depth: all of it is permanent overhead.

Required freshness Right answer
Under 5 seconds True streaming. Flink or Kafka Streams. You have earned the complexity.
1 to 15 minutes Micro-batch. Spark Structured Streaming with a trigger interval, or a 5 minute scheduled job on an incremental table. Nearly all the benefit, a fraction of the cost.
Over 30 minutes Batch. Anyone insisting on streaming here is buying a story, not a requirement.

The question to ask a stakeholder is not "how fresh do you want this data". Everyone answers "real time" to that. Ask instead: what decision will you make differently if this arrives in one minute rather than in one hour? If there is no answer, you have your requirement.

Next: data quality

Chapter 05 Reliability

Data quality, contracts, and the checks that pay for themselves

Most quality programs fail the same way: 4,000 tests, 300 alerts a week, nobody reads them, and the incident that costs real money is one nobody wrote a test for. Coverage is not the goal. Consequence is.

Five ways data goes wrong

Class Symptom Detection
Freshness Table did not update Max timestamp versus expected schedule. Cheapest, catches the most incidents.
Volume Row count spiked or collapsed Count against a seasonal baseline, never a fixed number.
Schema Column added, dropped or retyped Schema diff at the ingestion boundary, enforced in producer CI.
Values Nulls, duplicates, broken references, impossible ranges Assertions on the output table after each run.
Semantics Everything passes, the numbers mean something else now The expensive one. Only contracts and reconciliation catch it.

That last row is the killer. Upstream redefines "active user" from 30 day to 7 day. No test fires. The schema is identical, the volume is plausible, the nulls are fine. Three weeks later someone notices the board deck disagrees with the product dashboard. Semantic drift is invisible to schema validation, which is exactly why data contracts exist.

A data contract is five things, not a schema file

  1. Schema. Fields, types, nullability, and enumerated values.
  2. Semantics. What each field means in prose, with the definition of any derived or business term.
  3. Guarantees. Uniqueness, referential integrity, value ranges, expected volume band.
  4. SLA. Freshness and availability commitments, plus what happens when they are missed.
  5. Change policy. Who owns it, what counts as breaking, how much notice consumers get.

The point everyone misses

A contract enforced at the consumer is just monitoring with extra steps: you find out after the bad data has landed. A contract enforced in the producer's CI pipeline stops the breaking change from shipping at all. Same document, completely different value. If you can only do one thing, make schema validation a required check on the producing service's pull request.

Compatibility modes and the deploy order they imply

Mode Means Safe changes Upgrade first
Backward New schema can read data written with the old one Delete a field, add a field with a default Consumers
Forward Old schema can read data written with the new one Add a field, delete a field that had a default Producers
Full Both directions hold Only add or remove optional fields with defaults Either order
None No guarantee Anything Nothing. This is how outages happen.

The data test pyramid

Unit tests on transform logic Tiny fixtures, no cluster, milliseconds. Runs on every commit. Contract tests at the boundary Schema and semantics validated in the producer's CI. Blocks the merge. Assertions on output Not null, unique, referential, accepted range. Runs after each write. Monitors on trends Freshness, volume, distribution against a seasonal baseline. cheap fast expensive catches the weird stuff Most teams only do row 3
Fig 5.1 / Output assertions are the layer everyone builds first and the one that catches problems latest.

Why your static thresholds fire every Monday

"Alert if row count drops more than 20 percent" is the classic. Weekend traffic is 40 percent below weekday traffic, so it alerts every Saturday until someone mutes it, and then it is not a monitor, it is decoration.

The minimum viable seasonal check compares today to the same weekday over the last four weeks, and flags on median absolute deviation rather than percentage. It is a handful of lines of SQL and it removes most false positives.

SQL / seasonality-aware volume check
WITH daily AS (
  SELECT event_date, COUNT(*) AS rows_loaded
  FROM analytics.fct_payment
  WHERE event_date >= DATE_SUB('${run_date}', 28)
  GROUP BY 1
),
baseline AS (                                   -- same weekday, prior 4 weeks
  SELECT PERCENTILE_CONT(rows_loaded, 0.5) AS med,
         PERCENTILE_CONT(ABS(rows_loaded - AVG(rows_loaded) OVER ()), 0.5) AS mad
  FROM daily
  WHERE DAYOFWEEK(event_date) = DAYOFWEEK(DATE '${run_date}')
    AND event_date < '${run_date}'
)
SELECT
  d.rows_loaded,
  b.med,
  ABS(d.rows_loaded - b.med) / NULLIF(b.mad, 0) AS deviation_score,
  CASE WHEN ABS(d.rows_loaded - b.med) > 4 * b.mad
       THEN 'FAIL' ELSE 'PASS' END AS status
FROM daily d CROSS JOIN baseline b
WHERE d.event_date = '${run_date}';

Write, audit, publish

The pattern that separates teams who ship bad data from teams who catch it: never write directly to the table consumers read.

  1. Write to a staging location, or an Iceberg branch, or a snapshot that is not yet the current pointer.
  2. Audit by running assertions against that staged output. Row counts, key aggregates, referential checks, comparison to the previous run.
  3. Publish by an atomic swap, only if the audit passes. Iceberg branches make this a metadata operation with no data movement.
SQL / write-audit-publish on an Iceberg branch
-- WRITE: land the run on an isolated branch. Readers see nothing.
ALTER TABLE analytics.fct_payment CREATE BRANCH etl_run_8891;
INSERT INTO analytics.fct_payment.branch_etl_run_8891 SELECT ... ;

-- AUDIT: assert against the branch, not against production.
SELECT
  COUNT(*)                                       AS row_count,
  COUNT(*) - COUNT(DISTINCT payment_id)        AS dupes,
  SUM(CASE WHEN amount_usd < 0 THEN 1 ELSE 0 END) AS negatives
FROM analytics.fct_payment.branch_etl_run_8891
WHERE event_date = '${run_date}';

-- PUBLISH: atomic. Consumers move from old state to new in one step.
CALL catalog.system.fast_forward('analytics.fct_payment', 'main', 'etl_run_8891');

Blocking versus alerting: classify every check

If every check blocks the DAG, one flaky assertion halts the warehouse at 4am and someone disables checks entirely. If nothing blocks, garbage publishes. So classify:

  • Blocking: violations that make the output actively wrong. Duplicate primary keys, null keys, negative amounts, row count near zero, referential breaks on a join key. Fail the run and page.
  • Alerting: violations that are suspicious but survivable. Distribution shift, a new enum value, a slightly late arrival. Publish, notify the owner, review in the morning.
  • Quarantine: a subset of rows fails while the rest are fine. Route the bad rows to a quarantine table with the failure reason, publish the good ones, and alarm on quarantine volume. Never silently drop.

Check triage matrix

Interactive
Recommended check setChoose the three inputs.

Lineage is an on-call tool before it is a governance tool

Two ways to build it. Static lineage parses SQL and DAG definitions: complete coverage of what is declared, blind to anything dynamic. Runtime lineage reads query logs and execution plans: it captures what actually happened, including the ad hoc job someone runs from a laptop, but only after it has run at least once. Serious platforms do both and reconcile.

What makes it worth building is column-level granularity, because that is what answers the two questions that matter at 3am:

  • This column is wrong. Which reports and models consume it, and who do I tell?
  • I need to change this upstream field. What breaks?

SLOs, not test counts

Borrow the discipline from site reliability engineering. Define per dataset:

  • Freshness SLO. "Available by 06:00 local, 99 percent of days in a rolling 30 day window."
  • Completeness SLO. "Within 2 percent of the reconciled source count, 99.5 percent of days."
  • Error budget. Nine misses a quarter is the budget. Spend it and feature work stops until reliability work lands.

Where to spend your quality budget

Rank every table by blast radius: what breaks and who notices if this is wrong for a day. In most estates, twenty tables carry ninety percent of the consequence, usually the ones behind revenue reporting, regulatory filings and customer-facing numbers. Give those the full treatment: contracts, write-audit-publish, SLOs, on-call ownership. Give everything else freshness plus volume monitoring and move on. A quality program that treats all tables equally will run out of budget before it protects the ones that matter.

Next: privacy engineering

Chapter 06 Governance

Privacy engineering: governance as a build problem

Most companies treat privacy as a policy document and a spreadsheet of table owners. At scale that fails, because the hard parts are all engineering: propagating classification through lineage, deleting data you have copied forty times, and proving any of it to an auditor.

Here is the deal

Every privacy requirement reduces to one of four engineering questions. Where is it? (inventory and classification). Who can touch it, for what? (access and purpose). How long do we keep it? (retention and deletion). Can we prove any of the above? (audit). A policy PDF answers none of these. A platform answers all four automatically or it does not answer them at all.

Classify once, at the source, then propagate

Manual classification of derived tables does not scale and is never current. One table gets tagged, the four tables built from it do not, and the dashboard built from those is where the data actually leaks.

The pattern that works: tag columns at the source schema, then propagate tags along column-level lineage, with explicit downgrade rules.

Transform Tag on the output Reasoning
Copy, cast, rename Inherits fully Same information, different label
Concatenate with anything Inherits the strictest input tag Sensitivity is contagious in a join or concat
Salted hash or tokenize Downgrades to pseudonymous, not public Still links to an individual if you hold the mapping. This is not anonymization.
Bucket or truncate Downgrade only with a reviewed rule A birth date truncated to year is weaker. A postcode truncated to three digits may still identify.
Aggregate above a k threshold Downgrades, if k is enforced and small groups are suppressed A count of 1 is a disclosure wearing a hat

Hashing is not anonymizing

An email hashed with SHA-256 is still personal data under GDPR, because anyone holding the email can compute the same hash and re-identify. Hashing is pseudonymization: it reduces exposure, it does not remove obligations. Claiming otherwise in a design review is one of the fastest ways to lose credibility with a privacy team.

Purpose limitation is a join, and it belongs in the access path

Data collected for fraud prevention should not silently become training data for ad ranking. Encoded as engineering, purpose limitation means every dataset carries allowed-purpose tags, every access request carries a declared purpose, and the policy engine intersects them before the query runs.

Policy as code / purpose and classification check
# Deny by default. Grant only when purpose, classification and
# the requester's clearance all line up. Every decision is logged.
allow {
    input.dataset.classification == "pii_restricted"
    input.request.purpose in input.dataset.allowed_purposes
    input.user.clearance   >= 3
    input.request.justification != ""
    time.now_ns() < input.user.grant_expires_ns   # access is temporary
}

mask[column] {
    column := input.dataset.columns[_]
    column.tags[_] == "pii_direct"
    not input.user.clearance >= 4              # mask instead of deny
}

Two design points worth stealing. Mask rather than deny where possible, so an analyst can still do their job on the non-sensitive columns instead of filing a ticket and losing a day. And make grants expire, because permanent access granted for one project is the single largest source of over-permissioning in every estate I have seen.

Deletion: the genuinely hard one

A user asks to be deleted. You delete their row from the source database. Then you remember where else it lives.

source row user 8812 raw landing zone 30 daily snapshots SCD2 history rows ML feature store search index app + query logs backups, 90 days trained model weights 3 vendor systems Orange = you cannot just delete a row
Fig 6.1 / One deletion request, a dozen destinations. Three of them cannot be handled by a DELETE statement at all.
Approach How Trade-off
Hard delete Rewrite every affected partition without the subject's rows Truly gone. Expensive at scale, and it rewrites history so time travel and reproducibility break.
Crypto-shredding Encrypt each subject's sensitive fields with a per-subject key. Delete the key. Deletion becomes an O(1) key operation. Requires designing it in from day one, plus real key management. This is the scalable answer.
Tombstone plus filtered views Mark deleted, have every consumer read through a view that excludes them Cheap and fast, but the data is still there. Weakest defensibility, and it fails the moment someone queries the base table.
Retention expiry Partition by time, drop whole partitions on schedule Handles bulk retention elegantly. Does nothing for a specific individual's request.

Whichever you pick, you need a subject-to-storage index: given a subject id, which tables, partitions and systems contain their data. Without it every request is a full scan of the estate, and you cannot meet a 30 day statutory deadline with a full scan.

The two nobody plans for

Backups. You cannot surgically edit an immutable backup. The accepted practice is documented: deletions are applied on restore, and backups age out inside a defined window. Write that down before an auditor asks. Trained models. A model trained on a user's data has memorized some of it, and no DELETE reaches model weights. Options are retraining on a schedule with the deleted set excluded, or documenting your position on why the model output is not personal data. Neither is comfortable, and pretending the question does not exist is worse.

De-identification, honestly ranked

  1. Removing direct identifiers (name, email, phone) is a start, not anonymization. The classic result is that a large share of a population is uniquely identified by postcode, birth date and sex together.
  2. k-anonymity requires each combination of quasi-identifiers to appear at least k times. It stops singling out, but if all k people share a sensitive value, you learn it anyway. That is what l-diversity patches.
  3. Differential privacy adds calibrated noise so that the presence or absence of any single person barely changes the output, with a formal epsilon budget. It works well for aggregate counts and published statistics. It works badly for row-level analytics, and every query spends budget you cannot get back.

The practical stance: use tokenization and access control for operational data, k-anonymity plus suppression for internal analytical sharing, and differential privacy only where you publish aggregates externally. Anyone selling differential privacy as a general purpose privacy switch has not implemented it.

Consent is a dataset with an SLA

Consent state changes. A user opts out at 09:00. If your consent snapshot refreshes nightly, you have a fifteen hour window where pipelines process data you no longer have a basis to process. That is not a policy gap, it is a data freshness bug with legal consequences.

Treat consent as a first-class dataset: streamed rather than batched, joined at processing time rather than baked into a copy, with its own freshness monitor and an alarm that a compliance owner receives.

What agentic AI changed

Batch governance assumed a human wrote a query you could review. An agent composes tool calls at runtime, so the boundary has to be enforced in the moment.

  • Provenance on training and retrieval corpora. Record where every document came from, under what basis, and with what retention. When a source must be removed, you need to know what was built from it.
  • Runtime authorization, not design-time. The agent must act with the requesting user's permissions, not a broad service account. A service account with read-all is a data exfiltration path wearing a helpful interface.
  • Prompts and outputs are logs containing personal data. They fall under the same retention and access rules as any other table, and they are almost always the least governed store in the company.
  • Tenant isolation in retrieval. A vector index shared across customers with filtering applied after retrieval is a leak waiting for a bug. Partition the index, do not filter the results.

Deletion blast radius

Interactive

Tick everywhere a user record lands in your estate. The estimate shows what a single erasure request actually costs you.

Systems in scope1
Difficulty score1
Suggested designHard delete
ReadOne system. A targeted delete is enough.

The starter set, in build order

If you are starting from nothing

  1. Inventory. Every dataset, an owner, a classification. Incomplete but real beats perfect and imaginary.
  2. Tag at source and wire tag propagation into your lineage. This is the highest leverage thing on the list.
  3. Deny by default on restricted data, with masking and expiring grants instead of blanket denial.
  4. Retention as code. Every table has a retention value in its definition and a job that enforces it. No exceptions without a written approval.
  5. Build the subject index before your first erasure request arrives, not after.
  6. Log every access decision with the user, purpose and outcome. Compliance is the ability to produce evidence, and evidence has to be collected while it is happening.

The career argument

Most data engineers can build a pipeline. Far fewer can stand in front of a privacy counsel and explain how deletion propagates through derived tables and a feature store, or design a purpose-limitation check that does not stop analysts from working. That gap is where the leverage is, and it is getting wider as AI systems make governance a runtime problem instead of a quarterly review.

Back to top

What to do with this

Reading it is the cheap part. Here is the sequence that turns it into something you can defend in an interview or a design review.

  1. Pick your weakest chapter and build the smallest thing that proves it. A local Postgres plus Kafka plus Flink stack in Docker covers Chapters 1, 4 and 5 in a weekend.
  2. Break it on purpose. Kill the job mid-write and check for duplicates. Send a late event past the lateness bound and watch the counter move. Add a column upstream and see what your consumer does.
  3. Write down the failure and the fix in three sentences. That paragraph is worth more in an interview than any certification, because it is specific and nobody else has it.
  4. Do the arithmetic out loud. File counts, snapshot storage, lookback windows, error budgets. Engineers who quantify get believed.