DatabricksZero to Advanced

Turning raw data into trusted tables: layers, cleaning, dimensional models and history. You build one small warehouse on the practice data.

  • Needs orders_raw (2,040 rows), customers and products from Module 4
  • Tables you create: bronze_orders, silver_orders, silver_quarantine, dim_*, fact_orders, gold_*
  • Facts you can check as you go: 2,040 raw rows, 2,000 orders after dedupe, 1,965 valid orders, 35 invalid
  • Setup cell:
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")
8.01Concept

ETL vs ELT

By the end: contrast ETL with ELT and explain why Databricks favours ELT.

ExtractTransformbefore loadingLoad
ETL: transform on a separate engine, load the result.
ExtractLoadraw, unchangedTransformin the lakehouse
ELT: land the raw data first, transform it with Spark or SQL where it lives.
  • Cheap storage and scalable compute make load first, transform later practical
  • Raw data is kept, so you can rebuild any layer when rules change
  • SQL and PySpark run the same transformations (Module 3.11): choose per step
  • ETL still fits when data must be filtered or masked before it may be stored

Remember

ELT keeps a replayable raw copy. Rules change, the raw data doesn't.

8.02Hands-on on Free Edition

Bronze, Silver, Gold Architecture

By the end: build bronze, silver and gold tables and say what belongs in each.

Bronzeraw, as receivedSilvercleaned, typed, dedupedGoldbusiness-ready
Each layer is a Delta table you can query and rebuild.
Layer Holds Rules
Bronze Raw data plus ingestion metadata Append only, no business logic, keep everything
Silver Cleaned and conformed records Types, dedupe, standardise, validate
Gold Aggregates and models for use Shaped for reports, dashboards, ML
SQL
%sql
CREATE OR REPLACE TABLE bronze_orders AS
SELECT *, current_timestamp() AS _ingested_at, 'orders_raw' AS _source
FROM orders_raw
  • Bronze: same rows as the source (2,040) plus two metadata columns
Python
bronze = spark.table("bronze_orders")

w = Window.partitionBy("order_id").orderBy(F.desc("_ingested_at"))
silver = (
    bronze
    .withColumn("rn", F.row_number().over(w)).filter("rn = 1").drop("rn")
    .withColumn("status", F.lower(F.trim("status")))
    .withColumn("order_date", F.to_date("order_ts"))
    .withColumn("is_valid", F.col("customer_id").isNotNull()
                            & F.col("quantity").isNotNull() & (F.col("quantity") > 0))
    .drop("_source")
)
silver.write.mode("overwrite").saveAsTable("silver_orders")
print(spark.table("silver_orders").count())      # 2000
Python
(spark.table("silver_orders").filter("NOT is_valid")
      .write.mode("overwrite").saveAsTable("silver_quarantine"))
print(spark.table("silver_quarantine").count())  # 35: 20 no customer + 15 no quantity
Python
gold = (
    spark.table("silver_orders").filter("is_valid AND status IN ('completed', 'shipped')")
    .join(spark.table("products"), "product_id")
    .groupBy("order_date")
    .agg(F.count("*").alias("orders"),
         F.round(F.sum(F.col("quantity") * F.col("unit_price")), 2).alias("revenue"))
)
gold.write.mode("overwrite").saveAsTable("gold_daily_revenue")

Naming

bronze_, silver_, gold_ prefixes, or one schema per layer. Pick one convention and keep it.

8.03Hands-on on Free Edition

Data Cleaning and Standardization

By the end: clean and standardise data: trim, case, types, defaults and flags.

Python
bronze = spark.table("bronze_orders")

cleaned = (
    bronze
    .withColumn("status", F.lower(F.trim("status")))                   # consistent text
    .withColumn("order_date", F.to_date("order_ts"))                   # right type
    .withColumn("quantity", F.col("quantity").cast("int"))
    .withColumn("customer_id", F.coalesce("customer_id", F.lit(-1)))   # explicit unknown
)
display(cleaned.limit(5))
Problem Fix
Stray spaces, mixed case trim, lower / upper
Wrong types cast, to_date, to_timestamp
Missing values coalesce, default, or a validity flag
Different codes for one thing mapping table or CASE WHEN
Bad dates try_to_date / try_cast so errors become NULL instead of failing
  • Standardise the format. Don't invent values: unknown stays visibly unknown (-1, NULL, a flag)
  • Keep the original column too when a change loses information

