The Medallion Architecture
You now know how to shape data into clean relational tables, star schemas, and history-keeping dimensions. The medallion architecture is the organizing pattern that tells you where each of those transformations lives. It turns a pipeline from one tangled query into a sequence of layers you can inspect, re-run, and trust — bronze, silver, gold.
Open a terminal in your mini-griddp repo with DuckDB available (python -c "import duckdb" should be silent). We'll build everything in a single DuckDB database file and use schemas as our layers. No new tools today — this chapter is about structure, applied to the marketplace data you've been carrying since Course 2.
The medallion idea
Imagine one giant SQL query that reads raw CSVs, fixes bad dates, deduplicates, joins five sources, and produces the revenue report your CEO sees — all at once. When the number looks wrong (and it will), where do you even start debugging? You can't see the middle. You can't re-run just the broken part. And the next report has to copy-paste your cleaning logic all over again.
The medallion architecture (popularized by the lakehouse world, but useful in any warehouse) solves this by splitting your pipeline into three named layers, each with a clear job. Data flows strictly in one direction, getting more refined at every step:
The names are a convention — "raw / staging / marts" mean the same thing — but the discipline is the point: every transformation has exactly one layer it belongs in. When something breaks, you know which layer to inspect. When you need a new report, you build on silver instead of re-cleaning from scratch.
Bronze → silver → gold is a metaphor for increasing value, not increasing rarity. Bronze is plentiful and cheap (you keep everything). Gold is small, polished, and precious (the handful of tables the business actually reads). The work of refining happens in the middle.
Bronze: raw, immutable, append-only
The bronze layer is the landing zone: source data captured exactly as it arrived, with as little change as possible. No fixing typos, no renaming columns, no dropping bad rows. You read from the source and write it down faithfully — ideally appending, never overwriting, so every load adds a new batch rather than replacing the old one.
This feels wasteful to beginners ("why keep messy data?"), but it's the single most important habit in the architecture. Here's why you keep raw:
| Reason | What it buys you |
|---|---|
| Reprocessing | Found a bug in your cleaning logic? Re-run silver from bronze. You never have to re-fetch from the (slow, rate-limited, or already-changed) source. |
| Audit | "Why does this row say $0?" You can prove what the source actually sent, byte for byte, on the day it sent it. |
| Re-derivation | Bronze is the source of truth you can always rebuild everything from. If silver and gold were wiped, bronze regenerates them. The reverse is not true. |
| New questions | A column you ignored last year suddenly matters. If you'd dropped it at landing, it's gone forever. Bronze keeps the whole record. |
Bronze is often schema-on-read: you store the data first and interpret its structure later, when you read it. A messy CSV with mixed types lands as text; you decide what's an integer in the silver step. This means a malformed source can never block ingestion — you always capture it, then deal with it downstream where you can see it.
Don't be clever here. Every "small fix" you sneak into the landing step is a transformation with no audit trail and no home. If you find yourself writing a CASE WHEN in bronze, stop — that logic belongs in silver. Bronze's only job is capture faithfully.
Silver: cleaned, deduped, typed, conformed
Silver is where the engineering happens — the bulk of your effort and most of your code lives here. The goal is to turn faithful-but-messy bronze into clean, trustworthy, analysis-ready tables that still look roughly like the source (one silver table per source entity), just correct. Typical work in silver:
- Typing — cast the text columns from bronze into real
DATE,DECIMAL,INTEGERtypes, rejecting or flagging values that don't parse. - Cleaning — trim whitespace, normalize casing (
"US "→"us"), standardize nulls, fix obvious garbage. - Deduplication — the same row arrived twice from a retry? Keep one. (Pick the latest by a timestamp, drop the rest.)
- Conforming — make columns agree across sources: one canonical
customer_idformat, one currency, one timezone, consistent names. - Joining / enriching — stitch related sources together so downstream layers don't have to.
- Validating — drop or quarantine rows that fail business rules (negative hours, end before start).
Notice that silver still mirrors the source's grain — one row per rental, one row per customer. It is not yet shaped for any particular report. That's deliberate: a single clean silver table will feed many different gold marts, so it stays general-purpose.
If a transformation is about correctness ("this value is wrong / duplicated / mistyped"), it belongs in silver. If it's about a business question ("revenue per region per month"), it belongs in gold. Hold that distinction and most "which layer?" decisions answer themselves.
Gold: business-ready marts & metrics
Gold is the layer your BI dashboards, analysts, and stakeholders actually query. These are the dimensional models you built in Chapters 02–03 — fact tables, dimension tables, the star schema — plus aggregated metric tables built for specific questions. Gold is shaped for consumption: pre-joined, pre-aggregated, named in business language, fast to read.
| Layer | Grain / shape | Who reads it | Optimized for |
|---|---|---|---|
| Bronze | Exactly as the source sent it | Data engineers (debugging, reprocessing) | Faithful capture |
| Silver | One clean row per source entity | Engineers & advanced analysts | Correctness & reuse |
| Gold | Facts & dimensions; aggregates | BI tools, analysts, the business | Query speed & clarity |
A crucial point: one silver layer feeds many gold marts. A clean silver.rentals table might power a revenue star schema, a separate utilization mart, and a finance reconciliation table — all without re-cleaning the data three times. That reuse is exactly what makes the layering worth it.
Why layering beats one giant transform
You could, in principle, produce gold directly from sources in one heroic query. Engineers who've maintained that query never do it twice. Here's what layers buy you:
| Property | One giant transform | Bronze → silver → gold |
|---|---|---|
| Debuggability | The bug is somewhere in 300 lines; you can't see intermediate state. | Inspect each layer's output. A wrong number in gold? Check if silver was already wrong, then bronze. |
| Reprocessing | Re-run means re-fetching from sources — slow, sometimes impossible. | Rebuild silver/gold from bronze locally, as often as you like, safely. |
| Ownership | Everything tangled; nobody knows where a rule lives. | Each rule has one home. Cleaning lives in silver, metrics in gold. |
| Reusability | Every new report copy-pastes the cleaning logic. | Many gold marts share one clean silver table — clean once, use everywhere. |
When a gold number is wrong, you walk the layers backward: Is it wrong in silver too? If yes, the bug is in cleaning. If silver is fine, the bug is in the gold model. Is bronze even right? If bronze looks wrong, the source sent bad data and you have proof. This binary-search-through-layers is impossible with one monolithic query — and it's the everyday payoff of the architecture.
Hands-on: move the marketplace data through the layers
Let's build the three layers as schemas in one DuckDB database and push the marketplace rentals through them: land raw → clean to silver → build a gold star schema. Start a DuckDB session against a file so your work persists:
# from your mini-griddp repo
python -c "import duckdb; duckdb.connect('warehouse.duckdb')" # create the db file
duckdb warehouse.duckdb # open an interactive DuckDB shell (or use python)Step 1 — create the layer schemas and land raw data into bronze. We'll simulate a deliberately messy source: inconsistent casing, a duplicate row, a text date, and one invalid record. Bronze stores it all as text, exactly as received.
CREATE SCHEMA IF NOT EXISTS bronze;
CREATE SCHEMA IF NOT EXISTS silver;
CREATE SCHEMA IF NOT EXISTS gold;
-- Bronze: schema-on-read. Everything is VARCHAR; we capture, we don't judge.
CREATE OR REPLACE TABLE bronze.rentals (
rental_id VARCHAR,
customer VARCHAR,
gpu_model VARCHAR,
region VARCHAR,
hours VARCHAR,
rate_usd VARCHAR,
started_at VARCHAR,
loaded_at TIMESTAMP DEFAULT now() -- when WE landed it (audit)
);
INSERT INTO bronze.rentals
(rental_id, customer, gpu_model, region, hours, rate_usd, started_at)
VALUES
('1', 'acme', 'A100', 'US ', '10', '2.50', '2026-06-01'),
('2', 'Acme', 'A100', 'us', '5', '2.50', '2026-06-02'),
('3', 'globex', 'H100', 'EU', '8', '4.00', '2026-06-02'),
('3', 'globex', 'H100', 'EU', '8', '4.00', '2026-06-02'), -- dup (retry)
('4', 'initech','H100', 'eu', '-3', '4.00', '2026-06-03'), -- invalid hours
('5', 'globex', 'A100', 'US', '12', '2.50', 'not-a-date'); -- bad date
SELECT count(*) AS bronze_rows FROM bronze.rentals;bronze_rows = 6 — every record landed, including the duplicate, the negative-hours row, and the unparseable date. Bronze captured everything faithfully. Nothing was fixed or rejected; that's the next layer's job.
Step 2 — clean into silver. Now we type, normalize, deduplicate, and validate. Each fix is explicit and lives here, where we can read it:
CREATE OR REPLACE TABLE silver.rentals AS
WITH typed AS (
SELECT
CAST(rental_id AS INTEGER) AS rental_id,
lower(trim(customer)) AS customer, -- conform casing
gpu_model,
lower(trim(region)) AS region, -- "US " -> "us"
CAST(hours AS INTEGER) AS hours,
CAST(rate_usd AS DECIMAL(8,2)) AS rate_usd,
try_cast(started_at AS DATE) AS started_at, -- bad date -> NULL
loaded_at
FROM bronze.rentals
),
deduped AS ( -- keep one row per rental_id
SELECT *, row_number() OVER (
PARTITION BY rental_id ORDER BY loaded_at DESC) AS rn
FROM typed
)
SELECT rental_id, customer, gpu_model, region, hours, rate_usd, started_at
FROM deduped
WHERE rn = 1 -- drop the duplicate
AND hours > 0 -- drop invalid hours
AND started_at IS NOT NULL; -- drop the unparseable date
SELECT * FROM silver.rentals ORDER BY rental_id;3 clean rows — rentals 1, 2, and 3. The duplicate of rental 3 is gone, rental 4 (negative hours) is dropped, and rental 5 (bad date) is dropped. Regions are all lowercase, customer is normalized (acme == Acme), and started_at is a real DATE. Silver is now trustworthy.
Step 3 — build the gold star schema. Shape silver into the dimensional model from Chapters 02–03: a fact table at the rental grain plus a conformed customer dimension. Gold is what a dashboard would query.
-- Dimension: one row per customer (surrogate key)
CREATE OR REPLACE TABLE gold.dim_customer AS
SELECT row_number() OVER (ORDER BY customer) AS customer_key,
customer AS customer_name
FROM (SELECT DISTINCT customer FROM silver.rentals);
-- Fact: one row per rental, with the additive revenue measure
CREATE OR REPLACE TABLE gold.fct_rental AS
SELECT r.rental_id,
c.customer_key,
r.gpu_model,
r.region,
r.started_at,
r.hours,
r.rate_usd,
r.hours * r.rate_usd AS revenue_usd -- the metric the business wants
FROM silver.rentals r
JOIN gold.dim_customer c ON c.customer_name = r.customer;
-- A business question, answered off gold:
SELECT c.customer_name,
sum(f.revenue_usd) AS total_revenue
FROM gold.fct_rental f
JOIN gold.dim_customer c USING (customer_key)
GROUP BY 1 ORDER BY 2 DESC;Revenue per customer: globex = 32.00 (8h × $4.00), acme = 37.50 (10h × $2.50 + 5h × $2.50, both rentals rolled into one customer thanks to silver's conforming). Two customers, clean numbers, derived in three readable hops from raw. If a number ever looked wrong, you could now query silver.rentals, then bronze.rentals, to find exactly where it went off.
How medallion maps to modeling — and foreshadows dbt
You didn't learn a new modeling skill today; you learned where the skills you already have belong. The medallion layers are a home for everything from the previous chapters:
| Layer | What you learned earlier | In dbt (Chapter 07) |
|---|---|---|
| Bronze | The raw source — your rentals table, landed as-is | Sources / seeds |
| Silver | Relational cleanup & normalization (Ch. 01), SCD typing & dedup (Ch. 03) | Staging models (stg_*) |
| Gold | The star schema — facts & dimensions (Ch. 02–03) | Marts (fct_*, dim_*) |
The mapping is almost exact: gold is the star schema you built in the modeling chapters, and silver is the cleaning that feeds it. This is not a coincidence — it's why we taught modeling first. When we reach dbt in Chapter 07, you'll see that its conventional project layout is the medallion architecture made concrete: a staging/ folder (≈ silver) and a marts/ folder (= gold), each model in its own file, dependencies wired automatically. You're learning the pattern by hand now so the tool feels obvious later.
✓ Check yourself
Answer from memory before opening each one.
-
The dashboard shows
acmeat25.00instead of37.50, with a second customer calledAcmeat12.50. You querysilver.rentalsand find both spellings there too;bronze.rentalshas them exactly as the source sent them. Which layer holds the bug, and which two are doing their job?Show answer
Silver: the conforming step (
lower(trim(customer))) is missing or broken, so one customer survives as two. Gold is fine — it faithfully summed what silver gave it — and bronze is fine, because capturing the source's inconsistent casing untouched is its job. Walking backward from gold until the data first looks right is the everyday payoff of the layers. See Why layering beats one giant transform. -
The source keeps sending
'US 'with a trailing space. A teammate adds atrim()to the landing step: "it's obviously wrong, and it saves silver the work." What does that small fix cost?Show answer
Bronze stops being proof of what the source actually sent, and the fix becomes a transformation with no audit trail and no home — nobody looking for cleaning rules will look in the loader. Bronze's only job is to capture faithfully, so that silver and gold can always be rebuilt from it; any correction, however obvious, belongs in silver where it can be read and re-run. See Bronze.
-
In the silver step, replace
try_cast(started_at AS DATE)with a plainCASTand re-run it. What happens — and why did the same bad value cause no trouble at all when it landed in bronze?Show answer
The whole statement fails with a conversion error on
'not-a-date', so silver isn't built. Bronze was unaffected because it is schema-on-read: every column isVARCHAR, so a malformed value can never block ingestion.try_castturns the bad value intoNULL, and thestarted_at IS NOT NULLfilter then makes the decision about that row explicit — in silver, where you can see it. See schema-on-read and step 2. -
To speed up the revenue dashboard, an engineer proposes building
silver.rentalsas "revenue per customer per month". Next quarter the business asks for a GPU-utilization mart and a finance reconciliation table. What has the proposal cost, and what rule of thumb would have prevented it?Show answer
Both new marts need per-rental rows, which that silver table no longer has, so each would have to re-clean from bronze — the copy-pasted cleaning logic the layers exist to avoid. Silver stays at the source grain (one clean row per rental) so that one silver table can feed many gold marts. The rule: if a transformation is about correctness it is silver; if it is about a business question — and a monthly revenue rollup is — it is gold. See Silver and Gold.
-
In the hands-on, rental 5 (12 hours at $2.50) is dropped in silver because its date won't parse, so $30 never reaches gold. The chapter says failing rows can be dropped or quarantined. What decides between them, and what makes either choice recoverable?
Show answer
Dropping keeps silver simple and trustworthy, and is fine when the failures are genuine junk. Quarantining — writing failures to a side table, or flagging them — wins when the rows carry real money or when the number of failures is itself a signal that the source has changed, because a silent drop quietly understates revenue. Either way the row is still in bronze, so once the rule or the source is fixed you can rebuild silver and get it back. See Silver and why you keep raw.
Exercise — Place the transformations for a new messy CSV source
A new source arrives: gpu_usage.csv, an hourly export from a billing system. It has duplicate rows on retries, a price column stored as "$2.50" text, mixed-case regions, a free-text status field, and the business wants a "revenue by GPU model per month" dashboard from it. List which transformation belongs in bronze, silver, and gold.
BRONZE (capture faithfully, no changes)
- Load gpu_usage.csv exactly as received, all columns as text.
- Append, don't overwrite; stamp a loaded_at for audit.
- Keep the duplicates and the "$2.50" string AS-IS. Don't fix anything.
SILVER (correctness: type, clean, dedup, conform)
- Parse "$2.50" -> DECIMAL price (strip the $, cast).
- Lowercase/trim region; standardize the status values.
- Deduplicate retry rows (row_number over a key, keep latest).
- Cast the hourly timestamp to a real TIMESTAMP; drop/flag unparseable rows.
- Result: one clean row per usage record, same grain as the source.
GOLD (business question: marts & metrics)
- Build dim_gpu (one row per model) and a fact at the usage grain.
- Compute revenue = hours * price as an additive measure.
- Aggregate revenue by gpu_model and month for the dashboard.The tell: anything about fixing the data ("$2.50" → number, dedup, casing) is silver; anything about answering the question (revenue, by-model, by-month rollup) is gold; and "store it untouched" is bronze. If you sorted them that way, the architecture has clicked.
Takeaway
- Bronze keeps the source exactly as received, append-only and untouched, because it is the only layer you can rebuild everything else from.
- A fix that is about correctness (types, casing, duplicates, invalid rows) belongs in silver, and a rule about a business question (revenue per region) belongs in gold.
- Silver keeps the source's grain, one clean table per entity, so one silver table can feed many gold marts without re-cleaning.
- When a gold number is wrong, walk the layers backward: if silver is already wrong the bug is in cleaning, and if bronze is wrong the source sent bad data and you have proof.
- The layers map onto dbt as sources, staging models and marts, so the pattern you hand-built is the project layout you will use next.
Bronze was hand-typed here, and a real pipeline has to get data in reliably from live sources, so the next chapter covers ingestion. → Ingestion & ELT/ETL
Further reading
- What is a medallion architecture? — Databricks. The vendor definition of the three layers and their guarantees.
- Medallion architecture — Databricks docs, accessed 2026. What each layer holds and how data is validated as it moves up.
- How we structure our dbt projects — dbt Labs docs, accessed 2026. The staging, intermediate and marts layout that this chapter's layers become.