Skip to main content
Databricks beginner Lesson 5 of 10

The Medallion Architecture

Bronze, silver and gold as three concrete tables — what belongs in each layer, why bronze keeps the bad rows, and when the pattern is more structure than you need.

The medallion architecture is three tables with three jobs. It is not a Databricks feature — nothing enforces it — but it is the convention most lakehouse projects converge on, and the reasoning behind it is worth more than the vocabulary.

LayerHoldsRule
Bronzeraw, as ingested, nothing droppedappend-only, replayable
Silvercleaned, typed, deduplicated, one row per entityno business aggregation
Goldjoined and aggregated for consumptionreads only silver

The property that makes it work: each layer can be rebuilt from the one before it. Drop gold and rebuild from silver. Drop silver and rebuild from bronze. Only bronze needs the source files, and only bronze is irreplaceable.

Bronze: lose nothing

(spark.readStream.format("cloudFiles")
    .option("cloudFiles.format", "csv")
    .option("cloudFiles.schemaLocation", "/Volumes/bookshop/raw/_schemas/orders")
    .option("cloudFiles.schemaHints", "order_id bigint, amount decimal(10,2)")
    .option("header", "true")
    .load("/Volumes/bookshop/raw/landing/")
    .selectExpr("*", "current_timestamp() as _ingested_at", "_metadata.file_path as _source_file")
 .writeStream
    .option("checkpointLocation", "/Volumes/bookshop/raw/_checkpoints/bronze_orders")
    .trigger(availableNow=True)
    .toTable("bookshop.bronze.orders"))
{"batchId": 0, "numInputRows": 13932, "numFilesProcessed": 3}
select order_id, status, amount, _rescued_data is not null as has_problem
from bookshop.bronze.orders
order by order_id limit 4;
order_id  status     amount  has_problem
--------  ---------  ------  -----------
    1001  completed   25.50  false
    1002  COMPLETED   12.00  false
    1003  returned    40.00  false
    1412  completed    NULL  true

Two things bronze deliberately keeps. Row 1002’s COMPLETED is inconsistently cased — not bronze’s problem. Row 1412 failed to parse — recorded, not discarded.

No transformation, no filtering, no deduplication. The temptation to “just lowercase the status here” is the one to resist: the moment bronze transforms, it stops being a faithful record of what arrived, and a bug in that transformation is unrecoverable.

Silver: make it trustworthy

create or replace table bookshop.silver.orders as
with deduplicated as (
    select *,
           row_number() over (partition by order_id order by _ingested_at desc) as rn
    from bookshop.bronze.orders
    where _rescued_data is null          -- bad rows handled separately
      and order_id is not null
)
select
    order_id,
    customer_id,
    ordered_at,
    lower(trim(status))              as status,
    amount,
    amount >= 25                     as is_large_order,
    _ingested_at
from deduplicated
where rn = 1
  and status is not null;
num_affected_rows  num_inserted_rows
-----------------  -----------------
             9788               9788
select status, count(*) as n from bookshop.silver.orders group by 1 order by n desc;
status     n
---------  ----
completed  7204
returned   1588
refunded    996

COMPLETED and completed are now one value. 13,932 bronze rows became 9,788 silver rows — the difference is duplicates from overlapping file loads, rescued rows, and nulls, and it is worth reconciling that number rather than accepting it.

The deduplication window is the piece people leave out. Files often overlap, and row_number() over the ingestion timestamp keeps the latest version of each order.

Quarantine rather than drop:

create or replace table bookshop.silver.orders_quarantine as
select order_id, _rescued_data, _source_file, _ingested_at
from bookshop.bronze.orders
where _rescued_data is not null or order_id is null;
num_affected_rows  num_inserted_rows
-----------------  -----------------
              412                412
select
    get_json_object(_rescued_data, '$.amount') as bad_amount,
    count(*) as n
from bookshop.silver.orders_quarantine
group by 1 order by n desc limit 3;
bad_amount   n
-----------  ---
n/a          281
             98
unknown       33

Now the data-quality problem has a size and a shape, and someone can go to the source team with “281 rows a day arrive with n/a in amount” instead of a vague complaint.

Gold: answer questions

create or replace table bookshop.gold.customer_orders as
select
    c.customer_id,
    c.full_name,
    c.country_code,
    count(o.order_id)                                   as order_count,
    coalesce(sum(case when o.status = 'completed' then o.amount end), 0) as lifetime_value,
    min(o.ordered_at)                                   as first_order_at,
    max(o.ordered_at)                                   as last_order_at
from bookshop.silver.customers c
left join bookshop.silver.orders o using (customer_id)
group by 1, 2, 3;
num_affected_rows  num_inserted_rows
-----------------  -----------------
             4120               4120
