8-Part Series · Microsoft Fabric Data Engineering
Part 7 of 8

PySpark Transformations in Microsoft Fabric

From Raw Data to Production-Ready Delta Tables — Column Renaming, Delta Lake Upserts, JDBC Writes, and Staging Patterns.

✍️ Rohit Ram · Consultant, Data Analytics 📅 April 2026 ⏱ 15 min read
PySpark Delta Lake DeltaTable MERGE JDBC Upsert Warehouse Write Medallion Architecture

The Three-Folder Notebook Architecture

At the heart of this Microsoft Fabric project lies an elegant, scalable notebook architecture. Every transformation follows the same pattern, making it trivial to onboard new tables and achieve production scale. The architecture is organized into three layers:

Layer 1: Constants & Functions Notebook

nb_constants_and_functions is the backbone. It's a shared utility notebook that every other notebook references with %run in its first cell. This ensures all notebooks have access to:

Layer 2: Common Notebooks (Level 1)

These are the foundational transformation notebooks that read directly from the Landing layer and write to the Curated layer:

These are independent: each reads from its own source table, validates, transforms, and upserts to the curated layer. They can run in parallel.

Layer 3: Complex Notebooks (Level 2)

nb_cab_trips_f is a Level 2 notebook that depends on the transient output of two Level 1 notebooks. It performs complex joins and business logic.

Why %run First?

Every notebook starts with %run nb_constants_and_functions in cell 1. This:

📌

Why Notebooks Are Better Than Cluster Init Scripts: Using %run instead of cluster init scripts means: (1) you can update utilities without restarting the cluster, (2) every notebook gets a fresh token on every run, and (3) debugging is trivial — just navigate to the utility notebook.

The Constants & Functions Notebook Explained

Let's walk through every cell in nb_constants_and_functions. This is where the magic happens.

Cell 1: Imports & Token Retrieval

nb_constants_and_functions.py
from pyspark.sql.functions import col, lit, current_date, to_timestamp, concat, to_date, current_timestamp, upper, lower
import pandas as pd
from datetime import datetime
from notebookutils import mssparkutils

# Retrieve a fresh OAuth token for JDBC connections
token = mssparkutils.credentials.getToken("https://database.windows.net/")

The token is the critical piece. Every JDBC write operation in the warehouse requires authentication. By getting a fresh token at the start of each notebook run, we avoid token expiry issues, even for long-running transformations. The token is valid for ~1 hour, which is typically longer than any single notebook execution.

Cell 2: Column Rename Mapping

nb_constants_and_functions.py
# Load column rename mappings from CSV
df = pd.read_csv("/lakehouse/default/Files/Rename Columns/Column_Rename.csv")

# Normalize: strip whitespace and convert to lowercase
df['table_name'] = df['table_name'].str.strip().str.lower()
df['existing_column'] = df['existing_column'].str.strip().str.lower()
df['new_column'] = df['new_column'].str.strip()

def translate_entity_column(vr_ent, vr_column):
    """
    Look up the new column name for a given entity and old column.
    If not found, return the old column name (graceful fallback).
    """
    vr_ent, vr_column = vr_ent.lower(), vr_column.lower()
    try:
        new_column_name = df[(df.table_name == vr_ent) & (df.existing_column == vr_column)].new_column.values[0]
    except:
        new_column_name = vr_column
    return new_column_name

This CSV-based mapping is the secret to scalability. Instead of hardcoding column renames in each notebook, we maintain a single CSV file:

When a new column arrives from the source system, we just add one line to the CSV. No code changes. The translate_entity_column() function gracefully falls back to the original name if no mapping exists, so the pipeline won't break if we forget to add a mapping.

Cell 3: JDBC Execution Helper

nb_constants_and_functions.py
# JDBC URL for the SQL Warehouse in Production
jdbc_url = "jdbc:sqlserver://<your-workspace>.datawarehouse.fabric.microsoft.com:1433;database=dwh_taxi_business_prod;encrypt=true;trustServerCertificate=false"

def execute_sql_jdbc(sql_text, jdbc_url, token):
    """
    Execute SQL directly in the SQL Warehouse using JDBC.
    No Spark DataFrame involved — pure JDBC connection.
    """
    jvm = spark._sc._jvm
    props = jvm.java.util.Properties()
    props.setProperty("accessToken", token)

    conn = jvm.java.sql.DriverManager.getConnection(jdbc_url, props)
    stmt = conn.createStatement()
    stmt.execute(sql_text)
    stmt.close()
    conn.close()

