Production PySpark patterns
Idempotent upserts, safe schema evolution, parameterized notebooks, and structured logging for Fabric.
Parameterize the notebook
Use a parameter cell (toggle Toggle parameter cell in the notebook) so pipelines can override values:
# Parameters
run_date = "2026-01-01"
source_table = "bronze.orders"
target_table = "silver.orders"
full_reload = FalseNever read spark.conf for business parameters and never hard-code a
workspace-qualified path — it breaks the moment the notebook is deployed to
another workspace. See Variable libraries.
Idempotent upsert
from delta.tables import DeltaTable
from pyspark.sql import functions as F
incoming = (
spark.read.table(source_table)
.where(F.col("updated_at") >= run_date)
.withColumn("_ingested_at", F.current_timestamp())
)
if not spark.catalog.tableExists(target_table):
incoming.write.saveAsTable(target_table)
else:
(
DeltaTable.forName(spark, target_table).alias("t")
.merge(incoming.alias("s"), "t.order_id = s.order_id")
.whenMatchedUpdateAll(condition="s.updated_at > t.updated_at")
.whenNotMatchedInsertAll()
.execute()
)Re-running with the same run_date produces the same table. That is the whole
point.
Safe schema evolution
(incoming.write
.option("mergeSchema", "true") # additive columns only
.mode("append")
.saveAsTable(target_table))mergeSchema adds new columns. It does not handle type changes or drops.
For those, write a migration notebook that does an explicit
ALTER TABLE ... ALTER COLUMN or a versioned rebuild, and gate it in CI.
For partition-scoped reloads, prefer replaceWhere over delete+append:
(incoming.where(F.col("run_date") == run_date).write
.option("replaceWhere", f"run_date = '{run_date}'")
.mode("overwrite")
.saveAsTable(target_table))Structured logging
Print JSON so the Monitoring hub and any log shipper can parse it:
import json, time
def log(event, **kw):
print(json.dumps({"ts": time.time(), "event": event, "notebook": "silver_orders", **kw}))
log("start", run_date=run_date, full_reload=full_reload)
rows = incoming.count()
assert rows > 0, "no incoming rows — upstream is late"
log("read", rows=rows)Boundary assertions
expected_cols = {"order_id", "customer_id", "amount", "updated_at"}
missing = expected_cols - set(incoming.columns)
assert not missing, f"schema drift: missing {missing}"
# after write
written = spark.read.table(target_table).where(F.col("run_date") == run_date).count()
log("write", rows=written)
assert written >= rows * 0.99, "row loss during merge"Don't collect to the driver
# Bad — pulls the whole column to the driver, OOMs on scale
ids = [r.order_id for r in df.select("order_id").collect()]
# Good — keep it distributed
df.join(other_df, "order_id", "left_anti")Stay ahead of Fabric changes
Fabric runtime changes, API updates, and deprecations. No spam, unsubscribe anytime.
Data Engineering
Production PySpark on Fabric — patterns that survive Runtime upgrades and keep CU consumption predictable.
notebookutils reference
The practical reference for notebookutils (formerly mssparkutils) — filesystem, notebook orchestration, secrets, lakehouse, and runtime helpers in Fabric notebooks.