Transforming data in the warehouse
Raw tables from the tills and the app are not ready for a dashboard. This lesson shows how analytics teams turn them into the tested star schema you designed in From questions to a reporting model: SQL models in layers, built in the right order, checked by tests on every run, documented and reviewed like code.
By the end you can
- Explain ELT and why teams keep raw data untouched and transform it with SQL inside the warehouse
- Organise models into staging, intermediate and mart layers, linked with
ref()andsource() - Pick a materialisation (view, table or incremental) for each model, with a lookback window for late data
- Write tests as queries that return failing rows, and add source freshness checks
- Keep models in version control with documentation, lineage and a CI build on every change
The everyday picture
Think of a busy restaurant kitchen. Deliveries arrive in crates: vegetables with soil on them, fish on ice, sacks of rice. The crates go into the cold room as they came, so the cook can always go back to the original ingredients. At the prep station, every ingredient is washed and cut the same way, every time. Cooks combine prepped ingredients into sauces that several dishes share. At the pass, dishes are plated exactly as the customer will see them.
Someone tastes at each station, so a bad sauce stops there instead of reaching forty plates. Every recipe is written down. A new recipe is tasted by the head chef before it goes on the menu. A warehouse project works the same way: raw data kept as it arrived, a cleaning layer, shared building blocks, finished tables for dashboards, tests at each step, documentation, and review before changes go live.
ELT: load raw data first, then transform it with SQL
For a long time the usual pattern was ETL: extract data from source systems, transform it on a separate server, and load only the finished result. If the transformation had a bug, the raw data was gone and had to be extracted again.
Cloud warehouses such as BigQuery, Snowflake, Redshift and Databricks made storage cheap and computing power easy to scale, so most teams now do ELT. A loading tool (Fivetran, Airbyte or a small script) copies source tables into the warehouse as they are, and all reshaping happens afterwards, inside the warehouse, in SQL. The raw copy is always there, so you can rebuild everything when a rule changes; the logic is plain SQL that analysts already know; and the warehouse does the heavy work.
At Mangrove, a loader copies the POS exports, the app's orders and the back office's store list into raw schemas every hour. The rule is that nobody edits a raw table, ever. Every change to the data happens in a model: a file with one SELECT statement. A tool such as dbt (data build tool, open source and also sold as a hosted service) wraps each SELECT in a CREATE VIEW or CREATE TABLE AS and runs it. Other tools, such as Dataform and SQLMesh, work on similar ideas, so what you learn here carries over.
Models never type table names. They call two functions written in Jinja, the template language dbt uses, inside double curly braces:
-- models/staging/pos/stg_pos__sale.sql
select
upper(trim(till_id)) as till_id,
cast(receipt_no as integer) as receipt_no,
cast(store_id as integer) as store_id,
cast(sold_at as timestamp) as sold_at,
cast(round(cast(total as numeric) * 100) as integer) as total_cents,
_loaded_at -- when the loader copied this row
from {{ source('pos', 'sale') }} -- a raw table, declared in a .yml file
-- models/marts/dim_store.sql
select id as store_id, name as store_name, city
from {{ ref('stg_backoffice__site') }} -- another model
where kind = 'store'source('pos', 'sale') points at a raw table you declared once in a YAML file. ref('stg_backoffice__site') points at another model. Before running anything, dbt compiles each ref() into a full table name: your own development schema while you work (for example dbt_tomas.stg_backoffice__site), the production schema when the scheduled job runs. Because every dependency goes through these two functions, dbt can read all the files and draw the DAG of the project. dbt run then builds models in dependency order, and dbt run --select stg_pos__sale+ builds one model plus everything downstream of it.
Quick check
Why write {{ ref('stg_pos__sale') }} instead of typing analytics.stg_pos__sale in a model?
The function is about the graph and the environment. Speed and caching don't change: the compiled SQL is a normal table name. A hard-coded name works in SQL, but dbt can't see the dependency, so it may build the models in the wrong order, and your development work would read production tables.
Three layers: staging, intermediate, marts
A project with 200 models needs a shape, or nobody can find anything. Most teams follow the layering in dbt Labs' guide to project structure. Each layer has one job, and each layer reads only from the one below it.
- Staging (
stg_<source>__<entity>): one model per raw table, one row in for one row out. Rename columns to the house style, cast types, convert units (dollars as text into integer cents), standardise codes (trim spaces, one case), and remove exact re-sent duplicates. No joins and no business rules. Staging models are usually views. Nothing else in the project reads raw tables. - Intermediate (
int_...): the joins and reshaping that more than one mart needs. Attach sale lines to their sale header. Union till sale lines and delivered app order lines into one shape. Leave cancelled orders out. Users never query this layer. - Marts (
fct_...,dim_...): the tables dashboards and analysts read. Here that is the star schema from bi-01:fct_sale_lineat the grain "one row per sale line", withdim_store,dim_articleanddim_datearound it. Marts are tables or incremental tables, because people read them all day.
dim_store directly.The layers put each rule in exactly one place. If the POS starts sending prices with a comma as the decimal mark, you fix one staging model and every mart is right on the next run. If "cancelled app orders are not sales" lives in one intermediate model, finance's report and the trading dashboard cannot disagree about it.
Quick check
The app's raw order table has status values 'Delivered', 'delivered ' and 'DELIVERED'. Where should you fix this?
Making codes consistent is cleaning, and cleaning belongs in staging, once. Fixing it in dashboards repeats the rule in every report and they will drift apart. Editing raw data destroys the copy you rebuild from. The mart could do it, but then any other model reading staging would still see three spellings.
Materialisations: view, table or incremental
A model's materialisation says what dbt creates from your SELECT. You set it per model or per folder, and the SELECT itself does not change.
- View (the default): nothing is stored; the query runs every time someone reads it. Cheap to build, always current, slow when heavy and read often. Good for staging.
- Table: rebuilt from scratch on every run. Fast to read, but the build cost grows with the data, including old rows that never change.
- Incremental: the first run builds the whole table; later runs insert or update only new or changed rows. Cheap for big facts, with new ways to go wrong.
- Ephemeral: not created at all; dbt pastes the SQL into each model that refers to it, as a common table expression.
The fact table is the big one. Mangrove's stores make about 400,000 checkout transactions a day, at roughly 4 lines each, so about 1.6 million sale lines a day and roughly 1.2 billion over two years. Rebuilding all of that every night to add one day is wasteful. Here is the incremental version:
-- models/marts/fct_sale_line.sql
{{ config(
materialized = 'incremental',
unique_key = 'sale_line_id' -- reprocessed rows replace their old copy
) }}
select
sale_line_id, store_id, article_id,
sold_on, channel, qty, gross_cents, _loaded_at
from {{ ref('int_sale_lines_unioned') }}
{% if is_incremental() %}
-- later runs: rows the loader copied since the last run,
-- plus a 3-day lookback for tills that upload late
where _loaded_at > (select max(_loaded_at) from {{ this }}) - interval '3 days'
{% endif %}is_incremental() is true when the table already exists and you did not ask for a full rebuild. {{ this }} is the table being built, so the subquery finds the newest row already in it. With unique_key set, a row that comes through again replaces its old copy instead of becoming a duplicate (whether dbt does this with a MERGE or a delete-then-insert depends on the warehouse). dbt run --full-refresh rebuilds the table from nothing, which you do after changing the model's logic.
Two details decide whether an incremental model stays correct. First, filter on when the row was loaded, never on when the sale happened. Tills keep selling when the internet drops and upload the sales hours or days later. If the filter were sold_at > max(sold_at), a store that uploads Saturday's sales on Monday, after Monday's rows are in, would never get its Saturday into the table. Second, add a lookback window sized to how late data really arrives. Three days of reprocessing is about 6.4 million rows a night, around half of one percent of the table.
unique_key stops reprocessed rows from doubling.Quick check
A fact table gets about 1.6 million rows a day and already holds 1.2 billion. Some stores upload up to two days late. Which setup fits?
Incremental keeps the nightly work small; the load-time filter and lookback catch late uploads, and the unique key stops reprocessed rows from being counted twice. A view would recompute 1.2 billion rows on every dashboard click. A full rebuild works but costs more every day. Filtering on sale time silently skips any late upload.
Tests are queries that return the rows that break a rule
A data test in dbt is a SELECT that looks for bad rows. If it returns zero rows, the test passes. If it returns rows, they are the evidence, and you can run the same query to see them. You declare the four built-in generic tests next to the model, in a YAML file (in dbt 1.8 and later the key is data_tests:; older versions use tests:):
# models/marts/_marts.yml
version: 2
models:
- name: fct_sale_line
description: One row per sale line, from tills and delivered app orders.
columns:
- name: sale_line_id
description: Channel prefix plus the source's own line key.
data_tests:
- unique
- not_null
- name: store_id
data_tests:
- not_null
- relationships: # every store_id must exist in dim_store
to: ref('dim_store')
field: store_id
- name: channel
data_tests:
- accepted_values:
values: ['store', 'app']The arguments' exact layout has changed a little between versions, so check the docs for yours. What each test runs is roughly this:
-- unique: keys that appear more than once
select sale_line_id, count(*) as n
from analytics.fct_sale_line
where sale_line_id is not null
group by sale_line_id
having count(*) > 1;
-- relationships: child rows whose key has no parent (nulls are left to not_null)
select f.store_id
from analytics.fct_sale_line f
left join analytics.dim_store d on d.store_id = f.store_id
where f.store_id is not null
and d.store_id is null;not_null returns rows where the column is null, and accepted_values returns values outside the list. Notice that unique, relationships and accepted_values ignore nulls: catching nulls is not_null's job, so key columns get both. If your dim_store uses surrogate keys with history (type 2, from bi-01), the fact carries store_key and the test is the same.
A rule that no generic test covers becomes a singular test: a .sql file in the tests/ folder that returns the failing rows. For example, the lines of each till sale must add up to the receipt total:
-- tests/sale_lines_match_receipt_total.sql
select s.till_id, s.receipt_no, s.total_cents,
sum(l.qty * l.price_cents) as lines_cents
from {{ ref('stg_pos__sale') }} s
join {{ ref('stg_pos__sale_line') }} l
on l.till_id = s.till_id and l.receipt_no = s.receipt_no
group by s.till_id, s.receipt_no, s.total_cents
having s.total_cents <> sum(l.qty * l.price_cents)dbt build runs models and tests together in graph order: build a model, run its tests, then move on. When a test with the default severity (error) fails, every model downstream is skipped, so a broken staging model cannot flow into the fact table, and the dashboard keeps showing the last good build. A test set to severity: warn reports the problem and lets the run continue: use that for rules that are worth watching but should not stop the morning's numbers.
dbt build, each model's tests run straight after it is built. Zero failing rows lets the next models build. Any failing rows on an error-level test skip everything downstream, so bad data stops at the station where it was found.Test what would make a number wrong: the primary key of every model (unique plus not_null proves the grain), foreign keys in marts, code columns such as channel and status, and one or two business rules per mart. Fifty tests that fail every week teach people to ignore all of them.
Tests check the rows that arrived. A source freshness check asks whether rows arrived at all. You give each source the column that records load time and two limits, and dbt source freshness compares the newest value with the clock:
# models/staging/pos/_pos__sources.yml
version: 2
sources:
- name: pos
schema: raw_pos
loaded_at_field: _loaded_at
freshness:
warn_after: {count: 6, period: hour}
error_after: {count: 24, period: hour}
tables:
- name: sale
- name: sale_lineWhere the freshness settings sit in the file also differs slightly between recent versions. How to schedule these checks, alert the right person and show "data as of" on the dashboard comes later in this course.
Quick check
The relationships test from fct_sale_line.store_id to dim_store returns 2,140 rows. What does that tell you?
A test returns the failing rows, so 2,140 is the number of orphans. Duplicates in dim_store would show up in a unique test on it, and nulls are excluded from relationships; the not_null test catches those.
Documentation, lineage, version control and CI
The description fields in the YAML files are the documentation. They sit next to the tests, so they get updated when the model does. dbt docs generate builds a small website from them: every model, its columns and descriptions, its tests, the SQL, and a lineage graph. Lineage answers two questions you will hear every week: "where does this number come from?" (follow the graph up to the raw tables) and "if I change this model, what breaks?" (follow it down to the marts and dashboards).
All models live in a Git repository. Each person develops in their own schema, makes changes on a branch, and opens a pull request that someone else reviews before it reaches production. The review checks logic and the grain. A machine checks the rest: CI. At Mangrove, a GitHub Actions job runs on every pull request. It builds the changed models and everything downstream of them into a temporary schema and runs their tests. dbt can pick that set for you by comparing the project with the production run's saved state:
# CI: build and test only what changed, plus everything downstream of it;
# unchanged parents are read from production instead of rebuilt
dbt build --select state:modified+ --defer --state ./prod-artifactsThe pull request is merged only when that build is green. A good reviewer also compares a few totals between production and the pull request's copy, such as last week's sales by city: a change meant to tidy status codes should not move sales by 4%.
Do it
Build the path from the raw POS tables to a tested fact table, in the style of a dbt project.
Declare the sources and their freshness
One YAML file per source system: the loader's schema, the load-time column, freshness limits and tables. Follow the POS file shown earlier.
YAML# models/staging/app/_app__sources.yml version: 2 sources: - name: app schema: raw_app loaded_at_field: _loaded_at freshness: warn_after: {count: 2, period: hour} error_after: {count: 12, period: hour} tables: - name: order - name: order_lineWrite one staging model per raw table
Rename, cast, convert to cents, standardise codes, build one key column and keep the latest copy of a re-sent row.
row_number()works in every major warehouse.SQL-- models/staging/pos/stg_pos__sale_line.sql with renamed as ( select upper(trim(till_id)) || '-' || trim(receipt_no) || '-' || trim(line_no) as pos_line_key, upper(trim(till_id)) as till_id, cast(trim(receipt_no) as integer) as receipt_no, cast(trim(line_no) as integer) as line_no, cast(trim(article_code) as integer) as article_id, cast(trim(qty) as integer) as qty, cast(round(cast(trim(price) as numeric) * 100) as integer) as price_cents, _loaded_at from {{ source('pos', 'sale_line') }} ), ranked as ( select *, row_number() over (partition by pos_line_key order by _loaded_at desc) as copy_rank from renamed ) select pos_line_key, till_id, receipt_no, line_no, article_id, qty, price_cents, _loaded_at from ranked where copy_rank = 1 -- a re-sent line keeps only its latest copyUnion the channels in an intermediate model
One shape for both channels, with a channel prefix on the key so a till line and an app line can never share an id. Cancelled orders stay out.
SQL-- models/intermediate/int_sale_lines_unioned.sql select 'pos-' || l.pos_line_key as sale_line_id, s.store_id, l.article_id, cast(s.sold_at as date) as sold_on, 'store' as channel, l.qty, l.qty * l.price_cents as gross_cents, l._loaded_at from {{ ref('stg_pos__sale_line') }} l join {{ ref('stg_pos__sale') }} s on s.till_id = l.till_id and s.receipt_no = l.receipt_no union all select 'app-' || ol.app_line_key, o.store_id, ol.article_id, o.completed_on, 'app', ol.qty, ol.qty * ol.price_cents, ol._loaded_at from {{ ref('stg_app__order_line') }} ol join {{ ref('stg_app__order') }} o on o.order_id = ol.order_id where o.status in ('delivered', 'picked_up') -- cancelled orders are not salesBuild the fact incrementally and add its tests
Use the incremental
fct_sale_lineand the_marts.ymlfile from the lesson. Put auniqueandnot_nulltest onpos_line_keyin a_pos__models.ymlfile too, so duplicates are caught in staging, before they reach the fact.Run it the way production will
Check that the sources are fresh, then build and test everything the fact table depends on, in order. Look at the failing rows of any test before you change anything.
Terminaldbt source freshness dbt build --select +fct_sale_line # the fact and everything upstream, with tests dbt docs generate # the docs site and lineage graphOpen a pull request
Let CI build the changed models and their downstream into a temporary schema. Compare last week's sales by city between production and the pull request's copy, and write the difference, and why, in the description.
Bring it to your team
Ask these when you review a model or a pull request.
- Which layer does this logic belong in, and does anything other than a staging model read a raw table?
- What is this model's grain, and which test proves it?
- If a store's tills upload two days late, does this incremental model still pick the rows up?
- When this test fails at 5 am, who sees it, and what do they do?
- Did CI build and test everything downstream of this change, and did any key totals move?
Red flags
- Dashboards with custom SQL that reads raw tables.
- A model with no test on its primary key.
- An incremental filter on sale time with no lookback.
- Tests that have warned every day for weeks with nobody assigned to fix them.
- Changes deployed to production without a pull request or a CI build.
Practice
Write a staging model for a messy till export
The loader copies the POS sale-line export into raw_pos_sale_line, where every column is text. Tills that reconnect after an outage re-send lines, sometimes with stray spaces or a lowercase till id, and a later copy can correct the quantity. Return one row per till line with till_id (trimmed, upper case), receipt_no, line_no, article_id, qty (integers) and price_cents (an integer: '2.50' becomes 250). When a line appears more than once, keep the copy with the latest loaded_at. In a dbt model the FROM would be {{ source('pos', 'sale_line') }}; here it is the plain table name.
CREATE TABLE raw_pos_sale_line (till_id text, receipt_no text, line_no text, article_id text, qty text, price text, loaded_at timestamptz);
INSERT INTO raw_pos_sale_line VALUES
('T07', '1001', '1', '501', '2', '2.50', '2026-10-12 09:00+00'),
('T07', '1001', '2', '502', '1', '1.20', '2026-10-12 09:00+00'),
('T07', '1002', '1', '503', '3', '0.99', '2026-10-12 09:00+00'),
(' T07', '1001', '1', '501', '2', '2.50', '2026-10-12 15:00+00'),
('t07', '1001', '2', '502', '1', '1.20', '2026-10-12 15:00+00'),
('T09', '2001', '1', '501', '1', ' 2.50', '2026-10-12 09:00+00'),
('T09', '2001', '2', '504', '4', '0.35', '2026-10-12 09:00+00'),
('T09', '2002', '1', '505', '1', '12.00', '2026-10-12 09:00+00'),
('T09', '2002', '1', '505', '2', '12.00', '2026-10-13 08:00+00'),
('T12', '3001', '1', '502', '2', '1.20 ', '2026-10-12 09:00+00');
-- Staging: clean every column, then keep one copy per till line
SELECT till_id, -- 1. trim and upper-case
receipt_no, -- 2. cast receipt_no, line_no, article_id and qty to integers
line_no,
article_id,
qty,
price -- 3. turn '2.50' into 250 and call it price_cents
FROM raw_pos_sale_line;
-- 4. keep only the latest loaded_at copy of each (till_id, receipt_no, line_no),
-- after cleaning, so ' T07' and 'T07' count as the same till
[{"till_id": "T07", "receipt_no": 1001, "line_no": 1, "article_id": 501, "qty": 2, "price_cents": 250}, {"till_id": "T07", "receipt_no": 1001, "line_no": 2, "article_id": 502, "qty": 1, "price_cents": 120}, {"till_id": "T07", "receipt_no": 1002, "line_no": 1, "article_id": 503, "qty": 3, "price_cents": 99}, {"till_id": "T09", "receipt_no": 2001, "line_no": 1, "article_id": 501, "qty": 1, "price_cents": 250}, {"till_id": "T09", "receipt_no": 2001, "line_no": 2, "article_id": 504, "qty": 4, "price_cents": 35}, {"till_id": "T09", "receipt_no": 2002, "line_no": 1, "article_id": 505, "qty": 2, "price_cents": 1200}, {"till_id": "T12", "receipt_no": 3001, "line_no": 1, "article_id": 502, "qty": 2, "price_cents": 120}]
Show a solution
WITH cleaned AS (
SELECT upper(trim(till_id)) AS till_id,
trim(receipt_no)::int AS receipt_no,
trim(line_no)::int AS line_no,
trim(article_id)::int AS article_id,
trim(qty)::int AS qty,
round(trim(price)::numeric * 100)::int AS price_cents, -- dollars as text to cents
loaded_at
FROM raw_pos_sale_line
),
ranked AS (
SELECT *, row_number() OVER (PARTITION BY till_id, receipt_no, line_no
ORDER BY loaded_at DESC) AS copy_rank
FROM cleaned
)
SELECT till_id, receipt_no, line_no, article_id, qty, price_cents
FROM ranked
WHERE copy_rank = 1;Clean first, deduplicate second: if you partition by the raw till_id, ' T07' and 't07' look like different tills and the re-sent lines survive. SELECT DISTINCT is not enough either, because T09's corrected line differs in qty, so both copies would stay. In PostgreSQL, DISTINCT ON (till_id, receipt_no, line_no) … ORDER BY …, loaded_at DESC also works, but row_number() runs in every warehouse.
Write three tests as one query
Write the SQL that three generic tests on sale would run, as one query that returns every failing row. Return two columns: test_name and sale_id. The tests: 'not_null_sale_site_id' (site_id is missing), 'relationships_sale_site_id' (site_id is not an id in site; like dbt, ignore nulls here, because the not_null test catches them) and 'accepted_values_sale_channel' (channel is not exactly 'store' or 'app'). The starter has the first test done.
CREATE TABLE site (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL, kind text NOT NULL); CREATE TABLE sale (id int PRIMARY KEY, site_id int, sold_at timestamptz NOT NULL, total_cents int NOT NULL, channel text NOT NULL); INSERT INTO site VALUES (1, 'Harbour Road', 'East', 'store'), (2, 'Lake View', 'East', 'store'), (3, 'Old Market', 'North', 'store'), (4, 'East DC', 'East', 'dc'); INSERT INTO sale VALUES (1, 1, '2026-10-12 08:05+00', 1240, 'store'), (2, 1, '2026-10-12 08:40+00', 560, 'store'), (3, 2, '2026-10-12 09:10+00', 2310, 'app'), (4, 3, '2026-10-12 09:30+00', 890, 'store'), (5, 2, '2026-10-12 10:00+00', 1475, 'app'), (6, 3, '2026-10-12 10:20+00', 330, 'store'), (7, 999, '2026-10-12 10:45+00', 720, 'store'), (8, 1, '2026-10-12 11:00+00', 1880, 'App'), (9, NULL, '2026-10-12 11:15+00', 640, 'store'), (10, 2, '2026-10-12 11:30+00', 1150, 'web');
-- Each test returns its failing rows; UNION ALL puts them in one result
SELECT 'not_null_sale_site_id' AS test_name, id AS sale_id
FROM sale
WHERE site_id IS NULL;
-- add: UNION ALL the relationships test (site_id with no matching site.id, nulls ignored)
-- add: UNION ALL the accepted_values test (channel not in 'store', 'app')
[{"test_name": "not_null_sale_site_id", "sale_id": 9}, {"test_name": "relationships_sale_site_id", "sale_id": 7}, {"test_name": "accepted_values_sale_channel", "sale_id": 8}, {"test_name": "accepted_values_sale_channel", "sale_id": 10}]
Show a solution
SELECT 'not_null_sale_site_id' AS test_name, id AS sale_id
FROM sale
WHERE site_id IS NULL
UNION ALL
SELECT 'relationships_sale_site_id', s.id
FROM sale s
LEFT JOIN site t ON t.id = s.site_id
WHERE s.site_id IS NOT NULL -- nulls belong to the not_null test
AND t.id IS NULL -- no parent row found
UNION ALL
SELECT 'accepted_values_sale_channel', id
FROM sale
WHERE channel NOT IN ('store', 'app');Without s.site_id IS NOT NULL, sale 9 fails two tests and the relationships result double-counts a problem that has one cause. 'App' fails because accepted values are exact: the fix belongs in the staging model (lower(trim(channel))), and then the test passes for the right reason. 'web' is a new value someone has to decide about, which is exactly what this test is for.
Check yourself
An incremental fact table filters new rows with sold_at > (select max(sold_at) from {{ this }}). A store's tills were offline on Saturday and Sunday and uploaded on Monday afternoon, after Monday morning's sales were already in the table. What happens to the weekend's sales?
The filter compares sale time with the newest sale time already in the table, so anything that happened earlier is never selected. dbt does not check order, so there is no error. Filtering on load time with a lookback window fixes it.
On the first full build, unique passes on the key of both staging models (POS lines and app order lines) but fails on sale_line_id in the fact table built from their union. The fact is a plain table, not incremental. What is the most likely cause?
Keys that are unique inside each source can collide after a union. The model is not incremental and this is the first build, so reprocessing cannot be the cause. Turning the test down to a warning hides a real grain problem, and relationships tests do not create or remove rows.
A pull request changes stg_app__order to exclude test orders placed by staff accounts. What should happen before it is merged?
A change to staging flows into the intermediate union, the fact table and the dashboard, so all of those need building and testing. Comparing totals shows whether the change moved sales by the expected amount. Reading SQL catches logic errors, and only a build catches the rest.
Flashcards
Key takeaways
- Load raw data untouched, then transform it in the warehouse with SQL models: one SELECT per file, linked with
source()andref(). - Staging cleans, intermediate combines, marts serve; each rule lives in one place, and dashboards read only marts.
- Views for staging, tables for marts, incremental for big facts, filtered on load time with a lookback and a unique key.
- A test is a query that returns failing rows. Test every key with unique and not_null, add relationships and accepted_values where a wrong value changes a number, and check source freshness.
- Descriptions, lineage, pull requests and a CI build of everything downstream make changes safe to ship.
Sources and further reading
- How we structure our dbt projects, dbt Labs documentation
- Materializations, dbt Labs documentation
- Incremental models, dbt Labs documentation
- Add data tests to your DAG, dbt Labs documentation
- Sources (including freshness), dbt Labs documentation