This function is critical. It uses the Spark JVM to execute arbitrary SQL in the SQL Warehouse. Why not use spark.sql()? Because spark.sql() only works with Lakehouse tables. The SQL Warehouse is a separate compute engine. To write to it from Spark, we need JDBC.

The token-based authentication (OAuth 2.0) is more secure than username/password and avoids storing credentials in notebooks. The managed identity of the workspace automatically has permissions on the warehouse.

Cell 4: Watermark Helper Function

nb_constants_and_functions.py
def get_runtime(value, load_type):
    """
    Return the effective runtime for incremental load.
    - If load_type is 'FULL', always return 1900-01-01 (full reload)
    - If value is None or empty, return 1900-01-01 (first run ever)
    - Otherwise, return the stored value (incremental load from that datetime)
    """
    if load_type and load_type.lower() == "full":
        return datetime(1900, 1, 1, 0, 0, 0)
    if value is None or value == "":
        return datetime(1900, 1, 1, 0, 0, 0)
    return value

This is elegant. Instead of handling None checks all over the codebase, we centralize it. Returning 1900-01-01 is a trick: any timestamp in your data will be after 1900, so the filter WHERE last_modified_at >= 1900-01-01 effectively becomes "load everything."

This design means:

🔧

The 1900-01-01 Pattern: This is a common data engineering pattern. It avoids the complexity of null-checking and provides a sensible default that works with any timestamp data.

The Common Notebook: nb_com_drivers_d (Step by Step)

Now let's walk through a concrete Level 1 notebook. nb_com_drivers_d reads driver master data from the landing layer, validates it, transforms it, and upserts it to the curated layer. Every Level 1 notebook follows this same 12-step pattern.

Step 1: Read from Landing

nb_com_drivers_d
%run nb_constants_and_functions

com_drivers_d_landing = spark.read.table("lh_taxi_business.cab_landing.DRIVERSENTITY")

The first non-comment cell is always the %run. The second cell reads the landing table. In this project, landing tables are stored in the Lakehouse's delta format, so we use spark.read.table().

Step 2: Count Check & Early Exit

nb_com_drivers_d
try:
    com_drivers_d_landing_count = com_drivers_d_landing.limit(1).count()
    if not (com_drivers_d_landing_count > 0):
        notebookutils.notebook.exit({'error': "Staging Count: " + str(com_drivers_d_landing_count)})
except Exception as e:
    print("Exception: ", e)
    notebookutils.notebook.exit({'error': "Exception: " + str(e)})

This is a critical optimization. limit(1).count() checks if at least one row exists without scanning the entire table. If the table is empty (no new data since last run), we exit cleanly, saving Spark compute.

The try/except ensures that if something goes wrong during the existence check, the notebook exits with an error message instead of proceeding with an exception. This is better than a silent failure.

Step 3: Column Rename

nb_com_drivers_d
colList = com_drivers_d_landing.columns
entity = "DRIVERSENTITY"

for c in colList:
    com_drivers_d_landing = com_drivers_d_landing.withColumnRenamed(c, translate_entity_column(entity, c))

For each column in the landing table, we look up its new name using the translate_entity_column() function. If a mapping exists, we rename it. If not, it keeps the original name (graceful fallback). This is how we convert from SQL Server's ALL_CAPS to warehouse's lower_snake_case.

Step 4: Add Warehousing Metadata Columns

nb_com_drivers_d
com_drivers_d_landing = com_drivers_d_landing \
    .withColumn("integration_id", concat(col("driver_id"))) \
    .withColumn("data_source", lit("SQLServer")) \
    .withColumn("w_insert_date", current_timestamp()) \
    .withColumn("w_modified_date", current_timestamp()) \
    .withColumn("etl_created_by", lit("rohit.ram")) \
    .withColumn("etl_updated_by", lit("rohit.ram"))

These metadata columns are crucial for data stewardship and audit trails:

Step 5: Reorder Columns (Aesthetics & Convention)

nb_com_drivers_d
original_cols = com_drivers_d_landing.columns
first_cols = ["integration_id", "data_source"]
last_cols = ["w_insert_date", "w_modified_date", "etl_created_by", "etl_updated_by"]

[original_cols.remove(i) for i in (first_cols + last_cols)]

new_cols = first_cols + original_cols + last_cols
com_drivers_d_landing = com_drivers_d_landing.select(new_cols)

Column order matters for readability and debugging. We always put the key columns first (integration_id, data_source), then business columns, then audit columns at the end. This is a production convention.

Step 6: Write to Transient Layer (Temporary Storage)