ANSI mode

Serverless raises an error for an invalid cast. Use try_cast when bad values are expected, then route the NULL rows to quarantine.

8.04Hands-on on Free Edition

Deduplication

By the end: remove duplicates by key and keep the latest version of each record.

Python
b = spark.table("bronze_orders")
print(b.count(), b.select("order_id").distinct().count())     # 2040 2000
dropDuplicates(["id"])keeps an arbitrary rowfine for exact copiesWindow + row_numberyou choose whichlatest by timestamp or sequence
Python
# exact duplicates
exact = b.dropDuplicates(["order_id"])

# latest by ingestion time, deterministic
w = Window.partitionBy("order_id").orderBy(F.desc("_ingested_at"), F.desc("order_ts"))
latest = b.withColumn("rn", F.row_number().over(w)).filter("rn = 1").drop("rn")
print(exact.count(), latest.count())                          # 2000 2000
  • Pick the business key (order_id), then decide which row wins
  • Add a tie-breaker to the ordering so results are repeatable
  • Deduplicate in silver. Bronze keeps every copy
  • Check: rows before, rows after, rows removed. Log them
8.05Hands-on on Free Edition

Data Quality Validation

By the end: express data quality rules as code, count failures and quarantine bad rows.

Python
rules = {
    "customer_id is present": F.col("customer_id").isNotNull(),
    "quantity is present":    F.col("quantity").isNotNull(),
    "quantity is positive":   F.col("quantity") > 0,
    "status is known":        F.col("status").isin("completed", "shipped", "cancelled", "returned"),
    "order_ts is present":    F.col("order_ts").isNotNull(),
}

df = spark.table("bronze_orders")
report = df.select(
    [F.sum(F.when(~cond | cond.isNull(), 1).otherwise(0)).alias(name) for name, cond in rules.items()]
)
report.show(truncate=False)
Output
customer_id is present: 20
quantity is present:    15
quantity is positive:    15   <- NULL counts as a failure here
status is known:          0
order_ts is present:      0
  • Same idea as Lakeflow expectations (Module 9.8) and Delta CHECK constraints
  • Three actions per rule: warn (log), drop the row, fail the job
  • Quarantine failures with the rule name and a timestamp (you did this in topic 2)
  • Count and alert: a jump in failures means the source changed
SQL
%sql
ALTER TABLE silver_orders ADD CONSTRAINT status_known
CHECK (status IN ('completed', 'shipped', 'cancelled', 'returned'))

Output format

The output above is an illustration of the numbers (20, 15, 15, 0, 0). show() prints one row with one column per rule.

Validate at the layer boundary

Check bronze to silver (shape and types), and silver to gold (business rules). Failures go to quarantine, not to /dev/null.

8.06Hands-on on Free Edition

Incremental Transformations

By the end: transform only new data instead of rebuilding a table every run.

Bronzeappend onlyFind new rowskey or timestampMERGE into silver
Process the increment, not the whole table.
Python
# new rows arrive in bronze
spark.sql("""
  INSERT INTO bronze_orders
  SELECT order_id + 5000 AS order_id, customer_id, product_id, quantity, status, order_ts,
         current_timestamp() AS _ingested_at, 'orders_raw' AS _source
  FROM orders_raw WHERE order_id <= 10
""")

# silver picks up only rows newer than what it already has
last = spark.table("silver_orders").agg(F.max("_ingested_at")).first()[0]
new = spark.table("bronze_orders").filter(F.col("_ingested_at") > last)
print(new.count())                                                    # 10
Python
new_clean = (
    new.withColumn("status", F.lower(F.trim("status")))
       .withColumn("order_date", F.to_date("order_ts"))
       .withColumn("is_valid", F.col("customer_id").isNotNull()
                               & F.col("quantity").isNotNull() & (F.col("quantity") > 0))
       .drop("_source")
)
new_clean.createOrReplaceTempView("new_silver")
spark.sql("""
  MERGE INTO silver_orders t USING new_silver s ON t.order_id = s.order_id
  WHEN MATCHED THEN UPDATE SET *
  WHEN NOT MATCHED THEN INSERT *
""")
print(spark.table("silver_orders").count())                           # 2010
  • MERGE on the key makes the step safe to rerun
  • Options for finding new data: timestamp high-water mark, Delta change data feed (Module 5.14), or a stream with availableNow
  • In pipelines, streaming tables do exactly this for you (Module 9)
