DatabricksZero to Advanced

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 prefixed pat_
  • Setup:
Python
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")
21.01Hands-on on Free Edition

Full Load vs Incremental Load

By the end: choose full load or incremental load.

Full loadread and replace everythingsimple, always correctcost grows with the tableIncrementalread only new or changed rowscheap, scalesneeds a reliable change marker
Python
# 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
21.02Hands-on on Free Edition

Watermark-Based Ingestion

By the end: ingest incrementally with a watermark column.

Python
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) and MERGE, 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.

21.03Hands-on on Free Edition

CDC Pattern

By the end: capture changes with change data capture.

Source changesCDC feedop + sequenceApply to target
The feed says what happened and in which order.
Python
# 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)
21.04Hands-on on Free Edition

Idempotent Processing

By the end: make processing idempotent.

Run oncesame inputresult A {ok}Run twicesame inputstill result A {ok}
Idempotent: running the same input again gives the same table.
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
Python
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
21.05Hands-on on Free Edition

Upsert / MERGE Pattern

By the end: upsert with MERGE.

SQL
%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 ON to limit files scanned
  • WHEN NOT MATCHED BY SOURCE is 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.

21.06Hands-on on Free Edition

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)
Type 1UPDATE in placesimplehistory lostType 2new row per versionfull historyjoins need the validity window
SQL
%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_scd2 from Module 8.10
21.07Hands-on on Free Edition

Append-Only Event Log

By the end: store events as an append-only log.

Python
(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 VARIANT or JSON string)
  • Cost: storage grows. Set a retention and cluster by time
SQL
%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
21.08Hands-on on Free Edition

Dead Letter Queue and Quarantine

By the end: route bad records to a quarantine table.

Python
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.

21.09Hands-on on Free Edition

Retry with Backoff

By the end: retry transient failures with exponential backoff.

Python
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)
21.10Hands-on on Free Edition

Checkpoint and Recovery

By the end: checkpoint and resume work.

Process batchSave positionCrashResume
The position (offset, watermark, run id) is stored with the result, so a restart doesn't repeat or skip.
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
Python
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
21.11Hands-on on Free Edition

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_date and end_date parameters; daily runs pass yesterday
Python
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.

21.12Hands-on on Free Edition

Schema Evolution

By the end: handle schema evolution on purpose.

Python
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.schemaEvolutionMode and 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
21.13Hands-on on Free Edition

Data Contracts

By the end: write down data contracts and check them.

Python
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
21.14Hands-on on Free Edition

Config-Driven Pipelines

By the end: drive pipelines from configuration.

Python
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 variables section 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.

21.15Hands-on on Free Edition

Metadata-Driven Pipelines

By the end: drive pipelines from metadata stored in tables.

Python
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.

21.16Hands-on on Free Edition

Reusable Transformation Frameworks

By the end: build a small reusable transformation framework.

Python
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
Recap

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