Enforce a schema on a view¶
Let’s take a raw orders view — untyped string columns, cryptic names, and a
stray src_system column no one downstream wants — and lock it to an explicit
contract: exact column names, exact types, exact column list, in a fixed order.
You could hand-write that contract as a SQL transform: a SELECT with a
CAST(... AS ...) AS ... for every column, re-typed inline and re-derived each
time the raw shape drifts. Or you declare transform_type: schema, keep the
contract in one schema file, and let Lakehouse Plumber write the renames, the
casts, and the select. That is the idea on every page: declare your ETL, don’t
hand-write it.
Before you start¶
A schema transform sits in the middle of a flowgroup: it reads a view a load
produced and hands a typed view to a write. This guide extends the orders
ingest from Your first pipeline — a load action that streams raw CSV into
v_orders_raw. Any flowgroup with a load and a write fits the same shape.
Write the schema contract¶
Put the column contract in its own file. Each line is an arrow mapping —
source_column -> target_name: TYPE — so the schema reads as a table of what
goes in and what comes out. Create schemas/orders.yaml:
# Explicit column contract for the orders view, in arrow format:
# source_column -> target_name: TYPE
# Under strict enforcement every column NOT listed here is dropped, and the
# output columns are selected in exactly this order.
columns:
- "o_id -> order_id: BIGINT"
- "c_id -> customer_id: BIGINT"
- "gross_amt -> amount: DOUBLE"
- "o_ts -> order_ts: TIMESTAMP"
Each entry renames a cryptic raw column to a clear name and casts it to a target
type in one line. The arrow format has three other forms — old -> new renames
without a cast, col: TYPE casts without a rename, and a bare col keeps a
column as-is — but a clean source -> target: TYPE per column is all this
contract needs. The file is a versioned artifact in its own right: diff it, review
it, and reuse it across flowgroups that share the shape.
Declare the transform¶
Insert one transform action between the load and the write. Give it
transform_type: schema, point source at the view to reshape, point
schema_file at the contract, and set enforcement: strict:
# Guide: enforce an explicit schema on a view.
# Action chain: load (cloudFiles CSV, streaming) -> transform:schema (strict
# enforcement from an external schema file) -> write (streaming table).
pipeline: silver_orders
flowgroup: orders_typed
actions:
# 1. Load: stream raw CSV order files from the landing volume with Auto Loader.
# Raw columns are untyped strings with cryptic names, plus a src_system
# column the downstream table has no use for.
- name: load_orders_csv
type: load
source:
type: cloudfiles
path: "${landing_path}/orders/*.csv"
format: csv
target: v_orders_raw
# 2. Transform (schema): apply the explicit column contract in
# schemas/orders.yaml — rename each cryptic column to a clear name, cast it
# to its target type, and (strict) drop every column not listed.
- name: enforce_orders_schema
type: transform
transform_type: schema
source: v_orders_raw
schema_file: "schemas/orders.yaml"
enforcement: strict
target: v_orders_typed
# 3. Write: append the typed rows into the silver streaming table.
- name: write_orders_silver
type: write
source: v_orders_typed
write_target:
type: streaming_table
catalog: "${catalog}"
schema: "${silver_schema}"
table: orders
enforcement: strict is the field that makes this a contract rather than a
set of edits: only the columns named in the schema survive, every unlisted column
(the src_system cruft) is dropped, and the output columns come out in the
file’s order. The one-sentence contrast with a sql transform: there you would
retype the CAST(... AS ...) list and the surviving-column set inside the query
every time; here the contract lives in one file the transform reads.
Note
Under strict enforcement the schema columns must exist in the source —
a missing declared column fails the pipeline at run time, so a silent shape
change surfaces as an error rather than a wrong table. Omit enforcement (or
set permissive, the default) to cast and rename the listed columns while
letting every other column pass through unchanged.
Generate the pipeline¶
Validate the flowgroup, then generate the Lakeflow code:
$ lhp validate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ silver_orders 0 files
✓ validate (0.38s)
1 validated · 0.4s
$ lhp generate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ silver_orders 1 file
✓ generate (0.38s)
✓ format (0.05s)
✓ monitoring (0.00s)
1 pipeline generated · 1 file · 0.4s
Read what Lakehouse Plumber wrote¶
Open the generated file. This is the entire output — nothing is hidden behind a runtime:
# Generated by LakehousePlumber
# Pipeline: silver_orders
# FlowGroup: orders_typed
from pyspark.sql import functions as F
from pyspark.sql.types import StructType
from pyspark import pipelines as dp
# Pipeline Configuration
PIPELINE_ID = "silver_orders"
FLOWGROUP_ID = "orders_typed"
# ============================================================================
# SOURCE VIEWS
# ============================================================================
@dp.temporary_view()
def v_orders_raw():
"""Load data from csv files at /Volumes/dev_catalog/landing/files/orders/*.csv"""
df = (
spark.readStream.format("cloudFiles")
.option("cloudFiles.format", "csv")
.load("/Volumes/dev_catalog/landing/files/orders/*.csv")
)
return df
# ============================================================================
# TRANSFORMATION VIEWS
# ============================================================================
@dp.temporary_view()
def v_orders_typed():
"""Schema application: enforce_orders_schema"""
df = spark.readStream.table("v_orders_raw")
# Apply column renaming
df = df.withColumnRenamed("o_id", "order_id")
df = df.withColumnRenamed("c_id", "customer_id")
df = df.withColumnRenamed("gross_amt", "amount")
df = df.withColumnRenamed("o_ts", "order_ts")
# Apply type casting
df = df.withColumn("order_id", F.col("order_id").cast("BIGINT"))
df = df.withColumn("customer_id", F.col("customer_id").cast("BIGINT"))
df = df.withColumn("amount", F.col("amount").cast("DOUBLE"))
df = df.withColumn("order_ts", F.col("order_ts").cast("TIMESTAMP"))
# Strict schema enforcement - select only specified columns
# Schema-defined columns (will fail if missing)
columns_to_select = ["order_id", "customer_id", "amount", "order_ts"]
df = df.select(*columns_to_select)
return df
# ============================================================================
# TARGET TABLES
# ============================================================================
# Create the streaming table
dp.create_streaming_table(
name="dev_catalog.silver.orders", comment="Streaming table: orders"
)
# Define append flow(s)
@dp.append_flow(
target="dev_catalog.silver.orders",
name="f_orders_silver",
comment="Append flow to dev_catalog.silver.orders",
)
def f_orders_silver():
"""Append flow to dev_catalog.silver.orders"""
# Streaming flow
df = spark.readStream.table("v_orders_typed")
return df
The schema transform became a @dp.temporary_view that turns each contract line
into concrete PySpark: four withColumnRenamed calls map the cryptic names,
four withColumn(...).cast(...) calls set the types, and — because enforcement
is strict — a final df.select(...) keeps only the four declared columns,
in the declared order. src_system is never selected, so it never reaches the
silver table. The typed view is then wired into the append flow that feeds
dev_catalog.silver.orders.
What you just did¶
You wrote one action — 7 lines of YAML pointing at a 5-line schema file — and
Lakehouse Plumber generated the 24-line @dp.temporary_view: the four renames,
the four casts, and the select that enforces the column list and order. Across the
whole flowgroup: 30 lines of YAML became 83 lines of Lakeflow Python, with zero
lines of PySpark from you. You never wrote withColumnRenamed, a .cast(),
or a .select() by hand.
The contract is the payoff, not just the line count. Names, types, column set, and order all live in one reviewable file — change the shape there and the generated view follows, and a source that drifts out of contract fails loudly instead of leaking the wrong columns downstream.
What’s next¶
Reach for a SQL transform when the reshape needs real logic — a join, a filter, a computed column — beyond renaming and casting a fixed column list. The SQL transform guide covers that case and the
stream(...)rule.Gate the typed rows with a data-quality check — attach expectations to drop or quarantine bad records after the schema is enforced, covered in the data-quality guide.
For inline schemas (
schema_inline), thepermissivemode, the full arrow syntax, andreadMode, see the Transform action reference.