Python
# streaming alternative: reads only new bronze rows each run
(spark.readStream.table("bronze_orders")
      .writeStream.trigger(availableNow=True)
      .option("checkpointLocation", f"/Volumes/{catalog}/dbx_course/raw/_checkpoints/silver_stream")
      .toTable("silver_orders_stream"))
8.07Hands-on on Free Edition

Surrogate Keys

By the end: create surrogate keys with hashes or identity columns and choose between them.

Natural keycustomer_idfrom the sourcemay change or collide across systemsSurrogate keymade by youstable, simple joinssurvives source changes

Hash key: deterministic, no coordination, rebuildable:

Python
dim = spark.table("customers").select(
    F.xxhash64("customer_id").alias("customer_sk"), "customer_id", "name", "country", "signup_date"
)
display(dim.limit(3))

Identity column: sequential numbers assigned on insert:

SQL
%sql
CREATE OR REPLACE TABLE dim_product_id (
  product_sk   BIGINT GENERATED ALWAYS AS IDENTITY,
  product_id   INT,
  product_name STRING,
  category     STRING
)
SQL
%sql
INSERT INTO dim_product_id (product_id, product_name, category)
SELECT product_id, product_name, category FROM products
  • You can't insert values into a GENERATED ALWAYS column
  • Identity values are unique and increasing, not consecutive
  • Identity columns can limit concurrent writers to the table: check the docs before using one on a busy table
  • Hash keys: same input, same key, in any table or rebuild. Collisions are very unlikely with 64 bits, but not impossible
  • Don't use monotonically_increasing_id() as a stored key: values change between runs

Rule of thumb

Hash of the business key for rebuildable models. Identity when you need small sequential keys and have a single writer.

8.08Hands-on on Free Edition

Fact and Dimension Tables

By the end: build dimension and fact tables from the silver layer.

Silverclean ordersJoin dimensionssurrogate keysfact_ordersone row per order
A fact holds measures and keys. Descriptions live in dimensions.
Python
# dimensions
spark.table("customers").select(
    F.xxhash64("customer_id").alias("customer_sk"), "customer_id", "name", "country", "signup_date"
).write.mode("overwrite").saveAsTable("dim_customer")

spark.table("products").select(
    F.xxhash64("product_id").alias("product_sk"), "product_id", "product_name", "category", "unit_price"
).write.mode("overwrite").saveAsTable("dim_product")

spark.sql("""
  SELECT CAST(date_format(d, 'yyyyMMdd') AS INT) AS date_key, d AS date,
         year(d) AS year, month(d) AS month, date_format(d, 'EEEE') AS weekday,
         dayofweek(d) IN (1, 7) AS is_weekend
  FROM (SELECT explode(sequence(DATE'2025-01-01', DATE'2025-12-31', INTERVAL 1 DAY)) AS d)
""").write.mode("overwrite").saveAsTable("dim_date")
print(spark.table("dim_date").count())          # 365
Python
# fact: one row per valid order
(
    spark.table("silver_orders").filter("is_valid")
    .join(spark.table("products").select("product_id", "unit_price"), "product_id")
    .select(
        "order_id",
        F.xxhash64("customer_id").alias("customer_sk"),
        F.xxhash64("product_id").alias("product_sk"),
        F.date_format("order_date", "yyyyMMdd").cast("int").alias("date_key"),
        "quantity", "unit_price", (F.col("quantity") * F.col("unit_price")).alias("revenue"), "status",
    )
    .write.mode("overwrite").saveAsTable("fact_orders")
)
  • Fact: facts about an event, at a stated grain (one row per order)
  • Dimension: who, what, when, where: descriptive attributes
  • Join the fact to a dimension on the surrogate key
  • Rows with an unknown customer: send them to an unknown member (-1) rather than dropping them, or keep them in quarantine. Decide and document it

State the grain

Write down what one fact row means before building. Mixed grains (order and order line together) double-count.

8.09Concept

Star and Snowflake Schema

By the end: choose a star or a snowflake schema and say what each trades.

fact_orders keys + measures dim_customer dim_product dim_date dim_store
Star: one fact, dimensions one join away.
Stardenormalised dimensionsfewer joinssimple for BISnowflakedimensions split into sub-tablesless duplicationmore joins
  • Star is the default for analytics: simple, fast, easy to read
  • Snowflake when a dimension is huge or a hierarchy changes on its own (product, category, department)
  • On Delta, storage is cheap: favour star, with wide dimensions
  • Some teams skip modelling and use one wide gold table. Fine for one report, hard to reuse
