from pyspark.sql import functions as F
from pyspark.sql.window import Window
catalog = spark.sql("SELECT current_catalog()").first()[0]
spark.sql(f"USE SCHEMA {catalog}.dbx_course")
Module 21: Data Engineering Design Patterns
Reusable patterns: incremental loads, CDC, upserts, quarantine and metadata-driven pipelines.
Topics in this module
- Full Load vs Incremental Load
- Watermark-Based Ingestion
- CDC Pattern
- Idempotent Processing
- Upsert / MERGE Pattern
- SCD Type 1 and Type 2
- Append-Only Event Log
- Dead Letter Queue and Quarantine
- Retry with Backoff
- Checkpoint and Recovery
- Backfill and Replay
- Schema Evolution
- Data Contracts
- Config-Driven Pipelines
- Metadata-Driven Pipelines
- Reusable Transformation Frameworks
Sixteen patterns that appear in almost every pipeline. Each topic is a one-screen recipe with a name, a use case and a trade-off. Earlier modules show the mechanics. This module is the catalogue.
- Practice tables:
orders,customers(Module 4). Patterns write small tables prefixedpat_ - Setup:
Full Load vs Incremental Load
By the end: choose full load or incremental load.
# full load: replace the target with the whole source
spark.table("orders").write.mode("overwrite").saveAsTable("pat_orders_full")
| Use full load when | Use incremental when |
|---|---|
| The table is small | The table is large or grows daily |
| The source can't tell what changed | The source has an update timestamp, a sequence, a CDC feed or new files |
| Correctness matters more than cost | Cost and freshness matter |
- Incremental needs a reliable marker (topics 2 and 3) and a plan for deletes
- A common mix: incremental daily, full refresh weekly to repair any drift
Watermark-Based Ingestion
By the end: ingest incrementally with a watermark column.
spark.sql("CREATE TABLE IF NOT EXISTS pat_state (source STRING, high_water TIMESTAMP)")
spark.sql("CREATE TABLE IF NOT EXISTS pat_orders_incr AS SELECT * FROM orders WHERE 1 = 0")
def load_increment(source_table="orders", target="pat_orders_incr", col="order_ts"):
row = spark.table("pat_state").filter(F.col("source") == source_table).first()
last = row["high_water"] if row else None
src = spark.table(source_table)
new = src.filter(F.col(col) > F.lit(last)) if last else src
new = new.orderBy(col, "order_id").limit(1000) # a small, repeatable batch for the demo
new.createOrReplaceTempView("new_rows")
spark.sql(f"""
MERGE INTO {target} t USING new_rows s ON t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET * WHEN NOT MATCHED THEN INSERT *""")
new_max = new.agg(F.max(col)).first()[0]
if new_max: # move the watermark only after success
spark.sql("DELETE FROM pat_state WHERE source = '%s'" % source_table)
spark.createDataFrame([(source_table, new_max)], "source STRING, high_water TIMESTAMP") \
.write.mode("append").saveAsTable("pat_state")
return new.count()
print(load_increment(), spark.table("pat_orders_incr").count()) # 1000 1000
print(load_increment(), spark.table("pat_orders_incr").count()) # 1000 about 2000 (equal timestamps at the boundary can shift it)
- Watermark = the highest value already loaded. Next run reads
> watermark - Save the watermark after the write succeeds. A crash then repeats work, never skips it
- Use
>with a late-arrival margin (reread the last hour) andMERGE, so repeats are harmless - Ties on the marker value: use a sequence or ID, or
>=plus MERGE
Updated rows with old timestamps
A watermark on created_at misses updates. Use an updated_at or a change feed.
CDC Pattern
By the end: capture changes with change data capture.
# a small change feed: operation + sequence number
feed = spark.createDataFrame(
[(1, "Customer 1", "NL", "UPDATE", 3), (1, "Customer 1", "DE", "UPDATE", 5),
(2, "Customer 2", "FR", "DELETE", 4), (999, "New", "US", "INSERT", 6)],
"customer_id INT, name STRING, country STRING, op STRING, seq INT")
spark.sql("CREATE OR REPLACE TABLE pat_customers AS SELECT * FROM customers")
latest = (feed.withColumn("rn", F.row_number().over(Window.partitionBy("customer_id").orderBy(F.desc("seq"))))
.filter("rn = 1").drop("rn")) # keep only the last event per key
latest.createOrReplaceTempView("latest")
spark.sql("""
MERGE INTO pat_customers t USING latest s ON t.customer_id = s.customer_id
WHEN MATCHED AND s.op = 'DELETE' THEN DELETE
WHEN MATCHED THEN UPDATE SET name = s.name, country = s.country
WHEN NOT MATCHED AND s.op != 'DELETE' THEN INSERT (customer_id, name, country, signup_date)
VALUES (s.customer_id, s.name, s.country, NULL)""")
print(spark.table("pat_customers").count()) # 200: one deleted, one inserted
- Sources of change: database logs through a connector, Delta change data feed, files with an operation column
- Always dedupe per key by sequence before merging
- Pipelines do this declaratively:
AUTO CDC(Module 9.9)
Idempotent Processing
By the end: make processing idempotent.
| Idempotent | Not idempotent |
|---|---|
MERGE on a key |
INSERT / append of the same batch |
Overwrite a partition or range (replaceWhere) |
mode("append") without dedupe |
INSERT OVERWRITE |
Counters (count = count + 1) |
Deterministic ids (hash(key)) |
Random ids, current_timestamp() as a key |
day = "2025-03-01"
(spark.table("orders").filter(F.to_date("order_ts") == day)
.withColumn("order_date", F.to_date("order_ts"))
.write.mode("overwrite").option("replaceWhere", f"order_date = '{day}'")
.saveAsTable("pat_orders_by_day")) # run it twice: same table
- Test it: run twice and compare counts
- Idempotency makes retries, repair runs and backfills safe
- It is the pattern the others depend on
Upsert / MERGE Pattern
By the end: upsert with MERGE.
%sql
CREATE OR REPLACE TABLE pat_products AS SELECT * FROM products;
MERGE INTO pat_products t
USING (SELECT 1 AS product_id, 'Renamed product' AS product_name, 'Tools' AS category, 9.99 AS unit_price) s
ON t.product_id = s.product_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *
| Clause | Use |
|---|---|
WHEN MATCHED THEN UPDATE |
Update existing rows |
WHEN NOT MATCHED THEN INSERT |
Add new rows |
WHEN NOT MATCHED BY SOURCE THEN DELETE |
Remove rows missing from a full snapshot |
Condition (AND s.updated > t.updated) |
Update only when newer |
- One source row per target row: dedupe the source first, or the merge fails
- Add the match key and a partition/cluster column to the
ONto limit files scanned WHEN NOT MATCHED BY SOURCEis dangerous on partial loads: it deletes everything not in the batch
Duplicate source keys
Two source rows for one key cause an error (or ambiguity). Dedupe by key and sequence first.
SCD Type 1 and Type 2
By the end: keep history with slowly changing dimensions.
- Type 1: overwrite. The current value only
- Type 2: close the old row, insert a new one with
valid_from,valid_to,is_current - Full code: Module 8.10. Declarative:
AUTO CDC ... STORED AS SCD TYPE 2(Module 9.9)
%sql
-- the point-in-time join: facts get the dimension row valid at the event time
SELECT f.order_id, d.country
FROM orders f
JOIN dim_customer_scd2 d
ON f.customer_id = d.customer_id
AND CAST(f.order_ts AS DATE) >= d.valid_from
AND (d.valid_to IS NULL OR CAST(f.order_ts AS DATE) < d.valid_to)
LIMIT 5
- Use type 2 only for attributes whose history matters
- Track changes on selected columns, not every column
- Needs
dim_customer_scd2from Module 8.10
Append-Only Event Log
By the end: store events as an append-only log.
(spark.table("orders").withColumn("event_type", F.lit("order_placed"))
.select("order_id", "event_type", F.col("order_ts").alias("event_ts"),
F.to_json(F.struct("customer_id", "product_id", "quantity", "status")).alias("payload"))
.write.mode("overwrite").saveAsTable("pat_order_events"))
- Never update or delete: new facts are new rows
- Current state = a view or table derived from the log (latest event per key)
- Benefits: full audit, replay, time-ordered analysis, easy streaming
- Keep a schema for the envelope (id, type, time, payload) and evolve the payload (store as
VARIANTor JSON string) - Cost: storage grows. Set a retention and cluster by time
%sql
-- derive current state from the log
SELECT * FROM pat_order_events
QUALIFY row_number() OVER (PARTITION BY order_id ORDER BY event_ts DESC) = 1
LIMIT 5
Dead Letter Queue and Quarantine
By the end: route bad records to a quarantine table.
df = spark.table("orders_raw")
rules = (F.col("customer_id").isNotNull() & F.col("quantity").isNotNull() & (F.col("quantity") > 0))
good = df.filter(rules)
bad = (df.filter(~rules | rules.isNull())
.withColumn("_reason", F.when(F.col("customer_id").isNull(), "missing customer")
.when(F.col("quantity").isNull(), "missing quantity")
.otherwise("invalid quantity"))
.withColumn("_quarantined_at", F.current_timestamp()))
good.write.mode("overwrite").saveAsTable("pat_orders_good")
bad.write.mode("overwrite").saveAsTable("pat_orders_quarantine")
print(good.count(), bad.count())
- A dead letter queue or quarantine holds records that can't be processed, with the reason
- The pipeline continues with the good records
- Alert on the size of quarantine, and give someone the job of reviewing it
- Provide a path to replay fixed records: quarantine rows go back through the pipeline
- For streams, the same idea: a second sink for failed records
Never drop silently
A dropped record you can't count is a hidden bug.
Retry with Backoff
By the end: retry transient failures with exponential backoff.
import random, time
def retry(fn, attempts=4, base=1.0, retry_on=(ConnectionError, TimeoutError)):
for i in range(attempts):
try:
return fn()
except retry_on as e:
if i == attempts - 1:
raise # give up: the job fails loudly
wait = base * (2 ** i) + random.uniform(0, base) # 1s, 2s, 4s ... plus jitter
print(f"attempt {i + 1} failed ({e}); retrying in {wait:.1f}s")
time.sleep(wait)
calls = {"n": 0}
def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise ConnectionError("temporary")
return "ok"
print(retry(flaky)) # succeeds on the third attempt
- Exponential: wait longer each time. Jitter: a random part, so many clients don't retry together
- Retry only transient errors, with a limit
- The operation must be idempotent (topic 4)
- Use one layer for retries: the job's retry or this code, not both (Module 28)
- Record attempts in the log (Module 16)
Checkpoint and Recovery
By the end: checkpoint and resume work.
| Kind | Where the position lives |
|---|---|
| Streaming | Checkpoint folder (offsets and state) (Module 11.6) |
| Auto Loader / COPY INTO | Tracked file list (Module 7) |
| Incremental batch | A watermark table (topic 2) |
| Long batch | A control table of finished chunks |
spark.sql("CREATE TABLE IF NOT EXISTS pat_progress (chunk STRING, done_at TIMESTAMP)")
done = {r.chunk for r in spark.table("pat_progress").collect()}
for day in ["2025-03-01", "2025-03-02", "2025-03-03"]:
if day in done:
continue # resume: skip finished chunks
# ... process the day (idempotent write) ...
spark.createDataFrame([(day,)], "chunk STRING").withColumn("done_at", F.current_timestamp()) \
.write.mode("append").saveAsTable("pat_progress")
- Save progress after the write commits
- Never share a streaming checkpoint between queries
- Deleting a checkpoint = reprocessing from the start
Backfill and Replay
By the end: backfill and replay with the normal code path.
- Backfill: load a range that was missed. Replay: reprocess from a raw layer after a logic fix
- Design once: the pipeline takes
start_dateandend_dateparameters; daily runs pass yesterday
def run_range(start, end):
days = [r.d for r in spark.sql(
f"SELECT explode(sequence(DATE'{start}', DATE'{end}', INTERVAL 1 DAY)) AS d").collect()]
for d in days:
build_day(str(d)) # the same function the daily job calls
def build_day(day):
(spark.table("orders").filter(F.to_date("order_ts") == day)
.groupBy(F.to_date("order_ts").alias("order_date"))
.agg(F.count("*").alias("orders"))
.write.mode("overwrite").option("replaceWhere", f"order_date = '{day}'")
.saveAsTable("pat_daily_orders"))
run_range("2025-03-01", "2025-03-03")
- Chunk by day, run in order or in parallel, record progress (topic 10)
- Replay from bronze, not from the source (it may have changed or aged out)
- Details: Module 8.13, Module 10.13
Streaming and replay
A stream with a checkpoint won't re-read history. Replay in batch, or reset deliberately.
Schema Evolution
By the end: handle schema evolution on purpose.
spark.table("orders").limit(100).write.mode("overwrite").saveAsTable("pat_orders_evo")
# new batch with an extra column
new = spark.table("orders").limit(5).withColumn("coupon", F.lit("SPRING10"))
# default: Delta rejects the unknown column
try:
new.write.mode("append").saveAsTable("pat_orders_evo")
except Exception as e:
print("rejected:", str(e)[:80])
# explicit opt-in: add the column to the table
new.write.mode("append").option("mergeSchema", "true").saveAsTable("pat_orders_evo")
print(spark.table("pat_orders_evo").columns) # now includes coupon
| Change | Handling |
|---|---|
| New column | mergeSchema or ALTER TABLE ADD COLUMN. Old rows get NULL |
| Widened type (int → long) | Type widening, or rewrite |
| Renamed column | Column mapping, plus a new column and a view for old users |
| Dropped column | Stop reading it. Drop later, after consumers move |
| Type change | New column, migrate, retire the old one |
- Auto Loader:
cloudFiles.schemaEvolutionModeand the rescued data column (Module 7.7) - Decide the policy per layer: bronze accepts new fields, gold is strict and changes by agreement
- Alert when the schema changes
Data Contracts
By the end: write down data contracts and check them.
contract = {
"table": "pat_orders_full", # the copy of orders from topic 1
"owner": "sales-data",
"columns": {"order_id": "int", "customer_id": "int", "quantity": "int", "status": "string"},
"not_null": ["order_id"],
"unique": ["order_id"],
"freshness_hours": 24,
}
def check_contract(spark, c):
df = spark.table(c["table"])
types = dict(df.dtypes)
problems = []
for col, t in c["columns"].items():
if types.get(col) != t:
problems.append(f"{col}: expected {t}, got {types.get(col)}")
for col in c["not_null"]:
if df.filter(F.col(col).isNull()).count():
problems.append(f"{col} has nulls")
for col in c["unique"]:
if df.count() != df.select(col).distinct().count():
problems.append(f"{col} is not unique")
return problems
print(check_contract(spark, contract)) # [] = holds. A type message just means: adjust the contract to the real types
- A contract = schema, meaning, quality rules, freshness and an owner, agreed between producer and consumer
- Store it in Git next to the code, check it in CI and in every run
- Breaking changes (rename, remove, type change) follow a process: announce, version, migrate
- More in Module 14.5
Config-Driven Pipelines
By the end: drive pipelines from configuration.
config = {
"tables": [
{"name": "pat_customers_cfg", "source": "customers", "key": "customer_id", "filter": "country IS NOT NULL"},
{"name": "pat_products_cfg", "source": "products", "key": "product_id", "filter": "unit_price > 0"},
]
}
for t in config["tables"]:
df = spark.table(t["source"]).filter(t["filter"]).dropDuplicates([t["key"]])
df.write.mode("overwrite").saveAsTable(t["name"])
print(t["name"], df.count())
- Same code, many tables: a new table is a few lines of config, not a new notebook
- Config lives in a file in Git (YAML or JSON) and is validated when loaded
- Per-environment differences (catalog, paths) are config too (Module 26.4)
- Keep config declarative: what, not how. Logic stays in code
- A bundle
variablessection and job parameters feed the config (Module 15.11)
Config for what changes, code for what is logic
If you find yourself putting if statements in YAML, move the logic into code.
Metadata-Driven Pipelines
By the end: drive pipelines from metadata stored in tables.
spark.sql("""
CREATE OR REPLACE TABLE pat_meta AS
SELECT * FROM VALUES
('customers', 'pat_customers_meta', 'customer_id', 'country IS NOT NULL', true),
('products', 'pat_products_meta', 'product_id', 'unit_price > 0', true),
('orders', 'pat_orders_meta', 'order_id', 'quantity > 0', false)
AS t(source, target, key, filter_expr, enabled)
""")
for r in spark.table("pat_meta").filter("enabled").collect():
df = spark.table(r.source).filter(r.filter_expr).dropDuplicates([r.key])
df.write.mode("overwrite").saveAsTable(r.target)
print(r.target, df.count())
| Config file | Metadata table | |
|---|---|---|
| Lives in | Git | A Delta table |
| Change via | Pull request | Update a row |
| Review and history | Git | Table history (DESCRIBE HISTORY) |
| Best for | Few, stable definitions | Many sources, managed by many people |
- Add columns for status, last run, owner and the control table doubles as a run log
- Generate tasks from rows: a For each task (Module 10.10) runs one iteration per source
- Danger: logic hidden in data. Keep metadata small, validated and versioned
Too clever
A fully metadata-driven framework can be harder to debug than ten plain notebooks. Use it when the number of similar sources justifies it.
Reusable Transformation Frameworks
By the end: build a small reusable transformation framework.
from typing import Callable
from pyspark.sql import DataFrame
Transform = Callable[[DataFrame], DataFrame]
def trim_strings(df: DataFrame) -> DataFrame:
for name, t in df.dtypes:
if t == "string":
df = df.withColumn(name, F.trim(F.col(name)))
return df
def drop_duplicates_on(*keys: str) -> Transform:
return lambda df: df.dropDuplicates(list(keys))
def add_ingest_metadata(source: str) -> Transform:
return lambda df: df.withColumn("_ingested_at", F.current_timestamp()).withColumn("_source", F.lit(source))
def apply(df: DataFrame, *steps: Transform) -> DataFrame:
for step in steps:
df = step(df)
return df
result = apply(spark.table("customers"),
trim_strings, drop_duplicates_on("customer_id"), add_ingest_metadata("customers"))
print(result.count())
- Small DataFrame in, DataFrame out functions, composed in order
- Each step does one thing and is unit-tested (Module 14.1)
- Put them in a package (Module 26), not copied between notebooks
- Parameterised steps (
drop_duplicates_on("id")) cover many tables - Use
DataFrame.transform(fn)to chain:df.transform(trim_strings) - Version the package: consumers upgrade when ready
What you can do now
- Full and incremental loads, watermarks, CDC and idempotency
- MERGE, SCD, append-only logs, quarantine, retry and checkpoints
- Backfill, schema evolution and data contracts
- Config-driven and metadata-driven pipelines, reusable transformations