Landing, Bronze, Silver, and Gold Layers: A Practical Guide to the Medallion Architecture
In this article let us demystify the medallion architecture — what people mean when they talk about landing, bronze, silver, and gold layers, how to actually build them, and where things get messy in practice.
If you have spent any time around data lakes on Databricks or Spark, you have probably heard someone say “put it in bronze first” or “that table should be gold.” It sounds fancy until you realise it is just a way of organising your data processing so you don’t end up with a giant mess of notebooks no one understands six months later.
We will walk through what each layer does, what kind of transformations belong where, and the things I learned the hard way when implementing this on a real project.
What is the Medallion Architecture?
The medallion architecture is a pattern for structuring data in a lakehouse. You can think of it as progressive refinement — raw data comes in, gets cleaned and deduplicated, gets enriched and joined, and finally gets shaped into business-ready aggregates. Each stage is a layer: landing, bronze, silver, and gold.
Some teams call it bronze-silver-gold and skip the landing zone. Some add a raw or ingest layer before bronze. The naming does not matter much — what matters is the principle: never modify raw data in place, and always have a path back to what the source actually sent you.
| Layer | Purpose | Typical Format | Who Uses It |
|---|---|---|---|
| Landing | Raw incoming files, untouched | JSON, CSV, Parquet as-received | Data engineers for debugging |
| Bronze | Raw data with schema, append-only | Delta/Parquet with full history | Data engineers |
| Silver | Cleaned, deduplicated, joined data | Delta with merge/upsert logic | Analytics engineers, data scientists |
| Gold | Business aggregates, KPIs, curated views | Delta, aggregated tables | BI tools, dashboards, reports |
Landing Layer: The Inbox
The landing layer is the front door. Files arrive here from source systems — S3 uploads, GCS buckets, Kafka dumps, API extracts. You don’t transform anything here. You don’t even enforce a schema. If someone’s application sends a CSV with a rogue column on line 47, the landing layer keeps it exactly as-is.
Why bother keeping it? Because a year from now, someone will ask whether the revenue number in the gold table was wrong on a specific Tuesday, and the only way to find out is to go back to what the source actually produced. If you threw away the raw data or mutated it on ingestion, you have nothing to audit against.
A typical landing-to-bronze setup on Databricks might look like this:
1
2
3
4
5
6
7
8
9
10
# Read raw files as-is from landing zone
landing_df = spark.read.format("csv") \
.option("header", "true") \
.option("inferSchema", "false") \
.load("/mnt/landing/sales/2026/07/07/")
# Write to bronze without any transformation
landing_df.write.format("delta") \
.mode("append") \
.save("/mnt/bronze/sales_raw")
Notice we are not inferring types yet. That is deliberate. Landing just moves data from A to B. It should be fast and it should never fail because of a schema change.
Bronze Layer: Schema-On-Read, Append-Only
Bronze is where you first apply a schema. You read the raw files, cast columns to proper types, and store the result as Delta tables with append-only writes. You don’t deduplicate here. You don’t filter bad rows (well, maybe obvious garbage like empty files). The goal is to make the data queryable while preserving every record the source sent.
The bronze layer is often the first place where things go wrong. A source system changes its schema — a column gets renamed, a new field appears, a nullable field becomes required. If your bronze ingestion pipeline does a select * with schema inference and that schema shifts, your downstream silver and gold pipelines might quietly break or, worse, silently produce wrong results.
One thing I learned: always define your bronze schema explicitly, and fail loudly if something doesn’t match. A broken pipeline at ingestion is annoying. A pipeline that swallows bad data for three weeks before anyone notices is a disaster.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pyspark.sql.types import StructType, StructField, StringType, DoubleType, TimestampType
# Define schema explicitly — don't rely on inference
bronze_schema = StructType([
StructField("transaction_id", StringType(), False),
StructField("customer_id", StringType(), True),
StructField("amount", DoubleType(), True),
StructField("currency", StringType(), True),
StructField("event_timestamp", TimestampType(), True),
])
bronze_df = spark.read.format("csv") \
.option("header", "true") \
.schema(bronze_schema) \
.load("/mnt/landing/sales/2026/07/07/")
bronze_df.write.format("delta") \
.mode("append") \
.save("/mnt/bronze/sales")
A common pattern here is to add metadata columns — ingested_at, source_file_name, batch_id. These are invaluable when tracing data lineage later.
Silver Layer: Cleanse, Deduplicate, Join
This is where the real work happens. In the silver layer you remove duplicates, handle late-arriving data, join across tables, apply business rules, and shape the data into entities that make sense for your organisation.
The silver layer is where you stop thinking in terms of source systems and start thinking in terms of business entities. Raw transactions from the sales system and raw customer data from the CRM become a clean sales_transactions table and a customers table with consistent identifiers, standardised fields, and deduplication logic.
This is also where you need to be thoughtful about merge strategies. If your source sends full snapshots, you might use merge (upsert). If it sends CDC events, you need to reconstruct state from the change log. Both have pitfalls.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- Example silver transformation: deduplicate by keeping latest record
MERGE INTO silver.sales_transactions AS target
USING (
SELECT * FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY transaction_id
ORDER BY event_timestamp DESC
) AS rn
FROM bronze.sales
) WHERE rn = 1
) AS source
ON target.transaction_id = source.transaction_id
WHEN MATCHED AND target.event_timestamp < source.event_timestamp THEN
UPDATE SET *
WHEN NOT MATCHED THEN
INSERT *
A pattern I use often is to keep a silver_quarantine table for rows that fail validation. Instead of silently dropping bad records, dump them into quarantine with a failure_reason column. That way the business can decide if they care about those 23 rows with negative amounts, rather than the data engineer making that call unilaterally.
Gold Layer: Business-Ready Aggregates
Gold is what your dashboards hit. These tables are denormalised, aggregated, filtered, and shaped for specific business questions. Think daily revenue by region, customer churn metrics, or inventory turnover rates.
The gold layer is not one-size-fits-all. Marketing needs a different cut of the data than finance does. You might end up with multiple gold tables serving different teams, or you might maintain a single wide table and use views for specific consumers. There is no wrong answer as long as you document what each table contains and how it was built.
1
2
3
4
5
6
7
8
9
10
11
CREATE OR REPLACE TABLE gold.daily_sales_metrics AS
SELECT
DATE(event_timestamp) AS sale_date,
region,
currency,
COUNT(DISTINCT transaction_id) AS transactions,
SUM(amount) AS total_revenue,
AVG(amount) AS avg_transaction_value
FROM silver.sales_transactions
WHERE event_timestamp >= CURRENT_DATE - INTERVAL 90 DAYS
GROUP BY 1, 2, 3
One thing that catches people: gold tables are not free. Every aggregation is a decision about what to include and what to leave out. If you aggregate by region but the business later wants region and product_category, you either rebuild the gold table or your dashboard does expensive JOINs at query time. Think about common query patterns before you settle on aggregation granularity.
Things I Learned the Hard Way
Don’t skip the landing layer. When your bronze ingestion breaks and you need to reprocess, you will wish you kept the raw files. Storage is cheap. Reprocessing from a live source API is not.
Schema evolution will happen. Plan for it. Whether you use Delta Lake’s schema evolution features or handle it in your ingestion code, assume every source schema will change eventually. Write your pipelines so a new column doesn’t blow everything up.
Deduplication logic needs thought. ROW_NUMBER() OVER (PARTITION BY id ORDER BY timestamp DESC) is the easy answer, but what if the source doesn’t send reliable timestamps? What if you get genuine corrections to historical data? Have a conversation with the source team about what “latest” actually means.
In production, add monitoring. A simple job that checks row counts landing in each layer every day catches more problems than any amount of careful pipeline design. If bronze got 10,000 rows yesterday and 200 today, something is wrong. Alert on it.
Lineage matters more than you think. In a demo, you can trace a gold number back to bronze by following the notebooks. In production, with scheduled jobs, dependencies, and six months of changes, you will probably not remember which notebook feeds which table. Tag your Delta tables with metadata or use Unity Catalog’s lineage features if you are on Databricks.
Not every dataset needs all four layers. If you are ingesting a small reference table that changes once a month, going landing → bronze → silver → gold is overkill. A simple bronze → gold path with some light validation might be perfectly fine. Use the layers where they add value, not because a diagram told you to.
Wrapping Up
The medallion architecture is not a silver bullet — it is just a useful way of organising your data processing so you don’t paint yourself into a corner. Landing keeps the raw truth. Bronze makes it queryable. Silver makes it trustworthy. Gold makes it useful.
Start simple. You don’t need every layer on day one. A bronze-silver-gold pipeline with clear boundaries and good monitoring will serve you better than a perfect four-layer architecture that never ships.