8.10Hands-on on Free Edition

Slowly Changing Dimensions (SCD Type 1 and 2)

By the end: track history with slowly changing dimensions type 1 and type 2.

SCD 1overwrite the old valueno historysimplestSCD 2close the old row, add a new onefull historyvalid_from, valid_to, is_current

Build the SCD 2 table:

Python
(spark.table("customers")
      .select("customer_id", "name", "country",
              F.lit("2024-01-01").cast("date").alias("valid_from"),
              F.lit(None).cast("date").alias("valid_to"),
              F.lit(True).alias("is_current"))
      .write.mode("overwrite").saveAsTable("dim_customer_scd2"))

Apply changes (customers 1 and 2 move to XX, customer 201 is new):

Python
changes = spark.createDataFrame(
    [(1, "Customer 1", "XX"), (2, "Customer 2", "XX"), (201, "Customer 201", "NL")],
    "customer_id INT, name STRING, country STRING",
).withColumn("change_date", F.lit("2026-10-19").cast("date"))
changes.createOrReplaceTempView("changes")
SQL
%sql
MERGE INTO dim_customer_scd2 AS t
USING (
  -- rows that match on the key: close the old version
  SELECT c.customer_id AS merge_key, c.* FROM changes c
  UNION ALL
  -- changed rows again with a NULL key: they never match, so they are inserted
  SELECT NULL AS merge_key, c.*
  FROM changes c JOIN dim_customer_scd2 d
    ON c.customer_id = d.customer_id AND d.is_current AND c.country <> d.country
) AS s
ON t.customer_id = s.merge_key AND t.is_current = true
WHEN MATCHED AND s.country <> t.country THEN
  UPDATE SET is_current = false, valid_to = s.change_date
WHEN NOT MATCHED THEN
  INSERT (customer_id, name, country, valid_from, valid_to, is_current)
  VALUES (s.customer_id, s.name, s.country, s.change_date, NULL, true)
Python
t = spark.table("dim_customer_scd2")
print(t.count(), t.filter("is_current").count())     # 205 201
display(t.filter("customer_id IN (1, 201)").orderBy("customer_id", "valid_from"))
  • 200 original + 2 new versions + 1 new customer = 205 rows, 201 current
  • Query "as of" a date: valid_from <= d AND (valid_to IS NULL OR valid_to > d)
  • Facts should point to the dimension row valid at event time
  • Declaratively: AUTO CDC ... STORED AS SCD TYPE 2 in Lakeflow pipelines (Module 9.9)

SCD 2 only where history matters

Customer address or segment: yes. A typo fix: SCD 1. Every extra version costs storage and join complexity.

8.11Hands-on on Free Edition

Data Aggregations

By the end: build gold aggregates with group by, rollup and window functions.

Python
fact = spark.table("fact_orders")
dim_p = spark.table("dim_product")
dim_c = spark.table("dim_customer")

by_cat_country = (
    fact.filter("status IN ('completed', 'shipped')")
        .join(dim_p, "product_sk").join(dim_c, "customer_sk")
        .groupBy("category", "country")
        .agg(F.round(F.sum("revenue"), 2).alias("revenue"), F.count("*").alias("orders"))
)
display(by_cat_country.orderBy(F.desc("revenue")))
SQL
%sql
SELECT category, country, ROUND(SUM(revenue), 2) AS revenue
FROM fact_orders f
JOIN dim_product p USING (product_sk)
JOIN dim_customer c USING (customer_sk)
GROUP BY ROLLUP (category, country)
ORDER BY category, country
Need Use
Totals and subtotals ROLLUP, CUBE, GROUPING SETS
Share of total SUM(x) OVER ()
Running total, rank window functions (Module 4.6)
Count of different values approx_count_distinct when exact isn't needed
  • Gold tables are small, named for the question, and documented (gold_revenue_by_category_country)
  • Pre-aggregate what dashboards hit often. Keep detail in silver and the fact
  • Revenue rules (which statuses count) live in one place: gold code or a metric view
8.12Hands-on on Free Edition

Handling Late-Arriving Data

By the end: handle records that arrive after the period they belong to.

