W14 Dimensional Modeling I — Facts & Dimensions (kick off the Project Charter; design the pricing star schema) M08 — Dimensional Modeling I · Phase 2 · silver G2G1G9 ~16h
- Given raw CMS Hospital Price Transparency and NPPES fields, classify each column as a FACT MEASURE, a DIMENSION ATTRIBUTE, or a DEGENERATE attribute and defend each call in one sentence (target: correctly classify at least 90% of a 25-column sample).
- Design a star schema for the pricing domain: one price/charge FACT plus PROVIDER, PROCEDURE, FACILITY, and PAYER dimensions, each with a named surrogate key and at least one candidate business (natural) key.
- Author Project Charter v0.1 stating Maria's problem in five sentences or fewer, naming the persona, and defining 4+ measurable KPIs, each with a formula and a grain-of-measurement note.
- Explain, with a concrete CMS/NPPES example, why a flat 'one big table' fails Maria's compliance reporting (duplicate providers, un-attributable measures, no reusable conformed dimension).
- Produce a Delta-backed dimensional skeleton in Databricks: empty typed Delta tables for fact_charge and the four dimensions with surrogate-key and audit columns, ready for population in later weeks.
- Lesson 8.1 — Why dimensional modeling exists (1.5h): the analytics failure mode of the 'one big table.' Recognition-before-evaluation framing: a hiring manager reads a README that says 'monthly cost report was wrong because the same hospital appeared 3 ways' and instantly recognizes the problem. Facts = the measurable business events (a charge, a price); dimensions = the who/what/where/how context you filter and group by. Contrast with W13's 3NF source shape: sources normalize to avoid update anomalies; analytics denormalizes into conformed dimensions to make questions cheap and answers auditable. Deliberately DEFER grain and the bus matrix to W15 — this week only establishes the fact/dimension split.
- Lesson 8.2 — Anatomy of a fact table (2h): additive vs. semi-additive vs. non-additive measures (a charge amount is additive; a list price is non-additive/you cannot sum it across procedures). Foreign keys to dimensions, degenerate dimensions (a claim/line id that lives on the fact with no dimension table), and why a fact row should represent one business event. Worked on CMS MRF: the 'standard_charge|gross', 'standard_charge|negotiated_dollar', and 'standard_charge|min/max' columns are candidate measures; code|1|type, payer_name and plan_name point at dimensions.
- Lesson 8.3 — Anatomy of a dimension: surrogate vs. natural keys (2h): why analytics uses an integer/hash surrogate key instead of the NPI or hospital EIN directly (stability across source drift, room for SCD2 history later, protection when a natural key is dirty or duplicated). Candidate business keys in the worked data: NPI (provider), CMS Certification Number / EIN (facility), CPT/HCPCS/DRG code (procedure), payer_name+plan_name (payer). Flag that provider identity is dirty (name/address variants, duplicates) — this foreshadows the MDM closer but is NOT solved this week.
- Lesson 8.4 — The Project Charter as a data product spec (1.5h): translating Maria's pain into a written problem statement, a persona sketch, and 4+ KPIs with explicit formulas and measurement grain. Documentation-as-interview-prep: each KPI is a sentence you can defend in a screen. Introduce the four project KPIs (pricing-completeness pass rate, duplicate providers collapsed, records quarantined with reason, data-freshness SLA) as the charter's north star, and map each KPI to the fact/dimensions it will need.
Hands-on lab · ~9h
Inspect the REAL messy fields in a CMS Hospital Price Transparency machine-readable file and a small NPPES pull, classify every column as fact/dimension/degenerate, design the pricing star schema (fact_charge + dim_provider/procedure/facility/payer), materialize an empty typed Delta skeleton in Databricks Free Edition, and write Project Charter v0.1 with Maria's problem statement and 4+ KPIs. This is a modeling-and-framing week: you land and profile raw data to inform the model, but you do NOT yet load the fact (that comes as silver is built in later weeks).
- 1. Land real samples. In Databricks Free Edition, run the starter scaffold to download one hospital's CMS machine-readable pricing file (pick a hospital from the CMS index) into a Bronze landing path and pull ~50 provider records from the NPPES API for that hospital's city/state. Confirm both landed with a source column and an ingest timestamp (bronze contract: raw, no transforms).
- 2. Profile the mess. Use the scaffold's profiling TODO to print column names, inferred types, null rates, and 5 sample values per column for BOTH sources. Write down the specific messes you observe: pipe-delimited nested charge columns, blank/negative/text-in-numeric prices, payer/plan names as free text, NPI duplicates and name/address variants across NPPES rows, and schema drift between hospital files.
- 3. Classify columns. Build a table (in a notebook markdown cell or CSV) with one row per source column and a verdict: FACT MEASURE, DIMENSION ATTRIBUTE, or DEGENERATE, plus a one-sentence justification. Explicitly mark additive vs. non-additive for every candidate measure (e.g., gross charge = additive per line event; list/negotiated rate = non-additive reference price).
- 4. Draw the star. Produce a star-schema diagram (draw.io, Excalidraw, or a Markdown table) with fact_charge at center and dim_provider, dim_procedure, dim_facility, dim_payer around it. For each dimension name the surrogate key (e.g., provider_sk) and the candidate business/natural key (NPI, CPT/HCPCS/DRG, CCN/EIN, payer_name+plan_name). Note where provider identity is dirty and flag it as a future MDM concern (do not solve it now).
- 5. Materialize the Delta skeleton. Complete the scaffold TODOs to CREATE empty, explicitly-typed Delta tables for fact_charge and the four dimensions in your project schema, each dimension carrying its surrogate key, business key, and audit columns (source, ingest_ts). Fact_charge carries the four dimension surrogate keys, the measure columns, and a degenerate line/charge id. Run the create cell twice to confirm it is safe to re-run (idempotent DDL: CREATE TABLE IF NOT EXISTS or CREATE OR REPLACE with empty data).
- 6. Write Project Charter v0.1. Using the charter template, write Maria's problem statement (<=5 sentences), a one-paragraph persona, and the 4+ KPIs each with a formula and a measurement grain. Map each KPI to the fact/dimension columns it will need so the model and the charter agree.
- 7. Write the 'why' note. In a decisions.md, record: why you chose surrogate keys over NPI, which columns you rejected as measures and why, one schema-drift risk you already see between hospital files, and one open question you are deferring to W15 (grain) or the MDM closer (duplicate providers). These become interview answers.
- 8. Self-check against acceptance. Verify column classification is complete, the diagram has all five tables with keys, all five Delta tables exist and are empty/typed, the create cell re-ran cleanly, and the charter has 4+ KPIs with formulas. Commit charter v0.1, decisions.md, the classification table, and the diagram to the project repo/folder.
- Kimball Group — Dimensional Modeling Techniques (facts, dimensions, surrogate keys)
- Databricks — Star schema and dimensional modeling on the lakehouse
- NPPES NPI Registry API documentation (fields, query params)
- Delta Lake — CREATE TABLE and column types (Databricks SQL reference)
</> Starter scaffold — w14_land_and_skeleton.py python
# Databricks notebook — Week 14 starter scaffold (structure only; fill the TODOs)
# Bronze contract: land raw, add source + ingest timestamp, NO transforms.
import requests, json
from datetime import datetime
from pyspark.sql import functions as F
CATALOG = "workspace" # Free Edition default
SCHEMA = "pricing_integrity" # TODO: create if needed
BRONZE = "/Volumes/workspace/pricing_integrity/bronze" # TODO: adjust to your volume/path
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {CATALOG}.{SCHEMA}")
# --- 1. Land one CMS hospital pricing file (raw) --------------------------
# TODO: from the CMS hospital index, paste ONE hospital's machine-readable file URL.
CMS_FILE_URL = "<PASTE_A_HOSPITAL_MRF_URL>"
raw = requests.get(CMS_FILE_URL, timeout=60)
landing = f"{BRONZE}/cms_mrf/ingest_date={datetime.utcnow():%Y-%m-%d}"
dbutils.fs.put(f"{landing}/raw.txt", raw.text, overwrite=True)
# TODO: read it back as text/CSV/JSON depending on the file's real format and
# add columns: source = 'cms_mrf', ingest_ts = current_timestamp(). Keep it RAW.
# --- 2. Pull NPPES providers (raw) ---------------------------------------
# TODO: set city/state to match your CMS hospital so records overlap.
nppes = requests.get(
"https://npiregistry.cms.hhs.gov/api/",
params={"version": "2.1", "city": "<CITY>", "state": "<ST>", "limit": 50},
timeout=60,
).json()
nppes_rows = nppes.get("results", [])
nppes_df = (spark.createDataFrame([json.dumps(r) for r in nppes_rows], "string")
.withColumnRenamed("value", "raw_json")
.withColumn("source", F.lit("nppes_api"))
.withColumn("ingest_ts", F.current_timestamp()))
nppes_df.write.mode("overwrite").format("delta").save(f"{BRONZE}/nppes_raw")
# --- 3. Profile the mess (fill in) ---------------------------------------
# TODO: for BOTH sources print column names, types, null-rate, and 5 sample values.
# Write down: nested pipe columns, bad prices, free-text payers, NPI dupes, drift.
# --- 4/5. Dimensional skeleton (empty, typed Delta) ----------------------
# TODO: replace the '...' column lists with the columns YOU chose during design.
# Keep surrogate key + business key + audit columns on each dimension.
spark.sql(f"""CREATE TABLE IF NOT EXISTS {CATALOG}.{SCHEMA}.dim_provider (
provider_sk BIGINT, -- surrogate key (generate later)
npi STRING, -- business/natural key
-- TODO: provider_name, taxonomy, ... (from your NPPES profiling)
source STRING, ingest_ts TIMESTAMP
) USING DELTA""")
# TODO: dim_procedure (business key = CPT/HCPCS/DRG code), dim_facility (CCN/EIN),
# dim_payer (payer_name + plan_name) — same pattern as dim_provider.
# TODO: fact_charge — one row per charge event. Include the 4 dimension surrogate
# keys, your MEASURE columns (mark additive vs non-additive in a comment),
# a degenerate charge/line id, and audit columns. Create empty & typed only.
# NOTE: do NOT load the fact this week — population comes as silver is built.
# --- 6. Idempotency check ------------------------------------------------
# TODO: re-run the CREATE cell and confirm no error and row counts stay 0.
for t in ["dim_provider"]: # TODO: add your other 4 tables
print(t, spark.table(f"{CATALOG}.{SCHEMA}.{t}").count())
- The Data Warehouse Toolkit, 3rd Ed. (Kimball) O'Reilly · Book
- Data Warehouse Concepts, Design, and Data Integration (CU Boulder) Coursera · Course
- Introduction to Data Modeling in Snowflake DataCamp · Course
Deliverable, acceptance & live session
- PASS requires: (1) every column in the 25-column CMS/NPPES sample is classified with a one-sentence justification and each candidate measure marked additive/non-additive
- The star diagram shows all five tables, each dimension with a named surrogate key AND a candidate business key, and provider-identity dirtiness flagged as a future MDM item
- All five Delta tables exist, are explicitly typed, carry audit columns (source, ingest_ts), and the CREATE cell re-runs with zero errors and unchanged (0) row counts
- The charter has 4+ KPIs, each with a written formula and grain, and each KPI is mapped to the fact/dimension columns it depends on
- Decisions.md records the surrogate-vs-NPI choice, at least one rejected measure with reason, and one deferred question (grain to W15 or duplicates to the MDM closer).
Recap Walk through your star-schema diagram in 3 minutes: name the fact grain in plain words, point to each dimension's business key, and show the one column you found hardest to classify. Then read your charter's problem statement and one KPI formula aloud. Group compares how differently each learner's CMS hospital file was shaped (schema drift is the point).
Misconception The dominant misconception to surface and correct: 'a star schema is just one big denormalized table' (or its twin, 'more normalization is always better'). Show that facts and dimensions are deliberately split so measures stay addable and dimensions stay reusable/auditable — and that the negotiated/list price is a NON-additive measure you must not sum, which the one-big-table instinct gets wrong. Also correct 'use the NPI as the primary key' by connecting to next week's grain discussion and the later duplicate-provider MDM problem.
Hot seat "Defend your decision: 'You put standard_charge|negotiated_dollar in the fact and payer_name in a dimension — why not the reverse, and is that charge additive? Justify your surrogate keys over the NPI given that the same provider appears three times in your NPPES pull, and name the one KPI in your charter you would defend to a CMS auditor and exactly which fact/dimension columns compute it.'"
W15 Grain, conformed dimensions & the bus matrix M08 — Dimensional Modeling II · Phase 2 · silver G2G1 ~12h
- Write a one-sentence grain declaration for the pricing fact (e.g. 'one row per hospital CCN x billing code x payer/plan x charge-type') and encode it as an explicit GRAIN_KEYS list in code
- Prove the fact is at the declared grain by running a grain-uniqueness test that returns exactly 0 duplicate grain keys after quarantine
- Build an enterprise bus matrix (business processes down the rows: pricing, claims; conformed dimensions across the columns) marking every shared dimension with an X
- Design and materialize at least 2 conformed dimensions (dim_provider, dim_service) with surrogate keys and identical attributes usable by BOTH the pricing and claims processes
- Update the project charter with the locked grain decision plus a 'rejected alternatives' note explaining why a coarser and a finer grain were both rejected
- Declaring the grain (Kimball step 2 of 4): grain = the business event you measure, chosen BEFORE picking facts or dimensions; atomic vs summary grain; why 'the primary key of the source file' is not a grain declaration (1.5h)
- The double-counting trap: additive / semi-additive / non-additive facts, and how the wrong grain makes Maria's total spend over-count when negotiated prices are stored at mixed grains (1.0h)
- Conformed dimensions & the enterprise bus matrix: what 'conformed' really means (same key, same attributes, same values), drill-across queries, and why one dim_provider must serve both pricing and claims (1.5h)
- Surrogate keys & degenerate dimensions for a fact at grain: natural key -> surrogate key mapping, degenerate keys that live on the fact, keeping this Type-1/static for now (SCD2 history is next week, W16) (1.0h)
Hands-on lab · ~7h
Take the W14 silver pricing lines and provider rows, declare and enforce the fact grain, quarantine any row that violates it, then carve out two conformed dimensions (provider, service) that both the pricing process and a future claims process will share — proven by a bus matrix and a repeatable, idempotent rebuild.
- Write the grain declaration as ONE sentence at the top of your notebook README, then encode the same decision as a GRAIN_KEYS list in code so the sentence and the code can never drift apart
- Load silver_pricing_lines from W14 and compose a single grain_key by hashing the GRAIN_KEYS columns with sha2(concat_ws('||', ...), 256), coalescing nulls to a sentinel so a missing payer doesn't silently collide
- Run the grain-uniqueness test: group by grain_key, count, filter count>1; capture the violators — this MUST end at 0 for the fact to be trustworthy
- Build for failure: append every grain violator to a rejected_pricing Delta table with reason='GRAIN_VIOLATION', rejected_at=current_timestamp(), and the source_file, then print a LOUD alert (row count + sample) so a bad republish is never silent
- Build dim_provider conformed on NPI/CCN: select the shared attributes (npi, provider_name, org_name, city, state, taxonomy), assign a surrogate key, and write it as a Delta table — Type-1/overwrite only, no SCD2 yet
- Build dim_service conformed on billing_code + code_system (CPT/HCPCS/DRG) the same way, resolving the mess where one code carries several conflicting descriptions by keeping the most frequent description per code
- Rewrite the pricing fact to carry the surrogate keys (provider_sk, service_sk) plus its degenerate keys, so a drill-across from pricing to a future claims fact will join on the SAME conformed dimension keys
- Author the bus matrix as a small markdown/Delta table: rows = pricing, claims (planned); columns = dim_provider, dim_service, dim_date, dim_payer; mark shared dims with X and write one sentence on what conformance buys you
- Prove idempotency: run the whole notebook 10x with mode('overwrite') for the dims/fact and append-only for rejects; assert dim and fact row counts are identical across runs, then paste the counts into the charter
- Kimball Group — Dimensional Modeling Techniques (Declare the Grain, Conformed Dimensions, Enterprise Bus Matrix)
- Kimball Group — The Matrix (enterprise data warehouse bus matrix)
- Databricks — Upsert into a Delta table using MERGE (idempotent writes)
- CMS — Hospital price transparency data dictionary & template (machine-readable file layout)
</> Starter scaffold — w15_grain_and_conformed_dims.py python
# Databricks Free Edition — PySpark/Delta — Week 15 STARTER SCAFFOLD
# Provider Pricing Integrity: lock the fact GRAIN + build CONFORMED dims.
# Fill every TODO. This scaffold intentionally does NOT solve the lab.
from pyspark.sql import functions as F
SCHEMA = "ppi_silver"
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")
# --- 0. Grain declared IN CODE as the single source of truth --------------
# TODO: write your grain as ONE sentence in the README, then list the columns
# that make one fact row unique. Decide and DEFEND this in the charter.
GRAIN_KEYS = [
# "hospital_ccn", "billing_code", "code_system", "payer_plan", "charge_type",
] # TODO fill
# --- 1. Load W14 silver pricing lines -------------------------------------
pricing = spark.table(f"{SCHEMA}.silver_pricing_lines")
# --- 2. Compose the grain key ---------------------------------------------
# TODO: coalesce each key col to a sentinel, then sha2(concat_ws('||',...),256)
pricing_keyed = pricing # TODO .withColumn("grain_key", ...)
# --- 3. GRAIN-UNIQUENESS TEST (must reach 0 after quarantine) --------------
dupes = None # TODO groupBy("grain_key").count().filter(F.col("count") > 1)
# --- 4. Quarantine grain violators (build for failure, be LOUD) -----------
REJECT = f"{SCHEMA}.rejected_pricing"
# TODO: append violators to REJECT with reason='GRAIN_VIOLATION',
# rejected_at=current_timestamp(), source_file; then print count+sample.
# --- 5. Conformed dimensions (shared by pricing AND future claims) --------
# NOTE: Type-1 / overwrite only. SCD2 history is next week (W16). Do NOT add it.
providers = spark.table(f"{SCHEMA}.silver_providers")
# TODO dim_provider: select conformed attrs (npi, provider_name, org_name,
# city, state, taxonomy); add provider_sk; write Delta (overwrite).
# TODO dim_service : conform on billing_code+code_system; keep the most
# frequent description per code; add service_sk; write Delta (overwrite).
# --- 6. Idempotency: 10 reruns => identical dim/fact counts ----------------
# TODO: rewrite the fact with provider_sk/service_sk + degenerate keys,
# mode('overwrite'); print counts; assert they don't move across runs.
print("SCAFFOLD ONLY — steps above are not implemented yet")
- Data Warehousing Concepts DataCamp · Course
- Star Schema: The Complete Reference O'Reilly · Book
- The Data Warehouse Toolkit, 3rd Ed. (Kimball) O'Reilly · Book
Deliverable, acceptance & live session
- Grain stated in exactly one sentence and mirrored by GRAIN_KEYS in code
- Grain-uniqueness test returns 0 duplicate grain keys after quarantine
- Rejected_pricing contains every violator with reason='GRAIN_VIOLATION', a timestamp, and the source file, and the run prints a visible alert
- At least 2 conformed dimensions exist with surrogate keys and are referenced by the fact via those keys
- The bus matrix shows >=2 processes and marks at least one shared dimension
- Running the notebook 10x yields identical dim and fact row counts (pasted into the charter)
- Charter's grain section names one coarser and one finer grain that were rejected and why.
Recap Each learner reads their one-sentence grain declaration aloud and shows the grain-uniqueness test hitting 0, then displays their bus matrix and points to the single dimension that both pricing and claims will share.
Misconception 'The grain is just the primary key of the source file.' It isn't — grain is the business event you choose to measure, declared before you pick facts or dimensions, and it is frequently FINER than any single messy source row. A related trap: thinking 'conformed' only means the two dimensions have the same name, when it actually requires the same key, same attributes, and same values.
Hot seat "Defend your grain: 'Why one row per hospital x code x payer x charge-type, and not the coarser one-row-per-hospital-x-code (dropping payer)? Show exactly what breaks in Maria's spend total at the coarser grain.' Then defend conformance: 'Why one dim_provider shared with claims instead of a separate provider table per process — and what drill-across query would silently return wrong numbers if you didn't conform it?'"
W16 Recoverable History: SCD Type 2 & Effective Dating on the Provider and Price Dimensions M09 — SCD & History Tracking (SCD Type 2) · Phase 2 · silver G3G2 ~11h
- Implement an SCD Type 2 MERGE in Delta that closes the superseded current row (sets effective_end_ts, is_current=false) and inserts a new version when a tracked attribute changes — verified against two CMS snapshots.
- Distinguish tracked (Type 2) from overwrite (Type 1) columns and defend the choice per column in a written change-tracking policy tied to Maria's audit needs.
- Detect real changes with a row hash over tracked columns only, so untracked-column noise does NOT open spurious new versions.
- Prove idempotency: re-apply an identical snapshot 10x and confirm zero new versions opened and stable row counts.
- Answer a point-in-time (as-of) query that reconstructs exactly one provider/price version per business key for an arbitrary past date, proving no interval gaps or overlaps.
- SCD types 0/1/2/3 — what each does to history, and why a data engineer defaults to Type 2 for auditable dimensions while still using Type 1 for corrections you never want preserved (0.75h).
- The effective-dating model: effective_start_ts, effective_end_ts, is_current, and a surrogate key; half-open intervals [start, end) and why they prevent gaps/overlaps (1.0h).
- Change detection with row hashes: hashing only tracked columns, null-safe concat (coalesce to a sentinel) so concat_ws doesn't silently collapse nulls, and why this makes reruns cheap and idempotent (0.75h).
- Delta MERGE mechanics for SCD2 — the two-step 'mark-then-insert' pattern, and why a single MERGE cannot both close the old row and insert its replacement for the same key (1.0h).
- Point-in-time / as-of queries: reconstructing the state true on a past date, and validating that exactly one version resolves per business key (0.75h).
- Idempotency and late/re-published snapshots for SCD2: how unchanged rows must be no-ops, and what happens when CMS re-publishes the same file (0.75h).
Hands-on lab · ~6h
Take two CMS price-transparency snapshots (plus NPPES provider attributes) and historize them into two SCD Type 2 Delta dimensions — silver_provider_dim and silver_price_dim — with effective_start_ts / effective_end_ts / is_current / surrogate_key. Build a change-tracking policy (which columns are Type 2 vs Type 1), detect changes with a tracked-column row hash, apply the two-step mark-then-insert MERGE, quarantine key-violating rows, prove idempotency across 10 reruns, and answer a point-in-time audit query for Maria.
- Assemble two snapshots: take the W15 grain-correct silver feed as snapshot 1 (snapshot_ts = an earlier CMS republish date), then obtain a second, later CMS price file for the same hospital(s) as snapshot 2. Where a genuine second CMS snapshot isn't available for your facility, deliberately mutate a copy (change a negotiated_rate, a discounted_cash price, a provider's practice_city, and add one brand-new billing code) and document exactly what you changed and why — this simulates real CMS drift.
- Write the change-tracking policy: for EACH column in the provider and price dimensions, mark it Type 2 (track history), Type 1 (overwrite in place), or key/attribute. Justify at least three decisions in one sentence each tied to Maria (e.g. negotiated_rate = Type 2 because auditors reconstruct past prices; a fixed data-entry typo in provider_name = Type 1 because you never want the wrong value in history).
- Add a null-safe row hash over ONLY the tracked columns (sha2 over concat_ws with each tracked value coalesced to a '<NULL>' sentinel). Confirm two rows differing only in an UNTRACKED column produce the same hash, and two rows differing in a tracked value produce different hashes.
- Seed load: if the dimension does not yet exist, write snapshot 1 as all-current versions — effective_start_ts = snapshot_ts, effective_end_ts = NULL, is_current = true, surrogate_key = uuid(). Verify one current row per business key.
- Incremental SCD2 MERGE (two steps): join snapshot 2 to the current rows on the business key and classify each incoming row as new-key / changed / unchanged. Step A — MERGE to close changed current rows (set effective_end_ts = snapshot 2's snapshot_ts, is_current = false). Step B — append the changed rows AND the new keys as new current versions. Unchanged rows must be no-ops.
- Quarantine + audit: before hashing, route rows with a null/blank business key (e.g. missing NPI, missing billing code) to a *_rejected table with reason + timestamp and log a loud row count; append one audit-log row per run capturing rows_in, versions_closed, versions_opened, rows_rejected.
- Idempotency test: re-apply snapshot 2 ten times in a loop and assert versions_opened == 0 after the first application and total row counts are unchanged. If a rerun opens new versions, your unchanged-classification or hash is wrong — fix it before moving on.
- Point-in-time validation: run an as-of query for a date between the two snapshots and a date after both. Assert exactly ONE version resolves per business key for any as-of date (no gaps, no overlaps). Show the before-price and after-price for one facility to prove history is recoverable.
- Write the why-note (interview prep): document your interval convention ([start, end) half-open), why the MERGE had to be two steps, which columns you tracked vs overwrote and why, and how you proved idempotency. Be specific — these become your interview answers.
- Delta Lake — Upsert into a table using MERGE (SCD Type 2 example)
- Databricks — Slowly Changing Dimensions (SCD) Type 2 with MERGE
- PySpark — sha2 / concat_ws for change-detection hashes
- CMS Hospital Price Transparency — machine-readable file requirements
- NPPES NPI Registry API documentation
</> Starter scaffold — W16_silver_scd2_scaffold.py python
# W16_silver_scd2_scaffold.py
# SCAFFOLD ONLY - sets up structure + the two-step SCD2 pattern.
# You implement every TODO. Do NOT paste a full solution here; the point is
# that YOU can defend each MERGE clause in the live hot seat.
#
# Runs on Databricks Free Edition (Delta + PySpark). Assumes W15 landed a
# grain-correct silver feed. Here we historize it into an SCD2 dimension.
from pyspark.sql import functions as F
from delta.tables import DeltaTable
# --- 0. Config -------------------------------------------------------------
CATALOG_PATH = "workspace.default" # adjust to your Free Edition schema
DIM_TABLE = f"{CATALOG_PATH}.silver_provider_dim" # SCD2 target
# For the PRICE dimension, re-run this file with these swapped:
# DIM_TABLE = f"{CATALOG_PATH}.silver_price_dim"
BUSINESS_KEY = ["npi"] # natural/business key that IDENTIFIES the entity
# TODO: for price_dim the business key is a COMPOSITE (e.g. npi + code_type + billing_code + payer)
# - decide the key at the grain W15 fixed, and justify it in your why-note.
# Columns whose CHANGE should open a new version (Type 2 / tracked):
TRACKED_COLS = ["provider_name", "practice_city", "practice_state", "taxonomy_code"]
# TODO: for price_dim, TRACKED_COLS is the price fields (gross_charge, discounted_cash,
# negotiated_rate...). Decide per column: Type 2 (track) vs Type 1 (overwrite,
# e.g. a corrected typo you never want in history). Write that decision down.
# --- 1. Load the incoming snapshot ----------------------------------------
# `snapshot_ts` is the CMS republish date for THIS run - the effective date of any change.
def load_snapshot(snapshot_ts: str):
src = (spark.table(f"{CATALOG_PATH}.silver_provider_clean") # W15 output
.withColumn("snapshot_ts", F.lit(snapshot_ts).cast("timestamp")))
# TODO: drop/repair rows that violate the business key (null NPI) BEFORE hashing -
# route them to a *_rejected table with reason + timestamp (build-for-failure).
return src
# --- 2. Change-detection hash ---------------------------------------------
# A stable hash over ONLY the tracked columns lets us detect a real change
# without comparing every field, and prevents spurious new versions when an
# untracked column wiggles.
def add_row_hash(df):
# TODO: sha2(concat_ws over TRACKED_COLS, coalescing nulls to a sentinel like '<NULL>')
# Why coalesce? concat_ws silently drops nulls -> two different rows could hash equal.
return df # TODO: return df.withColumn("row_hash", F.sha2(...))
# --- 3. First (seed) load --------------------------------------------------
# If the dimension doesn't exist yet, every incoming row is a brand-new
# current version. Open the interval, leave the end open, mark is_current.
def seed_dimension(df):
seeded = (df.withColumn("effective_start_ts", F.col("snapshot_ts"))
.withColumn("effective_end_ts", F.lit(None).cast("timestamp")) # open interval
.withColumn("is_current", F.lit(True))
.withColumn("surrogate_key", F.expr("uuid()")))
# TODO: write `seeded` as a Delta table at DIM_TABLE. Guard it: only seed if
# the DeltaTable does not already exist. This branch runs ONCE.
pass
# --- 4. Incremental SCD2 MERGE (the two-step pattern) ---------------------
# You CANNOT both close an old row and insert its replacement in a single
# MERGE, because MERGE matches one target row per source key. So:
# Step A: MERGE - for keys whose row_hash CHANGED, set end_ts + is_current=False.
# Step B: INSERT - the changed rows AND the truly-new keys, as new current versions.
def apply_scd2(df):
dim = DeltaTable.forName(spark, DIM_TABLE)
current = spark.table(DIM_TABLE).filter("is_current = true")
# TODO: join df to `current` on BUSINESS_KEY; classify each incoming row as
# 'new_key' | 'changed' | 'unchanged' by comparing row_hash.
# (unchanged -> do NOTHING; that is what makes reruns idempotent.)
# ----- Step A: close out the superseded current rows -----
# TODO: dim.alias("t").merge(changed.alias("s"), key match AND t.is_current = true)
# .whenMatchedUpdate(set = {"effective_end_ts": "s.snapshot_ts",
# "is_current": "false"}).execute()
# Prove your interval [start, end) is half-open: no gaps, no overlaps.
# ----- Step B: insert new current versions (changed + new keys) -----
# TODO: to_insert = changed U new_key, with effective_start_ts = snapshot_ts,
# effective_end_ts = NULL, is_current = true, surrogate_key = uuid();
# then append to DIM_TABLE.
pass
# --- 5. Point-in-time (as-of) validation ----------------------------------
def as_of(as_of_ts: str):
return spark.sql(f"""
SELECT * FROM {DIM_TABLE}
WHERE effective_start_ts <= TIMESTAMP('{as_of_ts}')
AND (effective_end_ts > TIMESTAMP('{as_of_ts}') OR effective_end_ts IS NULL)
""")
# TODO: assert exactly ONE row per business key for any as_of_ts. Two rows ->
# your intervals overlap -> bug is in the Step A/B boundary handling.
# --- 6. Orchestration ------------------------------------------------------
if __name__ == "__main__":
# TODO: if DIM_TABLE missing -> seed_dimension(add_row_hash(load_snapshot(SNAP1)))
# else -> apply_scd2(add_row_hash(load_snapshot(SNAP2)))
# TODO: append an audit-log row: run_ts, snapshot_ts, rows_in, versions_closed,
# versions_opened, rows_rejected (append-only _audit table).
# TODO: IDEMPOTENCY TEST - apply_scd2 on the SAME snapshot 10x and assert
# versions_opened == 0 after the first application.
pass
- The Data Warehouse Toolkit, 3rd Ed. (Kimball) O'Reilly · Book
- Star Schema: The Complete Reference O'Reilly · Book
Deliverable, acceptance & live session
- Applying snapshot 2 closes exactly the changed current rows (effective_end_ts set, is_current=false) and opens new current versions for changed + new keys, with unchanged rows untouched
- Re-applying snapshot 2 ten times opens zero additional versions and leaves row counts stable (idempotency proven, not asserted)
- An as-of query for any date returns exactly one version per business key — no gaps, no overlaps — and demonstrably reproduces the pre-change price for a disputed facility
- Key-violating rows land in *_rejected with reason + timestamp and each run writes an audit row with versions_closed / versions_opened / rows_rejected
- The change-tracking policy and why-note are present and specific to Maria's audit needs.
Recap Walk the room through your two-step SCD2 MERGE on a real changed facility: show the old version closed and the new version opened, side by side. Then run your as-of query live for a date between the two snapshots and prove exactly one version resolves. State your interval convention out loud and your idempotency result (versions_opened after rerun #2 through #10).
Misconception The big one: trying to do SCD2 in a single MERGE that both updates the old row and inserts the new one — it can't, because MERGE matches one target row per source key, so you either lose the close-out or the insert. Related traps: closing the interval with an overlapping boundary (using <= on both ends) so an as-of query returns two versions; hashing untracked columns (or letting concat_ws drop nulls) so identical business rows look 'changed' and every rerun explodes the table; and treating a Type 1 correction as Type 2, permanently baking a typo into history.
Hot seat "Defend three decisions: (1) Which columns did you make Type 2 vs Type 1, and why — name one column you deliberately overwrite and justify destroying its history to Maria's auditor. (2) Show your interval boundary and prove no as-of date can return two versions (why half-open, not closed-closed). (3) Prove your pipeline is idempotent when CMS re-publishes an identical file — what exactly makes an unchanged row a no-op, and how did your 10x test confirm it?"
W17 Lakehouse & gold-layer modeling — the semantic layer M10 · Phase 2 · silver G2G10 ~11.75h
- Design and declare an explicit GRAIN for fact_price_by_facility_procedure (one row per facility x billing-code x payer x plan x charge-setting) and defend the grain in a written modeling note
- Build a star schema (1 fact + conformed dim_facility, dim_provider, dim_procedure) from SCD2 silver, deriving CURRENT-view dimensions without history fan-out
- Author the pricing-completeness KPI as ONE canonical Spark SQL definition, compute it per ingest run, and reconcile the number back to silver within a tolerance you state
- Expose a semantic VIEW (business-named columns, surrogate keys hidden) that enforces the serve-only contract — no transforms downstream
- Make the gold build idempotent (replace-partition / MERGE on grain key) and prove identical row counts + identical KPI values across 10 consecutive reruns
- Lakehouse gold layer & the serve-only contract: why gold is not 'SELECT * from silver' — denormalized for consumption, business-named, never re-cleaned, read-optimized (0.75h)
- Dimensional modeling for the lakehouse: fact vs dimension, declaring grain first, conformed dimensions, degenerate dimensions, surrogate vs business keys, additive/semi-additive/non-additive measures (1.5h)
- Consuming SCD2 silver into gold: current-view vs as-of snapshots, and how joining full history fans out a fact table and silently double-counts money (1.0h)
- The semantic layer: governed single-source KPI definitions, business naming, VIEW vs materialized table trade-offs, and why the metric definition must live in exactly one place (1.0h)
- Gold KPIs as tested metrics: writing the pricing-completeness definition, guarding against grain-driven double counting, and reconciling gold measures back to silver as an audit (1.0h)
Hands-on lab · ~6.5h
Turn the conformed, historized SILVER model (built W14–16, SCD2 finished W16) into the GOLD marts Maria actually uses: a star schema fact_price_by_facility_procedure with conformed dimensions, a per-run pricing-completeness KPI defined once in canonical SQL, and a business-named semantic view that hides silver internals. The build is serve-only (no re-cleaning), idempotent, reconciled back to silver, and any grain-violating rows are quarantined with a reason and timestamp — a mart that breaks loudly, never silently.
- Write the GRAIN statement first. In a markdown modeling note declare: 'one row of fact_price_by_facility_procedure = one facility x one billing code (CPT/HCPCS/DRG) x one payer x one plan x one charge-setting (inpatient/outpatient).' List which columns are the grain key and why negotiated_price is a NON-additive measure (you cannot sum prices across procedures). This note is your interview answer — be specific.
- Build CURRENT-view dimensions from SCD2 silver: dim_provider and dim_facility by filtering silver to is_current = true (do NOT carry all SCD2 versions into gold — prove to yourself with a count why that would fan out the fact). Build dim_procedure from the CMS billing-code set. Assign a surrogate key to each dimension and keep the source business key (npi, ccn, code) alongside it.
- Build fact_price_by_facility_procedure by joining silver pricing to the three dimensions on business keys, then keeping ONLY surrogate keys + measures (negotiated_price, gross_charge, discounted_cash_price as decimal(12,2)) + degenerate attributes (payer, plan, setting) + run_id + ingest_ts. Assert this is gold: no cleaning, no null-fixing here — if silver is dirty, that is a silver bug.
- Enforce the grain: check for duplicate grain keys in the fact. Route any duplicate-grain rows to gold_rejected with reason = 'grain_violation', the offending key, and a rejected_ts, and print an alert line + count. A silent duplicate here double-counts Maria's spend — fail loudly.
- Define the pricing-completeness KPI ONCE. Write a single canonical SQL: per facility, completeness_pass = (facility has a non-null negotiated_price for every code in the required shoppable-services set). Aggregate to kpi_pricing_completeness(run_id, run_ts, facilities_total, facilities_passing, pct_passing). Never redefine this metric anywhere else — the view and any report must read from this table.
- Create the semantic view vw_price_transparency with business-named, serve-only columns (facility_name, procedure_description, payer, plan, negotiated_price, pricing_complete_flag) — surrogate keys and silver internals hidden. Write two example analyst queries Maria would run (price for a procedure across facilities; facilities failing completeness) so a reviewer sees the mart is usable.
- Make it idempotent and prove it: write the fact with replaceWhere on run_id (or MERGE on the grain key) and the KPI table by overwrite-per-run. Run the whole gold build 10x in a loop and assert identical fact row count AND identical pct_passing every time; capture the run counts in your why-note.
- Reconcile gold to silver as an audit: compare distinct facility count and total distinct (facility, code) pairs in the fact against silver, and log any delta to gold_reconciliation(run_id, metric, silver_value, gold_value, delta) with a stated tolerance. Then update the Project Charter KPI list (pricing-completeness %) and add the gold layer to the architecture doc's data-flow map.
- Databricks — Medallion (bronze/silver/gold) lakehouse architecture
- Databricks — Star schema / dimensional modeling on the lakehouse
- Kimball Group — Dimensional Modeling Techniques (declare the grain, fact & dimension design)
- Delta Lake — Selective overwrite with replaceWhere (idempotent partition replace)
- Spark SQL — CREATE VIEW reference (semantic serve-only views)
- CMS — Hospital price transparency data dictionary & required data elements
</> Starter scaffold — 17_gold_price_transparency_scaffold.py python
# Databricks Free Edition | PySpark + Spark SQL | Delta
# Week 17 - GOLD: price-by-facility-procedure mart + completeness KPI + semantic view
# CONTRACT: gold = business-ready, SERVE-ONLY. No cleaning here. If silver is dirty, fix silver.
from pyspark.sql import functions as F
CATALOG = "main" # TODO: set your schema names
SILVER = f"{CATALOG}.silver"
GOLD = f"{CATALOG}.gold"
RUN_ID = dbutils.widgets.get("run_id") if dbutils.widgets.getAll() else "manual_run" # TODO
# --- 1. CURRENT-view dimensions from SCD2 silver -----------------------------
# Silver providers/facilities are SCD2 (is_current flag from W16). Gold uses CURRENT only.
silver_fac = spark.table(f"{SILVER}.dim_facility_scd2").where("is_current = true")
# TODO: assign a surrogate key + keep business key (ccn). Build dim_provider, dim_procedure the same way.
# TODO: PROVE (a count) why carrying ALL scd2 versions would fan out the fact. Write it in your note.
# --- 2. Fact: declare grain, then build it -----------------------------------
# GRAIN: one row = facility x billing_code x payer x plan x charge_setting (write this in the note)
silver_price = spark.table(f"{SILVER}.pricing_clean")
# TODO: join silver_price to the 3 dims on BUSINESS keys; keep ONLY surrogate keys + measures
# + degenerate attrs (payer, plan, setting) + run_id + ingest_ts.
# Measures as decimal(12,2). No null-fixing here.
fact = None # TODO build me
# --- 3. Enforce the grain (fail loudly) --------------------------------------
# TODO: find rows whose grain key is duplicated; route them to GOLD.gold_rejected
# with reason='grain_violation', the key, and rejected_ts. Print an alert + count.
# --- 4. Idempotent write ------------------------------------------------------
# TODO: write `fact` with .option("replaceWhere", f"run_id = '{RUN_ID}'") (or MERGE on grain key)
# fact.write.format("delta").mode("overwrite").option("replaceWhere", ...).saveAsTable(f"{GOLD}.fact_price_by_facility_procedure")
# --- 5. Canonical completeness KPI (define ONCE) -----------------------------
# The ONLY place this metric is defined. The view and any report must read this table.
kpi_sql = f"""
-- TODO: per facility, completeness_pass = has non-null negotiated_price for EVERY required code.
-- Aggregate to: run_id, run_ts, facilities_total, facilities_passing, pct_passing
SELECT NULL AS run_id -- replace me
"""
# spark.sql(kpi_sql).write.format("delta").mode("overwrite")\
# .option("replaceWhere", f"run_id = '{RUN_ID}'").saveAsTable(f"{GOLD}.kpi_pricing_completeness")
# --- 6. Semantic view (business-named, serve-only) ---------------------------
spark.sql(f"""
CREATE OR REPLACE VIEW {GOLD}.vw_price_transparency AS
SELECT
-- TODO: expose facility_name, procedure_description, payer, plan,
-- negotiated_price, pricing_complete_flag. HIDE surrogate keys + silver internals.
NULL AS facility_name
FROM {GOLD}.fact_price_by_facility_procedure f
-- TODO: join dims for the human-readable names; join kpi for the complete flag
""")
# --- 7. Reconcile gold -> silver (audit) -------------------------------------
# TODO: compare distinct facility count (and distinct facility+code pairs) gold vs silver.
# Log deltas to GOLD.gold_reconciliation(run_id, metric, silver_value, gold_value, delta)
# with a stated tolerance. A non-zero unexplained delta is a bug, not a rounding note.
# --- 8. Idempotency proof (run this AFTER the build works) --------------------
# TODO: loop the whole build 10x; assert identical fact.count() AND identical pct_passing each time.
# Capture the 10 identical numbers in your why-note.
- Introduction to Databricks DataCamp · Course
- Delta Lake: Up and Running O'Reilly · Book
Deliverable, acceptance & live session
- The fact table has a declared grain and ZERO duplicate grain keys in the served table — any duplicates are in gold_rejected with reason='grain_violation' + timestamp and an alert was printed. (2) Dimensions use CURRENT SCD2 rows only (is_current=true), verified by count. (3) pricing-completeness pct_passing is produced by ONE canonical SQL, stored per run_id, and reconciles to silver within your stated tolerance (logged in gold_reconciliation). (4) vw_price_transparency returns only business-named columns with surrogate keys hidden, and two working analyst queries are shown. (5) Running the gold build 10x yields identical fact row count AND identical pct_passing, with the 10 numbers captured in the why-note. (6) No cleaning/null-fixing logic exists in gold code.
Recap Walk the gold layer: how it differs from silver (denormalized, business-named, serve-only, never re-cleaned), the star schema you built, and the single canonical completeness KPI. Show vw_price_transparency answering a real Maria question and the 10x-identical idempotency numbers.
Misconception "Gold is just SELECT * from silver (or one giant joined table), and analysts should get the full SCD2 history so they can see everything." Wrong twice: gold is a deliberately modeled, serve-only star with a declared grain, and joining full SCD2 history into a fact fans it out and silently double-counts money — you take current-view dims and put history-as-of logic in silver, not in the mart the analyst sums.
Hot seat "Defend three decisions on the spot: (1) State your fact grain and justify why negotiated_price is non-additive — what breaks if someone SUMs it across procedures? (2) Why did you expose the completeness metric as a VIEW reading one canonical KPI table instead of hard-coding the calculation in the view (or in a BI tool)? (3) You built dimensions from is_current=true only — a reviewer asks 'what about a facility that changed names mid-quarter?' Explain why that as-of question belongs in silver history, not in this gold mart."
W18 MDM I — Reference Data, Crosswalks & Match Keys M11 — Master Data Management I · Phase 2 · silver G1G2 ~10.5h
- Build a governed provider REFERENCE table in silver (silver.dim_provider_ref) by standardizing NPPES name/address attributes (casefold, punctuation strip, USPS suffix normalization, entity-type split), persisted as Delta and idempotently MERGE-upserted on NPI.
- Design and populate a CROSSWALK table (xwalk_provider) linking pricing-file facility rows to NPPES NPI to a canonical facility_key, carrying an explicit match_status column so every downstream mart joins on exactly one key.
- Generate at least TWO deterministic blocking/match keys and produce candidate provider pairs WITHIN blocks, then measure the reduction from N-squared to actual comparisons and report block coverage (recall proxy).
- Detect and handle blocking failures loudly: quarantine records with no usable blocking attribute (reason + timestamp) and flag oversized/skewed blocks that would explode the pair count.
- Prove idempotency: rerun the reference + crosswalk + blocking build 10 times and assert identical dim row counts AND identical candidate-pair counts.
- Reference vs. master vs. transactional data (~0.75h): the three data classes and why they live in different layers — a provider registry is reference/master data that belongs in a silver dimension, while pricing rows are transactional; the layer contract (silver = clean+conformed+historized, gold = serve-only) and where a crosswalk sits.
- Crosswalks & the shared-key problem (~0.5h): what a crosswalk table is, natural vs. surrogate keys, why CMS price-transparency files almost never carry a clean NPI, and the match_status lifecycle (unmatched → candidate → resolved) that W19 will consume.
- Standardization & normalization for matching (~0.75h): why raw strings never join — casefolding, punctuation/whitespace stripping, USPS street-suffix abbreviations, and phonetic encodings (soundex/metaphone); splitting NPPES entity type 1 (individual) vs 2 (organization) because they match on different attributes.
- Blocking & match keys — beating N-squared (~0.75h): the combinatorial cost of all-pairs comparison, blocking predicates as a recall-vs-cost tradeoff, why you run MULTIPLE blocking passes (union of blocks) to protect recall, and how a single bad block key (e.g. state alone) creates skewed mega-blocks.
- Candidate-pair generation without a cartesian (~0.5h): self-join strictly within a block, deduping symmetric pairs with an a<b ordering rule, and block-size guardrails that cap or quarantine oversized blocks before they detonate the pair count.
Hands-on lab · ~7h
Turn the raw NPPES provider registry into governed reference data, wire it to the pricing facilities with a crosswalk, and lay the entity-resolution groundwork: standardize match attributes, compute two-plus deterministic blocking keys, and emit candidate provider pairs within blocks — quarantining un-blockable records and flagging skewed mega-blocks. You produce the inputs W19 will score and collapse into golden records; you do NOT score or survivor-merge anything yet.
- FRAME IT: write the 2-sentence business framing at the top of the notebook (duplicate providers corrupt Maria's cost + compliance rollups) and open the NPPES sample to name the specific mess you see — e.g. 'ST. MARY'S' vs 'SAINT MARYS', suite lines in address_1, trailing NPI whitespace, org vs individual rows mixed together.
- BUILD THE REFERENCE TABLE: from the silver NPPES landing, standardize match attributes into silver.dim_provider_ref — upper+trim, strip punctuation (regexp_replace), normalize USPS street suffixes (ST->STREET, AVE->AVENUE) via a small mapping, zero-pad/validate ZIP to zip5, and split entity_type_code 1 (individual: last/first name) vs 2 (organization: org name). Persist as Delta partitioned by state.
- MERGE-UPSERT ON NPI (idempotent): write dim_provider_ref with Delta MERGE keyed on NPI so a rerun updates in place instead of appending. This is the deliberate idempotency choice for a reference dimension — record WHY (stable natural key) in your why-note.
- BUILD THE CROSSWALK: create xwalk_provider linking each pricing-file facility row (source_system, source_record_id) to an NPI where one exists and to a canonical facility_key, with match_status defaulting to 'unmatched' for the (many) pricing rows that carry no clean NPI. This is the table W19 will fill in.
- COMPUTE BLOCKING KEYS: add >=2 deterministic block keys to the reference records — e.g. block_key_1 = soundex(org_or_last_name) || state, block_key_2 = zip5 || substring(name,1,3). Explain in the why-note why two passes (union) protects recall vs. any single key.
- QUARANTINE UN-BLOCKABLE RECORDS: any record missing every blocking attribute (no name AND no zip) goes to a rejected table with reason='no_usable_block_key' + ingest timestamp + source; count them and emit a loud alert row to the audit log (silent drops are worse than loud).
- GENERATE CANDIDATE PAIRS: self-join records WITHIN each block only, keep pairs with an a<b ordering rule to kill symmetric duplicates, and BEFORE the join compute block sizes — cap or flag any block above a threshold (e.g. >500 members) as 'oversized_block' so a bad key can't explode the pair count. Persist candidate_pairs (id_a, id_b, block_key, block_pass).
- MEASURE + AUDIT: write run metrics to the audit log — records standardized, records quarantined (by reason), distinct blocks, oversized blocks flagged, candidate pairs generated, and the reduction ratio (candidate_pairs / (N*(N-1)/2)). Then RERUN the whole build 10x and assert dim row count and candidate-pair count are identical each time; capture the proof in the why-note.
- Delta Lake — Upsert into a table using MERGE (idempotent reference upserts)
- Spark SQL built-in functions (soundex, regexp_replace, translate, substring, lpad)
- Splink — Blocking rules & the recall-vs-cost tradeoff (conceptual guide)
- USPS Publication 28 — Official street suffix abbreviations (for address standardization)
- NPPES data dissemination file layout / readme (field definitions, entity type code)
</> Starter scaffold — w18_reference_and_blocking.py python
# Week 18 - MDM I: reference data, crosswalks & match keys (STARTER SCAFFOLD)
# BUSINESS FRAMING: duplicate provider records make Maria's cost + compliance
# rollups double-count. This week we build governed reference data and the
# candidate pairs W19 will score. We do NOT score or merge here.
from pyspark.sql import functions as F
SILVER_NPPES = "silver.nppes_provider" # landed+typed from earlier weeks
SILVER_PRICING = "silver.pricing_facility" # pricing facility rows
DIM_REF = "silver.dim_provider_ref"
XWALK = "silver.xwalk_provider"
PAIRS = "silver.candidate_pairs"
REJECTED = "silver.rejected_records"
AUDIT = "silver.audit_log"
# ---------------------------------------------------------------
# 1) STANDARDIZE match attributes into the reference dimension
# ---------------------------------------------------------------
src = spark.table(SILVER_NPPES)
# TODO: casefold + trim + strip punctuation on name/address columns.
# Hint: F.upper(F.trim(F.regexp_replace(col, r"[^A-Za-z0-9 ]", "")))
# TODO: normalize USPS street suffixes (ST->STREET, AVE->AVENUE, ...).
# Build a small mapping and apply it; do NOT hardcode one example.
# TODO: derive zip5 (validate/zero-pad) and keep state.
# TODO: split entity_type_code: 1 = individual (last/first), 2 = organization
# (org name) -> a single std_name column chosen by entity type.
ref = (src
.withColumn("npi", F.trim(F.col("npi")))
# .withColumn("std_name", ...) # TODO
# .withColumn("std_addr", ...) # TODO
# .withColumn("zip5", ...) # TODO
)
# ---------------------------------------------------------------
# 2) IDEMPOTENT upsert of the reference dimension (MERGE on NPI)
# ---------------------------------------------------------------
# TODO: write `ref` to DIM_REF the first run (Delta, partitionBy state),
# then convert to a MERGE keyed on NPI so reruns update in place.
# WHY note: NPI is a stable natural key -> MERGE, not append.
# ---------------------------------------------------------------
# 3) CROSSWALK skeleton (W19 fills match_status)
# ---------------------------------------------------------------
# TODO: build xwalk_provider = one row per pricing facility source record:
# (source_system, source_record_id, npi_or_null, facility_key,
# match_status='unmatched'). Most pricing rows have NO clean NPI.
# ---------------------------------------------------------------
# 4) BLOCKING KEYS (>=2 deterministic passes)
# ---------------------------------------------------------------
# TODO: block_key_1 = soundex(std_name) concat state
# TODO: block_key_2 = zip5 concat substring(std_name,1,3)
# blocked = ref.withColumn("block_key_1", ...).withColumn("block_key_2", ...)
# ---------------------------------------------------------------
# 5) QUARANTINE un-blockable records (loud, not silent)
# ---------------------------------------------------------------
# TODO: rows with no name AND no zip -> REJECTED with
# reason='no_usable_block_key', ingest_ts, source. Count + audit.
# ---------------------------------------------------------------
# 6) CANDIDATE PAIRS within blocks (NO cartesian) + skew guard
# ---------------------------------------------------------------
def pairs_for(df, key):
# TODO: compute block sizes; flag/cap blocks over a threshold
# (e.g. >500) as 'oversized_block' before joining.
a = df.select(F.col("npi").alias("id_a"), F.col(key).alias("bk"))
b = df.select(F.col("npi").alias("id_b"), F.col(key).alias("bk"))
return (a.join(b, "bk")
.where(F.col("id_a") < F.col("id_b")) # kill symmetric dups
.withColumn("block_key", F.lit(key)))
# TODO: union pairs_for(blocked,"block_key_1") and block_key_2, dedup, write PAIRS.
# ---------------------------------------------------------------
# 7) AUDIT + reduction ratio, then rerun 10x to prove idempotency
# ---------------------------------------------------------------
# TODO: log records_standardized, records_quarantined, n_blocks,
# oversized_blocks, candidate_pairs, reduction_ratio to AUDIT.
# TODO: run this notebook 10x; assert DIM_REF count and PAIRS count are
# identical every run. Paste the proof into your why-note.
- Master Data Management (Loshin) O'Reilly · Book
- Cleaning Data in Python (record linkage chapter) DataCamp · Course
Deliverable, acceptance & live session
- Reference dim has exactly one row per NPI after a rerun (no duplicates from re-appends). xwalk_provider exists with a populated match_status column (unmatched default). candidate_pairs contains only within-block pairs with id_a < id_b (zero symmetric duplicates, zero cross-block pairs). Every record lacking all blocking attributes appears in the rejected table with reason='no_usable_block_key' + timestamp + source, and its count is echoed to the audit log. The audit log reports the reduction ratio (candidate_pairs vs full N-squared) and oversized-block flags. Running the notebook 10 times yields identical dim row count and identical candidate-pair count. No pair scoring, no survivorship, no golden-record merge is present (those are W19).
Recap Walk the cohort through the entity-resolution funnel drawn on one slide: raw provider rows -> standardized reference dim -> blocking keys -> candidate pairs (this week stops here) -> scoring + survivorship (next week). Demo one duplicate provider ('ST. MARY'S' vs 'SAINT MARYS') falling into the SAME block under block_key_1 but showing why a second pass on zip is needed to catch a third variant that soundex missed. Share the reduction-ratio number each learner got and discuss the spread.
Misconception The two big misconceptions to correct: (1) 'blocking finds the duplicates' — no, blocking only proposes CHEAP candidates; it trades recall for cost, and W19 does the actual matching. If your blocking drops a true duplicate, no downstream scorer can ever recover it, so recall (coverage) matters more than precision here. (2) 'more/bigger blocks are safer' — a lazy key like state-only creates mega-blocks whose self-join is nearly the N-squared you were trying to avoid; skew is the silent killer, which is why you measure block sizes and flag oversized blocks before joining.
Hot seat "Each learner defends: 'Show me your blocking keys. What true duplicate does this scheme MISS, and what is your recall estimate?' Follow-ups: Why two passes instead of one, and what did the union buy you in the reduction ratio? Why MERGE on NPI for the reference dim instead of overwrite or append — what breaks on rerun with each alternative? A pricing row with no NPI just landed — walk me through exactly how your crosswalk represents it today and what W19 will do with it. And: you flagged an oversized block — did you cap it, quarantine it, or split it with a finer key, and why is that the right call for Maria's compliance deadline?"
W19 MDM II — Entity Resolution & Golden Records: collapsing duplicate providers into one trustworthy record M11 — Master Data Management · Phase 2 · silver G2G1G9 ~11.0h
- Given a blocked candidate set from W18, generate candidate PAIRS and score each pair with at least three similarity features (edit distance on name, address similarity, phonetic/soundex agreement) — no all-pairs cross joins
- Combine feature scores into a single match confidence in [0,1] and defend a chosen AUTO-MERGE threshold and a REVIEW band with a labeled sample (precision/recall trade-off stated in words)
- Cluster matched pairs into entity groups and apply written SURVIVORSHIP rules (most-recent, most-complete, source-priority) to emit exactly ONE golden record per real provider, with a stable golden_provider_id
- Route every low-confidence pair to a governed human-review QUEUE table (pair, features, score, reason, timestamp, status) instead of silently guessing
- Make the whole resolution idempotent: rerun 10× and prove golden-record counts, golden IDs, and the review queue are stable (MERGE on deterministic keys, no duplicate golden rows)
- Entity resolution end to end (0.75h): the pipeline blocking → pairwise scoring → clustering → survivorship → golden record; why exact joins fail on real data and why NPI alone is NOT enough (same provider, multiple NPIs; one NPI reused/typo'd across files)
- Fuzzy matching mechanics (0.75h): Levenshtein/edit distance vs. token-based similarity vs. phonetic (soundex) — what each catches and misses; the classic 'William→Illiam', 'St'→'Saint', 'Dr.'→'Drive', suite-number, and abbreviation problems; using Spark's built-in levenshtein() and soundex() so it runs on Free Edition with no extra installs
- From feature scores to a decision (0.75h): weighting features into one confidence, choosing an auto-merge threshold and a review band, and the precision/recall trade-off framed as money/risk — a false merge hides a real facility's prices; a false split double-counts a provider in Maria's cost report
- Survivorship & golden records (0.5h): clustering matched pairs into groups (connected components / transitive closure intuition), then survivorship rules (recency, completeness, source priority) and lineage — every golden field traceable to the source row it came from
- Human-in-the-loop stewardship (0.5h): the review queue as a governance artifact — schema, statuses (pending/approved/rejected), why a steward's decision must be captured and fed back, and how this closes the ownership gap for a data product
- Idempotency for MDM (0.25h): why golden IDs must be deterministic (hash of stable cluster members, not a monotonic surrogate that changes every run) so reruns and CMS republishes don't churn golden_provider_id
Hands-on lab · ~7.5h
Take the blocked candidate set your W18 SILVER job produced and turn it into GOLDEN records. Generate candidate pairs inside each block, score each pair with edit-distance, address, and phonetic features, combine them into a confidence, auto-merge high-confidence pairs into clusters, apply written survivorship rules to emit one golden record per provider, and route everything in the ambiguous middle band to a human-review queue. Prove the whole thing reruns identically 10×.
- 1. RECOGNITION README first: write the 1–2 sentence business framing (duplicate providers break Maria's cost + compliance numbers) and name the SPECIFIC mess in your candidate set — e.g. 'HCA HOUSTON HEALTHCARE' vs 'Hca Houston Health Care', 'ST LUKES' vs 'Saint Lukes', suite-number-only address differences, and one provider holding two NPIs. Paste 5 real duplicate examples pulled from NPPES.
- 2. Load the W18 blocked candidates from SILVER (provider records already standardized + assigned a blocking key). Within each block, generate candidate PAIRS with a self-join filtered to id_left < id_right (never a full cross join) so you compare only plausible duplicates. Log the pair count and the largest block size (watch for a runaway block).
- 3. Build similarity FEATURES per pair with Spark built-ins only: normalized levenshtein() on standardized name, a token/levenshtein similarity on standardized address line, and a soundex() equality flag on the last name / core name token. Add same-state and same-postal-prefix boolean features. Keep each feature in [0,1].
- 4. Combine features into a single match_confidence (start with a documented weighted sum — TODO in the scaffold). Hand-label ~30 pairs as true/false duplicates, then pick an AUTO-MERGE threshold and a REVIEW band (e.g. >=0.90 auto, 0.65–0.90 review, <0.65 non-match). Write ONE paragraph defending the cut in precision/recall + money terms.
- 5. Cluster the auto-merge pairs into entity groups (transitive closure: if A~B and B~C then A,B,C are one provider). Use an iterative min-member-id label propagation over the accepted pairs (scaffolded) and assign each cluster a DETERMINISTIC golden_provider_id = hash of the sorted stable source keys — not a monotonically increasing id.
- 6. Apply SURVIVORSHIP rules to pick each golden field: most-recent ingest for volatile fields (address, phone), most-complete (non-null, longest) for name, and source-priority for NPI. Emit ONE golden row per cluster to a GOLDEN Delta table, plus a golden_lineage table mapping golden_provider_id → every contributing source row + which field it won.
- 7. QUARANTINE + REVIEW QUEUE: write every review-band pair to a review_queue Delta table with pair ids, all feature values, match_confidence, reason ('confidence in review band'), ingest timestamp, and status='pending'. Write clearly-broken inputs (null name, malformed NPI) to the existing rejected table with reason. Emit a one-line alert with counts.
- 8. IDEMPOTENCY proof: MERGE golden + review rows on their deterministic keys, then run the whole notebook 10× and assert golden_provider_id set, golden row count, and review_queue count are identical every run. Capture the KPI row: # duplicates collapsed, # golden records, # pairs sent to review, % change vs last run.
- 9. Write the 'why' note (interview answers): why levenshtein+soundex over exact join, why THIS threshold, which survivorship rule you chose per field and the failure it prevents, and why golden IDs are hashed. Update the KPI section of the Project Charter with 'duplicate providers collapsed into golden records' for this run.
- Spark SQL built-in functions — levenshtein(), soundex(), sha2()/hash (used for pair scoring + deterministic golden IDs)
- Delta Lake — MERGE (upsert) for idempotent golden + review-queue writes
- Robin Linacre — An Interactive Introduction to Probabilistic Record Linkage (why fuzzy matching, thresholds, and the precision/recall trade-off)
- Databricks docs — pyspark.sql.functions reference (levenshtein, soundex, when/otherwise, window fns for survivorship)
</> Starter scaffold — silver_to_golden_entity_resolution.py python
# silver_to_golden_entity_resolution.py
# Databricks Free Edition + Delta + PySpark. Run as a notebook.
# W18 produced SILVER provider records with a standardized name/address + a blocking_key.
# This week: pairs -> similarity features -> confidence -> cluster -> golden + review queue.
from pyspark.sql import functions as F, Window
SILVER = "workspace.default.silver_providers" # standardized + blocking_key from W18
GOLDEN = "workspace.default.golden_providers"
LINEAGE = "workspace.default.golden_lineage"
REVIEW = "workspace.default.provider_review_queue"
silver = spark.table(SILVER) # cols: src_key, npi, name_std, addr_std, state, postal, ingest_ts, source
# --- 1) Candidate PAIRS within each block (NO full cross join) ---
l = silver.alias("l"); r = silver.alias("r")
pairs = (l.join(r, (F.col("l.blocking_key") == F.col("r.blocking_key")) &
(F.col("l.src_key") < F.col("r.src_key"))) # dedupe + no self-pair
.select(F.col("l.src_key").alias("key_left"), F.col("r.src_key").alias("key_right"),
F.col("l.name_std").alias("name_l"), F.col("r.name_std").alias("name_r"),
F.col("l.addr_std").alias("addr_l"), F.col("r.addr_std").alias("addr_r"),
F.col("l.state").alias("state_l"), F.col("r.state").alias("state_r"),
F.col("l.postal").alias("postal_l"), F.col("r.postal").alias("postal_r")))
# TODO: log pairs.count() and the max block size; if one block explodes, tighten the W18 blocking_key.
# --- 2) Similarity FEATURES (Spark built-ins only) ---
def norm_lev(a, b):
# 1 - (edit_distance / max_len) -> in [0,1]; higher = more similar
maxlen = F.greatest(F.length(a), F.length(b), F.lit(1))
return F.lit(1.0) - (F.levenshtein(a, b) / maxlen)
feat = (pairs
.withColumn("f_name", norm_lev(F.col("name_l"), F.col("name_r")))
.withColumn("f_addr", norm_lev(F.col("addr_l"), F.col("addr_r")))
.withColumn("f_soundex", (F.soundex(F.col("name_l")) == F.soundex(F.col("name_r"))).cast("double"))
.withColumn("f_state", (F.col("state_l") == F.col("state_r")).cast("double")))
# TODO: add a postal-prefix feature and, if you like, a token-overlap feature for org names.
# --- 3) Combine into ONE confidence (defend your weights) ---
# TODO: replace this naive equal weighting after you hand-label ~30 pairs.
conf = feat.withColumn("match_confidence",
(F.col("f_name")*0.4 + F.col("f_addr")*0.3 + F.col("f_soundex")*0.2 + F.col("f_state")*0.1))
# --- 4) DECISION bands ---
AUTO, REVIEW_LO = 0.90, 0.65 # TODO: justify these from your labeled sample (precision vs recall).
accepted = conf.filter(F.col("match_confidence") >= AUTO)
to_review = conf.filter((F.col("match_confidence") >= REVIEW_LO) & (F.col("match_confidence") < AUTO))
# --- 5) CLUSTER accepted pairs into entity groups (transitive closure) ---
# TODO: implement iterative label propagation: each node's label = min(own, neighbors');
# iterate until labels stop changing. Then golden_provider_id = a DETERMINISTIC hash of
# the sorted src_keys in the cluster (F.sha2), NOT monotonically_increasing_id().
# --- 6) SURVIVORSHIP -> one golden row per cluster ---
# TODO: per golden_provider_id, choose fields by rule:
# address/phone = most-recent ingest_ts; name = most-complete (longest non-null);
# npi = source-priority. Write GOLDEN, and write LINEAGE (golden_id -> src_key -> field_won).
# --- 7) REVIEW QUEUE (governance artifact) ---
review_rows = (to_review
.withColumn("reason", F.lit("confidence_in_review_band"))
.withColumn("status", F.lit("pending"))
.withColumn("queued_ts", F.current_timestamp()))
# TODO: MERGE review_rows into REVIEW on (key_left, key_right) so reruns don't duplicate the queue.
# --- 8) IDEMPOTENCY ---
# TODO: MERGE golden + lineage on their deterministic keys; then run this notebook 10x and assert
# golden count, golden_provider_id set, and REVIEW count are identical every run.
# --- KPI line for the Charter ---
# TODO: print { collapsed = input_rows - golden_rows, golden_rows, review_rows, pct_change_vs_last }
- Hands-On Entity Resolution O'Reilly · Book
- Cleaning Data in Python (record linkage chapter) DataCamp · Course
Deliverable, acceptance & live session
- Passes when: (a) pairs are generated via blocked self-join with no full cross join and the largest block is bounded
- (b) each pair carries >=3 similarity features in [0,1] combined into one confidence
- (c) auto-merge threshold and review band are stated AND defended against a >=30-pair hand-labeled sample in precision/recall + money terms
- (d) golden_providers has exactly one row per cluster with a DETERMINISTIC (hashed) golden_provider_id and a survivorship rule named per field
- (e) every review-band pair lands in provider_review_queue with reason+timestamp+status and clearly-broken inputs land in the rejected table
- (f) running the notebook 10× yields identical golden count, identical golden_provider_id set, and identical review-queue count
- (g) README reports the KPI row (# collapsed, # golden, # in review, % change vs last run).
Recap Recap: W18 gave us standardization + blocking (cheap candidate generation); W19 turns candidates into truth. The chain is block → pair → fuzzy-score → threshold → cluster → survivorship → golden, with a human-review queue catching the ambiguous middle. Reinforce: NPI alone is not identity, silent auto-merges are worse than a loud review queue, and golden IDs must be deterministic so reruns don't churn.
Misconception The #1 misconception: 'a higher threshold is always safer.' It isn't — pushing the auto-merge threshold up shrinks false merges (precision) but leaves MORE true duplicates unresolved (recall), which double-counts providers in Maria's cost report and re-hides prices. The threshold is a business trade-off between two different failures, not a knob to max out. Second trap: using monotonically_increasing_id() (or row_number) for golden_provider_id — it changes every run and breaks idempotency and every downstream join.
Hot seat "Defend-your-decision hot seat: 'You set auto-merge at 0.90 and a 0.65–0.90 review band. Walk me through the two pairs on either side of that line and the money consequence of moving it 0.05 in each direction. Then defend ONE survivorship rule — why did the address come from most-recent but the name from most-complete, and what specific wrong number in Maria's report does each rule prevent? Finally: prove your golden_provider_id survives a rerun and a CMS republish unchanged.'"
W20 ETL vs ELT, Batch vs Incremental — Write the 4-Part Architecture Doc M13 — Architecture & Design Decisions · Phase 2 · silver G9G6G1 ~11.5h
- Decide ELT-over-ETL for Project 1 IN WRITING, citing at least 2 project-specific reasons tied to Delta Lake + Databricks Free Edition (e.g., cheap columnar storage lets raw land first; Spark SQL does the transform in-place).
- Produce a data-flow map that documents, for bronze→silver→gold, the GRAIN, the layer CONTRACT (raw / clean+conformed+historized / serve-only), and the exact transform boundary crossed between each pair of layers.
- Write a tool-selection log that names at least 3 alternatives considered (e.g., Snowflake, dbt+Postgres, plain Airflow+pandas) and gives a specific, defensible rejection reason for each versus Databricks+Delta.
- Author a failure plan enumerating at least 4 named failure modes (429 from NPPES API, schema drift in a hospital pricing file, late-arriving CMS republish, duplicate provider explosion in MDM) each with a DETECTION signal and a RESPONSE (quarantine / alert / fail-loud).
- Author an idempotency plan that assigns a rerun strategy (MERGE-on-key vs replace-partition vs full-overwrite) to EACH layer and specifies the 10x-rerun test that proves identical row counts.
- ETL vs ELT — where transforms live and why the lakehouse pushes work downstream. Extract-Load-Transform lands raw bytes in Delta first (bronze), then transforms with Spark SQL in-place; contrast with classic ETL where transforms happen in flight before landing. Why ELT wins when storage is cheap, sources are messy/re-parseable, and you want replayable raw. Map this directly onto the CMS pricing files (you cannot pre-clean formats you haven't seen yet). (1.5h)
- Batch vs incremental vs streaming — the reprocessing decision. Full reload vs delta/incremental load vs continuous streaming; latency, cost, and complexity trade-offs. Why CMS republishing monthly + NPPES monthly bulk = INCREMENTAL batch, not streaming; how idempotent MERGE makes incremental safe; when a full overwrite is actually the correct idempotency choice for a small dimension. Closes G6. (1.5h)
- The medallion architecture as enforceable LAYER CONTRACTS, not folders. Bronze = raw landed, no transforms, append-only, source + ingest timestamp; silver = validated, typed, conformed, historized (SCD2), quality-gated + quarantine; gold = business-ready, serve-only marts/KPIs. What is FORBIDDEN at each layer and why a contract you can point to is what separates a data product from a script. (1.0h)
- Architecture Decision Records (ADRs) and the tool-selection log — writing a decision so it survives you. Structure: context → options considered → decision → consequences. Why 'we chose Databricks' is worthless without the rejected alternatives; how a tool-selection log IS your interview answer to 'why this stack?'. (1.0h)
- Failure plans and idempotency plans as first-class design artifacts. Naming failure modes before they happen (429s, schema drift, late records, duplicate explosion); detection vs response; loud-not-silent. Choosing MERGE vs replace-partition vs overwrite per layer and designing the 10x-rerun proof. (1.5h)
Hands-on lab · ~5h
No new data this week — this is a design + decision week. The learner writes the 4-PART ARCHITECTURE DOC for Project 1 (Provider Pricing Integrity): a data-flow map (bronze→silver→gold with grain + contract per layer), a tool-selection log (why Databricks+Delta, with rejected alternatives), a failure plan (named failure modes → detection → response), and an idempotency plan (rerun strategy per layer + the 10x-rerun test). The starter scaffold introspects the Delta tables already built in W15–W19 so the map is grounded in REAL tables and row counts, not a fictional diagram. The doc explicitly DECIDES ELT + medallion + incremental and defends each choice.
- Run the starter scaffold notebook to inventory your existing Delta tables (bronze/silver/gold + rejected/quarantine) — capture table names, row counts, and partition columns as of today. Paste the output into your doc as the factual basis for the data-flow map; a hiring manager trusts a diagram backed by real numbers.
- Draw the DATA-FLOW MAP: for each hop (raw source→bronze, bronze→silver, silver→golden records→gold) write the GRAIN of the output table, the layer CONTRACT, and the single transform boundary being crossed (e.g., 'silver→gold: aggregate SCD2-current rows to facility×billing-code, no history, serve-only'). Use a Mermaid or ASCII diagram plus a table.
- Write the ELT-vs-ETL DECISION as an ADR: context (messy, re-parseable CMS files you haven't fully seen), options (ETL pre-clean-then-load vs ELT land-raw-then-transform), decision (ELT), and 2+ project-specific consequences tied to Delta (replayable bronze, cheap columnar storage, Spark SQL transforms in-place).
- Write the BATCH-VS-INCREMENTAL DECISION as an ADR: justify incremental batch over full reload and over streaming, citing CMS/NPPES monthly republish cadence and the data-freshness SLA KPI. State explicitly which table (if any) is a full-overwrite exception and why.
- Write the TOOL-SELECTION LOG: Databricks Free Edition + Delta chosen; list at least 3 rejected alternatives (e.g., Snowflake, dbt+Postgres, Airflow+pandas) each with a specific rejection reason (cost, no free lakehouse tier, no ACID on files, no SCD2 primitive, etc.). Note the phase constraint: Unity Catalog / DLT / DABs are deferred to Phase 3.
- Write the FAILURE PLAN table: at least 4 named failure modes (NPPES 429 rate-limit, hospital pricing-file schema drift, late-arriving CMS republish, duplicate-provider explosion in MDM) each with a DETECTION signal (row-count delta, expectation fail, null-rate spike) and a RESPONSE (retry-with-backoff, quarantine-to-rejected-with-reason+timestamp, alert, fail-loud). Cross-reference your existing rejected/quarantine tables from W16–W19.
- Write the IDEMPOTENCY PLAN table: assign a rerun strategy to EACH layer — bronze (append with dedup key or replace-by-ingest-date), silver (MERGE on business key / SCD2), gold (full-overwrite of derived mart or replace-partition) — and justify each. Define the 10x-RERUN TEST: the exact command sequence and the row-count invariant that must hold identical across 10 runs.
- Write the 1-page 'WHY' note (decisions = interview answers): the single hardest trade-off you made this week and the alternative you rejected, in language you could say out loud in an interview. Assemble all four parts into ARCHITECTURE.md in the repo and link it from the project README.
- Databricks — What is the medallion lakehouse architecture (bronze/silver/gold)?
- Delta Lake — MERGE INTO (upsert) reference for idempotent incremental writes
- Delta Lake — Idempotent table writes with txnAppId / txnVersion
- Michael Nygard — Documenting Architecture Decisions (the original ADR pattern)
- Databricks — Full vs incremental (change data) ingestion patterns
</> Starter scaffold — 20_architecture_table_inventory.py python
# Databricks Free Edition notebook — Week 20 STARTER SCAFFOLD
# Purpose: ground your data-flow map in REAL tables + row counts from W15-W19,
# not a fictional diagram. This only INVENTORIES; you write the doc.
#
# It does NOT decide ELT/incremental for you and does NOT draw the map.
# Fill every TODO. The prose (ADRs, failure plan, idempotency plan) is yours.
from pyspark.sql import functions as F
# TODO 1: point these at YOUR schema/catalog from earlier weeks.
# On Free Edition this is likely the default 'workspace' / hive_metastore.
SCHEMA = "provider_pricing" # TODO: your schema name
LAYER_PREFIXES = { # TODO: adjust to how you actually named tables
"bronze": "bronze_",
"silver": "silver_",
"gold": "gold_",
"reject": "rejected_", # quarantine tables from W16-W19
}
# --- Inventory every table and tag it with its intended layer ------------------
rows = []
tables = spark.catalog.listTables(SCHEMA)
for t in tables:
fqn = f"{SCHEMA}.{t.name}"
layer = next((L for L, p in LAYER_PREFIXES.items() if t.name.startswith(p)), "UNCLASSIFIED")
try:
cnt = spark.table(fqn).count()
except Exception as e:
cnt = -1 # e.g., view or permission issue — note it in the doc
# TODO 2: also capture partition columns so your idempotency plan can
# justify 'replace-partition' where you use it.
part_cols = None # TODO: read via DESCRIBE DETAIL <fqn> -> partitionColumns
rows.append((layer, t.name, cnt, part_cols))
inv = spark.createDataFrame(rows, ["layer", "table", "row_count", "partition_cols"]) \
.orderBy("layer", "table")
print("=== TABLE INVENTORY (paste into ARCHITECTURE.md data-flow map) ===")
inv.show(truncate=False)
# TODO 3: For EACH layer, write the CONTRACT you will defend in the doc:
# bronze = raw landed, append-only, source + ingest_ts, NO transforms
# silver = validated + typed + conformed + SCD2 historized + quarantine
# gold = business-ready, serve-only (KPIs for Maria), NO history
# Then write the transform BOUNDARY crossed between each pair.
# TODO 4: Flag any UNCLASSIFIED table — an unowned table is a contract gap
# and a great thing to name in your 'why' note.
unclassified = inv.filter(F.col("layer") == "UNCLASSIFIED")
print("UNCLASSIFIED tables (resolve or justify in the doc):")
unclassified.show(truncate=False)
# NOTE: This scaffold produces EVIDENCE. The four architecture-doc parts
# (data-flow map, tool-selection log, failure plan, idempotency plan)
# are written by YOU in ARCHITECTURE.md — that's the deliverable.- ETL and ELT in Python DataCamp · Course
- Fundamentals of Data Engineering O'Reilly · Book
- Designing Data-Intensive Applications, 2nd Ed. O'Reilly · Book
Deliverable, acceptance & live session
- (a) the data-flow map states GRAIN + CONTRACT + transform boundary for all three hops and cites real row counts from the inventory scaffold
- (b) the ELT and incremental decisions each list options considered AND ≥2 project-specific consequences (generic 'ELT is modern' is rejected)
- (c) the tool-selection log names ≥3 rejected alternatives with a specific reason each
- (d) the failure plan has ≥4 named modes each with a distinct detection signal and a concrete response (quarantine/retry/alert/fail-loud) referencing the actual rejected/quarantine tables
- (e) the idempotency plan assigns MERGE / replace-partition / overwrite per layer with justification and defines the exact 10x-rerun row-count invariant
- (f) the doc does NOT require Unity Catalog, DLT, or DABs (Phase 3). A peer must be able to read it in ~5 minutes and correctly predict what W21 will build.
Recap Each learner shares their ARCHITECTURE.md on screen for 3 minutes: walk the data-flow map, state the one transform boundary you found hardest to place, and read your ELT ADR's consequences aloud. Group compares idempotency strategies — who chose overwrite vs MERGE for gold, and why the choice differs.
Misconception 'The medallion layers are just folders / naming conventions.' Bronze/silver/gold are ENFORCEABLE CONTRACTS about what is forbidden at each layer (no transforms in bronze; no history in gold), not directories. If a learner cannot state what would VIOLATE a layer's contract, they've drawn folders, not designed an architecture. Second trap: 'incremental is always better than full reload' — a small conformed dimension is often correctly a full-overwrite for idempotency; the decision is per-table, not global.
Hot seat "Defend your decision to a skeptical staff engineer: 'This is overengineered. Just full-overwrite every table from the raw files every night — it's trivially idempotent and you never worry about late records or MERGE bugs. Why ELT, why incremental, why the whole medallion ceremony?' You must (1) justify ELT with a specific CMS-file-format reason, (2) justify incremental against BOTH full reload (cost/SLA on millions of NPPES rows) and streaming (no sub-daily source), and (3) concede where full-overwrite actually IS your chosen idempotency strategy — showing the decision is per-layer, not dogma."
W21 Landing the Mess: NPPES (API + bulk) & CMS Price-Transparency Files into an Append-Only Bronze M14 — Ingestion patterns & source systems → bronze · Phase 2 · silver G3G6 ~11h
- Land raw NPPES API responses and a CMS machine-readable pricing (MRF) file into two Bronze Delta tables that are append-only and carry ingest metadata (_source, _source_file, _ingest_ts, _batch_id, _ingest_date), verified by SELECTing distinct _source values and confirming zero transformation of the payload.
- Implement a paged NPPES puller that detects HTTP 429, honors Retry-After (falling back to exponential backoff + jitter), caps retries, classifies transient vs permanent failures, and on exhaustion raises loudly and writes a FAILED row to an ingest audit log instead of returning empty.
- Implement an 'expected file is absent' guard for the MRF ingest that logs a MISSING_SOURCE row (reason + timestamp) and raises, so a skipped source can never be mistaken for a successful empty run.
- Demonstrate run-level idempotency: rerun the same day's ingest 10x and show the Bronze count for that _ingest_date partition is unchanged (partition-overwrite within a run), while a new date appends — and articulate why Bronze is append-across-time but idempotent-within-run.
- Produce a queryable ingest audit log with exactly one row per source per run outcome (SUCCESS / EMPTY / MISSING_SOURCE / FAILED with reason), proving every run's fate is recorded.
- Ingestion patterns taxonomy: full snapshot vs incremental, push vs pull, batch-file vs API; how source-system realities (NPPES's 200-record page cap and rate limits, CMS's irregular MRF publish cadence, neither offering reliable change-tracking) force the pattern choice — and why you decide this before writing code. (~1h)
- The Bronze layer contract: raw landed, append-only, NO transforms. Why you keep the ugly original payload (store the API record as a raw JSON string, land MRF rows as-read), and the standard ingest-metadata columns that make Bronze auditable: _source, _source_file, _ingest_ts, _batch_id, _ingest_date. (~1h)
- Building for failure at ingest: HTTP 429 and rate limiting, the Retry-After header, exponential backoff + jitter, retry caps, and distinguishing transient (429/5xx → retry) from permanent (4xx → raise) failures. The ingest audit-log pattern as your alerting substitute on Free Edition. (~1.5h)
- The silent-skip problem: an expected source file that never showed up. Landing/freshness checks, loud-vs-silent failure, and why 'the job stayed green but ingested nothing' is the most dangerous outcome for Maria's compliance report. (~1h)
- Idempotent landing on Databricks Free Edition: append-only Delta partitioned by _ingest_date, overwrite-partition (replaceWhere) vs plain append trade-offs, and run-id lineage — chosen deliberately so a mid-run crash + rerun doesn't double-land today while yesterday stays intact. (~0.5h)
Hands-on lab · ~6h
You will stand up the Bronze ingestion layer of Project 1 on Databricks Free Edition: an append-only Delta landing zone fed by two sources — the live NPPES NPI Registry API (paged, rate-limited) and a CMS hospital MRF pricing file you upload to a Volume. You will make both sources carry source + ingest-timestamp metadata, make the API puller survive a 429, make the MRF path scream when the expected file is missing, prove the whole thing is idempotent within a run, and record every run's outcome in an audit log. No cleaning, typing, or dedup — that is the Bronze contract and it is deliberately deferred to Silver (W22+).
- Create the Bronze workspace objects: a schema/tables for bronze_nppes_providers and bronze_cms_mrf, plus an ingest_audit_log Delta table (columns: batch_id, source, status, row_count, reason, ingest_ts, ingest_date). Generate a per-run BATCH_ID (uuid) and INGEST_DATE at the top of the notebook so every write shares one lineage id.
- Recon the sources by hand before coding: hit https://npiregistry.cms.hhs.gov/api/?version=2.1&state=CA&limit=200 in a browser and note the 200-record cap and the skip-based paging; open the CMS Hospital Price Transparency page, download one hospital's machine-readable file, and eyeball how its format/columns differ from the next hospital's. Write down which ingestion pattern each source needs (API incremental-by-page vs batch file) and why.
- Build fetch_nppes_page(): implement the 429/backoff branch in the scaffold — read Retry-After if present, else exponential backoff (base * 2**attempt) with random jitter, cap at max_retries, treat 5xx as retryable and non-429 4xx as permanent (raise). Return resp.json()['results'] on 200. Keep the raw record; do not flatten it.
- Land NPPES to Bronze: page several states, wrap each raw record as a _payload JSON string, add the metadata columns, and write append-only but idempotent-per-run using .mode('overwrite').option('replaceWhere', "_ingest_date = '<today>'").partitionBy('_ingest_date'). Log a SUCCESS (or EMPTY) audit row.
- Add the missing-file guard and land an MRF: upload your downloaded hospital file to a Volume/DBFS landing path, then in ingest_mrf() check existence with dbutils.fs.ls BEFORE reading — if absent, write a MISSING_SOURCE audit row and raise FileNotFoundError. If present, read it as raw text/CSV/JSON with NO cleaning, add the same metadata, and land it with the same replaceWhere pattern.
- Idempotency test: run today's full ingest 10 times in a row. Confirm SELECT count(*) FROM bronze_nppes_providers WHERE _ingest_date = today is identical every time (partition overwrite), and that a simulated 'yesterday' partition is untouched. Record the before/after counts.
- Force the two failure modes on purpose: (a) drop max_retries to 1 and hammer the API / point at a bad param to provoke a raised failure, and (b) point ingest_mrf() at a path where the file does NOT exist. Confirm the job goes RED both times and that FAILED and MISSING_SOURCE rows land in the audit log with reason + timestamp.
- Query the audit log (SELECT source, status, row_count, reason, ingest_ts ORDER BY ingest_ts) and screenshot it. Write the 150–250 word why-note: why append-only Bronze instead of dedup-at-ingest, why partition-overwrite for run idempotency instead of MERGE, and how a MISSING_SOURCE run is now distinguishable from a clean one.
- NPPES API — parameters, versioning, and paging (skip/limit)
- MDN — HTTP 429 Too Many Requests & the Retry-After header
- Delta Lake — selectively overwrite data with replaceWhere (partition-level idempotent writes)
- Databricks — read JSON / CSV files into a DataFrame
- Databricks Free Edition — Volumes and the file system for landing raw files
- CMS Hospital Price Transparency — data & required file layouts
</> Starter scaffold — bronze_ingest_scaffold.py python
# Week 21 - Bronze ingestion scaffold (Databricks Free Edition + Delta)
# GOAL: land NPPES API + one CMS MRF file into Bronze, append-only, with
# source + ingest metadata, LOUD failures, and idempotent-per-run writes.
# CONTRACT: Bronze = raw landed, NO transforms. Keep the ugly payload.
import time, json, uuid, random, requests
from datetime import datetime, timezone
from pyspark.sql import Row
# ---- run identity (lineage) shared by every write this run ----
BATCH_ID = str(uuid.uuid4())
INGEST_DATE = datetime.now(timezone.utc).strftime("%Y-%m-%d")
CATALOG = "workspace" # Free Edition default; adjust to your workspace
BRONZE_PROVIDERS = f"{CATALOG}.default.bronze_nppes_providers"
BRONZE_MRF = f"{CATALOG}.default.bronze_cms_mrf"
AUDIT = f"{CATALOG}.default.ingest_audit_log"
NPPES_URL = "https://npiregistry.cms.hhs.gov/api/"
def now_utc():
return datetime.now(timezone.utc).isoformat()
def log_audit(source, status, rows, reason=""):
"""One queryable row per source per run outcome. This is your alerting."""
row = [Row(batch_id=BATCH_ID, source=source, status=status,
row_count=int(rows), reason=reason[:500],
ingest_ts=now_utc(), ingest_date=INGEST_DATE)]
(spark.createDataFrame(row)
.write.format("delta").mode("append").saveAsTable(AUDIT))
# -----------------------------------------------------------------
# 1) NPPES API pull -- handle the 429 (G6)
# -----------------------------------------------------------------
def fetch_nppes_page(params, max_retries=5):
"""Return the raw 'results' list for ONE page.
TODO(G6): loop with an attempt counter:
- resp = requests.get(NPPES_URL, params=params, timeout=30)
- if 200: return resp.json().get('results', [])
- if 429: sleep for int(Retry-After) if header present,
else backoff = (2 ** attempt) + random.random(); then retry
- if 5xx: retryable -> backoff + retry
- if other 4xx: PERMANENT -> raise (do not swallow)
- if attempts exhausted: raise (caller logs FAILED and the job goes red)
"""
raise NotImplementedError("Implement 429/backoff + return results list")
def ingest_nppes(states=("CA",), pages=5, limit=200):
try:
records = []
for st in states:
for i in range(pages):
params = {"version": "2.1", "state": st,
"limit": limit, "skip": i * limit}
page = fetch_nppes_page(params)
if not page:
break
# TODO: tag each raw record with its source state, keep it RAW
records.extend(page)
if not records:
log_audit("nppes_api", "EMPTY", 0, "no results returned")
return
# TODO(Bronze contract): build rows with ONE raw payload column +
# metadata. Example shape (fill in):
# rows = [Row(_payload=json.dumps(r), _source="nppes_api",
# _source_file=None, _ingest_ts=now_utc(),
# _batch_id=BATCH_ID, _ingest_date=INGEST_DATE)
# for r in records]
# df = spark.createDataFrame(rows)
df = None # TODO build df
# TODO(idempotency): append across days, overwrite TODAY's partition:
# (df.write.format("delta").mode("overwrite")
# .option("replaceWhere", f"_ingest_date = '{INGEST_DATE}'")
# .partitionBy("_ingest_date").saveAsTable(BRONZE_PROVIDERS))
log_audit("nppes_api", "SUCCESS", df.count() if df else 0)
except Exception as e:
log_audit("nppes_api", "FAILED", 0, reason=str(e))
raise # FAIL LOUD
# -----------------------------------------------------------------
# 2) CMS MRF file -- detect the file that never showed up (G6)
# -----------------------------------------------------------------
def ingest_mrf(landing_path):
"""Land one hospital MRF file already uploaded to a Volume/DBFS path.
TODO(silent-skip): BEFORE reading, verify the file exists (dbutils.fs.ls).
If absent:
log_audit('cms_mrf','MISSING_SOURCE',0,f'expected {landing_path}')
raise FileNotFoundError(landing_path) # 'no data' != 'clean run'
TODO(Bronze): read RAW (text/csv/json per the file) with NO cleaning;
add _source='cms_mrf', _source_file=landing_path, _ingest_ts,
_batch_id, _ingest_date; write with the SAME replaceWhere pattern;
then log SUCCESS with the row count.
"""
raise NotImplementedError
# ---- entrypoint (uncomment as you implement) ----
# ingest_nppes(states=("CA","NY"), pages=5)
# ingest_mrf("/Volumes/workspace/default/landing/hospital_mrf.json")
# display(spark.table(AUDIT).orderBy("ingest_ts"))
- Data Pipelines Pocket Reference O'Reilly · Book
- ETL and ELT in Python DataCamp · Course
Deliverable, acceptance & live session
- Bronze_nppes_providers and bronze_cms_mrf both contain raw payloads plus _source, _source_file, _ingest_ts, _batch_id, _ingest_date — and SELECT proves the payload is untransformed. 2) The API puller demonstrably survives an induced 429 (backoff visible in logs) and, when retries are exhausted, raises AND writes a FAILED audit row. 3) Pointing the MRF ingest at a non-existent path produces a MISSING_SOURCE audit row and a red job — never a green empty run. 4) Re-running today's ingest 10x yields an identical count for today's _ingest_date partition while a prior date is untouched. 5) ingest_audit_log has exactly one row per source per run outcome, each with a non-empty reason where status != SUCCESS. 6) The why-note names specific decisions, not generic ones. No typing, cleaning, or dedup appears anywhere in Bronze.
Recap Open by replaying the two failure demos live: trigger the 429/backoff path and the missing-file path, and show both landing as loud rows in the audit log while the job goes red. Then walk the group through the three decisions that define the week — (a) Bronze keeps the ugly raw payload and adds only metadata, (b) append-across-time but overwrite-today's-partition gives run idempotency without MERGE, (c) an audit log is your alerting substitute on Free Edition. Reinforce the layer contract line: Bronze is raw landed, nothing more; conforming and quality gates are next week's job, not this week's.
Misconception The killer misconception: 'my job ran green, so I have fresh data.' Learners conflate a successful run with a run that actually ingested something — the exact trap that silently corrupts Maria's compliance report when CMS didn't publish this month's file. The fix drilled this week: absence of an expected source is an EVENT that must be logged (MISSING_SOURCE) and must fail loudly; EMPTY, MISSING_SOURCE, and SUCCESS are three different states, and a pipeline that can't tell them apart is worse than one that's obviously broken. Secondary misconception to correct: 'I should dedup the duplicate providers as I ingest' — no, Bronze stays raw; dedup is a Silver/MDM concern later in the phase.
Hot seat "Each learner defends, in 3–4 minutes with their audit log on screen: 'Why is your Bronze append-only and un-deduped when you can already SEE duplicate providers in the NPPES payloads — why not clean at ingest?' Follow-ups the reviewer will push on: 'Defend partition-overwrite (replaceWhere) for run idempotency instead of MERGE-on-key — what does each buy you and what would break if a CMS file arrived mid-run and your job crashed?' and 'Your NPPES pull got a 429 at 2am and gave up — walk me through exactly what a human sees the next morning and how they know it was rate-limiting versus the file never existing.' A generic answer fails the hot seat; they must point at a specific audit row or metadata column."
W22 Medallion architecture — enforcing layer contracts M15 · Phase 2 · silver G3G2 ~10.0h
- Write a one-page LAYER CONTRACT for bronze, silver, and gold that states, for each layer, exactly what IS allowed, what is FORBIDDEN, the grain, and the owner — specific enough that a reviewer can point to any transform and say which layer it belongs in.
- Implement at least 3 automated contract assertions on the W21 project bronze (required ingest-metadata columns present; payload columns still raw/StringType; row count only ever grows append-only) that raise a loud, named error on violation.
- Build a thin silver-conform step that types, trims, and standardizes at least 4 fields (charge decimal, provider name, 2-letter state, zero-padded ZIP) and declares the silver primary-key grain in writing.
- Route every row that fails the silver contract to a rejected table with a specific _reject_reason + _reject_ts (zero silent drops), and prove passed+rejected = input count.
- Expose gold as a serve-only VIEW over silver and demonstrate (by a failing test) that adding any cleaning logic in gold violates the contract.
- Medallion as CONTRACTS, not folders (45 min): why bronze/silver/gold exist, what each layer promises its consumers, and how a leaked transform (casting in bronze, re-cleaning in gold) silently destroys reproducibility and auditability. Frame each layer as a contract with a producer and a consumer.
- The bronze contract — raw, append-only, provenance (40 min): source file, ingest timestamp, source system; why bronze must stay untyped/unfiltered so you can always replay history and prove what CMS actually published on a given day.
- The silver contract — clean + conform + (later) historize (55 min): typing, trimming, code/unit standardization, choosing and documenting the PRIMARY-KEY GRAIN, quality gates, and quarantine. We build the clean+conform half now; SCD2/CDC historize is flagged as the W23+ extension so silver stays honest.
- The gold contract — serve-only (30 min): business-ready marts/KPIs read from silver ONLY; no cleaning allowed downstream of silver; why 'if you need to clean it in gold, it belongs in silver' is the enforcement rule.
- Enforcing contracts with cheap tests (40 min): schema assertions, StringType checks, row-count monotonicity, and reconciliation (passed + rejected = input) as guardrails that fail loud in Databricks so a broken layer is never silent.
Hands-on lab · ~6.5h
Take the raw hospital-charge + NPPES provider bronze you landed in W21 and put it under contract. You will write a one-page Layer Contract, then encode it: automated assertions that prove bronze is still raw and append-only, a thin silver step that types and conforms the fields (with a documented PK grain) and quarantines contract violations to a rejected table with reasons, and a serve-only gold view. You finish by deliberately breaking a contract (e.g., casting in bronze) to watch the guardrail fail loud, then reverting — proving the contract is enforced, not just described.
- Write LAYER_CONTRACTS.md: for bronze, silver, gold list ALLOWED / FORBIDDEN / grain / owner. Bronze=raw landed + ingest metadata only; silver=typed+conformed+key-deduped (historize noted as W23+); gold=serve-only over silver. Keep it to one page a hiring manager can skim.
- Load the W21 bronze Delta table and run three bronze-contract assertions: (a) required metadata columns _source_file/_ingest_ts/_source_system exist; (b) payload/business columns are still StringType (no casting leaked in); (c) row count today >= row count at last run (append-only). Each assertion raises a NAMED AssertionError on failure.
- Build the silver-conform step (typing + standardization only): cast standard_charge to DECIMAL(12,2) and standardize its units/currency; trim + upper-case provider name; normalize state to 2 letters; zero-pad ZIP to 5. Do NOT compute any business aggregate here.
- Declare and document the SILVER PRIMARY-KEY GRAIN for one charge row (e.g., npi + payer + plan + billing_code + charge_type) as a comment and in LAYER_CONTRACTS.md; dedupe within the batch on that key, keeping the latest _ingest_ts.
- Define the silver quality gate (npi not null, standard_charge not null and > 0, PK components not null). Split the typed frame into passed and rejected; tag rejected rows with a SPECIFIC _reject_reason and _reject_ts, and write them append-only to silver_rejects.
- Write passed rows to the silver Delta table with a deliberately chosen idempotency strategy (MERGE on the PK, or overwrite partition). Write a one-line justification comment for WHY you chose it over the alternative.
- Run the full notebook 10 times end-to-end and record silver row counts each run; confirm the count is identical every time (idempotent) and that passed + rejected = input rows (reconciliation, zero silent drops).
- Create gold as a VIEW over silver only; then run a contract test that FAILS if a gold definition references bronze or introduces a cast/trim/coalesce — proving no cleaning is allowed downstream of silver.
- Break-it drill: temporarily add a cast to a bronze column, rerun, capture the loud failure in a screenshot/log, then revert. Write 3-5 sentences ('why note') on which contract broke and how the guardrail caught it — this becomes an interview answer.
- Databricks — What is the medallion lakehouse architecture?
- Delta Lake — Table batch reads and writes (schema, MERGE, overwrite)
- Delta Lake — Schema validation and enforcement
- CMS — Hospital Price Transparency data & requirements
</> Starter scaffold — w22_layer_contracts.py python
# W22 · Provider Pricing Integrity · Medallion layer-contract enforcement
# Databricks Free Edition | Delta Lake | PySpark. This is a STARTER scaffold.
# Fill every TODO. Do not paste a full solution here.
from pyspark.sql import functions as F, types as T
# --- CONFIG ---------------------------------------------------------------
BRONZE = "workspace.pricing.bronze_hospital_charges" # landed in W21 (raw, append-only)
SILVER = "workspace.pricing.silver_hospital_charges"
REJECT = "workspace.pricing.silver_rejects"
RUN_TS = F.current_timestamp()
REQUIRED_BRONZE_META = ["_source_file", "_ingest_ts", "_source_system"]
bronze = spark.read.table(BRONZE)
# === CONTRACT 1: BRONZE IS RAW + APPEND-ONLY =============================
# TODO 1: assert every name in REQUIRED_BRONZE_META is in bronze.columns;
# raise AssertionError naming the missing column (LOUD failure).
# TODO 2: assert payload/business columns are still StringType (no casting
# leaked into bronze). Iterate bronze.schema and check .dataType.
# TODO 3: assert append-only: today's count >= the count you logged last run
# (store last count in a small Delta audit table and compare).
# === CONTRACT 2: SILVER IS CLEAN + CONFORMED (typing half; historize=W23+) =
silver_typed = (
bronze
# TODO 4: cast standard_charge -> DECIMAL(12,2); strip '$', handle 'N/A'/ranges
# TODO 5: trim + upper() provider name; state -> 2 letters; ZIP -> lpad 5
# TODO 6: keep the ORIGINAL raw columns too, so audit can trace back to bronze
)
# TODO 7: DECLARE THE SILVER PRIMARY-KEY GRAIN in one line, e.g.
# PK = ["npi", "payer", "plan", "billing_code", "charge_type"]
# Document this same key in LAYER_CONTRACTS.md.
PK = [] # <-- fill in
# --- QUALITY GATE + QUARANTINE (no silent drops) -------------------------
gate = (
F.col("npi").isNotNull()
& F.col("standard_charge").isNotNull()
& (F.col("standard_charge") > 0)
# TODO 8: add NOT-NULL checks for each PK component + any range rules
)
passed = silver_typed.filter(gate)
rejected = (
silver_typed.filter(~gate)
.withColumn("_reject_reason", F.lit("TODO: name the SPECIFIC failing rule"))
.withColumn("_reject_ts", RUN_TS)
)
# TODO 9: dedupe `passed` within batch on PK, keeping latest _ingest_ts.
# TODO 10: write `rejected` (append) to REJECT.
# TODO 11: write `passed` to SILVER with a DELIBERATE idempotency strategy
# (MERGE on PK OR overwrite partition). Add a one-line comment
# justifying the choice. Rerun 10x -> identical SILVER counts.
# TODO 12: reconcile -> assert passed.count() + rejected.count() == input count.
# === CONTRACT 3: GOLD IS SERVE-ONLY =====================================
# TODO 13: create GOLD as a VIEW over SILVER ONLY (no bronze ref, no cast/
# trim/coalesce). If you need to clean it, it belongs in silver.
# TODO 14: write a contract test that FAILS if the gold definition text
# references BRONZE or contains a cleaning function.
- The Medallion Architecture Most Teams Get Wrong
- End-to-End Unity Catalog Setup — Medallion
- Real-World Medallion template (free)
- Databricks Certified Data Engineer Associate - Preparation (Derar Alhussein) Udemy · Course
- Delta Lake: Up and Running O'Reilly · Book
Deliverable, acceptance & live session
- Bronze assertions fail loud when metadata is missing, a payload column is cast, or the row count shrinks. Silver types/standardizes at least 4 fields and has a written PK grain that matches the dedupe key. Every non-passing row lands in silver_rejects with a specific _reject_reason + _reject_ts, and passed + rejected = input count (zero silent drops) is asserted in code. Running the notebook 10x yields identical silver counts. Gold is a VIEW over silver only, and the gold contract test fails when cleaning logic or a bronze reference is added. The README documents the idempotency choice and the break-it drill outcome.
Recap Each learner shows their LAYER_CONTRACTS.md on screen and points to one transform in their notebook, stating which layer it lives in and why. Group compares where people drew the bronze/silver line differently (e.g., is ZIP zero-padding a silver or gold job?) and reconciles to the rule 'clean once, in silver.' Then everyone displays their 10-run count log and their silver_rejects sample to prove the contract is enforced, not just written.
Misconception 'Medallion just means three folders/schemas.' No — the layer names are worthless without enforced contracts. If bronze can be re-typed, or gold can re-clean, you have three copies of ambiguity, not a pipeline. The value is the PROMISE each layer makes to its consumer, backed by a test that fails loud when the promise is broken. Also correct the sibling misconception that 'silver should do everything including business aggregates' — aggregates are gold; silver stops at clean+conform+(later)historize.
Hot seat "Defend your silver primary-key grain: 'Why is THIS the grain of one silver charge row, and what breaks if you picked a coarser or finer key?' Follow-up: 'You put ZIP zero-padding in silver and currency-symbol stripping in silver — justify why neither belongs in bronze or gold.' Then: 'Show the one line where you chose MERGE-on-PK vs overwrite-partition for idempotency and defend it against the alternative.' Be ready to point at the exact code and the LAYER_CONTRACTS.md line that authorizes each decision."
W23 CDC & Incremental I — Watermarks: load only what changed M16 — CDC & Incremental Loading I · Phase 2 · silver G3 ~10.5h
- Explain, in business terms, why full-refresh reprocessing of CMS pricing + NPPES fails on cost, compute, and freshness-SLA grounds, and define a watermark as the durable high-water mark that lets a run resume instead of restart.
- Design and create a Delta control/state table (source, layer, watermark_column, watermark_value, run_ts) that survives cluster restarts and is the single source of truth for 'where did we stop'.
- Implement an incremental bronze->silver read that selects only rows newer than the stored watermark (WHERE ingest_ts > watermark), measured against the full-table row count.
- Advance the watermark ONLY on successful processing (never on failure or an empty slice), and prove it by running the pipeline across three simulated CMS republish periods.
- Distinguish ingest-time from event-time watermarks and demonstrate, with a deliberately late/out-of-order record, which one silently drops data — and write the decision down as an interview-ready 'why' note.
- Full refresh vs incremental — the money argument (0.75h): why reprocessing millions of NPPES rows and every hospital pricing file on each CMS republish blows Free-Edition compute budgets and breaks Maria's freshness SLA; when full refresh is actually correct (small dims, first load, schema break) vs when incremental is mandatory.
- What a watermark IS (0.75h): a durable high-water mark = the maximum value of a monotonic column already processed; the read-process-advance loop; bootstrapping (null/epoch watermark on first run processes everything); backfill vs steady-state incremental.
- Where the watermark LIVES (0.5h): why a notebook variable / widget / config cell is a bug (lost on restart, not shared, not auditable) and why a Delta control table is the right home — one row per (source, layer), updated transactionally, queryable as an audit trail.
- Change detection strategies for CMS data (0.75h): file-arrival (new files since last run) vs a timestamp/last_updated column vs Delta ingest_timestamp; why bronze->silver should watermark on INGEST-time (append-only, monotonic, controlled by us) rather than the source's unreliable event/last_updated date.
- Late & out-of-order data, and the honest limit (0.75h): a record with an old event date but a new ingest time; how an event-time watermark silently skips it while an ingest-time watermark catches it; and the explicit boundary — a watermark tells you WHAT to read, it does NOT make reruns safe (that is idempotency, next week).
Hands-on lab · ~7h
Turn last week's one-shot medallion load into a resumable, watermark-driven incremental load. You will land the CMS pricing source as three simulated republish 'periods' into an append-only bronze (source + ingest_timestamp + period label), stand up a Delta control table that stores the high-water mark, read only the rows newer than that mark into silver, and advance the mark only on success. You will then prove the behavior: a no-new-data rerun processes 0 rows and leaves the watermark untouched; a fresh period flows only its own rows; and a deliberately late record exposes the ingest-time vs event-time trade-off. (Reruns still double-count on append — that is intentional and gets fixed next week with idempotency.)
- FRAME IT: open the lab README with a 1-2 sentence business framing in Maria's voice (why full refresh misses her compliance deadline and burns compute). State this run's KPI target: data-freshness SLA = new CMS period reflected in silver within one run, and 'rows processed per run << full-table count'.
- LAND PERIODS TO BRONZE (append-only): using the W22 pricing ingest, land the same CMS hospital pricing source THREE times as period_id = 2025-07, 2025-08, 2025-09. Simulate a republish: period 2 adds a handful of new facilities and changes prices on a few existing ones; period 3 changes a few more. Stamp every row with source_file, ingest_timestamp (current_timestamp at land), and period_id. Do NOT dedup or transform — bronze stays raw.
- BUILD THE CONTROL TABLE: create a Delta table etl_watermark(source STRING, layer STRING, watermark_column STRING, watermark_value TIMESTAMP, run_ts TIMESTAMP, rows_processed LONG). Insert a bootstrap row for (cms_pricing, silver) with watermark_value = TIMESTAMP '1970-01-01' so the first run processes everything. Write get_watermark() and set_watermark() helpers (TODOs in the scaffold).
- READ INCREMENTALLY: read the current watermark, then select bronze rows WHERE ingest_timestamp > watermark_value. Print incremental_count and full_count side by side so the compute saving is visible. On the first run all three periods may be new (depending on land timing) — confirm you understand why.
- PROCESS TO SILVER: apply your W22 silver conform/type logic to ONLY the incremental slice and write it to silver (append for now). Add a one-line comment: 'append double-counts on rerun — hardened with idempotent MERGE in W24.' Do not solve idempotency here.
- ADVANCE ON SUCCESS ONLY: after a successful write, compute new_watermark = max(ingest_timestamp) of the processed slice and update etl_watermark transactionally (also record run_ts and rows_processed). Guard it: if the slice is empty OR the write raised, do NOT advance — wrap in try/except and only call set_watermark() in the success path.
- PROVE INCREMENTAL BEHAVIOR (the test): (a) rerun immediately with no new data -> assert incremental_count == 0 and watermark_value unchanged; (b) land a NEW period 2025-10, rerun -> assert only that period's rows flow and the watermark advances to its max ingest_timestamp. Capture the etl_watermark history rows as evidence.
- EXPOSE THE LATE-DATA TRADE-OFF: land one record whose event/last_updated date is 2025-06 (older than everything) but whose ingest_timestamp is now. Show that the ingest-time watermark PROCESSES it, then reason (in a code comment + note) about how an event-time watermark on last_updated would have SKIPPED it. Do not switch strategies — document the choice.
- WRITE THE 'WHY' NOTE + UPDATE ARCHITECTURE DOC: 200-300 words defending watermark column choice (ingest-time), storage choice (Delta control table vs variable), the advance-on-success-only guard, and the known append limitation. Add an 'Incremental / watermark' component to the architecture doc's data-flow map and seed the idempotency-plan section.
- Delta Lake — batch reads and writes (filtering, append/overwrite, table updates)
- Databricks — Delta Lake on Databricks (tables, control-table patterns)
- Spark SQL — date & timestamp functions (current_timestamp, max, comparisons)
- Databricks — Change data capture concepts (background reading; do NOT implement DLT/CDF this week)
- CMS Hospital Price Transparency — data dictionary & file format guidance
</> Starter scaffold — w23_incremental_watermark_scaffold.py python
# Week 23 - CDC & Incremental I: watermark-based bronze->silver load
# Databricks Free Edition + Delta + PySpark. Run cells top to bottom.
from pyspark.sql import functions as F
CATALOG_PATH = "main.default" # adjust to your schema
BRONZE = f"{CATALOG_PATH}.bronze_cms_pricing"
SILVER = f"{CATALOG_PATH}.silver_cms_pricing"
WATERMARK = f"{CATALOG_PATH}.etl_watermark"
SOURCE, LAYER = "cms_pricing", "silver"
# --- 1. Control / state table (durable high-water mark) -------------------
spark.sql(f"""
CREATE TABLE IF NOT EXISTS {WATERMARK} (
source STRING, layer STRING, watermark_column STRING,
watermark_value TIMESTAMP, run_ts TIMESTAMP, rows_processed LONG
) USING DELTA
""")
# Bootstrap one row so the FIRST run processes everything (epoch watermark).
if spark.table(WATERMARK).where(f"source='{SOURCE}' AND layer='{LAYER}'").count() == 0:
spark.sql(f"""INSERT INTO {WATERMARK} VALUES
('{SOURCE}','{LAYER}','ingest_timestamp', TIMESTAMP'1970-01-01', NULL, 0)""")
def get_watermark():
# TODO: return the current watermark_value for (SOURCE, LAYER) as a Python datetime.
# Read from WATERMARK, filter, .collect(). This is your resume point.
raise NotImplementedError
def set_watermark(new_value, rows):
# TODO: UPDATE the WATERMARK row for (SOURCE, LAYER):
# watermark_value=new_value, run_ts=current_timestamp(), rows_processed=rows.
# Call this ONLY on the success path (see step below).
raise NotImplementedError
# --- 2. Land a CMS pricing 'period' to bronze (append-only) ---------------
def land_period(df_period, period_id, source_file):
# Bronze contract: raw + provenance ONLY. No dedup, no typing here.
(df_period
.withColumn("source_file", F.lit(source_file))
.withColumn("period_id", F.lit(period_id))
.withColumn("ingest_timestamp", F.current_timestamp())
.write.mode("append").saveAsTable(BRONZE))
# TODO: land 3 periods (2025-07/08/09). For 08 add a few new facilities and
# change prices on a few existing rows; for 09 change a few more. This
# simulates CMS republishing without waiting a month.
# --- 3. Incremental read: only rows newer than the watermark --------------
wm = get_watermark() # TODO returns datetime
bronze = spark.table(BRONZE)
incremental = bronze.where(F.col("ingest_timestamp") > F.lit(wm))
full_count, inc_count = bronze.count(), incremental.count()
print(f"full={full_count} incremental={inc_count} (saving={full_count-inc_count})")
# --- 4. Process ONLY the slice into silver, then advance on SUCCESS -------
try:
if inc_count == 0:
print("No new data - watermark stays put.") # do NOT advance
else:
# TODO: apply your W22 silver conform/type logic to `incremental` only.
silver_df = incremental # <-- replace with real transform
# NOTE: append double-counts on rerun. Intentional - W24 adds idempotent MERGE.
silver_df.write.mode("append").saveAsTable(SILVER)
# Advance watermark to the max ingest_timestamp we actually processed.
new_wm = incremental.agg(F.max("ingest_timestamp")).first()[0]
set_watermark(new_wm, inc_count) # success path ONLY
print(f"Advanced watermark -> {new_wm} ({inc_count} rows)")
except Exception as e:
# Failure path: DO NOT advance the watermark. Next run re-attempts the slice.
print(f"Run failed, watermark unchanged: {e}")
raise
# --- 5. TODO: prove it --------------------------------------------------
# (a) rerun with no new data -> assert inc_count == 0, watermark unchanged.
# (b) land period 2025-10 -> rerun -> assert only its rows flow, watermark advances.
# (c) land 1 late record (event date 2025-06, ingest now) -> show it IS processed;
# comment why an event-time watermark on last_updated would have skipped it.
# Then read `spark.table(WATERMARK)` history as evidence for your 'why' note.- Data Engineering Design Patterns O'Reilly · Book
- Databricks Certified Data Engineer Associate - Preparation (Derar Alhussein) Udemy · Course
Deliverable, acceptance & live session
- PASS requires: (1) watermark_value lives in a Delta table, not a notebook variable, and persists across a cluster restart
- A run with no new data processes exactly 0 rows and leaves watermark_value byte-for-byte unchanged
- After landing a new period, incremental_count equals ONLY that period's row count and the watermark advances to that period's max(ingest_timestamp)
- A simulated failure before the write leaves the watermark unadvanced (guard proven)
- The late-record case is demonstrated AND the ingest-vs-event-time choice is explained in writing
- The 'why' note names the known append double-count as an explicit W24 follow-up rather than hiding it.
Recap Last week you built the medallion bronze->silver->gold flow as a single full pass. This week you make it resumable: instead of reprocessing everything every time CMS republishes, you store a durable high-water mark and touch only what changed — the first half of incremental loading, before we make reruns safe next week.
Misconception The big one: 'a watermark makes my reruns safe.' It does not. A watermark only tells you WHAT to read; it says nothing about whether writing that slice twice corrupts silver (append double-counts — you saw it). Safe reruns are idempotency, which is next week. The second trap: watermarking on the source's last_updated/event date instead of your own ingest-time — a re-emitted file or a late record with a stale date silently gets skipped. We will pull up a learner's late-record output and watch the event-time approach drop it.
Hot seat "Defend three decisions live: (1) Why did you watermark on ingest_timestamp instead of the source's last_updated column — give the specific CMS failure mode that choice protects against. (2) Why is the watermark in a Delta control table and not a notebook widget or config cell — what breaks if a hiring manager reruns your notebook on a fresh cluster? (3) Show your advance-on-success-only guard and explain what your pipeline does on the NEXT run after a mid-run failure. Bonus: your append still double-counts on rerun — why did you ship it that way this week instead of hiding it?"
W24 CDC & incremental II — idempotency, backfills & the 10× test M16 — CDC & incremental loading · Phase 2 · silver G3G4 ~9.5h
- Implement an idempotent silver load using Delta Lake MERGE on the natural key (NPI) so reprocessing the same source batch yields identical row counts across 10 consecutive runs (count variance = 0, rows == distinct keys).
- Choose and defend an idempotency strategy per table — MERGE-on-key vs. replaceWhere/dynamic-partition-overwrite vs. full overwrite — and record the tradeoff in the architecture doc's idempotency plan.
- Backfill one historical CMS/NPPES month into an existing silver table without creating duplicates and without flipping the wrong SCD2 current-flag.
- Build an automated 10× rerun harness that asserts row count, distinct-key count, and current-flag count are stable and fails LOUD (raises) when they are not.
- Distinguish deterministic vs. non-deterministic transforms and explain in the why-note why a non-deterministic step (e.g., current_timestamp() in a business column) breaks the 10× test.
- Lesson 1 — What 'idempotent' actually means for data (0.5h): at-least-once delivery, retries after a half-finished job, and why append-only loads silently duplicate. Loud-vs-silent framing: a rerun that doubles Maria's provider count is a silent compliance error.
- Lesson 2 — Delta MERGE deep dive (1.0h): the ON clause and why it must be the true natural key; whenMatched update-on-change vs. update-all; whenNotMatched insert; why MERGE requires at most one source row per key (in-batch dedupe first); MERGE combined with the SCD2 two-step (close-old + insert-new) pattern from W23.
- Lesson 3 — Three idempotency strategies compared (0.75h): MERGE-on-key (upsert, best for dimensions/entities), replaceWhere / dynamic partition overwrite (best for partitioned fact reloads of one month), full overwrite (small reference tables). When each is correct, and the cost/blast-radius of each.
- Lesson 4 — Backfills without regret (0.75h): reprocessing a historical month vs. incremental append; deterministic transforms as the precondition; ordering hazards when backfilling into SCD2 history; how a backfill re-runs an OLD batch_id, not a new one.
- Lesson 5 — Proving it: idempotency as a testable contract (0.5h): the 10× test, assertion harness, and treating 'safe to re-run' as a claim you can demonstrate to an interviewer, not assert.
Hands-on lab · ~6h
Convert the W23 incremental silver load from append to an idempotent MERGE keyed on the provider natural key (NPI), then backfill one historical NPPES month into the existing silver dim_provider table. Finally, build and run a 10× proof harness that reloads the same batch ten times and asserts row count, distinct-key count, and current-flag count never move — failing loudly if they do. Every strategy choice is written into the idempotency plan section of the architecture doc as an interview answer.
- 1. Reproduce the bug first (recognition): take the W23 append-based silver load and run it twice on the same bronze batch. Query rows vs. distinct NPI and screenshot the duplication — this is the failure the rest of the lab kills.
- 2. Add in-batch dedupe: MERGE requires <=1 source row per key. Read one bronze source_batch, and reduce to one row per NPI by keeping the latest ingest_ts (window row_number). Confirm distinct NPI == row count of the source.
- 3. Make transforms deterministic: audit the conform step and remove/relocate any non-deterministic call (e.g., current_timestamp() written into a business column, random ordering). Move audit timestamps to metadata columns that are excluded from the change-detection comparison.
- 4. Rewrite the load as a Delta MERGE on t.npi = s.npi with whenMatchedUpdate ONLY when tracked attributes changed, and whenNotMatchedInsertAll. Wire the SCD2 close-old + insert-new pattern from W23 into the MERGE so history stays correct.
- 5. Backfill a historical month: point the load at an OLD batch_id (a prior NPPES monthly file) and run it. Verify no duplicate NPIs appear and that the current-flag still marks exactly one current row per provider.
- 6. Build the 10× harness: loop the full load 10 times over the same batch, collecting (rows, distinct_keys, current_rows) each pass; assert all three are constant across runs and that rows-with-current-flag == distinct NPI. Raise AssertionError naming the offending run number on any drift.
- 7. Choose and log strategy per table: pricing facts are month-partitioned — implement the pricing silver reload with replaceWhere/dynamic partition overwrite instead of MERGE, and write WHY (blast radius, cost, key cardinality) in the idempotency plan.
- 8. Quarantine + audit: confirm rejected records still route to the rejected table with reason + timestamp on reruns (a rerun must not resurrect quarantined rows), and append a row to the audit log capturing batch_id, strategy, run count, and the 10× result.
- 9. Write the why-note (150–250 words): which strategy per table and why, what made the load non-idempotent originally, and how the 10× test proves the fix.
- Delta Lake — Upsert into a table using MERGE (matched/not-matched, update-on-change)
- Databricks — MERGE INTO reference and SCD Type 2 with MERGE
- Databricks — Selectively overwrite data with replaceWhere / dynamic partition overwrite
- Delta Lake — Idempotent writes and deduplication using MERGE (insert-only dedupe)
</> Starter scaffold — silver_providers_idempotent_load.py python
# Week 24 - Idempotent silver load via Delta MERGE + 10x proof harness
# STARTER SCAFFOLD: complete the TODOs. Do NOT ship this as-is.
from pyspark.sql import functions as F, Window
from delta.tables import DeltaTable
NATURAL_KEY = "npi" # NPPES National Provider Identifier = the true natural key
BRONZE_TABLE = "bronze.nppes_providers"
SILVER_TABLE = "silver.dim_provider"
# A BACKFILL re-runs an OLD batch id. An incremental run uses the newest one.
BATCH_ID = "2026-08" # TODO: set to the CMS/NPPES month you are (re)processing
TRACKED_COLS = ["provider_name", "address_line1", "state", "taxonomy_code"] # attrs that define a 'change'
def read_bronze_batch(spark, batch_id):
df = spark.table(BRONZE_TABLE).where(F.col("source_batch") == batch_id)
# MERGE requires <= 1 source row per key. Bronze can hold the same NPI twice in one file.
# TODO: keep only the latest row per NATURAL_KEY using a window over ingest_ts (row_number == 1).
return df # TODO
def conform(df):
# Must be DETERMINISTIC: same input -> same output every run, or the 10x test will flip.
# TODO: type-cast, trim, upper-case state, standardize phone.
# TODO: keep audit timestamps in METADATA columns only, and exclude them from TRACKED_COLS.
return df # TODO
def upsert_silver(spark, src):
if not spark.catalog.tableExists(SILVER_TABLE):
src.write.format("delta").saveAsTable(SILVER_TABLE) # first load
return
tgt = DeltaTable.forName(spark, SILVER_TABLE)
change_pred = " OR ".join([f"t.{c} <> s.{c}" for c in TRACKED_COLS])
(tgt.alias("t")
.merge(src.alias("s"), f"t.{NATURAL_KEY} = s.{NATURAL_KEY} AND t.is_current = true")
# TODO: whenMatchedUpdate(condition=change_pred, ...) to CLOSE the old current row (SCD2 step 1)
# TODO: whenNotMatchedInsertAll() to add brand-new providers
# NOTE: the 'insert new version of a changed provider' step (SCD2 step 2) is a second write -
# decide where it lives and why (write it in your why-note).
.execute())
def run_load(spark, batch_id):
upsert_silver(spark, conform(read_bronze_batch(spark, batch_id)))
def prove_idempotent(spark, batch_id, runs=10):
seen = []
for i in range(runs):
run_load(spark, batch_id)
m = (spark.table(SILVER_TABLE)
.agg(F.count("*").alias("rows"),
F.countDistinct(NATURAL_KEY).alias("keys"),
F.sum(F.col("is_current").cast("int")).alias("current_rows"))
.collect()[0])
seen.append((m["rows"], m["keys"], m["current_rows"]))
print(f"run {i+1}: rows={m['rows']} keys={m['keys']} current={m['current_rows']}")
# TODO: assert every tuple in `seen` is identical to seen[0] -> FAIL LOUD with the run number.
# TODO: assert current_rows == distinct keys (exactly one current row per provider = no dup collapse).
return seen
- Data Engineering Design Patterns O'Reilly · Book
- Data Pipelines Pocket Reference O'Reilly · Book
Deliverable, acceptance & live session
- Running the silver load 10× on the same batch yields identical row count, distinct-NPI count, and current-flag count (variance = 0), and rows-with-current-flag == distinct NPI. The historical-month backfill introduces zero duplicate NPIs and exactly one current row per provider. The pricing fact reload uses partition overwrite, not MERGE, with a written justification. The 10× harness RAISES (fails loud) when fed a deliberately non-idempotent transform. Quarantined records are not resurrected by a rerun, and each run appends one audit-log row (batch_id, strategy, run count, 10× result).
Recap Each learner shows their before/after: the duplication screenshot from the append load, then the 10× harness printing ten identical count triples. In 3 minutes: name your natural key, your per-table strategy, and one non-deterministic thing you had to remove to make the counts stop moving.
Misconception 'MERGE makes any load idempotent.' It does not — MERGE is only idempotent if (a) the ON clause is the TRUE natural key, (b) there is at most one source row per key (in-batch dedupe done first), and (c) every transform is deterministic. A current_timestamp() written into a business column, or a duplicate key in the batch, will make even a MERGE-based load fail the 10× test. Idempotency is a property of the whole load, not of the MERGE keyword.
Hot seat "Defend your decision: 'You used MERGE-on-key for providers but partition overwrite for pricing — why not the same strategy for both? What is the blast radius if your idempotency assumption is wrong, and prove to me right now that a re-run of last month's backfill won't double-count Maria's facilities.' Learner must justify strategy-per-grain, cite their 10× evidence, and explain what their harness would catch."
W25 Spark I — the PySpark DataFrame API M17 — Spark I · Phase 2 · silver G7 ~10h
- Given a chain of 6+ DataFrame operations, predict on paper exactly which line triggers a Spark job, then confirm the prediction against the job count in the Spark UI with 100% accuracy.
- Re-express the Week 19–24 silver transforms (rename/select to contract, type casts, when/otherwise conforming, null handling, quality-gate split) using only the PySpark DataFrame API and produce row counts identical (delta = 0) to the prior SQL/pandas version on the same input.
- Derive df_valid and df_rejected from ONE validated DataFrame using a single predicate set, write both to Delta, and prove conservation: valid.count() + rejected.count() == input.count() exactly.
- Read df.explain() output and explain in writing, referencing the physical plan, why an early vs late filter yields the same result but a different scan cost.
- Demonstrate lazy evaluation empirically: show via wall-clock timing that adding transformations is near-instant while the first action incurs the full scan, and identify one place collect() would have crashed the driver at CMS scale.
- The Spark execution model (~1.0h): driver vs executors, partitions, the DAG. Lazy evaluation — transformations append to a lineage/plan and return a new DataFrame; nothing runs until an action. Narrow vs wide transformations named only as a preview (the shuffle deep-dive is W26, do not go there yet). Why laziness exists: Spark optimizes the whole plan before running it.
- The transformation-vs-action catalog (~1.0h): transformations you will use this week — select, filter/where, withColumn, withColumnRenamed, drop, cast, when/otherwise, distinct, na.fill/na.drop. Actions that trigger jobs — count, show, display, collect, take, first, write. The trap: display()/show() in a notebook fire a job, so 'it ran' during exploration hides where real execution happens.
- From SQL/pandas to the DataFrame API (~1.0h): mapping each silver SQL clause to a DataFrame call (WHERE→filter, CASE→when/otherwise, CAST→cast, COALESCE→na.fill/coalesce, SELECT alias→select+alias). Column expressions with F.col() and pyspark.sql.functions vs raw Python. Why collect() pulls all rows to the driver and will OOM on the full NPPES dump — the single most common scale bug.
- Reading query plans + Delta I/O in Spark (~1.0h): df.explain() logical vs physical plan, spotting the FileScan and Filter nodes; the Spark UI Jobs/Stages tabs; spark.read.format('delta').load(...) / table(...), write modes (overwrite, append) and how the lazy plan is what makes an idempotent rerun (W24) cheap and deterministic.
Hands-on lab · ~6h
Port the existing silver-layer transforms (built in Spark SQL / pandas across Weeks 19–24) to the PySpark DataFrame API, running against the FULL-scale bronze Delta tables on Databricks Free Edition — and instrument the run to prove you understand Spark's lazy execution model (which line triggers a job, why collect() is dangerous, what the physical plan shows). Output is the same silver + rejected Delta tables as before, produced by DataFrame code, with the transformation/action behavior documented.
- Read the bronze NPPES-provider and price-transparency Delta tables into DataFrames with spark.read.format('delta'). Run df.printSchema(); then build a lazy df.limit(20) WITHOUT calling display() yet — note that no job appears in the Spark UI. This is your first proof of laziness.
- Re-express the silver transforms as DataFrame transformations (fill the scaffold TODOs): select/withColumnRenamed to the silver contract columns, .cast() NPI/price/date types, F.when(...).otherwise(...) to conform enum-like fields (e.g., NPPES entity_type_code 1 vs 2 → 'Individual'/'Organization'), and na handling for missing/blank prices. These are ALL transformations — confirm nothing has executed yet.
- Instrument lazy eval: print a timestamp after the transform chain is built, then trigger exactly one action — df.count() — and print a second timestamp. Record the wall-clock delta. Open the Spark UI and confirm the number of jobs fired matches your on-paper prediction from the objective.
- Define the quality-gate predicate set ONCE (e.g., valid = NPI is 10 digits AND price is non-null AND >= 0). Derive df_valid = validated.filter(cond) and df_rejected = validated.filter(~cond), adding rejection_reason, ingest_ts, and validation_ts columns to the rejected frame. Assert df_valid.count() + df_rejected.count() == input.count() (conservation).
- Call df_valid.explain() and df_rejected.explain(). Save the physical-plan text to /architecture/spark_plans_w25.txt and write one paragraph identifying the FileScan and Filter nodes and where in the plan the quarantine split happens.
- Write df_valid to the silver Delta table using the idempotency mode you chose in W24 (overwrite partition / MERGE on key), and APPEND df_rejected to the rejected/quarantine Delta table with reason + timestamp. Do not re-teach idempotency — just apply W24's decision.
- Re-run the entire notebook 10 times. Confirm silver row count and rejected row count are byte-identical every run (idempotency holds through the DataFrame rewrite). Capture this run's KPIs: % facilities passing the pricing-completeness check, and # records quarantined by reason.
- Write the WHY note (docs/why_spark_w25.md): mark each call as transformation or action, name the ONE place you deliberately avoided collect() and what would have happened at full scale, and state in 2–3 sentences why the DataFrame version is preferable to the pandas version for the monthly CMS republish.
- PySpark DataFrame API reference (pyspark.sql.DataFrame)
- Spark RDD Programming Guide — Transformations and Actions (the lazy-evaluation model)
- pyspark.sql.functions reference (col, when, coalesce, cast helpers)
- Databricks — DataFrames tutorial (read/transform/write on Delta)
- Databricks — Interpreting query plans with df.explain() / EXPLAIN
</> Starter scaffold — silver_transform_spark.py python
# silver_transform_spark.py (Databricks Free Edition notebook)
# STARTER SCAFFOLD — lands structure + proves lazy eval. You fill the TODOs.
# Goal: re-express the Week 19-24 silver transforms in the PySpark DataFrame API.
from pyspark.sql import functions as F
from datetime import datetime
BRONZE_PROVIDERS = "workspace.bronze.nppes_providers" # <- adjust to your catalog/schema
SILVER_PROVIDERS = "workspace.silver.providers"
REJECTED_TABLE = "workspace.silver.providers_rejected"
# 1) READ bronze -> DataFrame (this is a transformation-ish setup; no job yet)
bronze = spark.read.format("delta").table(BRONZE_PROVIDERS)
bronze.printSchema() # printSchema reads metadata only
# 2) BUILD the transform chain — ALL transformations, still lazy.
# Map your prior SQL/pandas silver logic onto DataFrame calls.
conformed = (
bronze
# TODO: select + withColumnRenamed to the silver CONTRACT columns only
# .select(...)
# TODO: cast types — NPI to long/string(10), price to double, dates to date
# .withColumn("npi", F.col("npi").cast("string"))
# TODO: conform enum-like field with when/otherwise
# .withColumn("entity_type",
# F.when(F.col("entity_type_code") == 1, "Individual")
# .when(F.col("entity_type_code") == 2, "Organization")
# .otherwise("Unknown"))
# TODO: null-handling for missing/blank prices (na.fill or F.coalesce)
)
# 3) PROVE laziness: nothing above ran. Time the first ACTION.
t0 = datetime.now(); print("chain built, no job yet:", t0)
n_in = conformed.count() # <-- ACTION #1 (triggers the scan)
t1 = datetime.now(); print("after count():", t1, "rows:", n_in, "elapsed:", t1 - t0)
# TODO: open the Spark UI -> Jobs. How many jobs fired so far? Match your prediction.
# 4) QUALITY GATE — define the predicate ONCE, split into valid/rejected.
# TODO: replace with your real rule set (10-digit NPI, price non-null & >= 0, ...)
is_valid = (F.length("npi") == 10) & F.col("price").isNotNull() & (F.col("price") >= 0)
df_valid = conformed.filter(is_valid)
df_rejected = (
conformed.filter(~is_valid)
.withColumn("rejection_reason", F.lit("TODO: derive specific reason per rule"))
.withColumn("validation_ts", F.current_timestamp())
)
# TODO: assert conservation — valid + rejected must equal input (delta == 0)
# assert df_valid.count() + df_rejected.count() == n_in
# 5) READ THE PLAN (do NOT collect() the data to the driver!)
df_valid.explain(True) # save this output to /architecture/spark_plans_w25.txt
# 6) WRITE — apply YOUR W24 idempotency decision (overwrite partition / MERGE).
# TODO: write df_valid to SILVER_PROVIDERS (idempotent mode)
# TODO: append df_rejected to REJECTED_TABLE
# 7) KPIs for Maria
# TODO: pct_complete = df_valid.count() / n_in; print quarantined count by reason
- Introduction to PySpark DataCamp · Course
- PySpark - Apache Spark Programming for Beginners (Prashant Kumar Pandey) Udemy · Course
- Learning Spark, 2nd Ed. O'Reilly · Book
Deliverable, acceptance & live session
- ["Silver row count from the DataFrame version equals the prior SQL/pandas version on the same input (delta = 0)
- Conservation assert (valid + rejected == input) passes.", "The WHY note correctly labels every DataFrame call as a transformation or an action, and the learner's on-paper job-count prediction matches the Spark UI (verified live).", "df_rejected rows each carry a specific rejection_reason + validation_ts, and the rejected table is APPENDED while silver is written in the deliberately-chosen W24 idempotent mode.", "Running the notebook 10× yields byte-identical silver and rejected counts every time.", "spark_plans_w25.txt exists and the paragraph correctly points to the FileScan and Filter nodes
- No collect() of full data to the driver appears anywhere in the notebook.", "This run's KPIs (% completeness, # quarantined by reason) are printed and reflect the full-scale input, not a sample."]
Recap Open by re-running one learner's notebook live and pausing right before the first action: 'Predict — how many Spark jobs have run so far?' Reveal the Spark UI (zero jobs), then fire count() and watch one job appear. Recap the mental model out loud: transformations are a recipe, actions cook the meal. Then show a second learner's explain() output and read the physical plan together (FileScan → Filter → the quarantine split) so the group connects the code to what Spark actually does.
Misconception 'It printed output, so my transform ran when I wrote that line.' Wrong — display()/show()/count() are the actions that ran the whole accumulated chain at once; the withColumn/filter lines built a plan and executed nothing. The corollary trap: 'I'll just collect() to a pandas DataFrame to check it' — on the full NPPES dump that pulls millions of rows to the single driver and OOMs. The fix is to reason about WHERE the action is and to inspect with limit()/explain(), never collect() on full data.
Hot seat "Each learner defends: (1) Point to the exact line in your notebook that triggers the first Spark job and prove it against the Spark UI. (2) Name one place you could have written collect() and explain what it would have done to the driver on the full-scale dump — and what you did instead. (3) In your explain() output, show where the quarantine split happens and argue whether the filter runs before or after the file scan, and why that ordering is fine for your idempotency guarantee."
W26 Spark II — Joining CMS Pricing to NPPES Providers at Scale, Aggregating Gold KPIs, and Writing the MDM Matching UDF M17 — Spark II: joins, aggregations & UDFs at scale · Phase 2 · silver G7 ~10h
- Given the two multimillion-row silver Delta tables (CMS pricing, NPPES providers), implement inner, left-outer, left-anti, and left-semi joins on a provider key and report the exact match rate for the run.
- Decide between a shuffle join and a broadcast join based on the size of each side, apply the broadcast() hint correctly, and justify the choice in one written paragraph.
- Write and register a Python UDF that normalizes a provider name + address into a deterministic match/blocking key, and explain in writing why it executes on the executors and costs more than an equivalent built-in expression.
- Compute the gold KPIs (pricing-completeness %, quarantined-record counts by reason, data-freshness minutes) using groupBy/agg plus at least one window function.
- Route pricing rows with no provider match to a rejected Delta table with reason + timestamp, and prove the gold build is idempotent by running it 10x for identical output counts.
- Join mechanics in Spark SQL/PySpark: inner / left-outer / right / full, plus left-semi and left-anti — and why semi/anti are the correct, scalable replacement for 'filter rows by whether a match exists' (which people wrongly write as a subquery or a collected Python set).
- Shuffle join vs broadcast join: how Spark moves rows to co-locate keys, when the small side fits in memory so you can broadcast() it and skip the shuffle, and a first driver-vs-executor mental model — what a join, a UDF, and a .collect() each actually ship to the cluster. (Deep shuffle/partition/AQE internals are next week — here we stay at 'which strategy and why'.)
- Aggregations at scale: groupBy/agg with multiple aggregates, count vs approx_count_distinct, and window functions (row_number, rank, sum-over) for KPI rollups and a first look at ranking duplicate providers ahead of the MDM week.
- UDFs: plain Python UDF vs vectorized/pandas UDF vs a pure built-in expression; the serialization + row-at-a-time cost of Python UDFs; how to register a UDF once and reuse it in SQL and DataFrame code; and the rule 'reach for a built-in before a UDF' — with the matching UDF as the deliberate exception the MDM step will reuse.
- The data-quality join pattern: use a left-anti join to isolate unmatched pricing rows into quarantine with a reason, and treat the join match rate itself as an acceptance gate that can fail a run loudly.
Hands-on lab · ~6h
Join the silver CMS pricing table to the silver NPPES provider table at full scale, choosing join type and join strategy deliberately; write a reusable normalization/matching UDF that the MDM step will consume; quarantine unmatched pricing rows with a reason; then aggregate the gold KPIs with groupBy/window functions and prove the whole gold build is idempotent across 10 reruns.
- Load the two silver Delta tables (CMS pricing, NPPES providers) and profile them: row counts on each side, null rate on the NPI join key in pricing, and how many distinct facility names have no NPI at all. Write the numbers down — this is the 'why the join is hard' evidence and the baseline match rate you must beat.
- Do the exact-key join first: inner join pricing to providers on NPI, then re-do it as a left-outer join. Compute the match rate (matched / total pricing rows). Capture the unmatched rows from the left-outer result with a left-anti join into a rejected Delta table carrying reason='no_npi_match', the source file, and an ingest/quarantine timestamp.
- Make the broadcast-vs-shuffle decision explicitly: broadcast the smaller conformed provider slice (or a taxonomy/reference lookup) with broadcast() and run the join; then run it without the hint. Note in your why-note which side is small enough to broadcast, and state — in one or two sentences — what ships to the executors versus what stays on the driver.
- Write and register a Python UDF normalize_key(name, address) that lowercases, strips corporate suffixes (Inc, LLC, PC), collapses whitespace, and standardizes common address abbreviations (St→Street, Ste→Suite) to produce a deterministic blocking/match key. Add a short comment stating that this UDF runs per-row on the executors and is the deliberate exception to 'prefer built-ins'; this is the exact function the W29 MDM step will import.
- Use left-semi and left-anti deliberately: left-semi join providers to pricing to keep only providers that actually appear in the pricing data (shrinks the dimension), and confirm the left-anti unmatched set from step 2 matches your quarantine count. Do NOT collect either side to the driver to 'check membership'.
- Aggregate the gold KPIs from the matched, enriched dataset: pricing-completeness % per facility (groupBy facility, share of rows with a non-null price), quarantined-record count by reason, and data-freshness in minutes from the max ingest timestamp. Add one window function — row_number() over provider partitions ordered by data completeness — as a first preview of which duplicate record MDM will later prefer.
- Write the gold KPI table and the rejected table as Delta using a deliberate idempotency strategy (overwrite the KPI snapshot / MERGE on the KPI key), then run the entire gold build 10 times and assert identical row counts and identical KPI values each run. Record the run in the audit log (run timestamp, input row counts, match rate, quarantined count).
- Write the why-note (5–8 sentences): the join type chosen for each step and why, broadcast vs shuffle decision, why the matching logic is a UDF and not a built-in, the match-rate acceptance threshold you set, and what you'd do if a future CMS republish drops the match rate below it. Add the finalized KPIs to the Project Charter and the idempotency + failure sections to the Architecture Doc draft.
- Spark SQL — Join hints and JOIN types (Spark SQL Guide)
- Spark SQL performance tuning — Broadcast hints / broadcast join
- PySpark — pyspark.sql.functions (built-ins, aggregates, broadcast)
- PySpark — User Defined Functions (UDFs) and pandas/vectorized UDFs
- Spark SQL — Window functions
- Databricks — MERGE INTO / upsert into a Delta table
</> Starter scaffold — 26_spark_join_kpis_scaffold.py python
from pyspark.sql import functions as F
from pyspark.sql.functions import broadcast
# --- config: point these at YOUR silver tables from W21-W25 ---
SILVER_PRICING = "workspace.silver.cms_pricing"
SILVER_PROVIDERS = "workspace.silver.nppes_providers"
GOLD_KPI = "workspace.gold.pricing_kpis"
REJECTED = "workspace.silver.rejected_pricing"
pricing = spark.table(SILVER_PRICING)
providers = spark.table(SILVER_PROVIDERS)
# 1) PROFILE THE JOIN PROBLEM (evidence for your why-note)
# TODO: print pricing.count(), providers.count(),
# null rate of the NPI key in pricing, and # distinct facility names with no NPI.
# 2) MATCHING UDF the MDM step (W29) will REUSE.
# NOTE: this runs row-at-a-time on the EXECUTORS and is the deliberate
# exception to 'prefer a built-in'. Keep it pure + deterministic.
from pyspark.sql.types import StringType
def _normalize_key(name, address):
# TODO: lowercase; strip suffixes (inc, llc, pc); collapse whitespace;
# standardize address abbreviations (st->street, ste->suite).
# Return a stable blocking/match key string.
return None
normalize_key = F.udf(_normalize_key, StringType())
# TODO: also register for SQL reuse: spark.udf.register("normalize_key", _normalize_key)
# 3) EXACT-KEY JOIN + QUARANTINE
# TODO: choose the join type deliberately (inner vs left-outer) and join on NPI.
# matched = pricing.join(providers, on="npi", how="???")
# TODO: use a LEFT-ANTI join to capture unmatched pricing rows ->
# add reason="no_npi_match", source_file, quarantined_at=current_timestamp(),
# then append to REJECTED. (Do NOT .collect() to check membership.)
# 4) BROADCAST DECISION
# TODO: identify which side is small enough to broadcast (a reference/lookup
# or a filtered provider slice) and wrap it in broadcast(...).
# In one comment, state what ships to executors vs stays on driver.
# 5) GOLD KPIs (groupBy/agg + one window function)
# TODO: pricing-completeness % per facility; quarantined count by reason;
# freshness minutes from max ingest ts; row_number() over provider
# partitions ordered by completeness (MDM preview).
# 6) IDEMPOTENT WRITE + PROOF
# TODO: write GOLD_KPI deliberately (overwrite snapshot OR MERGE on kpi key),
# then run this build 10x and assert identical counts + KPI values.
# Append a row to your audit log (run ts, input counts, match rate, quarantined).
- Spark: The Definitive Guide O'Reilly · Book
- Cleaning Data with PySpark DataCamp · Course
- Introduction to Big Data with Spark and Hadoop (IBM) Coursera · Course
Deliverable, acceptance & live session
- Match rate for the run is computed and printed and meets the learner's stated threshold (or the run fails loudly). Unmatched pricing rows appear in the rejected table with reason + timestamp and their count equals the left-anti count. The matching UDF is registered and callable from both DataFrame and SQL. Gold KPIs are produced via groupBy/agg and at least one window function. The gold build run 10x yields identical row counts AND identical KPI values (idempotency proven), with an audit-log row per run. The why-note names the join type per step, the broadcast decision, why the matching logic is a UDF, and the match-rate acceptance threshold.
Recap Last week (W25, Spark I) you moved from single-table DataFrame transforms to reading and writing your silver Delta tables in Spark; this week you connect two of those tables at full scale — the join is where Spark either quietly shines or quietly drops your data, so we make the join type and join strategy explicit decisions rather than defaults.
Misconception 'A UDF is just a normal Python function, so writing the match logic as a UDF is fine and fast.' Correct it: a Python UDF is serialized and run row-at-a-time on the executors (Spark can't optimize inside it), so it's markedly slower than an equivalent built-in expression — the rule is 'reach for a built-in first,' and the normalization/matching UDF is the deliberate, justified exception because the logic is genuinely custom and the MDM step must reuse the exact same function. Related trap: 'collect the small table to the driver and check membership in a Python set' — that's what left-semi/left-anti joins are for, and collecting defeats the whole point of the cluster.
Hot seat "Defend three decisions in 3 minutes: (1) For each join in your pipeline, which join type did you use and why — and where did you use left-anti or left-semi instead of a filter? (2) Which side did you broadcast, how did you know it was small enough, and what shipped to the executors versus the driver? (3) Why is your matching logic a UDF and not a built-in, and what match-rate threshold would fail a future CMS republish — what happens when it does?"
W27 Spark internals — partitions, shuffles, skew & the Spark UI M18 · Phase 2 · silver G7G9 ~11.0h
- Read the Spark UI to locate a skewed stage: identify the straggler task whose duration and shuffle-read are >=10x the stage median, and name the exact join/stage causing it (not just 'the job is slow').
- Explain and predict partitioning behavior: state how many shuffle partitions a join will produce from spark.sql.shuffle.partitions, and compute per-key row counts to prove which blocking keys are hot.
- Apply and MEASURE two skew remedies on the provider-dedup join — salting the hot blocking key and broadcasting the small side — recording before/after wall-clock and max-task time, and achieving a >=2x reduction in the skewed stage's max-task time.
- Decide when Adaptive Query Execution (AQE) skew-join handling is sufficient versus when manual salting/broadcast is required, and justify the decision in writing with evidence from an AQE-off vs AQE-on comparison.
- Prove a performance fix preserved correctness: assert the candidate-pair output count is identical before and after tuning (a faster wrong answer is not a fix).
- Partitions 101 — how Spark splits data into partitions as the unit of parallelism; input partitions vs shuffle partitions; the role of spark.sql.shuffle.partitions; why partition count sets the ceiling on parallelism and why 'too few' and 'too many' both hurt. (1.0h)
- The shuffle — what operations trigger it (joins, groupBy, distinct, window), why moving data across the cluster is the single most expensive thing Spark does, and how to read shuffle read/write bytes and records in the Spark UI stage detail. (1.0h)
- Data skew — the difference between 'big' and 'skewed'; how a handful of hot keys (a few giant health systems) send millions of rows to ONE partition and create a straggler task while every other core sits idle; spotting it via the event timeline, the task summary (min/median/max), and the largest-task metrics. (1.0h)
- Remedies and when each applies — broadcast join and spark.sql.autoBroadcastJoinThreshold (kill the shuffle on the small side); salting a skewed key to spread one hot key across N partitions; and AQE skew-join splitting that Databricks does automatically. Trade-offs: broadcast needs a genuinely small side; salting adds work and must replicate the other side; AQE helps joins but not every case. (1.0h)
- Measure first — how to baseline a job, isolate the one stage that matters, and read a before/after honestly; why tuning blind (more cores, more partitions, copied Stack Overflow flags) wastes money and hides the real bottleneck. (0.5h)
Hands-on lab · ~6.5h
Take the provider-dedup candidate-pair join you built in W26 and make it FAST without making it WRONG. You will baseline it, use the Spark UI to prove the slowness is data skew from a few dominant health systems, apply salting and broadcast fixes, compare against Databricks' automatic AQE skew handling, and write a one-page before/after diagnosis that goes straight into your architecture doc. The whole lab is an exercise in 'measure first': every claim is backed by a Spark UI screenshot and a timing number, and every fix is proven to leave the join output unchanged.
- Reproduce your W26 candidate-pair dedup self-join on your silver provider table and run it once end-to-end to establish a BASELINE; force execution (e.g. .count()) and record wall-clock time and the shuffle-partition setting you used.
- Profile the blocking key: groupBy(block_key).count().orderBy(desc) and show the top 20. Name the SPECIFIC health systems (e.g. HCA, Kaiser, Ascension, Ascension-affiliated NPIs) whose thousands of provider records create the hot keys — write the counts down.
- Turn AQE OFF (spark.sql.adaptive.enabled=false) so the raw skew is visible, open the Spark UI, find the join stage, and identify the straggler: capture max vs median task time, the largest task's shuffle-read bytes, and a screenshot of the event timeline showing one long task while others finish early.
- Apply Fix A — salting: add a random salt 0..N-1 to the left side of the hot key and replicate the right side across all N salt values; pick N DELIBERATELY (try a couple of values), re-run, and record the new max-task time and wall-clock.
- Apply Fix B — broadcast: identify a genuinely small side (the CMS price-transparency hospital list, or a filtered provider subset under the broadcast threshold), wrap it in F.broadcast(), re-run the join, and record timing — noting that this eliminates the shuffle entirely rather than reshaping it.
- Turn AQE back ON and re-run the untouched baseline to see whether Databricks' automatic skew-join splitting alone closes the gap; assemble all four runs (baseline AQE-off, salt, broadcast, baseline AQE-on) into a single before/after runtime table.
- Correctness gate: assert the candidate-pair row count is IDENTICAL across the baseline and every fixed version; if a fix changes the count, it changed the join semantics and is rejected regardless of speed.
- Write the one-page 'Skew Diagnosis & Fix' note (before/after screenshots, the runtime table, the named hot keys, the chosen remedy and why over the alternatives, and the AQE on/off verdict) and append it as the performance section of your 4-part architecture doc.
- Spark SQL Performance Tuning (shuffle partitions, broadcast, join hints)
- Spark Web UI guide (stages, tasks, event timeline, shuffle metrics)
- Databricks — Adaptive Query Execution (AQE) incl. skew-join handling
- Databricks — Join hints and handling large-table joins / skew
</> Starter scaffold — w27_skew_diagnose_fix_starter.py python
# W27 - Spark skew: diagnose in the UI, fix with salt/broadcast, prove it stayed correct.
# STARTER SCAFFOLD. Fill every TODO. Do NOT paste in a full solution - the point is to MEASURE.
from pyspark.sql import functions as F
SILVER = "workspace.default.silver_providers" # TODO: point at YOUR W25/W26 silver provider table
providers = spark.read.table(SILVER)
# --- 1. Rebuild the SAME blocking key you used in W26 (keep it identical so counts match) --------
def with_block_key(df):
# TODO: recreate your real W26 blocking key (e.g. org-name token + state). Use YOUR columns.
return df.withColumn(
"block_key",
F.concat_ws("|", F.upper(F.col("org_name_token")), F.col("state"))
)
blocked = with_block_key(providers)
# --- 2. MEASURE FIRST: how skewed is the key? Name the health systems that dominate ------------
# TODO: show top 20 block_key by count; screenshot it; write down which orgs (HCA/Kaiser/...) are hot.
# blocked.groupBy("block_key").count().orderBy(F.desc("count")).show(20, truncate=False)
# --- 3. BASELINE the candidate-pair self-join with AQE OFF so the skew is visible in the UI -----
# spark.conf.set("spark.sql.adaptive.enabled", False) # TODO: enable to expose raw skew
# spark.conf.set("spark.sql.shuffle.partitions", 200) # TODO: record whatever value you use
l = blocked.alias("l")
r = blocked.alias("r")
candidate_pairs = (l.join(r, on="block_key")
.where(F.col("l.npi") < F.col("r.npi"))) # keep each unordered pair once
# TODO: force + time it (candidate_pairs.count()). In the Spark UI find the join stage and record:
# max task time, median task time, largest-task shuffle read. Screenshot the event timeline.
baseline_count = None # TODO: capture this - you will assert against it later.
# --- 4. FIX A: SALT the hot key --------------------------------------------------------------
SALT_N = 8 # TODO: choose N deliberately; justify with max-task time. Try a few, keep the evidence.
# TODO: left side gets a random salt 0..N-1; right side is REPLICATED across all N salts so salted
# keys still meet. Join on (block_key, salt). Re-run, re-time, re-read the UI vs baseline.
# --- 5. FIX B: BROADCAST the small side ------------------------------------------------------
# TODO: pick a genuinely small side (CMS hospital list, or a filtered subset under the broadcast
# threshold), wrap it F.broadcast(small_df), re-run, re-time. Note: this KILLS the shuffle.
# --- 6. AQE ON: does Databricks' automatic skew-join splitting alone fix it? ------------------
# spark.conf.set("spark.sql.adaptive.enabled", True)
# TODO: re-run the untouched baseline with AQE on; record result. Decide AQE-only vs salt vs
# broadcast and WRITE DOWN why in your one-pager.
# --- 7. CORRECTNESS GATE --------------------------------------------------------------------
# TODO: assert your fixed join produces the SAME count as baseline_count.
# assert fixed_count == baseline_count, f"fix changed output: {fixed_count} != {baseline_count}"
- High Performance Spark, 2nd Ed. O'Reilly · Book
- Apache Spark - Beyond Basics and Cracking Job Interviews (Prashant Kumar Pandey) Udemy · Course
Deliverable, acceptance & live session
- The Spark UI evidence names the specific stage and a straggler task with max-task time >=10x the median at baseline
- The chosen fix reduces the skewed stage's max-task time by >=2x (or cuts job wall-clock materially), shown in the runtime table
- A correctness assertion proves the candidate-pair count is IDENTICAL before and after the fix
- The writeup states which hot keys caused the skew, why the chosen remedy beat the alternatives, and the AQE-off vs AQE-on verdict in specific (not generic) language
- The salt N (if used) is justified by measured max-task time, not picked arbitrarily.
Recap Five-minute recap: partition = the unit of parallelism; the shuffle is the expensive step that joins/groupBy trigger; skew is a FEW hot keys, not overall size, sending one partition way more work than the rest, which shows up as a single straggler task in the Spark UI while other cores idle. Remedies: broadcast (kill the shuffle when one side is small), salt (spread one hot key across N partitions), AQE (Databricks splits skewed join partitions automatically). Rule of the week: measure first — baseline, isolate the one stage that matters, then tune.
Misconception 'Just add more partitions / a bigger cluster and it'll speed up.' Skew is uneven KEY distribution, not total volume — more partitions or more cores does nothing for one hot key, because all its rows still land in a single partition and one task still does all the work while the new cores sit idle. The related trap: 'AQE fixes everything.' AQE's skew-join splitting helps many joins but isn't a guarantee for every pattern, which is exactly why you compare AQE-on against a manual salt/broadcast and keep the evidence.
Hot seat "Defend your decision. You are asked: 'You salted the hot key with N=8 instead of 4 or 32 — show the max-task time that justifies exactly 8, and tell me what over-salting would have cost you. Then explain why you salted at all instead of just broadcasting — what made the small side not broadcast-able here? And prove your fix did not change the join output.' Come with the runtime table, the Spark UI screenshots, and the passing correctness assertion open."
W28 Data quality I — expectations & quarantine: making silver break loudly M24 — Data Quality I: Expectations & Quarantine · Phase 2 · silver G4 ~11h
- Given the project silver provider+pricing table, WRITE at least 8 specific, named expectations across four rule classes (schema/type, not-null, uniqueness, domain/accepted-code) and justify each in one sentence tied to a KPI or compliance need — no boilerplate 'column should not be null'.
- IMPLEMENT a validation pass in PySpark/Spark SQL that TAGS each row pass/fail per rule and SPLITS the DataFrame into a clean stream and a quarantine stream without dropping any input row (row-count in == clean + rejected).
- PERSIST rejected rows to a Delta `silver_rejected` table carrying failure_reason(s), the rule id, the offending value, source file, and a rejected_at timestamp — queryable so a human can triage why a row failed.
- COMPUTE per-run DQ metrics (rows in, rows passed, rows quarantined, % pass per rule) and FIRE a threshold alert (raise/log an alert row) when the quarantine rate for any rule exceeds a declared budget (e.g. > 5%).
- PROVE the DQ step is idempotent by running it 10× on the same input and showing identical clean and rejected counts (MERGE/overwrite-partition keyed on run, not blind append).
- Expectations as a data contract (0.75h): the difference between a check and a contract — a named rule + severity (warn vs. fail) + owner + a business reason. Map the four rule classes (schema/type conformance, null/completeness, uniqueness/grain, domain/accepted-code) onto the project's NPI, CPT/HCPCS code, and gross/negotiated price columns.
- Quarantine architecture & the flag-then-split pattern (1.0h): why you TAG rows before you split (so one row can fail multiple rules and you keep the audit), the anatomy of a `silver_rejected` table (natural key + full offending payload + reason array + rule id + rejected_at + source_file + run_id), and why quarantine beats dropping or halting — 'silent is worse than loud'.
- Severity, thresholds & alerting (0.75h): warn-vs-fail severities, per-rule quarantine budgets, and computing a run-level DQ summary; turning a breached threshold into an ALERT (alert row + printed/loud failure) vs. letting the run continue. Introduces the idea of a data-freshness/completeness SLA feeding the project KPI '% facilities passing pricing-completeness'.
- Idempotent DQ runs (0.5h): why re-running validation must not double-count rejects — keying rejected/summary writes on run_id and using overwrite-partition or MERGE; how this connects to W27's understanding of Spark actions and shuffle so metrics are computed once, not recomputed inconsistently.
Hands-on lab · ~8h
You extend the project's SILVER layer with an enforced data-quality gate. Starting from your conformed silver provider+pricing table (from earlier weeks) plus a small deck of intentionally bad rows you inject, you declare 8+ named expectations, implement a flag-then-split validator in PySpark, land failures in a Delta `silver_rejected` table with reasons and timestamps, compute a per-run DQ summary, fire a threshold alert when any rule's quarantine rate exceeds its budget, and prove the whole step is idempotent across 10 reruns. The clean stream continues down the pipeline; nothing is silently dropped.
- Assemble your test input: take your existing silver provider+pricing DataFrame and UNION a small hand-crafted 'bad-row deck' (10–20 rows) that violates each rule class on purpose — a null/blank NPI, a 9-digit NPI, a duplicate (npi, cpt_code) grain collision, a negotiated_price of -1 and one that is a string like 'N/A', a cpt_code not in your accepted-code set, a mangled payer name. Tag these so you can later confirm every planted row was caught.
- Write your expectations catalog as a Python list/dict of named rules — each with rule_id, target column(s), rule_class, severity (warn|fail), a Spark boolean expression, and a one-line business reason. Aim for 8+: e.g. NPI is exactly 10 digits (schema), NPI not null (completeness), (npi, cpt_code) unique (grain), cpt_code in accepted set (domain), gross_charge/negotiated_price are numeric and >= 0 (domain), payer_name not blank (completeness).
- Implement the flag pass: loop the catalog and add one boolean column per rule (`dq__<rule_id>` = true when the row PASSES). Add a `dq_failed_rules` array column collecting the rule_ids that failed, and a `dq_is_valid` = size(dq_failed_rules)==0. Do NOT filter yet — every input row still present. Assert row_count unchanged.
- Split the stream: clean = rows where dq_is_valid; rejected = rows where NOT dq_is_valid. For rejected, build the quarantine payload — original columns + dq_failed_rules (reason array) + first offending rule_id + rejected_at = current_timestamp() + source_file + run_id. Verify clean.count() + rejected.count() == input.count().
- Persist rejected rows to Delta table `silver_rejected`, partitioned by (or keyed on) run_id, using overwrite-partition or MERGE on run_id so a rerun replaces that run's rejects rather than appending duplicates. Write the clean stream forward to your silver table for the run.
- Compute the DQ run summary: rows_in, rows_passed, rows_quarantined, and per-rule pass_rate (%). Write it to a `dq_run_summary` Delta table keyed on run_id. This % pass feeds the project KPI '% facilities passing the pricing-completeness check per run'.
- Implement the threshold alert: for each rule with severity=fail, if its quarantine rate exceeds its declared budget (e.g. > 5%), emit an alert — append an `dq_alerts` row (run_id, rule_id, observed_rate, budget, fired_at) AND raise/print a loud, unmissable message. Decide deliberately: does a breach halt the run or continue with alert? Write your choice in the why-note.
- Prove idempotency: wrap steps 3–7 in a run function taking a fixed run_id and execute it 10× on the identical input. Assert that silver_rejected count, clean count, and dq_run_summary for that run_id are identical every time (no drift, no doubling). Capture the 10-run count table as evidence.
- Write the WHY-note (README section): list each rule and its business reason, state your severity/threshold choices and the budget numbers, justify overwrite-partition vs MERGE for idempotency, and confirm every planted bad row landed in silver_rejected with the correct reason (recovery/triage story).
- Delta Lake — MERGE INTO (idempotent upserts / keyed writes)
- Delta Lake — selectively overwrite / replaceWhere partition writes
- PySpark SQL functions (when/otherwise, array, size, current_timestamp)
- Databricks — expectations & quarantine patterns (concept reference; DLT syntax NOT required this phase)
- CMS Hospital Price Transparency — machine-readable file requirements (why these rules map to compliance)
</> Starter scaffold — silver_dq_gate.py python
# silver_dq_gate.py — Silver Data Quality gate (Databricks Free Edition + Delta + PySpark)
# Run per CMS republish. Flag -> split -> quarantine -> summarize -> alert. Idempotent by run_id.
from pyspark.sql import functions as F
# ---- 0. Run context ---------------------------------------------------------
# run_id makes writes idempotent: a rerun REPLACES this run's rejects/summary,
# it does not append. TODO: derive run_id from the CMS file version / ingest date.
RUN_ID = "TODO_run_id" # e.g. '2026-09-cms-v1'
SOURCE_FILE = "TODO_source_path"
# silver_conformed = your typed/conformed silver DF from prior weeks (+ your bad-row deck UNIONed in)
silver_conformed = spark.read.table("silver_provider_pricing_conformed") # TODO: your table
rows_in = silver_conformed.count()
# ---- 1. Expectations catalog ------------------------------------------------
# Each rule: id, class, severity ('warn'|'fail'), a PASS expression (True = good),
# a per-rule quarantine BUDGET (fraction), and a one-line business reason.
# TODO: write 8+ SPECIFIC rules. Two examples are stubbed — replace/extend them.
EXPECTATIONS = [
{"id": "npi_len10", "cls": "schema", "sev": "fail", "budget": 0.02,
"expr": F.col("npi").rlike("^[0-9]{10}$"),
"why": "A non-10-digit NPI cannot join to NPPES -> provider KPI is wrong."},
{"id": "price_nonneg_numeric", "cls": "domain", "sev": "fail", "budget": 0.05,
"expr": (F.col("negotiated_price").cast("double").isNotNull()) & (F.col("negotiated_price").cast("double") >= 0),
"why": "Negative / non-numeric prices corrupt the compliance cost report."},
# TODO: npi_not_null (completeness), (npi,cpt) uniqueness (grain — needs a window/groupBy),
# cpt_in_accepted_set (domain), payer_name_not_blank (completeness), gross_charge rules...
]
ACCEPTED_CPT = ["TODO", "fill", "your", "accepted", "code", "set"] # TODO domain list
# ---- 2. Flag pass (no filtering — every input row kept) ----------------------
df = silver_conformed
for rule in EXPECTATIONS:
df = df.withColumn(f"dq__{rule['id']}", rule["expr"]) # True = passed
# Collect failed rule ids into an array + validity flag.
# TODO: build dq_failed_rules as an array of the ids where dq__<id> is False.
# hint: F.array(*[F.when(~F.col(f"dq__{r['id']}"), F.lit(r['id'])) for r in EXPECTATIONS])
# then F.array_except(..., F.array(F.lit(None))) to drop nulls.
df = df.withColumn("dq_failed_rules", F.array()) # TODO replace
df = df.withColumn("dq_is_valid", F.size("dq_failed_rules") == 0)
assert df.count() == rows_in, "flag pass must not drop rows"
# ---- 3. Split ---------------------------------------------------------------
clean = df.filter("dq_is_valid")
rejected = df.filter("NOT dq_is_valid")
assert clean.count() + rejected.count() == rows_in, "split must conserve rows"
# ---- 4. Quarantine payload + write (idempotent on run_id) -------------------
rejected_out = (rejected
.withColumn("dq_failure_reason", F.col("dq_failed_rules"))
.withColumn("rejected_at", F.current_timestamp())
.withColumn("source_file", F.lit(SOURCE_FILE))
.withColumn("run_id", F.lit(RUN_ID)))
# TODO: write to Delta silver_rejected so a RERUN of this run_id REPLACES, not appends.
# option A: rejected_out.write.format('delta').mode('overwrite')
# .option('replaceWhere', f"run_id = '{RUN_ID}'").saveAsTable('silver_rejected')
# option B: MERGE on a natural key + run_id. Pick one; justify in the why-note.
# TODO: write `clean` forward to your silver serving table for this run.
# ---- 5. DQ run summary (feeds KPI: % passing pricing-completeness) -----------
# TODO: build one summary row per rule: run_id, rule_id, rows_in, rows_failed, pass_rate.
# write to Delta dq_run_summary keyed on (run_id, rule_id), idempotent.
# ---- 6. Threshold alert -----------------------------------------------------
# TODO: for each rule where sev=='fail', compute quarantine_rate = rows_failed/rows_in.
# if quarantine_rate > rule['budget']: append a dq_alerts row AND print a LOUD message.
# Decide: does a breach halt the run (raise) or continue with alert logged? Write it down.
- Databricks Data Quality: Quarantine Tables Are Not Dumping Grounds
- The Last Day of the Month Is Why You Have This Job!
- Data Quality Fundamentals O'Reilly · Book
- Introduction to Data Quality DataCamp · Course
Deliverable, acceptance & live session
- PASS requires: row conservation asserted (input count == clean + rejected, no silent drops)
- Every planted bad row from the deck appears in `silver_rejected` with the correct rule_id and a non-null rejected_at
- `silver_rejected` and `dq_run_summary` for a given run_id are byte-identical in count across 10 reruns (idempotency proven, not claimed)
- At least 8 rules spanning all four classes, each with a distinct business reason (no duplicated boilerplate)
- A threshold breach demonstrably fires an alert row AND a loud message
- The why-note names each rule's reason, states the budget numbers, and justifies the idempotency write strategy. Any rule justified only as 'should not be null' without a business/KPI/compliance reason is a FAIL.
Recap Each learner posts their expectations catalog (rules + business reasons) and their 10-run idempotency count table before the session. Recap: how many rules, spread across the four classes, and one screenshot of a `silver_rejected` row showing its reason. We contrast two learners' NPI/price rules to surface which choices are defensible.
Misconception 'Data quality means dropping the bad rows (or a final SELECT COUNT of NULLs at the end).' We correct this live: dropping is silent data loss you can't audit or recover, and an end-of-pipeline count neither stops bad data nor tells anyone why a row failed. The contract is tag-every-row, quarantine-with-reason, alert-past-a-threshold — loud beats silent, and the reject table is a recoverable, triageable asset, not a trash can.
Hot seat "Defend your rules: 'Walk me through one rule you wrote, the exact business or compliance consequence if it were missing, and why you set its quarantine budget at that number — not lower, not higher.' Then: 'You chose overwrite-partition (or MERGE) for the reject table. Prove it's idempotent and explain what would double-count if you'd blind-appended. And when your threshold breaches — does the run halt or continue? Justify that to a compliance officer.'"
W29 Data Quality II — Reconciliation & Audit: Proving the Pipeline Didn't Lose or Invent Rows M24 · Phase 2 · silver G4G1 ~10.0h
- Given a single pipeline run, write a reconciliation query that proves the completeness identity bronze_in = silver_passed + quarantined + dedup_collapsed to the exact row (variance = 0), and explain in writing where each 'lost' row went.
- Reconcile a numeric control total (sum of standard_charge and distinct provider count) across bronze, silver, and gold, and flag any layer where variance exceeds a declared tolerance (0 for counts, a stated % for money after currency cleaning).
- Design and populate an idempotent audit_log Delta table with one row per (run_id, layer) capturing rows_in, rows_out, rows_rejected, watermark_value, duration_seconds, status, and run_ts; re-running a run_id MERGEs, never duplicates.
- Implement a reconciliation CHECK that sets run status to FAIL and raises a loud alert when the identity doesn't balance or a control total drifts beyond tolerance — silent drift becomes a failed run.
- Query the audit_log across the last N runs to produce a run-over-run trend of pass rate, reject count, and freshness, demonstrating the log answers 'what happened on any past run' without re-reading the data.
- Reconciliation as a control, not a vibe (55 min): the completeness identity (in = out + rejected + collapsed), why 'roughly the same count' is a failed audit, and the difference between count reconciliation (tolerance must be 0) and metric/control-total reconciliation (tolerance is a stated business number after cleaning). Where rows legitimately disappear: quarantine, dedup collapse, inner-join drops — each must be named and counted, never unexplained.
- Anatomy of an audit log / run ledger (50 min): one row per run per layer; the columns that make it useful (run_id, layer, source, rows_in, rows_out, rows_rejected, watermark_value, duration_seconds, status, error_msg, run_ts); grain and primary key of the log itself; why the log is append-with-merge (idempotent on run_id+layer) and lives beside the data as a first-class Delta table, not a print statement.
- Control totals & watermarks as reconciliation anchors (45 min): choosing a monetary/volume control total that must be conserved (total standard_charge, distinct NPI count), reconciling it layer to layer, and tying the run to its incremental watermark (max ingest_ts / file version) so freshness and 'what window did this run cover' are auditable — reuses the incremental watermark from earlier weeks, now recorded rather than just used.
- Turning imbalance into a loud failure (40 min): computing variance, comparing to tolerance, setting status=FAIL, and raising (assert/raise + a written alert row) so a broken accounting stops the run instead of publishing a wrong KPI. Contrast with W28's row-level quarantine: quarantine handles bad records; reconciliation handles bad totals — both loud, different scope.
Hands-on lab · ~6.5h
Instrument the full running Provider Pricing Integrity pipeline with reconciliation and a persistent audit log. For a single incremental run you will count rows in/out/rejected at each hop (bronze→silver→gold), prove the completeness identity balances to the row, reconcile a money control total and a distinct-provider control total across layers, write one audit_log row per layer capturing counts + watermark + duration + status, and force the run to FAIL loudly when the accounting doesn't balance. You are not writing a test framework (that's W30) — you are building and manually verifying the ledger those tests will read.
- Set up the ledger: create an audit_log Delta table with an explicit schema (run_id STRING, layer STRING, source STRING, rows_in LONG, rows_out LONG, rows_rejected LONG, watermark_value STRING, control_total_num DECIMAL, distinct_key_count LONG, duration_seconds DOUBLE, status STRING, error_msg STRING, run_ts TIMESTAMP) and declare its primary key/grain in a comment: one row per (run_id, layer). Generate a run_id (e.g. uuid or run timestamp) once at the top of the run and thread it through every layer.
- Instrument bronze: for the current incremental batch, capture rows_in from the landed source files, the watermark_value (max ingest_ts or source file version this run covered), and start a timer. Write the bronze audit row via MERGE on (run_id, layer) so a rerun of the same run_id overwrites rather than appends — test by running the cell twice and confirming still one row.
- Instrument silver: count rows_out (records that passed W28's quality gates) and rows_rejected (records MERGEd to the quarantine/rejected table with reason + timestamp from last week). Prove the row-level identity rows_in == rows_out + rows_rejected + rows_dropped_by_dedup; compute each term with a query, and for any nonzero 'dropped' bucket, add a one-line written explanation of where those rows went. Write the silver audit row.
- Reconcile control totals across layers: compute SUM(standard_charge) and COUNT(DISTINCT npi) at bronze (raw, pre-clean), silver (post-clean/typed), and gold (serve). Build a small reconciliation result showing each layer's total and the variance vs the prior layer. Record the gold-layer control_total_num and distinct_key_count into the gold audit row.
- Declare tolerances and evaluate them: set count tolerance = 0 (any unexplained row loss fails) and a money tolerance you justify in writing (e.g. variance allowed only for rows whose charge was null/uncleanable and legitimately quarantined — quantify it). Write a reconciliation CHECK that compares each variance to its tolerance and derives a per-run status of PASS or FAIL.
- Make failure loud: when status == FAIL, raise an exception (or write a FAIL row + an alert record) that stops the run before gold is published, with error_msg naming which check failed and by how much. Demonstrate it: deliberately corrupt one run (e.g. drop a filter or inject 500 rows into gold) and show the run flips to FAIL and blocks publish; then revert and show PASS.
- Prove idempotency of the whole instrumented run: run the full pipeline for the same run_id 3–10 times and confirm (a) audit_log still holds exactly one row per (run_id, layer), (b) counts and control totals are identical every time, (c) gold row counts don't grow. Capture the before/after in a query.
- Query the ledger as a human would: write an audit_log summary query over the last N runs showing run_ts, rows_in, rows_out, rows_rejected, reject rate, control total, watermark, duration, and status — the run-over-run trend. Write 4–6 sentences on what run 'went wrong' would look like in this table and how you'd spot silent drift.
- Write the reconciliation 'why' note: which control totals you chose and why, your tolerance decisions, where rows legitimately disappear in your pipeline, and one anomaly you actually found while balancing the books (or state you found none and how you know).
- Delta Lake — MERGE INTO (upsert; the idempotent write pattern for the audit_log)
- Databricks — Delta table history & auditing (DESCRIBE HISTORY, table versions as watermark)
- PySpark — DataFrame.count and aggregate functions (sum, countDistinct) for control totals
- Reconciliation controls & control totals in data pipelines (concept primer)
</> Starter scaffold — audit_reconcile_scaffold.py python
# audit_reconcile_scaffold.py
# Week 29 - Reconciliation & Audit scaffold for the Provider Pricing Integrity pipeline.
# Databricks Free Edition + Delta + PySpark. This is a SCAFFOLD: it sets up the run_id,
# the audit_log table, and the reconciliation SHAPE. You fill the TODOs with your own
# counts, control totals, tolerance calls, and failure handling. Do NOT expect this to run
# a full pipeline as-is.
import uuid, time
from datetime import datetime
from pyspark.sql import functions as F
AUDIT_TABLE = "pricing.audit_log" # TODO: point at your schema
# --- 1. One run_id for the whole run, threaded through every layer -------------
RUN_ID = datetime.utcnow().strftime("%Y%m%dT%H%M%S") + "-" + uuid.uuid4().hex[:6]
print(f"RUN_ID = {RUN_ID}")
# --- 2. Create the ledger once (idempotent DDL). Grain: one row per (run_id, layer) ---
spark.sql(f"""
CREATE TABLE IF NOT EXISTS {AUDIT_TABLE} (
run_id STRING, layer STRING, source STRING,
rows_in LONG, rows_out LONG, rows_rejected LONG,
watermark_value STRING, control_total_num DECIMAL(18,2), distinct_key_count LONG,
duration_seconds DOUBLE, status STRING, error_msg STRING, run_ts TIMESTAMP
) USING DELTA
""")
# --- 3. Idempotent audit writer: MERGE on (run_id, layer), never append blindly ---
def write_audit_row(row: dict):
src = spark.createDataFrame([row])
src.createOrReplaceTempView("_audit_src")
# TODO: complete the MERGE so re-running the same (run_id, layer) UPDATES in place.
spark.sql(f"""
MERGE INTO {AUDIT_TABLE} t
USING _audit_src s
ON t.run_id = s.run_id AND t.layer = s.layer
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *
""") # <- confirm this leaves exactly ONE row per (run_id, layer) after 2 runs
# --- 4. Instrument one layer. Call this pattern for bronze, silver, gold ----------
def instrument_layer(layer, source, df_in, df_out, df_rejected, watermark):
t0 = time.time()
rows_in = df_in.count()
rows_out = df_out.count()
rows_rejected = df_rejected.count() if df_rejected is not None else 0
# TODO: control total(s). Clean currency BEFORE summing or bronze will never balance.
control_total = df_out.agg(F.sum("standard_charge")).first()[0] # TODO: cast/clean
distinct_keys = df_out.agg(F.countDistinct("npi")).first()[0]
write_audit_row({
"run_id": RUN_ID, "layer": layer, "source": source,
"rows_in": rows_in, "rows_out": rows_out, "rows_rejected": rows_rejected,
"watermark_value": str(watermark),
"control_total_num": control_total, "distinct_key_count": distinct_keys,
"duration_seconds": round(time.time() - t0, 2),
"status": "PENDING", "error_msg": None, "run_ts": datetime.utcnow(),
})
return rows_in, rows_out, rows_rejected
# --- 5. The completeness identity check. Counts MUST balance to 0 -----------------
def assert_completeness(rows_in, rows_out, rows_rejected, rows_dropped_by_dedup):
# TODO: the identity. Any nonzero residual is a FAILED run, not a rounding note.
residual = rows_in - (rows_out + rows_rejected + rows_dropped_by_dedup)
if residual != 0:
# TODO: write a FAIL audit row + raise loudly so gold is NOT published.
raise ValueError(f"RECONCILE FAIL: {residual} rows unaccounted for in run {RUN_ID}")
return True
# --- 6. Control-total tolerance. Counts tol=0; money tol is YOUR justified number ---
def check_control_total(bronze_total, silver_total, money_tolerance):
# TODO: variance = bronze_total - silver_total; the only allowed gap is charges you
# quarantined for being null/uncleanable. Quantify it, compare, PASS/FAIL.
pass
# TODO (lab): wire bronze->silver->gold through instrument_layer(), run assert_completeness()
# and check_control_total(), flip run status, then run the whole thing 3-10x and prove
# the audit_log holds one row per (run_id, layer) with identical numbers every time.- Databricks Data Quality: Quarantine Tables Are Not Dumping Grounds
- The Last Day of the Month Is Why You Have This Job!
- Data Quality Fundamentals O'Reilly · Book
- Data Engineering Design Patterns O'Reilly · Book
Deliverable, acceptance & live session
- The completeness identity residual is 0 and every nonzero disappearance bucket has a written explanation
- Control-total variances are computed at each layer and compared to a tolerance the learner justifies in writing (counts tolerance = 0)
- The audit_log holds exactly one row per (run_id, layer) after re-running the same run_id (no duplicate ledger rows), and counts/totals are byte-identical across 3+ reruns
- A corrupted run visibly flips status to FAIL, raises loudly, and prevents gold from being published, then a clean run returns PASS
- The reconciliation note names a specific control total, a specific tolerance number, and a specific finding — generic 'the data looks fine' is rejected.
Recap Walk through your audit_log across the last few runs on screen and narrate the story it tells: which run had the highest reject count and why, where your completeness identity balanced, and the moment it didn't. Show the before/after of your deliberately-corrupted FAIL run and the exact line that stopped gold from publishing.
Misconception 'The counts are close enough / off by a handful, that's basically balanced.' For count reconciliation the only acceptable variance is zero — a residual of 7 rows isn't rounding, it's 7 rows you can't explain, and in Maria's world that could be 7 hospitals missing from a compliance filing. Tolerance belongs only to cleaned monetary/volume totals, and even then the tolerance is a specific number you justified, not a shrug. 'Close' is a failed audit.
Hot seat "Defend three decisions in the hot seat: (1) Your money control-total tolerance is X — where exactly does that gap come from, and how do you know it's not silent data loss hiding as 'cleaning'? (2) Your completeness identity has a dedup_collapsed bucket of N — prove those N rows were true duplicates and not real providers you wrongly merged. (3) A hiring manager asks 'how do you know run 47 was correct?' — answer using only your audit_log, without re-reading the source data. Expect to be pushed on why silent drift is worse than a crash."
W30 Testing taxonomy for data — pytest & the Phase 2 portfolio M25 · Phase 2 · silver · Phase 2 portfolio G4G6G1 ~10.5h
- Classify every existing check in the Project 1 pipeline into the four-tier data-testing taxonomy (unit / integration / smoke / e2e) and defend each tier choice in one sentence
- Write and pass at least 8 pytest tests: ≥3 unit tests on pure transform functions, ≥2 integration tests on bronze→silver quarantine + SCD2, 1 smoke test on schema/PK contract, and ≥2 e2e tests asserting KPIs
- Prove idempotency with an automated test that runs the full pipeline twice on a fixture and asserts identical row counts AND identical golden-record counts (not a prose claim)
- Report all four Project 1 KPIs as actual numbers from a real full-data run, and assemble a README that passes a 30-second recognition test
- Finalize and cross-link the living deliverables (project charter + 4-part architecture doc) to the test suite and the golden-records table, completing portfolio Project 1
- The testing pyramid for DATA pipelines (~1.0h): why data tests differ from app tests (the input mutates underneath you), the four tiers and what each buys you, and the data-specific test families layered on top — schema/contract tests, PK-uniqueness, referential integrity, quarantine-reason coverage, and freshness. Map each tier to a piece of your existing pipeline.
- pytest mechanics on Databricks Free Edition (~1.0h): the anatomy of a test (arrange/act/assert), a session-scoped SparkSession fixture in conftest.py, @pytest.fixture for tiny deterministic data, @pytest.mark.parametrize for table-driven cases, tmp_path for throwaway Delta tables, chispa for DataFrame equality, and running pytest.main() inside a notebook vs. a .py module.
- Testing for failure & idempotency (~0.75h): writing NEGATIVE tests (feed a bad row, assert it lands in the rejected table with reason+timestamp), asserting SCD2 close/open on an updated record, and the run-twice idempotency assertion pattern — the same discipline you eyeballed in earlier weeks, now encoded so it fails loudly in CI.
- Portfolio assembly & the recognition test (~0.75h): README structure that front-loads business framing, reporting KPIs as real numbers with a small results table, the ‘prove a human did the work’ why-notes, and cross-linking the charter + 4-part architecture doc + golden records so the whole thing reads as one owned data product.
Hands-on lab · ~7h
Retrofit a real pytest suite onto the complete Provider Pricing Integrity pipeline, organized by the four-tier taxonomy, then assemble the finished Phase 2 portfolio. You refactor silver transforms into importable pure functions, freeze a tiny deterministic fixture that carries the CMS/NPPES mess (null price, "$"-string price, late record, duplicate provider), write unit/integration/smoke/e2e tests that encode your quarantine, SCD2, contract, and idempotency guarantees, run the full pipeline on real data to capture the four KPIs as numbers, and ship a recognition-ready README plus locked charter and architecture doc.
- Freeze fixtures: extract a ~50-row deterministic sample from your bronze CMS price + NPPES data into tests/fixtures/ (parquet or hardcoded rows) so tests never hit the network. It MUST contain at least one null price, one '$1,234.00'-formatted string price, one late-arriving record (older ingest date than its key already has), and one duplicate provider spelled/addressed two ways for the MDM test.
- Refactor for testability: pull your silver logic (price parsing, NPI normalization, address standardization, quarantine-reason tagging) out of notebook cells into pure importable functions in a transforms.py module — functions that take/return values or small DataFrames with no hidden I/O.
- Write ≥3 UNIT tests (@pytest.mark.parametrize): '$1,234.00' → 1234.00, ''/null price → quarantine reason 'MISSING_PRICE', and NPI ' 1234567890 ' → '1234567890'. These run in milliseconds with no Spark table.
- Write ≥2 INTEGRATION tests on the fixture (bronze→silver): assert bad rows land in the rejected table with reason + timestamp (and are absent from silver), and assert that re-ingesting an updated provider closes the prior SCD2 row (is_current=false, valid_to set) and opens exactly one new current row.
- Write 1 SMOKE test: run the pipeline end-to-end on the fixture and assert the silver/gold schema matches your written layer contract, the golden_provider_id PK is unique and non-null, and every output layer is non-empty — the fast 'is it alive and shaped right' check.
- Write ≥2 E2E tests: run ingest→bronze→silver→golden→gold on the fixture, assert the 4 KPIs are computed and sane; then run the ENTIRE pipeline a SECOND time and assert identical row counts and identical golden-record count — idempotency proven, not claimed.
- Run `pytest -v`, capture the green output, then execute one real full-data pipeline run and record the four KPIs as actual numbers: % facilities passing the completeness check, # duplicate providers collapsed into golden records, # records quarantined broken out by reason, and the data-freshness SLA figure.
- Assemble the portfolio: finalize README (business framing → architecture diagram → KPI results table → how-to-run → test suite summary), lock the project charter and the 4-part architecture doc, cross-link both to the test suite and golden-records table, and write the short 'why we test at each tier + why these idempotency choices' note that becomes your interview answers.
- pytest — Getting Started, fixtures, and parametrize (official docs)
- chispa — PySpark DataFrame equality assertions for tests
- Databricks — Unit testing for notebooks / testing code on Databricks
- Delta Lake — MERGE / upsert (SCD2 and idempotent writes reference)
- Martin Fowler — Test Pyramid (tiering intuition to adapt to data)
</> Starter scaffold — test_pipeline.py python
# test_pipeline.py — four-tier test suite for Provider Pricing Integrity (Project 1)
# Assumes a conftest.py with:
# @pytest.fixture(scope="session")
# def spark(): # local[2] + Delta configs on your machine; the notebook `spark` on Databricks
# ...
# and importable code: from transforms import parse_price, normalize_npi, tag_quarantine
# plus your pipeline entrypoints: run_bronze_to_silver(spark, src), run_full_pipeline(spark, src)
import pytest
# ----------------------------------------------------------------------
# TIER 1 — UNIT: pure functions, no Spark table, run in milliseconds
# ----------------------------------------------------------------------
@pytest.mark.parametrize("raw,expected", [
("$1,234.00", 1234.00),
("1234", 1234.00),
# TODO: add a null/'' case and assert it is flagged (not silently 0.0)
])
def test_parse_price(raw, expected):
from transforms import parse_price
assert parse_price(raw) == expected
def test_normalize_npi_strips_and_cases():
from transforms import normalize_npi
# TODO: assert ' 1234567890 ' -> '1234567890' (whitespace/dtype normalized)
...
# ----------------------------------------------------------------------
# TIER 2 — INTEGRATION: bronze->silver on the frozen fixture (real Spark)
# ----------------------------------------------------------------------
def test_bad_row_is_quarantined_with_reason(spark):
src = "tests/fixtures/bronze_sample" # the ~50-row deterministic mess
silver, rejected = None, None # TODO: run_bronze_to_silver(spark, src)
# TODO: assert the null-price row is ABSENT from silver
# TODO: assert it is PRESENT in rejected with reason=='MISSING_PRICE' and a non-null quarantine_ts
...
def test_scd2_closes_old_and_opens_new(spark):
# TODO: ingest a provider, then ingest an updated version of the same NPI
# TODO: assert exactly one row has is_current=true, and the prior row has valid_to set
...
# ----------------------------------------------------------------------
# TIER 3 — SMOKE: does it run, and is the OUTPUT CONTRACT intact?
# ----------------------------------------------------------------------
def test_gold_contract_and_pk(spark):
gold = None # TODO: run_full_pipeline(spark, "tests/fixtures/bronze_sample").gold
# TODO: assert set(gold.columns) == EXPECTED_GOLD_COLUMNS (your written layer contract)
# TODO: assert gold PK golden_provider_id is unique and non-null; assert gold is non-empty
...
# ----------------------------------------------------------------------
# TIER 4 — E2E: KPIs compute + IDEMPOTENCY (run twice, identical)
# ----------------------------------------------------------------------
def test_pipeline_is_idempotent(spark):
src = "tests/fixtures/bronze_sample"
first = None # TODO: run_full_pipeline(spark, src); capture counts
second = None # TODO: run_full_pipeline(spark, src) AGAIN (same src)
# TODO: assert first.silver_count == second.silver_count
# TODO: assert first.golden_record_count == second.golden_record_count # dedupe didn't drift
...
def test_kpis_are_computed_and_sane(spark):
kpis = None # TODO: run_full_pipeline(spark, "tests/fixtures/bronze_sample").kpis
# TODO: assert 0 <= kpis['pct_completeness_pass'] <= 100
# TODO: assert kpis['duplicates_collapsed'] >= 1 # the fixture's two-spelling provider must merge
...
- Python Testing with pytest, 2nd Ed. O'Reilly · Book
- Introduction to Testing in Python DataCamp · Course
Deliverable, acceptance & live session
- `pytest -v` runs green with ≥8 tests spanning all four tiers
- Deleting the price-parser body (or disabling the quarantine route) makes at least one test fail LOUDLY (proving the tests bite). The idempotency test runs the full pipeline twice on the fixture and asserts identical silver row counts and identical golden-record counts. The README states all four KPIs as concrete numbers (% completeness pass, # duplicates collapsed, # quarantined by reason, freshness SLA), a reader with no context can run the pipeline from the README alone, and the charter + architecture doc are cross-linked to the test suite and golden-records table. Every test tier is justified in one sentence in the why-notes.
Recap Open by screen-sharing your green `pytest -v` output and reading your KPI results table out loud in plain business terms: 'X% of facilities pass the pricing-completeness check, we collapsed N duplicate providers into golden records, we quarantined M records — here's the reason breakdown — and our freshness SLA is T.' Then walk one test from each tier and say what real regression it would catch.
Misconception 'More assert statements in a notebook = tested.' The four tiers are not decoration — they trade off speed vs. coverage vs. confidence, and a test only counts if it can FAIL: a green suite that still passes after you break the parser is testing nothing. Likewise, 'idempotent' and 'quarantines bad rows' are properties you must encode as tests that run twice / feed a bad row, not sentences in a README.
Hot seat "Defend your test design: (1) Point to one test in each tier and justify why it lives at that tier and not another. (2) Which single test would you keep if you could keep only one, and why? (3) Show the assertion that proves idempotency — and explain the mechanism (MERGE on key / partition replace / overwrite) it protects and why you chose it here. (4) Name one real regression each of your integration tests would catch that a unit test could not. Expect the reviewer to break your parser live and demand that a test go red."