Course 4 · The Craft

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.

Setup — do this first

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:

SOURCES BRONZE SILVER GOLD (Postgres, CSV, raw, as-received cleaned, typed, business-ready APIs, files) append-only deduped, joined marts & metrics │ │ │ │ ▼ ▼ ▼ ▼ ┌──────────┐ land ┌──────────┐ clean ┌──────────┐ model ┌──────────┐ │ rentals │ ──────▶ │ bronze. │ ─────▶ │ silver. │ ─────▶ │ gold. │ │ prices │ exact │ rentals │ fix │ rentals │ star │ fct_rent │ │ customers│ copy │ prices │ types │ (clean) │ schema │ dim_* │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ───────────────────────────────────────────────────────────────────────▶ each arrow is a transformation with ONE clear job & ONE home

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.

Why "medallion"?

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:

ReasonWhat it buys you
ReprocessingFound 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-derivationBronze 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 questionsA column you ignored last year suddenly matters. If you'd dropped it at landing, it's gone forever. Bronze keeps the whole record.
Schema-on-read

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.

⚠ The one rule of bronze

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, INTEGER types, 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_id format, 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.

Rule of thumb

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.

LayerGrain / shapeWho reads itOptimized for
BronzeExactly as the source sent itData engineers (debugging, reprocessing)Faithful capture
SilverOne clean row per source entityEngineers & advanced analystsCorrectness & reuse
GoldFacts & dimensions; aggregatesBI tools, analysts, the businessQuery 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:

PropertyOne giant transformBronze → silver → gold
DebuggabilityThe 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.
ReprocessingRe-run means re-fetching from sources — slow, sometimes impossible.Rebuild silver/gold from bronze locally, as often as you like, safely.
OwnershipEverything tangled; nobody knows where a rule lives.Each rule has one home. Cleaning lives in silver, metrics in gold.
ReusabilityEvery new report copy-pastes the cleaning logic.Many gold marts share one clean silver table — clean once, use everywhere.
The debugging move

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:

terminal
# 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.

sql · DuckDB
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;
✓ You should see

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:

sql · DuckDB
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;
✓ You should see

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.

sql · DuckDB
-- 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;
✓ You should see

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:

LayerWhat you learned earlierIn dbt (Chapter 07)
BronzeThe raw source — your rentals table, landed as-isSources / seeds
SilverRelational cleanup & normalization (Ch. 01), SCD typing & dedup (Ch. 03)Staging models (stg_*)
GoldThe 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

  • Can you state each layer's one job in a sentence — bronze, silver, gold?
  • Why do we keep raw data in bronze even though it's messy? Name two reasons.
  • A stakeholder's revenue number looks wrong. What's the layer-by-layer move to find the bug?
  • Why does silver stay at the source grain instead of being pre-aggregated?
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.

solution
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.

Next

You can organize transformations into layers — but we hand-typed the raw data into bronze. Real pipelines have to get the data in reliably from live sources, which is its own craft. → Ingestion & ELT/ETL