nb_com_drivers_d
com_drivers_d_landing.write \
    .format("delta") \
    .mode("overwrite") \
    .saveAsTable("cab_transient.com_drivers_d")

The transient layer is a Lakehouse schema that holds intermediate data between landing and curated. It's temporary (can be dropped/recreated daily). We write it in overwrite mode because each run replaces the previous run's transient data.

Step 7: Read from Transient (Preparation for Curated Upsert)

nb_com_drivers_d
com_drivers_d_transient = spark.read.table("lh_taxi_business.cab_transient.com_drivers_d")

We read back from transient. This seems redundant (we just wrote it), but it's a safety check: if the write failed silently, we'll catch it when the read fails.

Step 8: THE KEY STEP — Upsert to Curated Layer with DeltaTable.merge()

nb_com_drivers_d
from delta.tables import DeltaTable

if spark.catalog.tableExists("cab_curated.com_drivers_d"):
    delta_table = DeltaTable.forName(spark, "cab_curated.com_drivers_d")
    print("Started Upsert")

    delta_table.alias("t").merge(
        com_drivers_d_transient.alias("s"),
        "t.integration_id = s.integration_id"
    ).whenMatchedUpdateAll() \
     .whenNotMatchedInsertAll() \
     .execute()

    print("Completed Upsert")
else:
    com_drivers_d_transient.write.format("delta").mode("overwrite").saveAsTable("cab_curated.com_drivers_d")
    print("Table is Created")

This is the heart of the medallion architecture. The DeltaTable.merge() operation is an SQL MERGE statement implemented in Spark:

This ensures idempotency. If you run this notebook twice on the same data, you get the same result. No duplicate keys, no stale data.

⚠️

The Staging Table Pattern — Why We Don't Write Directly to Curated: You might ask: why not read from landing, transform, and merge directly to curated? Because if the merge fails halfway, you've locked the table. By using a transient staging table, we separate concerns: (1) transform to transient (fast, isolated), (2) verify the result, (3) merge to curated (single atomic operation).

Step 9: Write to SQL Warehouse Staging Table (via JDBC)

nb_com_drivers_d
com_drivers_d_curated = spark.read.table("lh_taxi_business.cab_curated.com_drivers_d")

com_drivers_d_curated.write \
    .format("jdbc") \
    .option("url", jdbc_url) \
    .option("dbtable", "[staging].[com_drivers_d_stg]") \
    .option("accessToken", token) \
    .mode("append") \
    .save()

We read the curated data and write it to the SQL Warehouse's staging table using JDBC. Note: mode("append"). We don't overwrite because there might be data from parallel runs. The staging table is temporary and will be merged into the main table in the next step.

Step 10: MERGE into EDW Table (Dynamically Generated SQL)

nb_com_drivers_d
columns = com_drivers_d_curated.columns
key_column = "integration_id"
update_columns = [c for c in columns if c != key_column]

update_set = ",\n    ".join([f"target.{c} = source.{c}" for c in update_columns])
insert_cols = ", ".join(columns)
insert_vals = ", ".join([f"source.{c}" for c in columns])

merge_sql = f"""
MERGE [edw].[com_drivers_d] AS target
USING [staging].[com_drivers_d_stg] AS source
ON target.{key_column} = source.{key_column}
WHEN MATCHED THEN UPDATE SET
    {update_set}
WHEN NOT MATCHED THEN
    INSERT ({insert_cols})
    VALUES ({insert_vals});
"""

execute_sql_jdbc(merge_sql, jdbc_url, token)

This is brilliant: the MERGE SQL is dynamically generated from the DataFrame schema. If tomorrow you add a new column to the source system and the CSV mapping is updated, this merge SQL automatically includes it. No hardcoded column lists. Zero maintenance.

Step 11: Cleanup Staging Table

nb_com_drivers_d
truncate_sql = "DELETE FROM [staging].[com_drivers_d_stg]"
execute_sql_jdbc(truncate_sql, jdbc_url, token)

After a successful merge, we clean up the staging table so it's ready for the next run. This prevents stale data from being merged again.

📌

The Data Flow Visualized: Landing (raw SQL) → Rename & Enrich → Transient (validated) → Merge → Curated (Lakehouse) → JDBC Write → Staging (SQL Warehouse) → SQL MERGE → EDW (final truth)

The Complex Notebook: nb_cab_trips_f (Multi-Source Join)

This Level 2 notebook demonstrates how to handle dependent tables and complex joins. It reads from two transient tables (trips_header and trips_line) and joins them with watermark filters.

Reading the Config Table for Watermarks