Event happensorder_tsArrives lateingested days laterFixmerge by key, recompute affected days
Event time and ingestion time differ. Design for both.
Python
# a late order: belongs to 2025-02-03 but arrives now
late = spark.createDataFrame(
    [(9001, 5, 7, 2, "completed", "2025-02-03 10:00:00")],
    "order_id INT, customer_id INT, product_id INT, quantity INT, status STRING, order_ts STRING",
).withColumn("order_ts", F.to_timestamp("order_ts")) \
 .withColumn("_ingested_at", F.current_timestamp()) \
 .withColumn("_source", F.lit("late"))
late.write.mode("append").saveAsTable("bronze_orders")
Python
# recompute only the affected days in gold
affected = [r[0] for r in late.select(F.to_date("order_ts")).distinct().collect()]
print(affected)
  • Key-based MERGE into silver already handles late rows (topic 6)
  • Then rebuild only the affected partitions or dates of gold: replaceWhere or MERGE on the date
  • Use a lookback window in scheduled jobs: reprocess the last N days every run
  • Late dimension data: load the fact with an inferred member, fix it when the dimension row arrives
  • Streaming: watermarks bound how late data may be (Module 11)

Know your lateness

Measure the gap between event time and ingestion time. Set the lookback to cover almost all of it.

8.13Hands-on on Free Edition

Historical Backfills

By the end: rebuild history safely with a parameterised, idempotent backfill.

Python
def build_gold_daily(start, end):
    """Rebuild gold_daily_revenue for [start, end] and nothing else."""
    df = (
        spark.table("fact_orders")
        .filter("status IN ('completed', 'shipped')")
        .join(spark.table("dim_date"), "date_key")
        .filter(F.col("date").between(start, end))
        .groupBy(F.col("date").alias("order_date"))
        .agg(F.count("*").alias("orders"), F.round(F.sum("revenue"), 2).alias("revenue"))
    )
    (df.write.mode("overwrite")
       .option("replaceWhere", f"order_date BETWEEN '{start}' AND '{end}'")
       .saveAsTable("gold_daily_revenue"))


build_gold_daily("2025-01-01", "2025-03-31")      # a quarter
build_gold_daily("2025-01-01", "2025-03-31")      # same again: same result
Pick a date rangeRebuild that slicereplaceWhereVerify counts
A backfill is the normal pipeline pointed at old dates.
  • Same code for daily runs and backfills: parameters, not a separate script
  • Idempotent: running a range twice gives the same table
  • Chunk big ranges (a month at a time) to limit compute and make restarts cheap
  • Backfill in a copy or with time travel available, then verify before pointing consumers at it
  • Lakeflow Jobs can run a backfill over a date range (Module 10.13)

Don't overwrite the whole table

mode("overwrite") without replaceWhere replaces every row, not just your range.

8.14Hands-on on Free Edition

Data Reconciliation

By the end: prove that each layer matches the one before it.

Python
bronze_rows   = spark.table("bronze_orders").count()
bronze_keys   = spark.table("bronze_orders").select("order_id").distinct().count()
silver_rows   = spark.table("silver_orders").count()
quarantine    = spark.table("silver_quarantine").count()
fact_rows     = spark.table("fact_orders").count()

print("bronze distinct keys == silver rows:", bronze_keys == silver_rows)
print("silver == fact + quarantine:        ", silver_rows == fact_rows + quarantine)
  • Expect True and True on a clean run of topics 2 and 8 (topics 6 and 12 add rows to bronze and silver, so rebuild first or adjust)
  • Reconcile counts, sums and key sets between layers and against the source
SQL
%sql
-- keys in silver that are not in the fact (they must be the quarantined ones)
SELECT order_id FROM silver_orders
EXCEPT
SELECT order_id FROM fact_orders
Check Catches
Row counts per layer Lost or duplicated rows
Sum of key measures (revenue) Wrong joins, filters, types
Key set differences (EXCEPT) Which rows disappeared
Source vs bronze counts Ingestion gaps
Freshness (max(ingested_at)) Stalled pipelines
  • Run reconciliation after each load and store the results in a small audit table
  • A failed reconciliation should fail the job or page someone

Trust, but verify

Numbers that don't reconcile are bugs. Build the checks in, and keep the history of results.

Recap

What you can do now

  • ETL vs ELT, and the bronze, silver and gold layers
  • Cleaning, deduplication, validation rules and quarantine
  • Incremental transformations with MERGE
  • Surrogate keys, facts, dimensions, star and snowflake schemas
  • SCD type 1 and 2, aggregations, late data, backfills and reconciliation