Build a dimension from periodic snapshots¶
Some sources never hand you a change feed. A partner drops a full CSV export of every customer each night; an upstream job overwrites a bronze table with the current state on a schedule. You get snapshots — the whole table, over and over — and it is your job to turn that sequence of full copies into a dimension that remembers how each customer changed.
You could hand-write it: a create_streaming_table, then a
create_auto_cdc_from_snapshot_flow call, then the loop that hands successive
snapshot versions to the engine, then the functools.partial that feeds it
your parameters. Or you declare mode: snapshot_cdc on one write action
and let Lakehouse Plumber write that Lakeflow for you. That is the idea on every
page: declare your ETL, don’t hand-write it.
That is also the difference from change-feed CDC. When your source already emits
row-level change events with a sequence column, you use cdc mode and
create_auto_cdc_flow — the change-feed CDC guide covers that; snapshot CDC is
for when all you get is a periodic full dump and the engine must derive the
inserts, updates, and deletes by comparing each snapshot to the one before it.
Let’s build a silver dim_customer from periodic customer_snapshots,
keeping full SCD Type 2 history, without touching the apply-changes API.
Before you start¶
You need a Lakehouse Plumber project (see the Get Started course for
lhp init) and a bronze customer_snapshots table that carries a
snapshot_id column stamping each full copy with its version. Snapshot CDC
reads that source directly — it does not create it, and this flowgroup needs no
load action to feed it.
Declare the snapshot-CDC write¶
A snapshot-CDC dimension is a single write action: a streaming_table
target with mode: snapshot_cdc and a snapshot_cdc_config block. Create
pipelines/silver_dim_customer.yaml:
# A single snapshot-CDC write. No load action: the source function reads the
# snapshot table directly and hands back (DataFrame, version) pairs, so the
# flowgroup is self-contained.
pipeline: silver_dim
flowgroup: dim_customer
actions:
- name: write_dim_customer
type: write
write_target:
type: streaming_table
catalog: "${catalog}"
schema: "${silver_schema}"
table: dim_customer
mode: snapshot_cdc
snapshot_cdc_config:
source_function:
file: functions/customer_snapshot.py
function: next_customer_snapshot
parameters: # resolved from substitutions, bound as kwargs
snapshot_table: "${catalog}.${bronze_schema}.customer_snapshots"
keys: [customer_id]
stored_as_scd_type: 2
track_history_column_list: [name, address, tier]
The emphasized line does the work. mode: snapshot_cdc selects the entire
generated shape — a create_auto_cdc_from_snapshot_flow instead of the
append_flow a standard streaming table gets. The snapshot_cdc_config
carries the rest: keys names the primary key the engine matches rows on,
stored_as_scd_type: 2 keeps full history (a new row per change, with validity
windows the engine maintains), and track_history_column_list narrows which
columns open a new history row — a changed name, address, or tier
starts a new version; changes to any other column update the current row in
place.
The source is a source_function rather than an action-level source,
which is what lets this flowgroup stand alone with no load. The function
returns the snapshots one version at a time; its parameters are bound to it
at generate time. The write action reference documents the alternative — a plain
source: table reference — along with SCD Type 1 and the other tuning knobs.
Note
The snapshot function runs outside Lakeflow’s decorated context — the engine
calls it as a plain callable. It therefore cannot read a view or table built
by another action in this pipeline; it must read its source directly, which
is exactly why no load action feeds it. Point it at a real table or path.
Write the snapshot function¶
source_function points at a Python file and a function name. The function
takes the last version the engine processed and returns the next snapshot as a
(DataFrame, version) pair — or None when there are no newer snapshots
left. Create functions/customer_snapshot.py:
from typing import Optional, Tuple
from pyspark.sql import DataFrame
def next_customer_snapshot(
latest_version: Optional[int],
*,
snapshot_table: str,
) -> Optional[Tuple[DataFrame, int]]:
"""Return the next customer snapshot and its version, or None when exhausted."""
if latest_version is None:
query = f"SELECT min(snapshot_id) AS v FROM {snapshot_table}"
else:
query = (
f"SELECT min(snapshot_id) AS v FROM {snapshot_table} "
f"WHERE snapshot_id > {latest_version}"
)
nxt = spark.sql(query).collect()[0].v
if nxt is None:
return None
df = spark.sql(f"SELECT * FROM {snapshot_table} WHERE snapshot_id = {nxt}").drop(
"snapshot_id"
)
return (df, int(nxt))
On the first run latest_version is None, so the function returns the
oldest snapshot; on each later run it returns the next snapshot_id greater
than the last one processed, and None once it has caught up. The engine walks
that sequence in order, diffing each full snapshot against the running state to
derive the SCD changes. The snapshot_table name is a parameter, not
hard-coded — so the same function is reusable across catalogs and testable on
its own.
Point the tokens at your environment¶
The ${...} tokens resolve from a per-environment substitutions file. Create
substitutions/dev.yaml:
# Development environment tokens.
# Referenced from flowgroups with ${token} syntax.
dev:
catalog: dev_catalog
bronze_schema: bronze
silver_schema: silver
This is the only place environment-specific values live. The
snapshot_table parameter composes them — ${catalog}.${bronze_schema}.customer_snapshots
— and LHP resolves the whole string before binding it to the function. To run
against production later, you write a prod.yaml; the flowgroup and the
function never change.
Generate the pipeline¶
Validate first, then generate:
$ lhp validate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ silver_dim 0 files
✓ validate (0.37s)
1 validated · 0.4s
$ lhp generate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ silver_dim 1 file
✓ generate (0.34s)
✓ format (0.05s)
✓ monitoring (0.00s)
1 pipeline generated · 1 file · 0.4s
validate resolves every token and checks the snapshot-CDC config before you
commit to generating; generate writes the Python.
Read what Lakehouse Plumber wrote¶
Open generated/dev/silver_dim/dim_customer.py. This is the entire output —
nothing is hidden behind a runtime:
# Generated by LakehousePlumber
# Pipeline: silver_dim
# FlowGroup: dim_customer
from functools import partial
from pyspark import pipelines as dp
import custom_python_functions.customer_snapshot as _snap_customer_snapshot
_snap_customer_snapshot.dbutils = dbutils
_snap_customer_snapshot.spark = spark
# Pipeline Configuration
PIPELINE_ID = "silver_dim"
FLOWGROUP_ID = "dim_customer"
# ============================================================================
# TARGET TABLES
# ============================================================================
# Create the streaming table for snapshot CDC
dp.create_streaming_table(
name="dev_catalog.silver.dim_customer", comment="Streaming table: dim_customer"
)
# Snapshot CDC mode using create_auto_cdc_from_snapshot_flow
dp.create_auto_cdc_from_snapshot_flow(
target="dev_catalog.silver.dim_customer",
source=partial(
_snap_customer_snapshot.next_customer_snapshot,
snapshot_table="dev_catalog.bronze.customer_snapshots",
),
keys=["customer_id"],
stored_as_scd_type=2,
track_history_column_list=["name", "address", "tier"],
)
LHP emitted a create_streaming_table for the dimension, then the
create_auto_cdc_from_snapshot_flow that drives it: your keys,
stored_as_scd_type, and track_history_column_list passed straight
through, and your function wrapped in a functools.partial with
snapshot_table already resolved to dev_catalog.bronze.customer_snapshots.
It also copied your function into custom_python_functions/, imported it under
an alias, and injected spark and dbutils into its namespace so the plain
callable resolves those names against the pipeline’s session at run time. You
described which snapshots, keyed how, tracked to what depth; LHP wrote the
apply-changes-from-snapshot declaration.
What you just did¶
Twenty-four lines of YAML — plus a twenty-five-line snapshot function that is
genuinely yours, the versioning logic no framework can guess — compiled to a
thirty-six-line Lakeflow module. Every line of the apply-changes-from-snapshot
wiring came from LHP, not from you: the create_streaming_table, the
create_auto_cdc_from_snapshot_flow call, the functools.partial binding,
the module import, and the spark/dbutils injection. Change keys or the
tracked columns and regenerate, and LHP rewrites the declaration to match — you
never touch the generated Python.
What’s next¶
Supply snapshots from a table instead of a function. When the current state already lives in one Delta table, a plain
source:reference replacessource_function. The write action reference documents both forms.Switch to SCD Type 1. Set
stored_as_scd_type: 1to overwrite each row in place instead of keeping history — one field, same flow.Compare with change-feed CDC. When your source emits row-level change events with a sequence column,
cdcmode andcreate_auto_cdc_flowfit better than snapshots. See the change-feed CDC guide.