from pyspark.sql import functions as F
catalog = spark.sql("SELECT current_catalog()").first()[0]
spark.sql(f"USE SCHEMA {catalog}.dbx_course")
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {catalog}.dbx_pipeline")
landing = f"/Volumes/{catalog}/dbx_course/raw/pipeline_landing/orders"
def land(batch, lo, hi):
(spark.table("orders").filter(F.col("order_id").between(lo, hi))
.coalesce(1).write.mode("overwrite").json(f"{landing}/batch_{batch}"))
land(1, 1, 1000)
print(landing)
# a small change feed for customers (used in topic 9)
spark.sql("""
CREATE OR REPLACE TABLE customers_feed AS
SELECT customer_id, name, country, 'INSERT' AS operation, 1 AS seq FROM customers
""")
Module 9: Lakeflow Pipelines
Declarative pipelines with streaming tables, materialized views and expectations.
Topics in this module
- Introduction to Lakeflow
- Spark Declarative Pipelines
- Lakeflow Pipelines Editor
- Streaming Tables
- Materialized Views
- Declarative Transformations
- Pipeline Dependencies and Flows
- Data Quality Expectations
- CDC Processing with Lakeflow
- Incremental Refresh
- Pipeline Parameters
- Pipeline Monitoring and Event Logs
- Pipeline Recovery and Troubleshooting
Declare the tables, let Databricks run the pipeline. You write what each table should contain. Lakeflow works out order, incremental processing, retries and monitoring.
- Product name: Lakeflow Spark Declarative Pipelines (earlier "Delta Live Tables", DLT). Code uses
from pyspark import pipelines as dp - Pipeline code is files in a pipeline, not a notebook you run cell by cell. The sections below show the file contents
- Free Edition allows one active pipeline at a time. Reuse a single pipeline and add files to it
- Topics 4–9 build one pipeline: add each snippet to a file in
transformations, then run the whole pipeline (datasets can be defined in any order) - Run this setup cell in a normal notebook first: it lands two batches of JSON files and a small CDC feed
Introduction to Lakeflow
By the end: say what Lakeflow pipelines are and when to use one.
- A pipeline = a set of datasets (tables, views) plus the queries that fill them
- It handles order (from dependencies), parallelism, retries, checkpoints and incremental processing
- Writes managed tables in Unity Catalog. Runs on serverless compute by default
| Use a pipeline when | Use a plain job when |
|---|---|
| Layered ETL (bronze, silver, gold) | One-off or very custom logic |
| You want built-in quality rules and CDC | You call external systems and APIs a lot |
| Incremental load without writing checkpoint code | You need full control of every write |
Names
Older material says DLT / Delta Live Tables. Same product line, now Lakeflow Spark Declarative Pipelines. Python moved from import dlt to from pyspark import pipelines as dp.
Spark Declarative Pipelines
By the end: list the dataset types and what each one is for.
| Streaming table | Materialized view | |
|---|---|---|
| Source | stream (new rows only) | batch query (whole result) |
| Updated by | processing new data | refresh (incremental when possible) |
| Python | @dp.table |
@dp.materialized_view |
| SQL | CREATE STREAMING TABLE |
CREATE MATERIALIZED VIEW |
- Flow = one query that writes into a table. A table has a default flow
- Sink = a streaming target outside the pipeline (Delta, Kafka, Event Hubs)
- Each dataset is defined once in a workspace. Exception: streaming tables can take several append flows (topic 7)
Remember
Streaming table for new data coming in. Materialized view for a result derived from other tables.
Lakeflow Pipelines Editor
By the end: create a pipeline in the Lakeflow Pipelines Editor and run it.
Create it:
- New → ETL pipeline
- Name it
dbx_orders. Default catalog: your catalog. Default schema:dbx_pipeline - Choose Python (or SQL) for the starter file in
transformations - Leave compute as serverless (the default)
| Folder | Holds |
|---|---|
transformations |
Source files that define tables |
explorations |
Notebooks and queries. Not run by the pipeline |
utilities |
Python modules you can import |
- Editor parts: file browser, code editor, pipeline graph, data preview, issues, event log
- Dry run checks code and names without writing data
- Run file updates only the tables in the open file. Run pipeline updates everything
- Run the whole pipeline before you call it done: Run file can hide errors in other files
Develop with dry run
Dry run first, run pipeline second. Errors in names and columns show up without touching data.
Streaming Tables
By the end: define a streaming table that ingests files with Auto Loader.
Put this in transformations/orders.py:
from pyspark import pipelines as dp
from pyspark.sql import functions as F
@dp.table(comment="Raw orders as received")
def orders_bronze():
return (
spark.readStream.format("cloudFiles")
.option("cloudFiles.format", "json")
.option("cloudFiles.inferColumnTypes", "true")
.option("pathGlobFilter", "*.json")
.load(spark.conf.get("course.landing"))
.withColumn("_ingested_at", F.current_timestamp())
)
SQL equivalent in transformations/orders.sql:
CREATE OR REFRESH STREAMING TABLE orders_bronze
COMMENT 'Raw orders as received'
AS SELECT *, current_timestamp() AS _ingested_at
FROM STREAM read_files('${course.landing}', format => 'json');
- The function returns a streaming DataFrame. Don't call
collect(),count(),saveAsTable()ortoTable()inside it - The table name is the function name (or
name=) - Schema and checkpoints are managed for you: no
schemaLocationorcheckpointLocation - The path comes from a pipeline parameter,
course.landing(topic 11) @dp.table(...)options:comment,cluster_by,partition_cols,schema,table_properties
Dataset code runs more than once
The pipeline may evaluate your function several times. Keep it to the query. No writes, prints with side effects, or counting.
Materialized Views
By the end: define a materialized view that stays up to date.
@dp.materialized_view(comment="Revenue per day, valid completed or shipped orders")
def gold_daily_revenue():
o = spark.read.table("orders_silver").filter("status IN ('completed', 'shipped')")
p = spark.read.table("dbx_course.products")
return (
o.join(p, "product_id")
.groupBy("order_date")
.agg(F.count("*").alias("orders"),
F.round(F.sum(F.col("quantity") * F.col("unit_price")), 2).alias("revenue"))
)
CREATE MATERIALIZED VIEW gold_daily_revenue AS
SELECT order_date, COUNT(*) AS orders, ROUND(SUM(quantity * unit_price), 2) AS revenue
FROM orders_silver o JOIN dbx_course.products p USING (product_id)
WHERE status IN ('completed', 'shipped')
GROUP BY order_date;
- Written as a batch query:
spark.read.table, notreadStream - Each update brings it in line with the sources, incrementally if Databricks can
- Table names without a schema resolve to the pipeline's default catalog and schema
- Other tables need a schema (
dbx_course.products) refresh_policy:auto(default),incremental,incremental_strict,full
Aggregates are materialized views
Streaming tables for append-only layers, materialized views for joins and aggregates. They recompute correctly when data is corrected.
Declarative Transformations
By the end: describe the code as declarations and see the pipeline graph.
- You write what: a dataset and the query that defines it
- You don't write how: order, checkpoints, retries, upserts
- Read another dataset by name. That one line creates a dependency edge
@dp.table
def orders_silver():
return (
spark.readStream.table("orders_bronze") # reads the table above
.withColumn("status", F.lower(F.trim("status")))
.withColumn("order_date", F.to_date("order_ts"))
.dropDuplicates(["order_id"])
)
- Streaming reads use
spark.readStream.table(...). Batch reads usespark.read.table(...) - Streaming
dropDuplicateswithout a watermark keeps state for every key. Fine at this size, costly at scale (Module 11) - Use views for steps you don't want to publish (below)
- No
pivot()(needs the full data up front). Use conditional aggregation
Pipeline Dependencies and Flows
By the end: wire datasets with views, private tables and multiple append flows.
@dp.view # not stored, only inside the pipeline
def valid_orders():
return spark.readStream.table("orders_bronze").filter("quantity > 0")
@dp.table(private=True) # stored, but not published to the catalog
def orders_enriched():
return spark.readStream.table("valid_orders")
Several sources into one table:
dp.create_streaming_table("all_orders")
@dp.append_flow(target="all_orders", name="web")
def from_web():
return spark.readStream.table("web_orders")
@dp.append_flow(target="all_orders", name="shop")
def from_shop():
return spark.readStream.table("shop_orders")
@dp.append_flow(target="all_orders", name="history_backfill", once=True)
def backfill():
return spark.read.table("old_orders") # batch, loaded one time
- A flow reads a source, applies logic, writes to a target
- Append flows add new sources without a
UNION once=Trueloads history one time (batch source). It reruns only on a full refresh- The pipeline runs independent flows in parallel
Data Quality Expectations
By the end: add data quality expectations and choose what happens on failure.
@dp.table
@dp.expect("has_customer", "customer_id IS NOT NULL") # keep row, log it
@dp.expect_or_drop("positive_qty", "quantity > 0") # drop row, log it
@dp.expect_or_fail("has_order_id", "order_id IS NOT NULL") # stop the update
def orders_clean():
return spark.readStream.table("orders_silver")
CREATE OR REFRESH STREAMING TABLE orders_clean (
CONSTRAINT has_customer EXPECT (customer_id IS NOT NULL),
CONSTRAINT positive_qty EXPECT (quantity > 0) ON VIOLATION DROP ROW,
CONSTRAINT has_order_id EXPECT (order_id IS NOT NULL) ON VIOLATION FAIL UPDATE
) AS SELECT * FROM STREAM orders_silver;
| Decorator | On violation |
|---|---|
expect |
Keep the row, record the count |
expect_or_drop |
Drop the row, record the count |
expect_or_fail |
Stop the update |
expect_all, expect_all_or_drop, expect_all_or_fail |
Same, with a dict of rules |
rules = {"has_customer": "customer_id IS NOT NULL", "has_ts": "order_ts IS NOT NULL"}
@dp.table
@dp.expect_all_or_drop(rules)
def orders_checked():
return spark.readStream.table("orders_silver")
- Decorators go after
@dp.tableand above the function - Descriptions must be unique per dataset
- Counts of passed, failed and dropped rows go to the event log (topic 12)
- Dropped rows vanish. To keep them, route failures to a quarantine table with the inverse condition
Start with warn, then tighten
Begin with expect to see failure rates. Move to drop or fail when you trust the rule.
CDC Processing with Lakeflow
By the end: apply a change feed to a table with AUTO CDC, as SCD 1 or SCD 2.
@dp.view
def customer_changes():
return spark.readStream.table("dbx_course.customers_feed")
dp.create_streaming_table("customers_current")
dp.create_auto_cdc_flow(
target="customers_current",
source="customer_changes",
keys=["customer_id"],
sequence_by="seq",
apply_as_deletes=F.expr("operation = 'DELETE'"),
except_column_list=["operation", "seq"],
stored_as_scd_type=1,
)
CREATE OR REFRESH STREAMING TABLE customers_history;
CREATE FLOW customers_cdc AS AUTO CDC INTO customers_history
FROM STREAM(dbx_course.customers_feed)
KEYS (customer_id)
APPLY AS DELETE WHEN operation = 'DELETE'
SEQUENCE BY seq
COLUMNS * EXCEPT (operation, seq)
STORED AS SCD TYPE 2;
| SCD type 1 | SCD type 2 | |
|---|---|---|
| Result | Latest row per key | Every version |
| Extra columns | none | __START_AT, __END_AT |
| Current row | all rows | __END_AT IS NULL |
| Deletes | row removed | version ended |
- Add changes by inserting rows into
customers_feedwith a higherseq, then update the pipeline sequence_byhandles late and out-of-order events: the highest sequence wins- The source must be a stream. The target is a streaming table created first
- This replaces the hand-written SCD 2
MERGEfrom Module 8.10 apply_changes()is the older name forcreate_auto_cdc_flow()
Free Edition
Run this as part of the one allowed pipeline. Add the file, do a dry run, then update.
Incremental Refresh
By the end: understand when updates are incremental and when they are full.
- Normal refresh: streaming tables process new records. Materialized views use an incremental refresh when cheaper
- Full refresh: streaming tables clear data and checkpoints and reprocess the source. Source files that no longer exist are lost
- Incremental refresh of materialized views needs serverless compute
- Techniques seen in the event log:
ROW_BASED,GROUP_AGGREGATE,WINDOW_FUNCTION,APPEND_ONLY,PARTITION_OVERWRITE,FULL_RECOMPUTE,NO_OP - Float
SUM/AVGcan force full recompute: cast toDECIMAL WITH RECURSIVEand non-deterministic functions (except time functions inWHERE) fall back to full
-- which technique was used?
SELECT timestamp, message
FROM event_log(TABLE(<catalog>.dbx_pipeline.gold_daily_revenue))
WHERE event_type = 'planning_information'
ORDER BY timestamp DESC
Full refresh on a streaming table
It reprocesses from the source. If the source has aged out (old files, expired Kafka data), the rows are gone for good. Protect tables with pipelines.reset.allowed = false.
Pipeline Parameters
By the end: pass values into a pipeline with parameters and configuration.
Set it in pipeline Settings → Configuration:
| Key | Value |
|---|---|
course.landing |
/Volumes/<catalog>/dbx_course/raw/pipeline_landing/orders |
course.min_qty |
1 |
Read it:
min_qty = int(spark.conf.get("course.min_qty")) # always a string
@dp.table
def orders_valid():
return spark.readStream.table("orders_bronze").filter(F.col("quantity") >= min_qty)
SELECT * FROM STREAM orders_bronze WHERE quantity >= ${course.min_qty};
- Configuration keys are read with
spark.conf.get("key")in Python and${key}in SQL - Values are always strings: cast them
- Use dotted names (
course.min_qty) so they don't clash with Spark settings - Newer pipeline parameters are set per update and used with
:namein SQL only, e.g.USE CATALOG IDENTIFIER(:source_catalog) - Per-environment values (dev, prod) belong here: same code, different configuration
No hard-coded paths
Paths, catalogs and thresholds are configuration. Then one pipeline definition serves every environment.
Pipeline Monitoring and Event Logs
By the end: monitor an update and query the event log.
- The pipeline page shows each update, its graph, row counts and data quality numbers
- Click a table in the graph for flow metrics, expectation results and the schema
- The event log is a Delta table with every event of every update
-- by pipeline id (copy it from the pipeline settings)
SELECT timestamp, event_type, message
FROM event_log('<pipeline-id>')
ORDER BY timestamp DESC
LIMIT 20
event_type |
What it tells you |
|---|---|
create_update, update_progress |
Update started, state changes |
flow_progress |
Per-flow metrics, data quality counts |
flow_definition |
Which datasets feed which (lineage) |
planning_information |
Refresh technique used |
user_action |
Who created or started the pipeline |
- Expectation results are in
details:flow_progress.data_quality.expectations:passed_records,failed_records;dropped_recordsis next to it - By default the log is a hidden table, readable by the run-as user. Publish it to a named table in Advanced settings → event log
- Put a view over the published log before granting access to others
Don't delete the log
Deleting the event log table or its schema can make later updates fail.
Pipeline Recovery and Troubleshooting
By the end: recover from a failed update and find the cause.
- Read the issues panel and the event log error first: it names the failing flow and the cause
- Refresh failed tables reruns the failed tables and what depends on them
- Select tables for refresh reruns a chosen subset. Excluded tables aren't refreshed, even if downstream
- Full refresh selection reprocesses data for chosen tables
- Validate (dry run) finds name and column errors without writing data
| Symptom | Likely cause |
|---|---|
| Table not found | Name resolved in the wrong default catalog or schema |
| Dataset defined twice | Same table name in two files or pipelines |
expect_or_fail stopped the update |
A rule failed. Check data quality in the log |
| Schema change error from a source | Auto Loader evolution (Module 7.7). Rerun or full refresh |
| Works in Run file, fails in Run pipeline | Another file has an error |
- Development runs (UI) reuse compute and don't retry, so errors show fast
- Job-triggered runs retry and restart for recoverable errors: use them for production (Module 10)
Fix forward
Fix the code, then refresh only what failed. Use full refresh as the exception.
What you can do now
- Pipelines, dataset types, the editor and the dependency graph
- Streaming tables with Auto Loader, materialized views and flows
- Expectations, AUTO CDC (SCD 1 and 2) and incremental refresh
- Parameters, event log monitoring and recovery