Menu

Delta Lake course · Lesson 4 of 11

Medallion Architecture: Bronze, Silver and Gold Layers Explained

Medallion architecture explained simply: what goes in the bronze, silver and gold layers, how data moves between them, and a runnable PySpark and Delta example.

  • Intermediate
  • 10 min read
  • Updated Oct 2026
On this page
  1. Why use layers at all?
  2. Bronze: keep the raw data
  3. Silver: clean, check and deduplicate
  4. Gold: build for the business
  5. A worked example: orders from bronze to gold
  6. Batch or streaming: the layers stay the same
  7. The same idea on other platforms
  8. Common mistakes
  9. What interviewers ask
  10. Key takeaway

Medallion architecture is a simple way to organise the tables in a lakehouse into three layers, by how clean the data is:

  • Bronze keeps the raw data exactly as it arrived.
  • Silver holds the same data cleaned: typed, checked and deduplicated.
  • Gold holds business-ready tables: facts, dimensions and totals for dashboards and ML.

Each layer is built only from the layer before it. Because bronze keeps everything, you can always rebuild silver and gold when a rule changes or a bug is found, without going back to the source systems.

How data moves through the layers

Bronzeraw, as it arrived

  1. Landfiles, CDC, API data
  2. Append onlyplus load time and source

Silverclean and trusted

  1. Type and checkbad rows to quarantine
  2. Deduplicateone row per key (MERGE)

Goldready for the business

  1. Model and totalfacts, dimensions, KPIs
  2. ServeBI, reports, ML

Each layer reads only the one before it, so any later layer can be rebuilt.

Why use layers at all?

Without layers, one job usually reads the source, cleans it, joins it and writes a report table in a single step. That works until something goes wrong. Then you cannot tell whether the problem is in the source data or in your logic, and fixing a bug means pulling everything from the source again.

Layers split that one big step into three smaller ones, each with a single job:

Benefit How the layers give it
Rebuild without the source Bronze keeps every raw record, so a fixed rule can be replayed over all history.
Find problems faster If gold looks wrong, compare it with silver; if silver looks wrong, compare it with bronze.
Trust Users read only silver and gold, where checks have already run.
Reuse Many gold tables can share one clean silver table, instead of each cleaning the raw data again.
Clear ownership Ingestion owns bronze, the data engineering team owns silver, domain teams often own gold.

Bronze: keep the raw data

Bronze is the landing zone. Its only job is to store what arrived, completely and safely.

  • Append only. Never update or delete bronze rows in normal runs. It is your record of what the source sent.
  • Keep the raw shape. Store values as they came, often as strings or the original JSON, so a bad value never fails the load.
  • Add load metadata. At least the load time and the source file or batch ID, so you can trace any row back to where it came from.
  • No business logic. No joins, no filters, no renaming to business terms. Those belong later.

Bronze can grow large. Keep it on cheap storage, partition or cluster it by load date, and set a retention rule that matches how far back you might need to replay.

Silver: clean, check and deduplicate

Silver is where data becomes trustworthy. Most of the real data engineering work happens here.

  • Type the columns. Turn strings into numbers, dates and timestamps.
  • Check the data. Apply quality rules: required fields, valid ranges, allowed values. Rows that fail go to a quarantine table with the reason, so nothing disappears silently.
  • Deduplicate. Keep one row per business key, usually the latest version.
  • Apply changes safely. Use MERGE (upsert) on the key, so running the job twice gives the same table. This is what makes reruns and backfills safe.
  • Conform. Use the same names, codes and units for the same thing across sources, so silver tables join cleanly.

Silver tables usually still look like the source entities (orders, customers, payments), just clean.

Gold: build for the business

Gold tables are designed for the people and tools that use them: dashboards, analysts, finance reports and ML features.

  • Model the business. Star schemas with facts and dimensions, or wide tables for one dashboard.
  • Pre-compute totals. Daily revenue, active users, churn rate: the numbers people ask for again and again.
  • Use business names. net_revenue, not amt_2.

Gold is often rebuilt in full (it is usually small) or refreshed for the most recent days only.

A worked example: orders from bronze to gold

The example lands one file of order events, with three typical problems: a duplicate row, a later update to order 1 (it was refunded), and a broken amount on order 3.

First, a local Spark session with Delta Lake, and the raw file.

import json, os, tempfile
from pyspark.sql import SparkSession, functions as F, Window
from delta import configure_spark_with_delta_pip
from delta.tables import DeltaTable

