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 8: Data Transformation & Modeling
Bronze, silver and gold layers, dimensional modeling and slowly changing dimensions.
Topics in this module
- ETL vs ELT
- Bronze, Silver, Gold Architecture
- Data Cleaning and Standardization
- Deduplication
- Data Quality Validation
- Incremental Transformations
- Surrogate Keys
- Fact and Dimension Tables
- Star and Snowflake Schema
- Slowly Changing Dimensions (SCD Type 1 and 2)
- Data Aggregations
- Handling Late-Arriving Data
- Historical Backfills
- Data Reconciliation
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),customersandproductsfrom 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:
ETL vs ELT
By the end: contrast ETL with ELT and explain why Databricks favours ELT.
- 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.
Bronze, Silver, Gold Architecture
By the end: build bronze, silver and gold tables and say what belongs in each.
| 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
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
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
(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
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.
Data Cleaning and Standardization
By the end: clean and standardise data: trim, case, types, defaults and flags.
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.
Deduplication
By the end: remove duplicates by key and keep the latest version of each record.
b = spark.table("bronze_orders")
print(b.count(), b.select("order_id").distinct().count()) # 2040 2000
# 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
Data Quality Validation
By the end: express data quality rules as code, count failures and quarantine bad rows.
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)
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
CHECKconstraints - 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
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.
Incremental Transformations
By the end: transform only new data instead of rebuilding a table every run.
# 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
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)
# 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"))
Surrogate Keys
By the end: create surrogate keys with hashes or identity columns and choose between them.
Hash key: deterministic, no coordination, rebuildable:
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
CREATE OR REPLACE TABLE dim_product_id (
product_sk BIGINT GENERATED ALWAYS AS IDENTITY,
product_id INT,
product_name STRING,
category STRING
)
%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 ALWAYScolumn - 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.
Fact and Dimension Tables
By the end: build dimension and fact tables from the silver layer.
# 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
# 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.
Star and Snowflake Schema
By the end: choose a star or a snowflake schema and say what each trades.
- 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
Slowly Changing Dimensions (SCD Type 1 and 2)
By the end: track history with slowly changing dimensions type 1 and type 2.
Build the SCD 2 table:
(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):
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
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)
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 2in 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.
Data Aggregations
By the end: build gold aggregates with group by, rollup and window functions.
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
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
Handling Late-Arriving Data
By the end: handle records that arrive after the period they belong to.
# 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")
# 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:
replaceWhereorMERGEon 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.
Historical Backfills
By the end: rebuild history safely with a parameterised, idempotent backfill.
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
- 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.
Data Reconciliation
By the end: prove that each layer matches the one before it.
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
TrueandTrueon 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
-- 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.
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