nb_cab_trips_f
# Read the config table that tracks last pipeline runtimes
landing_control_tbl = spark.read.table("dwh_taxi_business_dev.config.landing_control_tbl")

# Convert to a dictionary for fast lookup
landing_control_tbl = {
    row["destination_table_name"]: {
        "runtime": row["last_pipeline_runtime"],
        "load_type": row["load_type"]
    }
    for row in landing_control_tbl.collect()
}

Applying Watermark Filters from Dependent Tables

nb_cab_trips_f
# Get the last runtime for the TRIPS HEADER source
trips_header_runtime = landing_control_tbl.get("TRIPSHEADERENTITY")
com_trips_header_f_last_pipeline_runtime = get_runtime(
    trips_header_runtime.get("runtime"),
    trips_header_runtime.get("load_type")
)

# Read transient data and filter to only new records since last run
com_trips_header_f_transient = spark.read.table("lh_taxi_business.cab_transient.com_trips_header_f") \
    .filter(col("last_modified_at") >= lit(str(com_trips_header_f_last_pipeline_runtime)))

# Do the same for trips line items
trips_line_runtime = landing_control_tbl.get("TRIPSLINEENTITY")
com_trips_line_f_last_pipeline_runtime = get_runtime(
    trips_line_runtime.get("runtime"),
    trips_line_runtime.get("load_type")
)

com_trips_line_f_transient = spark.read.table("lh_taxi_business.cab_transient.com_trips_line_f") \
    .filter(col("last_modified_at") >= lit(str(com_trips_line_f_last_pipeline_runtime)))

Preparing for Join (Column Renaming to Avoid Conflicts)

nb_cab_trips_f
# Rename key columns to avoid ambiguity in the join
com_trips_header_f_transient = com_trips_header_f_transient \
    .withColumnRenamed("integration_id", "trips_header_integration_id") \
    .withColumnRenamed("trip_id", "trips_header_trip_id")

# Drop common columns from line items (keep only line-specific columns)
inner_left_join_cols = list(set(com_trips_line_f_transient.columns) - set(com_trips_header_f_transient.columns))
com_trips_line_f_transient = com_trips_line_f_transient.select(inner_left_join_cols)

The Join

nb_cab_trips_f
# Left join: keep all header rows, even if they have no line items
cab_trips_f = com_trips_header_f_transient.join(
    com_trips_line_f_transient,
    com_trips_header_f_transient.trips_header_trip_id == com_trips_line_f_transient.trip_id,
    "left"
)

# Create a composite integration_id (header_id ~ line_id)
cab_trips_f = cab_trips_f.withColumn("integration_id",
    concat(col("trips_header_integration_id"), lit("~"), col("trips_line_integration_id"))
)

The composite integration_id (using "~" as a separator) is a production pattern. It uniquely identifies each fact row across multiple source tables. The left join ensures we keep trips even if they have no line items (canceled trips, for example).

Medallion Architecture Data Flow

Landing Layer Rename & Enrich Metadata + Keys Transient Layer Lakehouse Delta Curated Layer DeltaTable.merge() Merge on integration_id EDW SQL MERGE JDBC Write From Landing Delta to Production EDW

Why This Architecture Is Production-Ready

This architecture has been battle-tested at scale. Here's why it works:

1. Standardized Pattern = Easy Scaling

Every Level 1 notebook follows the exact same 11-step pattern. To add a new table to the pipeline:

  1. Copy an existing Level 1 notebook
  2. Change the landing table name
  3. Change the entity name for the column rename
  4. Change the key column for the upsert
  5. Done. 20 minutes to production.

2. Idempotency Guaranteed

The integration_id-based merge ensures that running the pipeline 10 times on the same data produces the same result. No duplicate keys, no data loss. This is critical for reliability.

3. Audit Trail Built-In

Every row has data_source, w_insert_date, w_modified_date, and etl_created_by. Data governance teams can trace any value back to its source and timestamp.

4. Staging Decouples Concerns

By writing to staging before merging, we avoid table locks. Large datasets can be written without blocking reads.

🚀

For New Data Analyst: What This Demonstrates: This architecture shows: (1) understanding of the medallion pattern used at Uber, Airbnb, and Databricks, (2) practical knowledge of Delta Lake semantics, (3) ability to design reusable, scalable templates, (4) production thinking (audit trails, idempotency, error handling), (5) mastery of PySpark transformations and JDBC integration.

Key Takeaways

Rohit Ram
Rohit Ram
Senior Data Analyst · LinkedIn

Building production-grade data platforms on Microsoft Fabric. This series documents a real project — every SQL query, every design decision, every challenge encountered and solved.