Compose a pipeline from many flowgroups¶
A real pipeline is rarely one flowgroup. You ingest orders, you ingest customers, then you build a silver fact on top of both. That is three units of work, and cramming them into one giant flowgroup buries the boundaries between them.
You could hand-write one sprawling Lakeflow module and hope the reader keeps the
layers straight. Or you write three focused flowgroups, give them the same
pipeline name, and let Lakehouse Plumber generate one module per flowgroup
into a single pipeline. That is the idea on every page: declare your ETL,
don’t hand-write it — and let the tool handle composition too.
Let’s build a sales pipeline from three flowgroups: two bronze ingests that
share one file, and a silver flowgroup that reads the bronze tables they write.
Before you start¶
You need Lakehouse Plumber installed and a project to work in. If you followed the Get Started course you already have one; otherwise scaffold an empty project first. This guide assumes CSV files land in a volume for the bronze ingests to read.
Give every flowgroup the same pipeline name¶
A flowgroup is a YAML file describing a sequence of actions. A pipeline
is what you deploy. The bridge between them is one field: pipeline. Every
flowgroup that names the same pipeline value composes into that one
pipeline — Lakehouse Plumber generates one Python module per flowgroup and
writes them all into generated/<env>/<pipeline>/.
That is the whole composition rule. Flowgroups can live in separate files or be
packed together; what puts them in the same pipeline is the shared pipeline
name, nothing else.
Feed the next layer from the tables you wrote¶
The silver flowgroup reads what the bronze ingests produced. Create
pipelines/silver_orders.yaml as a third flowgroup in the same sales
pipeline:
# A third flowgroup in the SAME `sales` pipeline. It reads the two bronze
# TABLES the ingests wrote: tables are global, so a table written in one
# flowgroup is visible to any other flowgroup that names it.
# Action chain: load (delta) x2 -> transform:sql (join) -> write.
pipeline: sales
flowgroup: orders_silver
actions:
# Stream the bronze orders table this pipeline's orders_ingest wrote.
- name: load_orders_bronze
type: load
readMode: stream
source:
type: delta
catalog: "${catalog}"
schema: "${bronze_schema}"
table: orders
target: v_orders_bronze
# Read the bronze customers table as a static snapshot (the dimension).
- name: load_customers_bronze
type: load
readMode: batch
source:
type: delta
catalog: "${catalog}"
schema: "${bronze_schema}"
table: customers
target: v_customers_bronze
# Enrich each order with its customer. stream(...) on the fact, plain name on
# the dimension — a stream-static join (see the SQL transform guide).
- name: build_orders_fct
type: transform
transform_type: sql
source:
- v_orders_bronze
- v_customers_bronze
sql: |
SELECT
o.order_id,
o.amount,
c.customer_name,
c.region
FROM stream(v_orders_bronze) AS o
JOIN v_customers_bronze AS c
ON o.customer_id = c.customer_id
target: v_orders_fct
# Write the enriched fact into a silver streaming table.
- name: write_orders_fct
type: write
source: v_orders_fct
write_target:
type: streaming_table
catalog: "${catalog}"
schema: "${silver_schema}"
table: orders_fct
Two load actions read the bronze orders and customers tables the
ingests wrote, and a SQL transform joins them into an enriched fact. This works
because of one rule worth internalising:
Tables are global. A streaming table or materialized view written by one flowgroup is a real Unity Catalog object, visible to any other flowgroup — in the same pipeline or a different one — that names it. That is how the silver flowgroup reaches the bronze
orderstable.Views are pipeline-scoped. A
targetview (av_...name) produced by an action is visible only to flowgroups in the same pipeline, never across pipelines.
Because the silver flowgroup depends on the bronze tables, keep both in one
pipeline so Lakeflow orders the work correctly at run time. The
stream(...) / plain-name split in the join is a stream-static read; the SQL
transform guide covers it in full — this page stays on how the flowgroups
connect.
Generate the pipeline¶
Validate every flowgroup, then generate. Both commands operate on the whole project at once:
$ lhp validate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ sales 0 files
✓ validate (0.53s)
1 validated · 0.5s
$ lhp generate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ sales 3 files
✓ generate (0.43s)
✓ format (0.05s)
✓ monitoring (0.00s)
1 pipeline generated · 3 files · 0.5s
One pipeline, sales, three files. All three flowgroups — across two YAML
files — generated into generated/dev/sales/: orders_ingest.py,
customers_ingest.py, and orders_silver.py.
Read what Lakehouse Plumber wrote¶
Open generated/dev/sales/orders_silver.py. This is the entire silver
module — and it shows the cross-flowgroup link resolved to concrete table names:
# Generated by LakehousePlumber
# Pipeline: sales
# FlowGroup: orders_silver
from pyspark import pipelines as dp
# Pipeline Configuration
PIPELINE_ID = "sales"
FLOWGROUP_ID = "orders_silver"
# ============================================================================
# SOURCE VIEWS
# ============================================================================
@dp.temporary_view()
def v_orders_bronze():
"""Delta source: dev_catalog.bronze.orders"""
df = spark.readStream.table("dev_catalog.bronze.orders")
return df
@dp.temporary_view()
def v_customers_bronze():
"""Delta source: dev_catalog.bronze.customers"""
df = spark.read.table("dev_catalog.bronze.customers")
return df
# ============================================================================
# TRANSFORMATION VIEWS
# ============================================================================
@dp.temporary_view(comment="SQL transform: build_orders_fct")
def v_orders_fct():
"""SQL transform: build_orders_fct"""
df = spark.sql("""SELECT
o.order_id,
o.amount,
c.customer_name,
c.region
FROM stream(v_orders_bronze) AS o
JOIN v_customers_bronze AS c
ON o.customer_id = c.customer_id""")
return df
# ============================================================================
# TARGET TABLES
# ============================================================================
# Create the streaming table
dp.create_streaming_table(
name="dev_catalog.silver.orders_fct", comment="Streaming table: orders_fct"
)
# Define append flow(s)
@dp.append_flow(
target="dev_catalog.silver.orders_fct",
name="f_orders_fct",
comment="Append flow to dev_catalog.silver.orders_fct",
)
def f_orders_fct():
"""Append flow to dev_catalog.silver.orders_fct"""
# Streaming flow
df = spark.readStream.table("v_orders_fct")
return df
The two load actions became spark.readStream.table("dev_catalog.bronze.orders")
and spark.read.table("dev_catalog.bronze.customers") — reading exactly the
tables orders_ingest and customers_ingest create in the other two
modules. Lakehouse Plumber never invents a runtime registry: the flowgroups are
wired together by the plain table names you declared.
What you just did¶
Three flowgroups, one pipeline name. One lhp generate turned 104 lines
of YAML across two files into 174 lines of Lakeflow Python across three modules
in a single pipeline directory — zero lines of PySpark from you. You never
wrote create_streaming_table, append_flow, or a readStream.table
call to join the layers.
The composition scales the same way. A fourth ingest is a fourth entry in the
array; a gold flowgroup reading the silver orders_fct table is a fourth
file. Each is a few lines of YAML — not another module of hand-written
plumbing.
What’s next¶
Cut repetition with presets. When flowgroups repeat the same load or write settings, factor them into a preset and attach it once, covered in the presets guide.
Fan one pattern across many pipelines. When you need the same pipeline per region or tenant, a blueprint expands one definition into many pipelines, covered in the blueprints guide.
Trace the wiring.
lhp dagrenders the dependency graph across flowgroups so you can see the bronze-to-silver edges before you deploy.