Delta Lake courseLesson 4 of 11
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.
On this page
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.
Bronzeraw, as it arrived
- Landfiles, CDC, API data
- Append onlyplus load time and source
Silverclean and trusted
- Type and checkbad rows to quarantine
- Deduplicateone row per key (MERGE)
Goldready for the business
- Model and totalfacts, dimensions, KPIs
- 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, notamt_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
foreachBatchandMERGE. - 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
- Cleaning data in bronze. If bronze drops or fixes rows, you lose the ability to replay the true raw data. Keep bronze raw.
- Dropping bad rows silently. Quarantine them with a reason and alert when the count jumps, or you will never know data went missing.
- Appending in silver. A plain append duplicates rows on every retry or backfill. Use
MERGEon the key, or replace whole partitions. - 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.
- 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.
- Thinking medallion is a data model. It only says where cleaning happens. Gold still needs real modelling, usually a star schema; see modelling methodologies.
- 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?” (
MERGEon 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.
Progress is saved in this browser only. No account needed.

