Medallion (bronze/silver/gold), wide tables / One Big Table, partitioning & clustering as the physical model, dbt models, and the semantic / metrics layer.
⏱ 18 min readTopics chapter readerLevel · Dimensional & Warehouse
01 · Overview
Start here
⏱ 5 min · Topic 1 of 12
You know three warehouse methods: Kimball, Inmon and Data Vault. This chapter asks a newer question. In a cloud warehouse with dbt, a tool that builds tables from SQL files, how do you lay out your tables?
Many cloud warehouses bill for the data a query reads, or for the computer time it uses. So the shape of a table affects the cost of every query that touches it.
Recap: a layer is a group of tables at one stage of cleaning, such as raw, cleaned and serving. A star puts one fact table in the middle with dimension tables around it. The grain says what one row of a table means.
What you will learn
The medallion layers (bronze, silver, gold), what each holds, and the tests for each.
How dbt builds the layers: model names, ref() and incremental models.
When to use a dbt snapshot to keep history.
One big table or a star: build both for the shop and compare the costs.
The semantic layer: define paid revenue once.
Names and contracts, and what to say in an interview.
02 · Layers
The medallion architecture
⏱ 12 min · Topic 2 of 12
The medallion architecture sorts tables into three layers: bronze, silver and gold. Each layer is cleaner than the one before it.
Three layers
Layer
Other names
What it holds
Shop example
Bronze
Raw, source
Data exactly as the source sent it.
bronze_orders
Silver
Cleaned, staging, intermediate
Clean data, often one row for each thing.
stg_orders
Gold
Serving, marts
Models that people and dashboards use.
fct_orders, dim_customer, obt_orders
Bronze: keep what arrived
Bronze holds the data as it came, plus a load time. You never fix a row here. On 2026-01-23 the source sent order 103 again, so bronze has it twice:
bronze_orders (7 rows)
order_id
customer_id
status
amount
order_date
_loaded_at
101
1
paid
40.00
2026-01-03
2026-01-22 02:00
102
1
Paid
25.00
2026-01-10
2026-01-22 02:00
103
2
refunded
60.00
2026-01-11
2026-01-22 02:00
103
2
refunded
60.00
2026-01-11
2026-01-23 02:00
104
3
paid
15.50
2026-01-15
2026-01-22 02:00
105
3
pending
NULL
2026-01-20
2026-01-22 02:00
106
4
paid
80.00
2026-01-21
2026-01-22 02:00
Order 102 says Paid with a capital letter. Order 103 sits in two loads. Bronze keeps both, because it is your proof of what arrived.
Silver: clean, one row each
Silver fixes what is wrong but changes no meaning. It does three things:
It keeps the newest copy of each order, so order 103 appears once.
It writes status in lower case, so Paid becomes paid.
It changes text into real types: amount into a number, order_date into a date.
The incremental model in the dbt section shows the code. This is the result:
stg_orders (6 rows)
order_id
customer_id
status
amount
order_date
_loaded_at
101
1
paid
40.00
2026-01-03
2026-01-22 02:00
102
1
paid
25.00
2026-01-10
2026-01-22 02:00
103
2
refunded
60.00
2026-01-11
2026-01-23 02:00
104
3
paid
15.50
2026-01-15
2026-01-22 02:00
105
3
pending
NULL
2026-01-20
2026-01-22 02:00
106
4
paid
80.00
2026-01-21
2026-01-22 02:00
There are six rows, one for each order. Order 103 kept its newer copy, loaded on 2026-01-23.
Silver is not always one table for each source. Many teams put a Data Vault here, as the chapter Inmon vs Kimball vs Data Vault showed. A dbt snapshot keeps old versions of a source table, so it sits beside bronze.
Gold: the tables people use
Gold holds the star, and the wide tables that a later section builds. Here is fct_orders, the fact table. Its customer_key values are the dimension keys from earlier chapters:
fct_orders (6 rows)
order_id
customer_key
order_date
status
amount
101
1001
2026-01-03
paid
40.00
102
1006
2026-01-10
paid
25.00
103
1002
2026-01-11
refunded
60.00
104
1003
2026-01-15
paid
15.50
105
1003
2026-01-20
pending
NULL
106
1004
2026-01-21
paid
80.00
Orders 101 and 102 use keys 1001 and 1006. These are Asha’s IN row and Asha’s DE row.
Watch out
Keep report rules in gold, such as what counts as paid revenue. Silver should only fix data, not decide its meaning. If silver filters out refunds, no later model can count them again.
03 · Layers
Tests at each layer
⏱ 6 min · Topic 3 of 12
Each layer makes a different promise, so each layer gets different tests:
One promise for each layer
Layer
Promise
Tests
Bronze
The data arrived, on time.
Source freshness and a row count. No business rules.
Silver
One clean row for each order.
unique, not_null and accepted_values.
Gold
The numbers are right.
Facts point at real dimension rows, and totals match silver.
Here are the tests as dbt YAML, in two files. The first file is for bronze, and the second is for silver and gold:
Freshness warns after 12 hours and fails after 24, and unique passes because order 103 appears once in silver. Newer dbt versions move some keys; check the docs for yours.
A singular test is one SQL file. It fails when it returns rows. This one checks that gold and silver agree on paid revenue:
tests/assert_paid_revenue_matches_silver.sqlworked example
Both totals are 160.50 (40.00 + 25.00 + 15.50 + 80.00). The query returns no rows, so the test passes.
Watch out
A test passes when it finds no bad rows. An empty table has no bad rows, so it passes too. Also check that each table has rows.
04 · dbt
dbt layers, ref and incremental
⏱ 11 min · Topic 4 of 12
dbt builds your layers from SQL files. Each file is a model: one select statement that dbt turns into a table or view. The file name tells you the layer:
dbt model names
Prefix
Layer
Job
Shop model
stg_
Silver
Clean one source table.
stg_orders
int_
Silver
Join or reshape between steps.
int_order_items_rollup
fct_
Gold
Facts at a stated grain.
fct_orders
dim_
Gold
Dimensions.
dim_customer
obt_
Gold
One big table.
obt_orders
Bronze is not a model. dbt reads it as a source. The folders are usually staging, intermediate and marts.
ref() links the models
In a model you write ref() with another model’s name, not a table name. Then dbt learns which model must build first. It also fills in the right schema for development and for production.
This model counts the items in each order. It reads stg_order_items with ref():
Order 101 has two items, Pen 2 and Notebook 1, so item_count is 2 and units is 3. Order 105 has no items, so it has no row here.
Incremental models
A normal model rebuilds every row on each run. An incremental model builds only the new rows after its first run. On a large table this saves computer time.
In the code, source() reads a bronze table, as ref() reads a model. The config line sets materialized, which tells dbt how to build the model. It can build a view, a table, or an incremental table.
The silver orders model fits well. On later runs it reads only bronze rows loaded since the last run:
stg_orders as an incremental model, run 2worked example
Run 2 read one new bronze row. With unique_key, dbt updates row 103 instead of adding a second one. On most warehouses it does this with a MERGE, so the table still has six rows.
The is_incremental() check is false on the first run. It is also false with --full-refresh, which rebuilds the whole table. Then the model reads all of bronze.
{{ this }} means the table this model already built.
The chapter Incremental Models Done Right covers late data in depth.
Watch out
The append strategy (one way dbt adds new rows) only inserts rows, and so does merge without a unique_key. Then the re-sent order 103 appears twice in silver, and the unique test fails. The row_number() step also matters: two copies in one batch can make a merge fail, or add both copies.
05 · History
dbt snapshots keep history
⏱ 11 min · Topic 5 of 12
The source customers table is overwritten when a customer moves. Today Asha’s row says DE, and the IN is gone:
shop_raw.customers (the source today) (5 rows)
customer_id
name
country
updated_at
1
Asha
DE
2026-01-08
2
Ben
GB
2026-01-01
3
Carla
US
2026-01-01
4
Dev
NULL
2026-01-01
5
Omar
IN
2026-01-01
Recap: SCD Type 2 keeps one row for each version of a customer, with valid_from and valid_to dates. A dbt snapshot builds those versions for you. Each run, it compares the source with its saved copy:
snapshots/snap_customers.sql (timestamp strategy)worked example
A real customer_key is a hash: a long text code made from customer_id and dbt_valid_from. The table shows 1001 to 1006, as in earlier chapters, to stay short.
Since dbt 1.9, the dbt_valid_to_current config can store the far-future date for you. Also, dbt 1.9 and later prefer snapshots in YAML, with schema instead of target_schema.
An as-of join picks the dimension row that was valid on the order date. This is how fct_orders gets its customer_key:
Order 101 (2026-01-03) falls in Asha’s IN row, 1001. Order 102 (2026-01-10) falls in Asha’s DE row, 1006. Dev has a NULL country but still has a row, 1004, so order 106 keeps its key.
Two strategies
dbt must decide that a row changed. It has two strategies:
timestamp: dbt compares an updated_at column. It is cheap, and dbt_valid_from is the updated_at value. Use it when the source keeps updated_at correct.
check: dbt compares the columns you list in check_cols, such as country. Use it when there is no reliable updated_at.
Both strategies see only the state at each run. If Asha moved twice between two runs, the snapshot keeps only the last state.
Watch out
With the check strategy, dbt_valid_from is the time the snapshot ran, not the day Asha moved. If the snapshot runs on 2026-01-09, Asha’s DE row starts on 2026-01-09. An order from 2026-01-08 then matches the IN row.
When to snapshot
Snapshot when the source overwrites rows and nothing else keeps the history.
Skip it when the source already keeps history, such as a change log. A change data capture (CDC) feed also does this: it sends every insert, update and delete. Build the SCD Type 2 table from that feed.
Snapshot raw source tables with a simple select, not finished models.
A row deleted in the source stays open in the snapshot. To close it, set hard_deletes to invalidate (older versions: invalidate_hard_deletes).
Protect the snapshot table. dbt cannot rebuild lost versions, because the source no longer has them.
The chapter Snapshots and Slowly Changing Dimensions in dbt covers the details.
06 · One big table
Build one big table
⏱ 5 min · Topic 6 of 12
A one big table (OBT) is a wide table. It holds the facts and the dimension columns already joined, so a dashboard reads it with no joins.
Build it from the star, not from raw data. This model joins fct_orders, dim_customer and the item rollup. The grain is one row for each order:
The inputs are the tables you saw above. The join on customer_key gives each order the country at order time: IN for order 101, DE for order 102. The left join keeps order 105, which has no items, and coalesce turns its NULLs into 0.
Now a dashboard asks for paid revenue by country. It reads one table:
Paid revenue by country (one big table)worked example
It matches the star result from the chapter Inmon vs Kimball vs Data Vault. There is no join to write, so the query is hard to get wrong.
07 · One big table
Star or one big table
⏱ 12 min · Topic 7 of 12
The star and the one big table answer the same question. They differ in what happens when things change. Look at three costs.
Cost 1: bytes scanned
Recap: a columnar warehouse reads only the columns a query names. The revenue query reads status, amount and country from obt_orders. The other columns cost nothing for that query.
So a wide table is not slow because it is wide. Its saving is the join, not the scan. A star reads similar columns from two tables and joins them, and a small dimension is cheap to join.
A partition is a part of a table that holds one range of values, such as one day. A date filter helps both designs when the table is partitioned by order_date. Measure your own query in your warehouse, and do not guess.
Cost 2: the country is frozen
obt_orders picked one country for each order when it was built. It used the as-of country. A star can answer a second question from the same tables: revenue by country today.
The first join finds the order’s dimension row. The second join finds that customer’s current row:
Paid revenue by country today (star)worked example
Asha is in DE now, so orders 101 (40.00) and 102 (25.00) both land in DE. Compare with the answer at order time, where IN had 40.00.
Here are both answers side by side:
Paid revenue by country, two questions
country
At order time
By country today
NULL
80.00
80.00
IN
40.00
no row
DE
25.00
65.00
US
15.50
15.50
The one big table holds only the first answer. To also answer the second, add a country_today column. Then every move by a customer means rewriting that customer’s rows.
Cost 3: repeated values
The name Asha is stored on both of Asha’s rows. Carla and US are stored on both of Carla’s rows. In a columnar format, repeated values compress well, so the extra storage is often small.
The bigger cost is change. A wrong customer name means rewriting every row of that customer in the wide table. A star fixes only the dimension rows: one row for Carla, not Carla’s two orders.
Watch out
Add one row for each order item, and orders 101 and 106 repeat. Paid SUM(amount) then gives 280.50, not 160.50. Choose one grain for each wide table, and sum only measures that belong to that grain.
Side by side
Star vs one big table
Question
Star
One big table
How many joins does a dashboard write?
One for each dimension it uses.
None.
What happens to a country change?
The as-of join or the current join, from the same tables.
It holds the country chosen at build time.
Where is a value stored?
Once for each version, in its dimension.
On every row that uses it.
How do you fix a wrong value?
Update that customer’s dimension rows.
Rewrite every row that holds it.
What if a new question needs a new column?
Join another dimension.
Rebuild the table with the new column.
A common answer is to use both. Keep the star as the truth. Build the wide table from it for a few busy dashboards, and rebuild it when the star changes.
08 · Metrics
The semantic / metrics layer
⏱ 7 min · Topic 8 of 12
A metric is a number the business tracks, such as paid revenue. If each dashboard writes its own SQL, the same name gets different numbers.
Two dashboards both say revenue. One adds every amount, and one adds only paid orders:
Same name, two numbers
Dashboard
Rule in its SQL
Revenue
Sales
SUM(amount) with no filter. It includes refunded order 103.
220.50
Finance
SUM(amount) where status = paid.
160.50
A semantic layer is one place where you define each metric once. Tools ask it for the metric by name. The definition is in code, so it is reviewed like code.
This is paid revenue in dbt’s MetricFlow format. A measure says how to add up one column, and the metric uses the measure:
Now every tool that asks for paid_revenue gets 160.50 for the whole shop. The YAML shape changes between dbt versions, so check the docs for yours.
An entity is the key of the model (order_id). Here a dimension is a column you group or filter by, not a dimension table.
The semantic layer does not fix a wrong grain. Point it at a table with one row for each order item, and paid_revenue becomes 280.50. Define the metric on a table whose grain matches the measure.
The chapter The Semantic Layer and MetricFlow covers the tool.
09 · Habits
Names and contracts
⏱ 5 min · Topic 9 of 12
Recap: a name should tell you the layer, and a data contract should tell you the columns. Both help the next engineer.
Use the prefixes from the dbt section. Put the grain in the model description too, because a short name does not always show it. Keep one name for one thing, for example order_id in every layer.
In dbt, a model contract makes the build fail when the table does not match its promise. Gold tables that other teams read are the best place for one:
When a contract is enforced, every column needs a data_type. If a model returns a different type, or drops amount, the build fails before anyone reads bad data. The column customer_key is text, because the dimension builds it as a hash.
The chapter Data Contracts, Schema Evolution & Governance covers versions and how to change a contract safely.
10 · Recap
Recap and interview answer
⏱ 5 min · Topic 10 of 12
Five ideas to keep:
Bronze keeps what arrived. Silver cleans it to one row each. Gold holds the models people use, with the business rules.
Test the promise of each layer: it arrived, it is clean, it is right.
dbt builds layers from models. ref() sets the build order, and unique_key keeps an incremental model from adding duplicates.
Snapshot a source that overwrites history. A snapshot only sees the state at each run.
Keep a star as the truth, and define each metric once in a semantic layer.
Check yourself
Why does bronze keep order 103 twice, and silver keep it once?
What does obt_orders give for Asha’s country on order 101, and what does a current-country build give?
Why is paid SUM(amount) 280.50 on a table with one row for each order item?
Interview question: star or one big table?
Start with the grain: one row for each order. Then explain when a wide table helps: a few busy dashboards that ask fixed questions. Name the cost: a changed customer value means a rebuild of that table.
11 · Practice
Practice lab
⏱ 3 min · Topic 11 of 12
Each exercise opens a canvas for an entity-relationship diagram (ERD). You draw the tables, and the review checks them against sample rows.
Start at the top. The last two are harder, so they are optional.