On this page
Replace all rows that match a grouping key¶
Some sources don’t emit row-level change events. Instead, whenever something about an entity changes, they resend the entity’s complete current set of rows. For example, when a subset of records in a source file are changed and the whole file is reingested. Or when an order-management system re-exports every line of an order the moment any line is added, repriced, or removed - you want the target table to hold, for each order, exactly the lines the source most recently sent, swapping the old set for the new one, and leaving orders that weren’t included in the latest partial snapshot alone.
That is a replace, not a merge, and it is the one thing AUTO CDC can’t do.
Replace, not merge¶
mode: replace generates a Databricks REPLACE USING flow. For every key
value present in an incoming batch, it deletes the target rows for that key and
writes the batch’s rows for that key in their place. The crucial part:
replace_using is a grouping key, not a primary key. It does not have to
be unique, one key can own many rows, and REPLACE USING keeps every row the
source sent for that key; it never collapses them into one.
That is different from mode: cdc (AUTO CDC), which merges a stream of
insert/update/delete events onto a unique primary key and resolves all the
changes for a key into a single current row (or a versioned SCD Type 2
history). Same streaming-table target, fundamentally different operation:
Aspect |
|
|
|---|---|---|
Operation |
Replace a key’s rows as a whole set |
Merge change events onto a key |
Key |
Grouping key — need not be unique |
Primary key — one logical row per key |
Rows per key |
Many, kept exactly as sent (appended, not combined) |
One current row (SCD 1) or a versioned history (SCD 2) |
Several source rows for one key |
All are written to the target |
Collapsed / merged into a single row |
Source shape |
Partial snapshots — all rows for each key the batch carries (changed or not) |
Change deltas (explicit insert / update / delete) |
Removing rows |
Implicit — a row missing from a key’s new set is deleted; absent keys are untouched |
Explicit only — needs a delete event ( |
A worked example makes the difference concrete — watch the line that gets
removed. Order 42 has three lines in the target. Upstream, one line’s
quantity changes, one new line is added, and one line is deleted, so the
source resends order 42 with its complete current set (a newer
updated_at):
BEFORE — target rows for order 42
order_id product qty
42 widget 2
42 gadget 5
42 bolt 10
BATCH — the complete current set the source resends (updated_at = 2024-06-02)
order_id product qty
42 widget 3 # qty changed
42 bolt 10 # unchanged
42 cable 1 # new line
# gadget is absent — it was deleted upstream
AFTER — target rows for order 42, via REPLACE USING (replace_using=[order_id])
order_id product qty
42 widget 3
42 bolt 10
42 cable 1
# the whole set was replaced; gadget is gone — deleted with no delete event,
# purely by being absent from the batch
Three things happened in one step: widget was updated, cable inserted,
and gadget deleted, and the delete is the part an upsert can’t do. AUTO
CDC applies only the rows a change feed carries: hand it the same batch and it
would upsert widget and cable but never learn that gadget was
removed, so the stale line would linger until the source emitted an explicit
delete event (apply_as_deletes). Even with a unique per-line key, AUTO CDC
can’t infer a delete from an absence. REPLACE USING can, because a partial
snapshot states the whole truth for its keys — so a row that drops out of the
set drops out of the table.
Reach for mode: replace when the source resends complete sets keyed by a
grouping column; reach for mode: cdc when it emits per-row changes against a
real primary key.
Note
REPLACE USING maintains only the current state of each key; the target
always reflects the latest snapshot. It cannot keep a versioned SCD Type 2
history (validity windows, superseded rows preserved over time). When you need
history, use mode: cdc with scd_type: 2.
Before you start¶
You need a Lakehouse Plumber project (see the Get Started course for
lhp init) and a streaming source of partial snapshots. This guide reads
a bronze order_line_updates table that another pipeline lands — each batch
carries the full set of current lines for the order_id values it contains
(whether or not they changed), plus an updated_at that orders competing
batches for an order.
Two rules are worth knowing before you write it. The source must be a streaming
read (readMode: stream); a batch read is rejected. And the target must be
served by this single replace flow: it cannot share its table with any other
write action, so Lakehouse Plumber always creates the table for you
(create_table is forced true).
Declare the replace write¶
The flowgroup has two actions: a load that streams the snapshots into a
view, and a write whose write_target is a streaming_table in
mode: replace. Create pipelines/order_lines_current.yaml:
# Keep a current-state order-lines table from periodic partial snapshots.
# Action chain: load (delta, streaming read of order-line snapshots) -> write (streaming_table, mode: replace).
#
# Each order has many line rows. Whenever an order changes upstream, the source
# resends the COMPLETE current set of lines for that order_id. REPLACE USING
# swaps out every existing line for the order_ids in the batch and appends the
# incoming lines as-is — order_id is a grouping key, not a unique row key, so an
# order keeps as many line rows as the source sent. Nothing is merged.
pipeline: order_lines_current
flowgroup: order_lines_current
actions:
# 1. Load: stream the bronze order-line snapshots another pipeline lands. Each
# batch carries the full set of current lines for the order_ids it contains.
- name: load_order_line_updates
type: load
readMode: stream
source:
type: delta
catalog: "${catalog}"
schema: "${bronze_schema}"
table: order_line_updates
target: v_order_line_updates
# 2. Write: replace each order's line rows as a whole set, keyed by order_id.
- name: write_order_lines_current
type: write
source: v_order_line_updates
write_target:
type: streaming_table
catalog: "${catalog}"
schema: "${silver_schema}"
table: order_lines_current
mode: replace
replace_config:
replace_using: [order_id]
sequence_by: updated_at
mode: replace is the switch; replace_config is the whole contract, and
both fields map straight to the Databricks REPLACE USING parameters:
replace_using— the grouping key columns. Every row in a batch that carries a given key replaces that key’s rows in the target; keys not present in the batch are left untouched. Here a singleorder_id, but it need not be unique — an order owns many line rows and they are all kept.sequence_by— a single column that orders competing batches for a key, so a late-arriving stale batch never overwrites a newer one; the highest value wins. Unlikecdc_config, which also accepts a list, REPLACE USING takes exactly one sequence column.
The load reads with readMode: stream, so each new batch of snapshots
flows through continuously. The ${...} tokens resolve per environment from
substitutions/dev.yaml, so the same flowgroup targets dev, staging, and prod
unchanged.
Generate the pipeline¶
Validate first, then generate:
$ lhp validate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ order_lines_current 0 files
✓ validate (0.29s)
1 validated · 0.3s
$ lhp generate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ order_lines_current 1 file
✓ generate (0.29s)
✓ format (0.01s)
✓ monitoring (0.00s)
1 pipeline generated · 1 file · 0.3s
validate resolves the tokens and checks the replace_config — that
replace_using is a non-empty list and sequence_by is a single column —
and that the source is a streaming read, before you commit to generating.
Read what Lakehouse Plumber wrote¶
Open generated/dev/order_lines_current/order_lines_current.py. This is the
entire output — nothing is hidden behind a runtime:
# Generated by LakehousePlumber
# Pipeline: order_lines_current
# FlowGroup: order_lines_current
from pyspark import pipelines as dp
# Pipeline Configuration
PIPELINE_ID = "order_lines_current"
FLOWGROUP_ID = "order_lines_current"
# ============================================================================
# SOURCE VIEWS
# ============================================================================
@dp.temporary_view()
def v_order_line_updates():
"""Delta source: dev_catalog.bronze.order_line_updates"""
df = spark.readStream.table("dev_catalog.bronze.order_line_updates")
return df
# ============================================================================
# TARGET TABLES
# ============================================================================
# Create the streaming table for the replace-using flow
dp.create_streaming_table(
name="dev_catalog.silver.order_lines_current",
comment="Streaming table: order_lines_current",
)
# Replace-using flow: f_order_lines_current
@dp.replace_flow(
target="dev_catalog.silver.order_lines_current",
name="f_order_lines_current",
replace_using=["order_id"],
sequence_by="updated_at",
comment="Replace-using flow to dev_catalog.silver.order_lines_current",
)
def f_order_lines_current():
"""Replace-using flow to dev_catalog.silver.order_lines_current"""
# Streaming flow (REPLACE USING requires a streaming source)
df = spark.readStream.table("v_order_line_updates")
return df
There is no MERGE and no foreachBatch — and, unlike AUTO CDC, no
apply-changes call either. LHP wrote two things: a dp.create_streaming_table
that declares the target, and one @dp.replace_flow that replaces each key’s
rows into it. Both fields of your replace_config came through as arguments —
replace_using=["order_id"] and sequence_by="updated_at" — and the
${...} tokens resolved into the three-part name
dev_catalog.silver.order_lines_current. The flow reads spark.readStream,
because REPLACE USING requires a streaming source.
Note
@dp.replace_flow is Databricks’ REPLACE USING flow. Lakehouse Plumber
decides how the flow is wired and declared; Databricks performs the
per-key replacement at run time. Because the target is served by this single
flow, LHP always emits the dp.create_streaming_table alongside it — you
cannot add a second flow to this table.
What you just did¶
A short replace_config block compiled to a dp.create_streaming_table
plus a @dp.replace_flow, and zero lines of set-replacement logic came
from you: not the delete-the-old-set-then-insert, not the grouping by key, not
the sequencing that keeps a stale batch from clobbering fresh data. You declared
which key groups the rows and which column orders batches, and LHP owns the
rest.
What’s next¶
Merge change events onto a primary key instead. When the source emits per-row inserts/updates/deletes against a unique key and you want one current row (or SCD history) per key, use
mode: cdc(AUTO CDC) — covered in its own guide.See the field reference.
replace_usingandsequence_by, the forced table creation, and the single-flow and streaming-source constraints are all listed in the write action reference.
Syntax and options¶
See Replace rows in a streaming table for all supported settings and defaults.