builder = (
    SparkSession.builder.master("local[2]").appName("medallion")
    .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
    .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog")
    .config("spark.sql.shuffle.partitions", "2")
    .config("spark.ui.showConsoleProgress", "false")
)
spark = configure_spark_with_delta_pip(builder).getOrCreate()
spark.sparkContext.setLogLevel("ERROR")
base = tempfile.mkdtemp()
landing, bronze, silver, quarantine, gold = (f"{base}/{n}" for n in
    ("landing", "bronze_orders", "silver_orders", "quarantine_orders", "gold_daily_revenue"))

# Raw order events as they arrive: a duplicate, a later update and a broken record.
os.makedirs(landing)
events = [
    {"order_id": "1", "customer": "ana", "amount": "40.00", "status": "paid", "updated_at": "2026-10-01T09:00:00"},
    {"order_id": "2", "customer": "ben", "amount": "25.50", "status": "paid", "updated_at": "2026-10-01T10:00:00"},
    {"order_id": "2", "customer": "ben", "amount": "25.50", "status": "paid", "updated_at": "2026-10-01T10:00:00"},
    {"order_id": "3", "customer": "cy", "amount": "oops", "status": "paid", "updated_at": "2026-10-02T11:00:00"},
    {"order_id": "1", "customer": "ana", "amount": "40.00", "status": "refunded", "updated_at": "2026-10-02T12:00:00"},
    {"order_id": "4", "customer": "dee", "amount": "60.00", "status": "paid", "updated_at": "2026-10-02T13:00:00"},
]
with open(f"{landing}/orders_2026-10-02.json", "w") as f:
    f.write("\n".join(json.dumps(e) for e in events))

Bronze. Read every field as a string, so nothing fails to load, add the source file and load time, and append.

raw = (spark.read.schema("order_id STRING, customer STRING, amount STRING, status STRING, updated_at STRING")
       .json(landing)
       .withColumn("_source_file", F.col("_metadata.file_name"))
       .withColumn("_ingested_at", F.current_timestamp()))
raw.write.format("delta").mode("append").save(bronze)
print("bronze rows:", spark.read.format("delta").load(bronze).count())
bronze rows: 6

All six records are kept, including the duplicate and the broken one. That is exactly what bronze is for.

Silver. Type the columns, send rows that fail the checks to quarantine, keep the latest version of each order, and MERGE into the silver table. The function runs twice on purpose: the second run must change nothing.

def build_silver():
    b = spark.read.format("delta").load(bronze)
    typed = (b.withColumn("amount_num", F.expr("try_cast(amount AS DECIMAL(10,2))"))
              .withColumn("updated_ts", F.to_timestamp("updated_at")))
    bad = typed.filter(F.col("amount_num").isNull() | F.col("updated_ts").isNull())
    good = typed.filter(F.col("amount_num").isNotNull() & F.col("updated_ts").isNotNull())
    (bad.select("order_id", "amount", "_source_file", F.lit("amount is not a number").alias("reason"))
        .write.format("delta").mode("overwrite").save(quarantine))
    latest = (good.withColumn("rn", F.row_number().over(
                  Window.partitionBy("order_id").orderBy(F.desc("updated_ts"))))
              .filter("rn = 1")
              .select(F.col("order_id").cast("int").alias("order_id"), "customer",
                      F.col("amount_num").alias("amount"), "status", F.col("updated_ts").alias("updated_at")))
    if not DeltaTable.isDeltaTable(spark, silver):
        latest.limit(0).write.format("delta").save(silver)
    (DeltaTable.forPath(spark, silver).alias("t")
        .merge(latest.alias("s"), "t.order_id = s.order_id")
        .whenMatchedUpdateAll("s.updated_at > t.updated_at")
        .whenNotMatchedInsertAll()
        .execute())

build_silver()
build_silver()  # run it twice: MERGE makes the second run change nothing
spark.read.format("delta").load(silver).orderBy("order_id").show(truncate=False)
spark.read.format("delta").load(quarantine).show(truncate=False)
+--------+--------+------+--------+-------------------+
|order_id|customer|amount|status  |updated_at         |
+--------+--------+------+--------+-------------------+
|1       |ana     |40.00 |refunded|2026-10-02 12:00:00|
|2       |ben     |25.50 |paid    |2026-10-01 10:00:00|
|4       |dee     |60.00 |paid    |2026-10-02 13:00:00|
+--------+--------+------+--------+-------------------+

+--------+------+----------------------+----------------------+
|order_id|amount|_source_file          |reason                |
+--------+------+----------------------+----------------------+
|3       |oops  |orders_2026-10-02.json|amount is not a number|
+--------+------+----------------------+----------------------+

