03Ingestion
This route decides how each source reaches bronze, and where the quality gate lives. Pick the pattern from the shape of the source and ingestion stays reviewable; pick it per source and you get six bespoke pipelines and six people who each understand one. The second decision — that validation does not live in the pipeline that publishes — is the one nobody misses until a wrong number arrives through a chain of green jobs.
One decision table
There are four source shapes. The shape is a property of the source, not a choice you get to make.
#Pick by source shape, not by source system
| Source shape | Pattern | Lands in | Cost of the wrong choice |
|---|---|---|---|
| Files in a Volume, schema drifts | Auto Loader streaming table, schema location per stream | bronze.shop_order | a folder listing that double-counts any file landing twice |
| Change feed with a sequence (CDC) | AUTO CDC flow, sequence_by the change counter | bronze.sap_vbak, bronze.sap_vbap | a MERGE keyed on a timestamp: same-second changes apply in arbitrary order |
| Full extract, no change timestamp | append every extract, diff consecutive snapshots | bronze.sap_kna1 | no history at all, or every unchanged row versioned daily |
| Dated reference file, insert-only | Auto Loader, unique on (currency, rate_date) | bronze.fx_rate | one duplicated rate row fans out the join and doubles amount_eur |
| API with a paging cursor | land the raw response as files, then pattern one | a Volume, then bronze | a parse bug you cannot re-run — the API will not return that page again |
Doing it
- Answer both questions in the ticket first: can I tell what changed, can I tell what was deleted.
- Land raw before parsing for anything you cannot re-request.
- Give each stream its own schema location and checkpoint; never share a path.
- Add the three technical columns in the framework, so a new source cannot forget them.
Defaults
- Four patterns, each selected by an observable property of the source.
- Raw lands before it is parsed; parsing belongs downstream of a durable copy.
- Bronze is append-only and carries the three technical columns.
- One stream, one schema location, one checkpoint.
Gotchas
- Choosing CDC because the source system offers CDC, when what arrives is a nightly full dump. sequence_by is then identical for every row in the file, and last-writer-wins picks an arbitrary version of each customer.
- Reading a Volume with spark.read 'because there are only a few files'. It works for a year; then the folder holds 400,000 files, the nightly listing takes six hours, and you migrate under pressure.
- Parsing during ingestion. A field the shop team changed on Tuesday is unrecoverable for Monday, because bronze holds your interpretation of the file rather than the file.
- Assuming a full extract lets you skip history. It is the only shape that reveals deletions — and the only one where a truncated file is indistinguishable from a mass delete.
The three patterns this warehouse needs
The webshop drops files. SAP sends a change feed for orders and a full dump for customers. The ECB rates file is the Auto Loader pattern again with the volume turned down — one small CSV a day, unique on (currency, rate_date) — so it earns a row in the table above and no section of its own.
# old — still runs
import dlt
# current
from pyspark import pipelines as dp
# @dp.table -> a STREAMING TABLE
# @dp.materialized_view -> a materialized view
# @dp.temporary_view -> the former @dlt.view@table produced whichever object the query implied; it now always declares a streaming table. A snippet copied from a 2024 post therefore runs, builds the wrong object, and announces itself as a cost line rather than an error.#Auto Loader — bronze.shop_order
/Volumes/<catalog>/bronze/landing/shop_order/
dt=2026-07-28/
shop_order_2026-07-28T02-15-04Z_0001.json
shop_order_2026-07-28T02-15-04Z_0002.json
dt=2026-07-29/
...
/Volumes/<catalog>/bronze/_schemas/shop_order/ <- cloudFiles.schemaLocation
/Volumes/<catalog>/bronze/_checkpoints/shop_order/ <- stream checkpoint
<catalog> is dwh_dev | dwh_test | dwh_prod — never a literal in codefrom pyspark import pipelines as dp
from pyspark.sql import functions as F
CATALOG = spark.conf.get("dwh.catalog")
LANDING = f"/Volumes/{CATALOG}/bronze/landing/shop_order"
SCHEMA_LOC = f"/Volumes/{CATALOG}/bronze/_schemas/shop_order"
BATCH_ID = spark.conf.get("dwh.batch_id", "manual")
@dp.table(
name="shop_order",
comment="One row per webshop order document. Raw; technical columns only.",
)
def bronze_shop_order():
return (
spark.readStream.format("cloudFiles")
.option("cloudFiles.format", "json")
.option("cloudFiles.schemaLocation", SCHEMA_LOC)
.option("cloudFiles.schemaEvolutionMode", "addNewColumns")
.option("cloudFiles.inferColumnTypes", "true")
.option(
"cloudFiles.schemaHints",
"order_id STRING, order_ts TIMESTAMP, total_amount DECIMAL(18,2)",
)
.option("rescuedDataColumn", "_rescued_data")
.load(LANDING)
.select(
"*",
F.current_timestamp().alias("_tech_loaded_at"),
F.col("_metadata.file_path").alias("_tech_source_file"),
F.lit(BATCH_ID).alias("_tech_batch_id"),
)
)| schemaEvolutionMode | New column arrives | Use for |
|---|---|---|
| addNewColumns (default with a schema location) | fails once, records the column, succeeds on retry | bronze, where you want the column and can absorb a retry |
| rescue | never fails; the value lands in _rescued_data as JSON | a source adding fields weekly that nothing may block |
| failOnNewColumns | fails, and stays failed until someone changes the code | an interface contract you want broken loudly |
| none | the column is ignored, silently, forever | almost never — this is how you lose data with a green pipeline |
rescuedDataColumn on even with addNewColumns: evolution handles new columns, the rescue column also catches type mismatches and case collisions.Doing it
- Fix the Volume layout before the first file: dt= subfolders, one document per file, timestamp and sequence in the name.
- Take the catalog from pipeline configuration. A /Volumes/dwh_prod path left in a dev pipeline reads production files and writes them into a dev table, and nothing errors.
- Set schemaHints for every column downstream code names by hand. Inference is a sample, and samples lie.
- Keep _rescued_data, and alert on it being non-null rather than on it existing.
- Measure listing time weekly; move to file-event notification before file count becomes a problem.
Defaults
- One stream, one schema location, one checkpoint — never shared, never reused.
- addNewColumns in bronze; rescue only where blocking is unacceptable; never none.
- Land the raw document; parse in silver.
- maxFilesPerTrigger on the first backfill, so it does not size the cluster for two years.
Gotchas
- Sharing a schema location between two streams. The schemas merge, each stream starts seeing the other's columns, and unpicking it means a full re-ingest of both. It looks tidy when you write it.
- Deleting the checkpoint to 'reprocess'. Bronze is append-only, so every file is read again and every row exists twice. Nothing errors; the discovery is a stakeholder remarking that revenue looks unusually strong.
- schemaEvolutionMode = none. New fields are dropped silently and the pipeline stays green; the failure surfaces when someone asks for a field that has 'been in the feed for months'.
- Inferring types with no hints. A first batch where total_amount is always absent produces no such column at all, and the file that finally carries it restarts the stream mid-backfill.
#AUTO CDC — bronze.sap_vbak and bronze.sap_vbap
APPLY CHANGES INTO and dlt.apply_changes.from pyspark import pipelines as dp
from pyspark.sql import functions as F
@dp.temporary_view(name="vbak_typed")
def vbak_typed():
"""SAP names in, canonical names out. Renaming only — no logic."""
return spark.readStream.table("bronze.sap_vbak").selectExpr(
"VBELN AS order_id",
"KUNNR AS customer_id",
"ERDAT AS order_date",
"LOEKZ AS deletion_flag",
"change_type",
"change_seq",
)
dp.create_streaming_table(
name="sales_order",
comment="One row per order, current state (SCD1). Applied from SAP VBAK CDC.",
)
dp.create_auto_cdc_flow(
target="sales_order",
source="vbak_typed",
keys=["order_id"],
sequence_by=F.col("change_seq"),
apply_as_deletes=F.expr("change_type = 'D' OR deletion_flag = 'X'"),
except_column_list=["change_type", "change_seq", "deletion_flag"],
stored_as_scd_type=1,
)
# The item side is the same shape. Its source view, vbap_typed, is the one that
# carries logic (rename + currency conversion) and is shown in full under
# "The transform is a function" below. VBAP has no LOEKZ, so an item is deleted
# by a change record only — the deletion flag is a header concern.
dp.create_streaming_table(name="sales_order_line", comment="One row per order line (SCD1).")
dp.create_auto_cdc_flow(
target="sales_order_line",
source="vbap_typed",
keys=["order_id", "line_number"],
sequence_by=F.col("change_seq"),
apply_as_deletes=F.expr("change_type = 'D'"),
except_column_list=["change_type", "change_seq"],
stored_as_scd_type=1,
)sequence_by alone decides which of two changes to the same order wins, so it must increase strictly per key — the source's change counter or log sequence number. If the feed offers only a timestamp, sequence by a struct of (timestamp, counter), or make the source team add one.silver.sales_order to SCD2 later is stored_as_scd_type=2 plus a backfill, not a rewrite of merge logic eleven tables depend on.Doing it
- Confirm what the feed contains before designing: physical deletes, a deletion flag, or neither. With SAP it is usually a flag.
- Sequence by a monotonic counter. If the feed has none, that is a written requirement on the source team.
- Strip every control column with except_column_list so change_type never reaches silver.
- Keep header and line as two independent flows. The join belongs in gold, where the unknown member exists.
Defaults
- sequence_by a strictly increasing per-key value; never a bare timestamp.
- apply_as_deletes reads the source's real deletion signal, flag or record type.
- SCD1 in silver for orders; history belongs to the gold dimensions.
- ADVANCED edition, so CDC and expectations need no second decision.
Gotchas
- sequence_by on a second-granularity timestamp. Two changes in the same second apply in whatever order the engine chose that run, and re-running picks a different winner — non-determinism no test catches unless the test replays.
- Missing the deletion flag. SAP marks an order deleted rather than removing it, so apply_as_deletes reading only change_type = 'D' leaves deleted orders alive in silver and inside revenue forever. Rule 1 covers only CANCELLED.
- Joining VBAK to VBAP in silver. Lines arrive without their header often enough to matter: the inner join drops them, the outer join yields rows with no customer, and neither is an error.
- Assuming a full refresh replays CDC correctly. It replays whatever bronze still holds — if bronze was vacuumed or truncated, the rebuilt silver is missing changes and looks entirely healthy.
- changedDelta Live Tables is now Lakeflow pipelines, running Spark Declarative Pipelines. Existing DLT code keeps working with no migration; `import dlt` becomes `from pyspark import pipelines as dp`.Stand 29.07.2026
- verifiedPipeline product editions are CORE, PRO and ADVANCED. Expectations require ADVANCED. ADVANCED is the default.Stand 29.07.2026
#Snapshot comparison — bronze.sap_kna1, the hard case
silver.customer is the SCD2 built by comparing consecutive snapshots. This source carries a fourth technical column, _tech_extract_date, the extract's business date from the filename.from pyspark import pipelines as dp
from pyspark.sql import functions as F
dp.create_streaming_table(
name="customer",
comment="One row per customer per validity interval (SCD2), from snapshot comparison.",
)
def next_kna1_snapshot(latest_version):
"""(DataFrame, version) for the next unprocessed extract, or None when caught up."""
days = [
r._tech_extract_date
for r in spark.read.table("bronze.sap_kna1")
.select("_tech_extract_date")
.distinct()
.orderBy("_tech_extract_date")
.collect()
]
pending = [d for d in days if latest_version is None or d > latest_version]
if not pending:
return None
day = pending[0]
df = (
spark.read.table("bronze.sap_kna1")
.filter(F.col("_tech_extract_date") == day)
.selectExpr( # SAP names in, canonical names out
"KUNNR AS customer_id",
"NAME1 AS customer_name",
"STRAS AS street",
"PSTLZ AS postal_code",
"ORT01 AS city",
"LAND1 AS country",
"KUKLA AS customer_segment",
)
)
return df, day
dp.create_auto_cdc_from_snapshot_flow(
target="customer",
source=next_kna1_snapshot,
keys=["customer_id"],
stored_as_scd_type=2,
track_history_column_list=["customer_segment"],
)track_history_column_list is where business rule 4 lives: a change of customer_segment opens a new version, an address change updates in place. Without it every corrected postcode becomes an interval, and the three segment moves that mattered sit under nine hundred that did not.customer_id present yesterday and missing today was deleted in SAP. The snapshot flow applies that as a close-out; a hash-diff against current silver never sees it, because it only looks at rows that are present.silver.customer from today's file — discards every question beginning as of. Which segment was this customer in when the order was placed becomes unanswerable, and it is asked in month four by whoever owns the sales targets.Doing it
- Carry the extract's business date into bronze and order snapshots by it — never by _tech_loaded_at, which changes when you re-run.
- Take the SCD2 clock from the extract date. current_timestamp() makes a backfill rewrite history with today's date.
- Agree the tracked column list with the business once and write it into the concept. It is rule 4, not a setting.
- Quarantine an extract more than a few percent smaller than the previous one — do not process it, do not drop it.
Defaults
- Bronze keeps every extract in full — the only record of what the source said that day.
- Order snapshots by the extract's business date, never by load time.
- track_history_column_list encodes the business rule; everything else updates in place.
- Deletions close the interval; the row is never removed.
Gotchas
- Hash-diffing against current silver instead of against the previous snapshot. Deletions become invisible: a customer removed from SAP stays current forever, and the only symptom is a segment count that never goes down.
- Hashing all columns. A postcode correction across 3,000 rows creates 3,000 new intervals; the dimension grows without carrying information and every as-of join gets slower for nothing.
- Taking _tech_valid_from from current_timestamp(). Re-running a week of extracts on Friday stamps them all Friday, intervals overlap, and every as-of query returns duplicates. The clock is an input, not an ambient fact.
- Vacuuming bronze.sap_kna1 to save storage. This is the one pattern where bronze retention is a correctness requirement: silver cannot be rebuilt from snapshots you deleted.
Testable transforms, and where the gate goes
Two structural decisions that determine whether ingestion can be tested at all, and whether bad data can reach a dashboard. Neither is about ingestion technology, and both are lost by default.
#The transform is a function; the expectations are a dict
from pyspark.sql import DataFrame, functions as F
EXPECTATIONS = {
"line_key_present": "order_id IS NOT NULL AND line_number IS NOT NULL",
"known_currency": "currency IN ('EUR', 'CHF')",
"rate_resolved": "fx_rate IS NOT NULL",
"amount_positive": "amount_eur > 0 OR change_type = 'D'",
"order_not_ancient": "order_ts >= TIMESTAMP'2020-01-01 00:00:00'",
}
def to_silver_sales_order(lines: DataFrame, rates: DataFrame) -> DataFrame:
"""SAP item fields in, canonical names out, valued in EUR at the order date.
lines: bronze.sap_vbap change records.
rates: silver.fx_rate — (currency, rate_date, fx_rate), one row per day.
Business rule 2: amount_eur = ROUND(quantity * unit_price * fx_rate, 2).
"""
return (
lines.selectExpr(
"VBELN AS order_id",
"POSNR AS line_number",
"MATNR AS product_id",
"KWMENG AS quantity",
"NETWR / NULLIF(KWMENG, 0) AS unit_price",
"WAERK AS currency",
"CAST(ERDAT AS TIMESTAMP) AS order_ts",
"change_type",
"change_seq",
)
.withColumn("rate_date", F.to_date("order_ts"))
.join(rates, on=["currency", "rate_date"], how="left")
.withColumn(
"amount_eur",
F.round(F.col("quantity") * F.col("unit_price") * F.col("fx_rate"), 2),
)
.drop("rate_date")
)from pyspark import pipelines as dp
from dwh.pipelines.transforms.silver_sales_order import EXPECTATIONS, to_silver_sales_order
@dp.temporary_view(name="vbap_typed")
@dp.expect_all_or_drop(EXPECTATIONS)
def vbap_typed():
return to_silver_sales_order(
spark.readStream.table("bronze.sap_vbap"),
spark.read.table("silver.fx_rate"),
)vbap_typed view the AUTO CDC flow above consumes, and the placement is deliberate: expectations cannot be attached to an AUTO CDC target, only to the flow's source. That is also where you want them — a row dropped here never becomes a version in silver.sales_order_line, whereas a row dropped after the apply would leave a gap in the change sequence.rate_resolved earns its place: the rate join is a LEFT join, so a missing ECB rate produces fx_rate = NULL, then amount_eur = NULL, then a revenue total that is quietly short by one currency for one day. Nothing fails. amount_positive allows deletes through because a delete record carries no amount; rule 5 — positive amounts on non-cancelled lines — is completed in gold, where status exists.| Decorator | Violating row | Update | Use for |
|---|---|---|---|
| expect_all | kept | succeeds | anything you want measured before you dare enforce it |
| expect_all_or_drop | dropped, counted in the event log | succeeds | rows unusable downstream and safe to lose |
| expect_all_or_fail | written nowhere | fails immediately | invariants where publishing nothing beats publishing something wrong |
SELECT
e.timestamp,
x.name AS expectation,
x.dataset,
x.passed_records,
x.failed_records
FROM event_log(TABLE(dwh_prod.silver.sales_order_line)) AS e
LATERAL VIEW explode(
from_json(
e.details:flow_progress.data_quality.expectations,
'array<struct<name: string, dataset: string,
passed_records: bigint, failed_records: bigint>>'
)
) AS x
WHERE e.event_type = 'flow_progress'
AND e.timestamp > current_timestamp() - INTERVAL 1 DAY
ORDER BY x.failed_records DESC;Doing it
- Put every transform in transforms/ with no pipeline import. Mocking does not make a decorated module testable.
- Keep EXPECTATIONS beside the function producing the rows it constrains, and import it in both places.
- Start every new rule at warn; promote to drop or fail after watching failed-records for a week.
- Read the event log per run rather than the run status — a drop expectation is a success at job level.
Defaults
- transforms/ imports nothing from the pipeline API. Ever.
- EXPECTATIONS is a plain dict, imported by the pipeline and by the test.
- New rules land as warn; promotion is a decision with evidence behind it.
- Alerts read the event log; the run status is not a quality signal.
Gotchas
- expect_all_or_drop on a rule that is slightly too strict. Rows disappear, the pipeline is green, the number is quietly low — the most expensive expectation failure, because the system reports success while losing data.
- Testing the pipeline module instead of the transform. Importing it registers datasets and the test needs a pipeline context; the usual response is to mock the decorator, which tests the mock.
- Rules copied into the test file. They drift, always in the same direction: the test relaxes, because relaxing is what makes it pass.
- Reading only the run status. A drop expectation that removed 400,000 rows and a clean run are indistinguishable from the jobs UI.
- verifiedPipeline product editions are CORE, PRO and ADVANCED. Expectations require ADVANCED. ADVANCED is the default.Stand 29.07.2026
- changedDelta Live Tables is now Lakeflow pipelines, running Spark Declarative Pipelines. Existing DLT code keeps working with no migration; `import dlt` becomes `from pyspark import pipelines as dp`.Stand 29.07.2026
#Validation tables do not gate. A job does.
dwh_ingest bronze.* -> silver.* expectations live here
|
gate one SQL task; raise_error on breach, counts in the message
|
dwh_publish silver.* -> gold.* does not start if the gate failed-- owner: data-platform rota
-- tolerance: 0.5 %, agreed with sales ops 06.2026
WITH recent AS (
SELECT o.order_id,
c.customer_id IS NULL AS unresolved
FROM IDENTIFIER(:catalog || '.silver.sales_order') o
LEFT JOIN IDENTIFIER(:catalog || '.silver.customer') c
ON c.customer_id = o.customer_id
AND c._tech_is_current
WHERE o.order_date >= current_date() - INTERVAL 1 DAY
)
SELECT CASE
WHEN COUNT(*) = 0 THEN 'ok - no orders in window'
WHEN SUM(IF(unresolved, 1, 0)) / COUNT(*) <= 0.005 THEN 'ok'
ELSE raise_error(CONCAT(
'gate silver->gold: ',
CAST(SUM(IF(unresolved, 1, 0)) AS STRING), ' of ',
CAST(COUNT(*) AS STRING),
' orders resolve to no current customer'))
END AS gate
FROM recent;resources:
jobs:
dwh_daily:
name: dwh_daily
tasks:
- task_key: ingest
pipeline_task:
pipeline_id: ${resources.pipelines.dwh_ingest.id}
- task_key: gate
depends_on:
- task_key: ingest
sql_task:
warehouse_id: ${var.gate_warehouse_id}
parameters:
catalog: ${var.catalog}
file:
path: ../tests/sql/gate_silver_to_gold.sql
source: WORKSPACE
- task_key: publish
depends_on:
- task_key: gate
pipeline_task:
pipeline_id: ${resources.pipelines.dwh_publish.id}Doing it
- Two pipelines, one job: dwh_ingest ends at silver, dwh_publish starts at silver.
- Write gates as SQL files in the repo, one assertion per file, and let a breach call raise_error so the task fails with both counts in the message.
- Parameterise the catalog. A gate file with dwh_prod baked in passes the test deployment by reading production.
- Give every gate a tolerance and a named owner in a comment at the top. A gate with neither gets disabled the third time it fires.
- Decide per gate whether failing means stop or publish-and-alert, and put the decision in the runbook.
Defaults
- Two pipelines with a job between them; the gate is a task, never a table.
- A breach raises; the message carries both counts, so the alert already says how bad it is.
- Tolerances are business numbers, agreed and written down.
- One alert per chain, routed to a rota — see the monitoring route.
Gotchas
- A validation table inside the publishing pipeline. It computes, it goes green, and gold publishes regardless, because nothing downstream reads it. The team believes there is a gate for months; there has never been one.
- expect_all_or_fail used as the gate. One malformed row from one source stops the entire update including the eleven tables that were fine — and at 03:00 nobody consciously chose to trade a wrong number for no number.
- Forcing a dependency edge so gold 'waits' for validation. Lineage then shows fct_order_line derived from a quality metric, and the next person to read the DAG is misled about where the numbers come from.
- A gate with no tolerance. It fires on three rows, someone re-runs the job with the check commented out, and the commented line is still there in December.
Straight to the documentation
The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.
- What happened to Delta Live Tables? — the rename map — read before copying any DLT-era snippet, because @table changed meaning
- Auto Loader schema inference and evolution — the evolution modes, and precisely what each does with a column it has not seen
- AUTO CDC APIs in Lakeflow pipelines — the parameter list — sequence_by, apply_as_deletes, track_history_column_list — before anyone hand-rolls a MERGE
- Manage data quality with pipeline expectations — warn / drop / fail semantics and the portable-rules pattern this route builds on
- Pipeline event log — the schema behind flow_progress.data_quality — needed to build the expectation dashboard