Learn how to design and build a production-ready medallion architecture in Microsoft Fabric using three separate lakehouses for Bronze, Silver, and Gold layers. This lesson goes beyond theory — you'll write real PySpark notebooks with MERGE patterns, quarantine logic, and Delta optimization for Power BI Direct Lake reporting.

Picture this: your team has spent months building a Fabric lakehouse. Data is flowing in from a dozen sources — an ERP system, a CRM, flat files from partners, and a real-time event stream. But somewhere along the way, it turned into a swamp. Raw files sit next to cleaned tables. Half-transformed staging data is getting queried by Power BI dashboards. Nobody is sure which version of the "customer" table is the right one. Reports are inconsistent, pipeline failures corrupt downstream data, and onboarding a new data engineer means a week of archaeology.
This is the problem medallion architecture was designed to solve. By organizing your lakehouse data into three distinct layers — Bronze (raw), Silver (cleaned), and Gold (business-ready) — you give every piece of data a clear contract, a defined purpose, and a predictable place to live. More importantly, you give your team a shared mental model. When everyone agrees on what "Bronze" means, you stop having arguments about whether a table is ready for reporting.
By the end of this lesson, you'll have a working, production-pattern medallion architecture deployed in Microsoft Fabric, built around a realistic retail scenario. You'll understand not just the structure but the reasoning behind each decision.
What you'll learn:
This lesson assumes you're comfortable with the Fabric fundamentals. Specifically, you should already know:
You don't need to have Spark expertise. The code in this lesson is explained step by step.
Before building anything, you need to internalize why three layers, not two, not five.
Bronze is a landing zone, not a storage format. The prime directive of Bronze is: accept everything, transform nothing. When data arrives from a source system, you want an exact, timestamped copy of it as-is. This means raw JSON, CSV with bad rows, XML, or binary files. Why? Because source systems change, upstream bugs happen, and sometimes you need to re-process data from scratch. Bronze is your insurance policy. You should be able to delete everything downstream and rebuild from Bronze.
Silver is where trust is established. Silver data has been validated, deduplicated, typed, and conformed to your naming standards. It answers the question: "Is this data correct?" A Silver table for orders has a proper order_date timestamp, not the string "01/13/24". It has no duplicate records caused by a pipeline re-running twice. It follows your organization's business rules for things like NULL handling. Silver tables are structured as Delta tables in Parquet format — they're queryable by engineers, analysts, and other pipelines.
Gold is purpose-built for consumers. Gold tables are shaped for specific use cases: a dimensional model for the finance team, an aggregated summary for an executive dashboard, a denormalized wide table for a machine learning feature store. Gold tables change when business questions change, while Silver tables change when source systems change. That distinction is crucial.
Key insight
One of the most common mistakes in medallion implementations is letting Gold tables reach back to Bronze. If your Gold layer is ever reading from Bronze, something has gone wrong. Each layer should only read from the layer immediately below it. This keeps your data lineage clean and your pipelines debuggable.
In Microsoft Fabric, you have a choice about how to physically organize the three layers. Two main patterns exist:
Pattern A — Three separate Lakehouses: You create bronze_lakehouse, silver_lakehouse, and gold_lakehouse in the same workspace (or separate workspaces for larger teams). Each lakehouse is a distinct Fabric item with its own file system, Delta tables, and SQL Analytics Endpoint.
Pattern B — One Lakehouse with folder conventions: Everything lives in a single lakehouse. Bronze data sits in Files/bronze/, Silver tables are created with a silver_ prefix, and Gold tables use gold_. This works for small teams but becomes messy at scale.
For production work, three separate lakehouses is the better pattern, and that's what we'll use here. The reasons are practical:
Note
All three lakehouses still live on OneLake under the same tenant. There's no data duplication cost at the storage infrastructure level, only at the logical table level. When your Silver notebook reads from Bronze, it's reading Delta files stored in OneLake regardless.
For this lesson, our scenario is a mid-sized retail company. Data sources include:
In your Fabric workspace, create three lakehouses:
RetailBronzeRetailSilver and RetailGoldInside each lakehouse, you'll see the familiar two-pane view: Files on the left and Tables on the right. Bronze will use the Files section heavily (raw files land there). Silver and Gold will live primarily in the Tables section as managed Delta tables.
In RetailBronze, create a folder structure under Files to organize by source system. Using the lakehouse Files explorer, create folders:
Files/pos_transactions/Files/product_catalog/Files/store_metadata/Within each source folder, add date-partitioned subfolders following the pattern YYYY/MM/DD/. You won't create these manually — your pipeline will create them dynamically — but understanding the intended structure helps.
The Bronze ingestion layer is built with Data Pipelines. Pipelines are the right tool here because they handle scheduling, retry logic, and parameter-driven paths without requiring you to write orchestration code. If you haven't built a pipeline before, Orchestrating Loads with Fabric Data Pipelines: Copy Activities, Parameters, and Schedules walks through the mechanics.
For our POS transaction CSV, here's the pipeline pattern:
Create a new Data Pipeline in your workspace called pl_bronze_pos_transactions.
Add a Copy Data activity with these settings:
RetailBronze lakehouse, Files sectionFiles/pos_transactions/@{formatDateTime(utcNow(), 'yyyy')}/@{formatDateTime(utcNow(), 'MM')}/@{formatDateTime(utcNow(), 'dd')}/
Warning
It's tempting to convert CSV to Parquet during the Bronze copy to save storage costs. Resist this. Parquet conversion silently drops rows with malformed types. In six months when you need to investigate a bad record, you'll wish you had the original. Bronze storage is cheap; debugging is not.
Add a second Copy Data activity for the product catalog JSON from the REST API:
Files/product_catalog/@{formatDateTime(utcNow(), 'yyyy')}/@{formatDateTime(utcNow(), 'MM')}/@{formatDateTime(utcNow(), 'dd')}/catalog.jsonSchedule the pipeline to run at 2:00 AM daily. Add a pipeline-level timeout of 30 minutes and configure the activity retry count to 3 with a 5-minute interval. This means a transient network issue won't cause a data gap.
By the end of this setup, your Bronze lakehouse accumulates raw files in a predictable structure every day. Anyone on the team can go find exactly what the source system sent on any given date. That's the Bronze contract.
The Silver transformation is where the real engineering happens. We'll use Spark notebooks for this layer because the transformations are complex enough to need code — type casting, deduplication logic, business rule validation — and Spark's distributed processing handles the scale.
Tip
Dataflow Gen2 is excellent for simpler Silver transformations, especially if your team has Power Query skills. But when you need row-level validation logic, complex deduplication, or joins across large tables, a Spark notebook gives you more control and better performance. See Ingesting Data with Dataflow Gen2: Power Query Skills in Microsoft Fabric for the Dataflow path.
Create a new Spark notebook in your workspace called nb_silver_pos_transactions. Attach it to a Spark environment (or use the workspace default). Then add RetailBronze and RetailSilver as lakehouse references in the notebook's lakehouse pane — you'll see an option to add a lakehouse when editing the notebook. Add RetailBronze as a secondary lakehouse so you can read from it, and RetailSilver as the primary (default) so writes go there.
Here's the full Silver transformation notebook for POS transactions:
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
from pyspark.sql.types import (
StructType, StructField, StringType,
DoubleType, IntegerType, TimestampType
)
from delta.tables import DeltaTable
from datetime import datetime, timedelta
spark = SparkSession.builder.getOrCreate()
# ── 1. Read yesterday's Bronze files ────────────────────────────────────────
yesterday = (datetime.utcnow() - timedelta(days=1)).strftime("%Y/%m/%d")
bronze_path = f"abfss://RetailBronze@onelake.dfs.fabric.microsoft.com/<workspace-id>/Files/pos_transactions/{yesterday}/"
raw_df = (
spark.read
.option("header", "true")
.option("inferSchema", "false") # Never infer schema in production
.csv(bronze_path)
)
print(f"Raw record count: {raw_df.count()}")
raw_df.printSchema()
Note
Replace <workspace-id> with your actual Fabric workspace GUID, which you can find in your workspace URL. In a production notebook, store this as a notebook parameter rather than hardcoding it.
# ── 2. Define and enforce the target schema ──────────────────────────────────
# Cast all columns explicitly — never trust inferSchema on Bronze data
typed_df = (
raw_df
.select(
F.col("transaction_id").cast(StringType()),
F.col("store_id").cast(StringType()),
F.col("product_id").cast(StringType()),
F.col("quantity").cast(IntegerType()),
F.col("unit_price").cast(DoubleType()),
F.col("transaction_timestamp").cast(TimestampType()),
F.col("payment_method").cast(StringType()),
F.col("cashier_id").cast(StringType()),
)
.withColumn("_ingestion_date", F.lit(yesterday))
.withColumn("_source_file", F.input_file_name())
)
# ── 3. Validate: identify and quarantine bad rows ────────────────────────────
# Good rows: required fields not null, quantity > 0, price >= 0
valid_df = typed_df.filter(
F.col("transaction_id").isNotNull()
& F.col("store_id").isNotNull()
& F.col("quantity").isNotNull()
& (F.col("quantity") > 0)
& F.col("unit_price").isNotNull()
& (F.col("unit_price") >= 0)
& F.col("transaction_timestamp").isNotNull()
)
invalid_df = typed_df.subtract(valid_df)
invalid_count = invalid_df.count()
valid_count = valid_df.count()
print(f"Valid rows: {valid_count} | Invalid rows: {invalid_count}")
# Write quarantined rows to a separate Silver table for investigation
if invalid_count > 0:
(
invalid_df
.write
.format("delta")
.mode("append")
.partitionBy("_ingestion_date")
.saveAsTable("silver_pos_transactions_quarantine")
)
# ── 4. Deduplicate ───────────────────────────────────────────────────────────
# Source system occasionally re-sends records. Keep the latest version
# of each transaction_id based on transaction_timestamp.
deduped_df = (
valid_df
.withColumn(
"_row_num",
F.row_number().over(
Window.partitionBy("transaction_id")
.orderBy(F.col("transaction_timestamp").desc())
)
)
.filter(F.col("_row_num") == 1)
.drop("_row_num")
)
print(f"After dedup: {deduped_df.count()} rows")
# ── 5. Add derived columns used across many downstream Gold tables ────────────
enriched_df = (
deduped_df
.withColumn("revenue", F.round(F.col("quantity") * F.col("unit_price"), 2))
.withColumn("transaction_date", F.to_date("transaction_timestamp"))
.withColumn("transaction_hour", F.hour("transaction_timestamp"))
)
# ── 6. Upsert into the Silver Delta table (MERGE pattern) ────────────────────
# Upsert handles pipeline reruns gracefully — same transaction_id won't
# create duplicate rows if this notebook runs twice for the same day.
silver_table_name = "silver_pos_transactions"
if spark.catalog.tableExists(silver_table_name):
silver_table = DeltaTable.forName(spark, silver_table_name)
(
silver_table.alias("existing")
.merge(
enriched_df.alias("incoming"),
"existing.transaction_id = incoming.transaction_id"
)
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute()
)
else:
# First run — create the table
(
enriched_df
.write
.format("delta")
.mode("overwrite")
.partitionBy("transaction_date")
.saveAsTable(silver_table_name)
)
print(f"Silver table updated: {silver_table_name}")
This notebook does five important things that distinguish a production Silver layer from a quick transformation script:
revenue and transaction_date are calculated here so every Gold table downstream gets a consistent definitionKey insight
The from pyspark.sql.window import Window import is needed for the row_number() dedup. Add it to your imports block at the top. This is the kind of detail that trips up notebooks in production — the error message ("name 'Window' is not defined") doesn't always make it obvious. For a deeper dive into notebook patterns, see Transforming Data with Spark Notebooks in Microsoft Fabric: PySpark for Lakehouse Tables.
Gold tables are different in character from Silver. Where Silver has one table per source entity (one for transactions, one for products, one for stores), Gold has one table per business question. Your retail stakeholders have asked for:
Let's build the first one.
Create a new notebook called nb_gold_daily_sales_summary. Add RetailSilver as a secondary lakehouse and RetailGold as the primary.
from pyspark.sql import functions as F
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
# ── Read from Silver tables (joins across entities) ──────────────────────────
transactions = spark.table("RetailSilver.silver_pos_transactions")
products = spark.table("RetailSilver.silver_products")
stores = spark.table("RetailSilver.silver_stores")
# ── Build the Gold aggregation ────────────────────────────────────────────────
daily_summary = (
transactions
.join(products, on="product_id", how="left")
.join(stores, on="store_id", how="left")
.groupBy(
"transaction_date",
"store_id",
F.col("store_name"),
F.col("store_region"),
F.col("product_category"),
)
.agg(
F.sum("revenue").alias("total_revenue"),
F.sum("quantity").alias("total_units_sold"),
F.countDistinct("transaction_id").alias("transaction_count"),
F.avg("unit_price").alias("avg_unit_price"),
F.countDistinct("cashier_id").alias("unique_cashiers"),
)
.withColumn("revenue_per_transaction",
F.round(F.col("total_revenue") / F.col("transaction_count"), 2))
.withColumn("_gold_updated_at", F.current_timestamp())
)
# ── Write to Gold — full refresh pattern for aggregations ────────────────────
# For aggregate tables, a full refresh is often simpler and more reliable
# than a MERGE. The table is rebuilt each run from Silver truth.
(
daily_summary
.write
.format("delta")
.mode("overwrite")
.option("overwriteSchema", "true")
.partitionBy("transaction_date")
.saveAsTable("gold_daily_sales_summary")
)
print("Gold table refreshed: gold_daily_sales_summary")
spark.sql("SELECT COUNT(*) as row_count FROM gold_daily_sales_summary").show()
Notice the design choice at the bottom: this Gold table uses a full overwrite rather than MERGE. That's intentional. For aggregate tables, a full overwrite is simpler, faster, and avoids subtle bugs where a MERGE might leave stale aggregations from a day that had late-arriving corrections in Silver. The Silver MERGE pattern protects you from duplication; the Gold overwrite guarantees freshness.
Tip
Not all Gold tables should be full-refresh. A Gold table representing a slowly changing customer dimension, or a running balance, needs MERGE semantics. Match the write pattern to the business logic, not to a blanket rule. The test is: "If I run this twice, is the result correct?" Both patterns pass this test when applied appropriately.
For the Power BI team to get maximum performance using Direct Lake mode, your Gold Delta tables need to be optimized. Add this at the end of your Gold notebook:
# ── Optimize Delta table for Direct Lake ─────────────────────────────────────
# OPTIMIZE compacts small files into larger ones — critical for Direct Lake
# V-Order encoding is applied automatically in Fabric Spark environments
spark.sql("OPTIMIZE gold_daily_sales_summary ZORDER BY (transaction_date, store_region)")
spark.sql("VACUUM gold_daily_sales_summary RETAIN 168 HOURS")
ZORDER BY co-locates rows with similar values for your filter columns in the same files. When Power BI renders a report filtered by transaction_date and store_region, Delta can skip entire files rather than scanning the whole table. This makes a measurable difference at scale.
The three notebooks (Bronze ingestion, Silver transformation, Gold aggregation) need to run in sequence every morning. Build this as a single orchestrating Data Pipeline called pl_medallion_full_refresh.
The pipeline structure is:
nb_silver_pos_transactionsnb_silver_productsnb_gold_daily_sales_summaryChain Activities 1 and 2 to run in parallel (connect them both to Activity 3 using "on success" connectors). Activities 3 and 4 run in parallel. Activity 5 depends on both 3 and 4 completing.
Set the pipeline schedule to trigger at 2:30 AM, giving the upstream source systems time to export their files. Set activity-level timeout on each Notebook activity to 45 minutes — a notebook that runs indefinitely usually means it's stuck on a shuffle operation or a network issue.
Warning
When a pipeline fails midway — say, the Silver notebook crashes — you need to know it's safe to re-run from the failed step. This is why idempotency in your notebooks is non-negotiable. The Silver MERGE and the Gold overwrite both tolerate being run again. If you add any new transformations, ask yourself: "If this runs twice, is the result the same?" If not, you have an idempotency bug.
Now that you understand the pattern end-to-end, build this yourself using a realistic dataset.
Your scenario: You're implementing a medallion lakehouse for a fictional e-commerce company called Northfield Retail. Use the NYC Yellow Taxi Trip dataset as a stand-in (it's free, large, and has all the messiness of real data).
Step 1: Set up Bronze
NorthfieldBronze, NorthfieldSilver, NorthfieldGoldNorthfieldBronze/Files/taxi_trips/YYYY/MM/ without transformationStep 2: Build Silver
tpep_pickup_datetime string format)passenger_count is null or zero, trip_distance is negative, or fare_amount is negativesilver_taxi_trips_quarantinesilver_taxi_trips partitioned by trip dateStep 3: Build Gold
gold_daily_trip_summary aggregating by pickup_date and payment_typetotal_fare, avg_trip_distance, total_trips, avg_tip_pct (tip / fare)pickup_dateStretch goal: Add a second Gold table called gold_hourly_demand that shows trip count by hour of day and day of week. Think about what filter columns to ZORDER on for this one.
Mistake 1: Schema drift breaking Silver notebooks
Source systems change their schemas without notice. A new column appears in the CSV, or a column is renamed. Your Silver notebook fails with a AnalysisException: cannot resolve column name.
Fix: Explicitly select only the columns you need in your Silver notebook (as shown above). Unknown new columns in Bronze are silently ignored. If a column disappears, your explicit cast will fail with a clear error rather than producing silently wrong data.
Mistake 2: Silver tables getting too many small files
If your pipeline runs every 15 minutes and appends to Silver, you'll accumulate thousands of tiny Delta files over time. Queries slow to a crawl.
Fix: Run OPTIMIZE on Silver tables weekly (not just Gold). Add it as a scheduled notebook that runs Sunday night. Also consider increasing the Spark shuffle partition count for small datasets: spark.conf.set("spark.sql.shuffle.partitions", "8") — the default of 200 is designed for cluster-scale data.
Mistake 3: Reading from the wrong layer in Gold
A new engineer writes a Gold notebook that reads directly from a Bronze JSON file because "it had the field I needed." Three months later, the Bronze file format changes and the Gold notebook silently starts returning wrong data.
Fix: Enforce layer isolation through lakehouse permissions. Give the Gold notebooks' service principal read access to Silver only, not Bronze. Let the security model enforce the architectural rule.
Mistake 4: Not adding _ingestion_date and _source_file to Bronze records
When a data quality issue is found months later, engineers need to trace a bad record back to its source file. Without metadata columns like _ingestion_date and _source_file, this becomes a needle-in-a-haystack problem.
Fix: Always add F.lit(ingestion_date) and F.input_file_name() as system columns in your Silver notebook when reading Bronze files. These columns carry through to quarantine tables where they're especially valuable.
Mistake 5: Gold tables designed for one team, broken by another
The finance team needs daily revenue totals. The marketing team adds a requirement to the same Gold table for email-opened attribution. The two requirements conflict and the table becomes messy and slow.
Fix: Gold tables should be purpose-built and team-specific. It's fine — good, actually — to have gold_finance_daily_revenue and gold_marketing_campaign_performance as separate tables even if they share some Silver source data. Gold is cheap to rebuild; confusion is expensive.
Tip
Use a naming convention in Gold that includes the consuming team or use case: gold_[team]_[subject]_[grain]. For example: gold_exec_store_performance_daily or gold_supply_inventory_snapshot_weekly. The grain suffix (_daily, _weekly, _snapshot) prevents someone from treating a daily table as an hourly one.
You've now built a medallion architecture that does what the pattern promises: raw data lands safely in Bronze, trust is established in Silver, and business-ready aggregations are served from Gold. More importantly, you understand why each layer is designed the way it is — which means you can adapt the pattern when reality doesn't match the textbook.
The key decisions to remember:
From here, the natural next steps are:
The medallion pattern isn't just a folder structure — it's a set of contracts between your team members about data quality and data lineage. The architecture documents your decisions. When something breaks at 3 AM, that documentation is what gets you back to sleep by 4.