Skip to content
R Roesli.
Go back
lakehouse

Build a medallion Lakehouse in Microsoft Fabric from raw to gold

A practical and architectural guide to Bronze, Silver, Gold, data quality, harmonization, and optional serving layers in a Fabric Lakehouse.

The medallion pattern is useful when each layer has a different contract. It is not useful when a diagram simply needs three colors.

This walkthrough uses a Fabric trial workspace and a small orders dataset. The Files/raw folder is a landing and replay boundary, not a fourth quality tier. Bronze is the durable Delta representation of that input.

The contracts

LayerMain questionTypical workConsumers
Landing / ingestionCan I replay what arrived?Preserve files, source metadata, arrival time, batch IDIngestion and operations
BronzeWhat did the source tell us?Append source-faithful records with minimal handlingEngineering, audit, replay
SilverWhat do we believe is valid?Validate, deduplicate, harmonize, join, quarantineEngineers, analysts, data scientists
GoldHow should the business consume it?Models, aggregates, KPIs, serving optimizationPower BI, applications, business teams

Every boundary should clarify ownership, quality, recovery, access, or performance. Otherwise the layer is architecture theatre.

What we will build

landing/raw_orders.csv
        |
        v
bronze_orders
        |
        v
silver_orders
        |
        v
gold_orders_by_region

Prerequisites

You need:

  1. a Fabric workspace;
  2. a Lakehouse;
  3. a raw CSV file; and
  4. a notebook with PySpark.

1. Create the workspace and Lakehouse

Create:

blog_lakehouse_medallion
sales_lakehouse

For this walkthrough, create:

Files/raw/

Managed Delta tables live under Tables; matching Files/bronze, Files/silver, and Files/gold folders are not required. Larger designs may use separate schemas, Lakehouses, or workspaces when ownership or security requires it.

2. Load the raw data

Upload:

order_id,customer_id,order_date,amount,status,region
1001,501,2026-09-14,124.90,paid,North
1002,502,2026-09-15,80.00,paid,South
1003,503,2026-09-16,210.75,fulfilled,North
1004,504,2026-09-17,52.10,returned,West

Store it at:

Files/raw/orders.csv

Read it:

from pyspark.sql import functions as F

raw_df = spark.read.option("header", True).option("inferSchema", True).csv("Files/raw/orders.csv")
raw_df.show(10)

Keep this landing copy unchanged so transformation logic can be replayed.

3. Create Bronze

The compact tutorial applies technical normalization:

bronze_df = (
    raw_df
    .withColumnRenamed("order_id", "order_id")
    .withColumnRenamed("customer_id", "customer_id")
    .withColumn("order_date", F.to_date(F.col("order_date")))
    .withColumn("status", F.lower(F.trim(F.col("status"))))
    .withColumn("amount", F.col("amount").cast("decimal(18,2)"))
    .na.fill({
        "region": "Unknown",
        "status": "unknown"
    })
)

bronze_df.write.mode("overwrite").format("delta").saveAsTable("bronze_orders")

For stricter source fidelity, append instead of overwrite and add source file, ingestion timestamp, batch ID, and source-system metadata. Move value cleanup into Silver.

4. Create Silver

Silver should retain the lowest useful business grain. In this example it keeps one detailed row per order and applies a small contract:

silver_df = (
    spark.table("bronze_orders")
    .filter(F.col("amount").isNotNull())
    .withColumn("net_amount", F.col("amount"))
    .withColumn("order_month", F.date_format(F.col("order_date"), "yyyy-MM"))
    .withColumn("is_paid", F.when(F.col("status") == "paid", 1).otherwise(0))
)

silver_df.write.mode("overwrite").format("delta").saveAsTable("silver_orders")

Production code should not silently lose invalid rows. Write them to silver_orders_quarantine with quality_rule, quality_reason, source_batch_id, and quarantined_at.

5. Publish Gold

Gold is a consumer contract. It should answer a business question, not repeat source cleanup. One Silver table can feed several Gold products with different measures or grains.

gold_df = (
    spark.table("silver_orders")
    .groupBy("region", "order_month")
    .agg(
        F.sum("net_amount").alias("total_revenue"),
        F.count("order_id").alias("order_count"),
        F.sum("is_paid").alias("paid_orders")
    )
    .orderBy("order_month", "region")
)

gold_df.write.mode("overwrite").format("delta").saveAsTable("gold_orders_by_region")

For larger reporting solutions, Gold commonly contains fact and dimension tables at useful grains. A semantic model can then own relationships, measures, and user-facing terminology.

6. Validate the layers

spark.sql("SHOW TABLES").show(truncate=False)
spark.sql("SELECT * FROM gold_orders_by_region ORDER BY order_month, region").show(50, truncate=False)

The checks should also record counts of accepted, rejected, corrected, and late records. Those are operational metrics, not temporary notebook output.

Why Silver exists

Silver is where source records become dependable domain records. It makes decisions about:

  1. Validity: does the record satisfy schema and business rules?
  2. Uniqueness: is it new, corrected, or duplicated?
  3. Identity: which business entity does it reference?
  4. Consistency: are units, currencies, time zones, and codes aligned?
  5. History: how are late updates, deletions, and changing dimensions handled?

If every Gold product repeats those decisions, the organization has multiple definitions of trusted data.

Put quality checks at the boundary

BoundaryPurposeExamplesFailure handling
Landing to BronzeProtect replayabilityFile readable, expected columns, batch completePreserve payload; alert or quarantine
Bronze to SilverDecide record validityTypes, required fields, ranges, duplicates, relationshipsAccepted and rejected rows with reasons
Silver to GoldProtect business meaningReconciliation, KPI rules, freshness, completenessBlock or flag publication
ConsumptionProtect the user contractMeasures, RLS, report totals, SLA checksAlert the product owner

For orders, check that order_id is present and unique, customer_id resolves, amount is non-negative, dates are plausible, status is canonical, and accepted plus rejected rows reconcile to Bronze.

Harmonization belongs mainly in Silver

ERP, e-commerce, and marketplace sources may disagree on customer IDs, product codes, statuses, currencies, units, time zones, and region names. Silver maps those variations to canonical keys and vocabularies using crosswalks, reference data, master-data matching, or survivorship rules.

Keep source keys and original codes beside canonical values. Gold can then apply consumer-specific interpretations without losing traceability.

Do you need a Platinum layer?

Usually, no. Platinum is not part of the standard Bronze-Silver-Gold pattern. Add another serving tier only when it has a distinct contract, owner, security boundary, or operational requirement, such as:

Names such as certified, serving, regulatory, or features can be clearer than platinum. If you cannot explain the extra owner and contract, Gold is probably enough.

One Lakehouse or several?

This tutorial keeps everything together for clarity. Production can use:

Do not copy data merely to satisfy a diagram. OneLake shortcuts can expose existing data without duplication.

Further reading

Common pitfalls

The takeaway

A medallion Lakehouse is a data operating model:

preserve -> validate and harmonize -> publish for a consumer

Keep Bronze replayable, make Silver dependable at useful detail, make Gold explicit about its consumer, and add another layer only when its contract is genuinely different.


Share this post:

Continue exploring

Previous Post
Fix missing or stale Lakehouse tables in the Fabric SQL endpoint
Next Post
Plan Microsoft Fabric capacity from workload patterns
Community

Join the conversation

Sign in with GitHub to leave a comment.

GitHub

Loading comments…

Sign in with GitHub to comment