Section 3: Data Transformation, Cleansing, and Quality¶
The exam page calls this section "Data Transformation, Cleansing, and Quality" (10%); the 2026-10-09 exam guide PDF calls it "Data Manipulation" (12%). The four objectives are the same: advanced transformations at scale, semi-structured data with VARIANT, AI functions inside pipelines, and data-quality expectations.
Source: the π exam page and its linked exam guide.
Table of Contents¶
- Window functions
- Joins at scale
- Aggregations and deduplication
- Semi-structured data with VARIANT
- AI functions in pipelines
- Data quality expectations
- How the exam tests this section
Window functions¶
A window function computes a value for each row from a set of related rows, without collapsing them the way GROUP BY does. The window is defined by three parts: PARTITION BY (which rows belong together), ORDER BY (their order), and an optional frame (ROWS BETWEEN ... or RANGE BETWEEN ...).
The patterns the exam uses most:
-- Latest record per key (the classic dedup)
SELECT * FROM (
SELECT *, row_number() OVER (PARTITION BY customer_id ORDER BY updated_at DESC) AS rn
FROM bronze.customers)
WHERE rn = 1;
-- Databricks shorthand for the same thing
SELECT * FROM bronze.customers
QUALIFY row_number() OVER (PARTITION BY customer_id ORDER BY updated_at DESC) = 1;
-- Running total and 7-row moving average
SELECT order_date, amount,
sum(amount) OVER (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total,
avg(amount) OVER (ORDER BY order_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7
FROM gold.daily_sales;
-- Change from previous event
SELECT device_id, ts, reading,
reading - lag(reading) OVER (PARTITION BY device_id ORDER BY ts) AS delta
FROM silver.readings;
Know the differences between row_number (always unique), rank (ties share a rank, then gaps) and dense_rank (ties share, no gaps). The PySpark form uses Window.partitionBy(...).orderBy(...) with F.row_number().over(w).
At scale, a window with PARTITION BY shuffles all rows of a key to one task. A very skewed key (one customer with half the rows) makes one task do half the work. A window with no PARTITION BY moves every row to a single task; avoid it on large data. Watch for both in the query profile (Section 6).
π Window functions - syntax, frames, ranking and analytic functions
Joins at scale¶
Spark picks a physical join strategy. Knowing which, and why, is what turns a slow join into a fast one.
| Strategy | When Spark uses it | Cost |
|---|---|---|
| Broadcast hash join | One side is small (under spark.sql.autoBroadcastJoinThreshold, or AQE sees it is small at runtime) | No shuffle of the big side; fastest |
| Shuffle hash join | Medium sides; one side's partitions fit in memory | Shuffles both sides |
| Sort-merge join | Both sides large (the default for big-to-big) | Shuffles and sorts both sides |
Tools you control:
- Hints:
SELECT /*+ BROADCAST(d) */ ...forces a broadcast when you know the dimension is small. - Adaptive Query Execution (AQE), on by default, switches a sort-merge join to broadcast when the real size is small, coalesces tiny shuffle partitions and splits skewed partitions during skew joins.
- Filter and project early so less data shuffles. Join on the keys that a table is clustered by so data skipping helps.
Join types matter for correctness, not just speed: LEFT ANTI JOIN finds rows with no match (new records), LEFT SEMI JOIN keeps rows that have a match without duplicating them. Stream-static joins (streaming fact, static Delta dimension) pick up the latest dimension version each micro-batch; stream-stream joins need watermarks on both sides.
π Adaptive Query Execution - runtime join conversion and skew handling
Aggregations and deduplication¶
Aggregations are groupBy().agg(...) in PySpark and GROUP BY in SQL. At scale, three things matter:
- Exact vs approximate distinct.
count(DISTINCT x)on billions of rows is expensive.approx_count_distinct(x)(HyperLogLog) is far cheaper with a small, tunable error. Use it for dashboards; use exact counts for billing. - Grouping sets.
GROUP BY GROUPING SETS ((region), (region, product), ()),ROLLUPandCUBEproduce several aggregation levels in one pass. - Pivot.
pivot()turns row values into columns; give it the list of values so Spark does not have to compute them first.
Deduplication in batch is dropDuplicates(["key"]) (arbitrary row kept) or the row_number() pattern above (the row you choose is kept). In streaming, use dropDuplicatesWithinWatermark so the state of seen keys is bounded.
Semi-structured data with VARIANT¶
VARIANT is a Databricks SQL type for semi-structured data such as JSON. It stores the data in an efficient binary encoding, keeps the full nested structure, and lets you query fields without declaring a schema up front. Compared with keeping JSON as a STRING, reads are much faster because fields do not have to be re-parsed from text on every query. Compared with parsing into a fixed STRUCT, it does not break when the payload shape changes.
-- Bronze: store raw payloads as VARIANT
CREATE TABLE bronze.events AS
SELECT parse_json(raw_string) AS payload, ingested_at
FROM raw_events;
-- Silver: extract typed fields
SELECT
payload:user.id::BIGINT AS user_id, -- colon path + cast
variant_get(payload, '$.device.os', 'STRING') AS os, -- fails on bad cast
try_variant_get(payload, '$.amount', 'DECIMAL(10,2)') AS amount -- NULL on bad cast
FROM bronze.events;
The functions to know:
| Function | What it does |
|---|---|
parse_json(str) | String to VARIANT; error on invalid JSON |
try_parse_json(str) | Same, but NULL on invalid JSON (safer in bronze) |
variant_get(v, path, type) | Extract a field at a JSON path and cast; error if the cast fails |
try_variant_get(v, path, type) | Same, NULL if the cast fails |
v:field.sub[0] | Colon path syntax; returns VARIANT, cast with ::TYPE |
schema_of_variant(v) / schema_of_variant_agg(v) | Discover the shape of one value, or across rows |
variant_explode(v) | One row per element of an array or object |
to_json(v) | Back to a string |
Auto Loader and read_files can ingest JSON straight into a VARIANT column, which is the modern bronze pattern for event payloads whose shape you do not control. Databricks can also shred frequently accessed fields of a VARIANT column into separate columnar storage for faster reads; check the current docs for the table property and runtime requirements.
π Query variant data - path syntax, casting, explode π parse_json - string to VARIANT π variant_get - typed extraction π try_variant_get - extraction that returns NULL on failure
AI functions in pipelines¶
AI functions are SQL functions that call a model from inside a query, so enrichment and classification run as part of the pipeline instead of a separate service.
ai_query(endpoint, request, ...)is the general one. It sends each row'srequestto a Model Serving endpoint (a Databricks-hosted foundation model, an external model, or your own custom model) and returns the response. You can ask for structured output with aresponseFormat(a JSON schema), and setfailOnError => falseso one bad row returns an error value instead of failing the whole query.- Task-specific functions wrap common jobs with no prompt to write:
ai_classify,ai_extract,ai_analyze_sentiment,ai_summarize,ai_translate,ai_mask(redact entities),ai_fix_grammar,ai_similarity,ai_gen, andai_parse_document(text and layout from PDFs and images, often fed frombinaryFileingestion).
CREATE OR REFRESH MATERIALIZED VIEW silver.tickets_enriched AS
SELECT ticket_id, body,
ai_classify(body, ARRAY('billing', 'bug', 'feature request', 'other')) AS category,
ai_query('support-summary-endpoint', -- any Model Serving endpoint
concat('Summarise this support ticket in one sentence: ', body)) AS summary
FROM silver.tickets;
Production points:
- AI functions are designed for batch inference at scale; Databricks parallelises the calls. Run them in a materialized view or a scheduled job, not row by row from a notebook loop.
- Cost and determinism. Every row is a model call. Process only new or changed rows (a streaming table, or a materialized view with incremental refresh), and do not re-run inference on unchanged history.
- Governance. The endpoint is a Unity Catalog-governed object; the caller needs permission to query it. Use
ai_maskor a column mask before sending PII to a model if policy requires.
π AI functions - task-specific functions and batch inference π ai_query - parameters, structured output, error handling
Data quality expectations¶
An expectation is a named Boolean constraint on a pipeline dataset. Every row is checked, and the pipeline records how many passed and failed in the event log (Section 5). You choose what happens on failure:
| Action | Python | SQL | Effect |
|---|---|---|---|
| Warn (default) | @dp.expect | EXPECT (cond) | Bad rows are kept; metric recorded |
| Drop | @dp.expect_or_drop | EXPECT (cond) ON VIOLATION DROP ROW | Bad rows are removed from the target |
| Fail | @dp.expect_or_fail | EXPECT (cond) ON VIOLATION FAIL UPDATE | The update stops; the transaction rolls back |
expect_all, expect_all_or_drop and expect_all_or_fail take a dictionary of several rules.
CREATE OR REFRESH STREAMING TABLE silver.orders (
CONSTRAINT valid_id EXPECT (order_id IS NOT NULL) ON VIOLATION FAIL UPDATE,
CONSTRAINT valid_amount EXPECT (amount >= 0) ON VIOLATION DROP ROW,
CONSTRAINT has_country EXPECT (country IS NOT NULL)
) AS SELECT * FROM STREAM(bronze.orders);
Quarantine is the pattern when you must neither keep bad rows in silver nor lose them. There is no "quarantine" keyword; you build it from two datasets reading the same source with opposite rules:
rules = {"valid_id": "order_id IS NOT NULL", "valid_amount": "amount >= 0"}
quarantine_rule = "NOT({})".format(" AND ".join(rules.values()))
@dp.table
@dp.expect_all_or_drop(rules)
def orders_clean():
return spark.readStream.table("bronze.orders")
@dp.table
def orders_quarantine():
return spark.readStream.table("bronze.orders").where(quarantine_rule)
A data steward reviews the quarantine table, fixes the source, and reprocesses. An alternative is one table with an is_quarantined flag column and two views over it.
Choosing the action:
- Fail when a bad row means something is fundamentally broken (a null primary key, a currency that does not exist) and downstream must not see partial data.
- Drop when bad rows are expected noise and losing them is acceptable.
- Warn when you want visibility first, before deciding on a stricter rule.
- Quarantine when the requirement says "do not lose records" and "keep silver clean".
Outside pipelines, the same ideas are Delta CHECK constraints (ALTER TABLE t ADD CONSTRAINT c CHECK (amount >= 0), which fail the write) and data quality monitoring on tables (Section 5).
π Manage data quality with pipeline expectations - actions, multiple expectations, quarantine examples
How the exam tests this section¶
- "Latest row per key" is
row_number()orQUALIFY; "ties must share a position" isrankordense_rank. - JSON of varying shape, fast field access, no fixed schema is
VARIANTwithparse_jsonandvariant_getor colon paths. Regex on a string and exploding into a fixed struct are the distractors. - "Classify / enrich / extract inside the pipeline" is an AI function, usually
ai_queryor a task-specific one, run in batch. - Expectations questions turn on the action word: keep and count (warn), remove (drop), stop (fail), keep separately (quarantine).
Continue with Section 4: Data Sharing and Federation.