create or replace table bookshop.gold.daily_revenue as
select
    o.ordered_at,
    c.country_code,
    count(*)        as orders,
    sum(o.amount)   as revenue,
    sum(case when o.is_large_order then 1 else 0 end) as large_orders
from bookshop.silver.orders o
join bookshop.silver.customers c using (customer_id)
where o.status = 'completed'
group by 1, 2;
num_affected_rows  num_inserted_rows
-----------------  -----------------
              184                184
select * from bookshop.gold.daily_revenue order by ordered_at limit 4;
ordered_at  country_code  orders  revenue  large_orders
----------  ------------  ------  -------  ------------
2026-01-04  GB                42   1088.40           18
2026-01-04  US                31    802.11           14
2026-01-05  GB                38    944.02           16
2026-01-05  US                29    714.88           11

Gold reads silver, never bronze. That rule is what stops the lower(trim(status)) logic being reimplemented in six dashboards — and reimplemented slightly differently in one of them.

The layer boundaries in practice

Bronze                          Silver                        Gold
─────────────────────────────   ───────────────────────────   ─────────────────────────
append raw rows                 dedup on business key         join across entities
add _ingested_at, _source_file  cast and normalise types       aggregate
keep _rescued_data              trim, lowercase, standardise   apply business definitions
never filter                    filter genuinely invalid       shape for consumption
never join                      light lookups only             one table per question

Common mistakes, in order of how often they cause trouble:

  1. Transforming in bronze. Now a bad transformation is permanent.
  2. Aggregating in silver. Gold then cannot answer a question at a different grain.
  3. Reading bronze from gold. The cleaning is bypassed and nobody notices until numbers disagree between two dashboards.
  4. Dropping bad rows at ingestion. The bug becomes undiscoverable.

Rebuilding

drop table bookshop.gold.customer_orders;
drop table bookshop.gold.daily_revenue;
-- rerun the gold cell
num_affected_rows  num_inserted_rows
-----------------  -----------------
             4120               4120

Gold rebuilt from silver in seconds, with no reference to any source file. Test this deliberately — a gold table that cannot be rebuilt has accumulated state it should not have, usually a manual fix someone applied directly.

When it is too much

Three layers on a project with one clean source and two dashboards is ceremony. Bronze plus gold is a legitimate architecture when the source is already conformed. Equally, a large domain often needs a fourth layer — feature tables for ML, or export-shaped tables for reverse ETL — and adding it is not a violation of anything.

Keep the rule, negotiate the layer count: every table is rebuildable from the layer below, and nothing skips a layer.

Practice

1. Count rows in bronze and silver and account for the difference.
select
    (select count(*) from bookshop.bronze.orders)            as bronze,
    (select count(*) from bookshop.silver.orders)            as silver,
    (select count(*) from bookshop.silver.orders_quarantine) as quarantined;
bronze  silver  quarantined
------  ------  -----------
 13932    9788          412

13,932 − 9,788 − 412 = 3,732 rows lost to deduplication. If that number is a surprise, the overlap between incoming files is larger than anyone thought — worth knowing.

2. Normalise a status column in silver and check the distinct values.
-- bronze
status
---------
completed
COMPLETED
Completed
returned

-- silver
status
---------
completed
returned

Four values became two. Doing this once in silver rather than in each gold table is the entire argument for the middle layer.

3. Rebuild a gold table from scratch.
num_inserted_rows
-----------------
             4120

Same row count, no source files touched. A gold table that cannot survive this is holding state that exists nowhere else, which will be discovered at the worst possible moment.

4. Query the quarantine table and group by the failure reason.
select get_json_object(_rescued_data, '$.amount') as bad_value, count(*) as n
from bookshop.silver.orders_quarantine
group by 1 order by n desc;
bad_value    n
-----------  ---
n/a          281
             98
unknown       33

Three distinct upstream bugs, each with a count. Two of them are fixable with a null_if-style rule; the third needs a conversation with whoever emits unknown.

Next: declarative pipelines — the same three layers with dependencies and quality rules managed for you.

Frequently Asked Questions

What are the bronze, silver and gold layers?
Bronze is raw ingested data kept as it arrived, silver is cleaned and conformed one-row-per-entity data, and gold is aggregated and joined tables serving dashboards. The value is that each layer can be rebuilt from the one before it.
Should bad records go in bronze?
Yes. Bronze's job is to lose nothing, so malformed rows land with their problems recorded — in `_rescued_data` or a quarantine table. Filtering at ingestion means the record is gone for good and the bug is undiscoverable.
Do I always need three layers?
No. Two are enough for a small project where the source is already clean, and some domains need a fourth for feature tables or exports. The names matter less than the rule behind them: each layer rebuildable from the previous one.
How is this different from staging and marts in dbt?
Mostly in naming. Bronze has no dbt equivalent because dbt starts from data already loaded, silver maps to staging plus intermediate models, and gold maps to marts. The layering discipline is the same idea arrived at from two directions.