Silver now has one row per order. Order 2 appears once, order 1 shows its latest state (refunded), and order 3 is waiting in quarantine with the reason and the file it came from, instead of being lost. Running the step a second time left the table unchanged, so retries are safe.

Gold. A table the business actually asks for: paid revenue per day.

(spark.read.format("delta").load(silver)
    .filter("status = 'paid'")
    .groupBy(F.to_date("updated_at").alias("order_date"))
    .agg(F.sum("amount").alias("revenue"), F.count("*").alias("orders"))
    .write.format("delta").mode("overwrite").save(gold))
spark.read.format("delta").load(gold).orderBy("order_date").show()
spark.stop()
+----------+-------+------+
|order_date|revenue|orders|
+----------+-------+------+
|2026-10-01|  25.50|     1|
|2026-10-02|  60.00|     1|
+----------+-------+------+

The refunded order and the broken record are not counted, and nothing is counted twice. If someone later fixes order 3 at the source, the next load lands it in bronze, silver picks it up through the MERGE, and gold is rebuilt with the right total.

Batch or streaming: the layers stay the same

The example runs as a batch, but the same three layers work for streaming. A common pattern:

  • Bronze: a streaming read of new files or Kafka messages, appended as they arrive (on Databricks, Auto Loader).
  • Silver: a stream that reads bronze, checks and deduplicates, and writes with foreachBatch and MERGE.
  • Gold: often refreshed on a schedule, because most dashboards do not need second-by-second data.

Running streams with the availableNow trigger on a schedule gives you incremental processing without paying for a cluster that runs all day.

The same idea on other platforms

Medallion is a naming convention for layers, not a product feature, so it maps onto any platform.

Platform Bronze Silver Gold
Databricks Streaming tables loaded by Auto Loader or Lakeflow Connect Streaming tables with expectations and AUTO CDC Materialized views or tables; one Unity Catalog catalog or schema per layer
dbt (any warehouse) Sources (raw tables loaded by an ingestion tool) Staging and intermediate models, with tests Marts: facts and dimensions
Snowflake Raw tables loaded by Snowpipe or COPY INTO Tables built with Streams and Tasks, or Dynamic Tables Dynamic Tables, views or tables for BI

For the Databricks side in more detail, see Databricks workspace, jobs and the lakehouse and Auto Loader and declarative pipelines.

Common mistakes

  1. Cleaning data in bronze. If bronze drops or fixes rows, you lose the ability to replay the true raw data. Keep bronze raw.
  2. Dropping bad rows silently. Quarantine them with a reason and alert when the count jumps, or you will never know data went missing.
  3. Appending in silver. A plain append duplicates rows on every retry or backfill. Use MERGE on the key, or replace whole partitions.
  4. Gold reading bronze directly. It skips the checks and the deduplication, and two gold tables end up cleaning the same data in two different ways.
  5. Too many layers. “Silver-2”, “gold-final” and “platinum” tables that nobody can explain. Keep three layers, and write down what each one is for.
  6. Thinking medallion is a data model. It only says where cleaning happens. Gold still needs real modelling, usually a star schema; see modelling methodologies.
  7. Keeping bronze forever by accident. Raw data costs money and may hold personal data. Set retention deliberately.

What interviewers ask

  • “What is the medallion architecture, and why use it?” (Top 100 DE question 14)
  • “How would you implement it on Databricks?” (Databricks question 12)
  • “What belongs in bronze and what does not?” (raw, append-only, load metadata; no business logic)
  • “How do you make the silver step safe to rerun?” (MERGE on the key, or partition overwrite; see idempotency in data pipelines)
  • “What do you do with records that fail validation?” (quarantine with a reason; see data quality checks and contracts)
  • “Is medallion the same as a star schema?” (no: medallion is layering, the star schema is how you model gold)

A strong answer explains the job of each layer and why the layers make rebuilding, debugging and trust easier, not just the three colour names.

Key takeaway

Bronze keeps the raw data so you can always replay it. Silver makes it trustworthy: typed, checked, deduplicated and merged safely. Gold shapes it for the business. Keep each layer to its one job, build each only from the layer before it, and every table in the lakehouse can be rebuilt and explained.

By , Data Engineer and Founder of DataDank · Last reviewed Oct 2026 · Examples run locally on PySpark 4.2.0 with delta-spark 4.4.0 (open-source Delta), verified with scripts/verify-examples.py. The same layers work with Apache Iceberg tables or in a cloud warehouse.

Progress is saved in this browser only. No account needed.

Search
Filter by type