1. Courses
  2. Data
  3. Dashboards and Business Intelligence
  4. Stop 2

35 minIntermediate

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() and source()
  • 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 : 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 . 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 : 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:

SQL
-- 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 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?

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_line at the grain "one row per sale line", with dim_store, dim_article and dim_date around it. Marts are tables or incremental tables, because people read them all day.
Sources as loaded POS export App orders Store list source() Staging clean only stg_pos stg_app stg_site ref() Intermediate join, union int_sale_lines ref() Marts facts, dims fct_sale_line dim_store reads BI tool marts only Trading dashboard
Each layer reads only the one above it. Staging cleans one raw table each, the intermediate model unions the two sales channels, and the dashboard reads marts only. The store list needs no intermediate step, so its staging model feeds 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?

Materialisations: view, table or incremental

A model's 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:

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

Table every night all ~1.2 billion rows cost grows every day Incremental every night old rows: left as built 3 days new late till upload reprocessed each night: about 6.4 million rows
A table model rebuilds every row each night. An incremental model leaves old rows alone and reprocesses only rows loaded recently, plus a three-day lookback. A till that uploads two days late still lands inside the window, and 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?

Tests are queries that return the rows that break a rule

A 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 next to the model, in a YAML file (in dbt 1.8 and later the key is data_tests:; older versions use tests:):

YAML
# 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:

SQL
-- 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 : 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:

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

1. build stg_pos 2. run its tests select bad rows 0 rows returned pass rows returned fail (error) 3. build int, fct int, fct skipped dashboard keeps the last good data
In 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 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:

YAML
# 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_line

Where 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?

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

Terminal
# 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-artifacts

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

  1. 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_line
  2. Write 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 copy
  3. Union 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 sales
  4. Build the fact incrementally and add its tests

    Use the incremental fct_sale_line and the _marts.yml file from the lesson. Put a unique and not_null test on pos_line_key in a _pos__models.yml file too, so duplicates are caught in staging, before they reach the fact.

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

    Terminal
    dbt source freshness
    dbt build --select +fct_sale_line     # the fact and everything upstream, with tests
    dbt docs generate                     # the docs site and lineage graph
  6. Open 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.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- 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
Ctrl + Enter runs
Show a solution
SQL
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.

Pass the exercise to finish this task.

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.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- 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')
Ctrl + Enter runs
Show a solution
SQL
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.

Pass the exercise to finish this task.

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?

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?

A pull request changes stg_app__order to exclude test orders placed by staff accounts. What should happen before it is merged?

Flashcards

Open my review

Key takeaways

  • Load raw data untouched, then transform it in the warehouse with SQL models: one SELECT per file, linked with source() and ref().
  • 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

Episode transcriptThe till that sold everything twice · about 12 minutes · tap any line to play from there
  1. NadiaImran, last Monday leadership looked at three different sales numbers again. Do you know where one of the gaps came from?
  2. ImranI assumed definitions. Finance takes off returns and tax, and the app team counts cancelled orders.
  3. NadiaThat's part of it. But when Tomás and I rebuilt the pipeline, we found till sales counted twice. About fifty thousand dollars in four weeks.
  4. ImranTwice? How does a till sell something twice?
  5. NadiaIt doesn't. When a till comes back online after an outage, it re-sends its whole day. The old dashboard query never noticed.
  6. NadiaSo today is about how analytics teams turn raw tables into tables a dashboard can trust. Layers of SQL models, tests on every run, and review before anything goes live.
  7. ImranStart me somewhere simple.
  8. NadiaA restaurant kitchen. Crates arrive: vegetables with soil on them, fish on ice. They go into the cold room as they came, so the cook can always go back to the original ingredients.
  9. NadiaAt the prep station, everything is washed and cut the same way every time. Cooks combine prepped ingredients into sauces several dishes share. At the pass, plates look exactly as the customer will see them.
  10. ImranAnd someone tastes at each station.
  11. NadiaYes, so a bad sauce stops there instead of reaching forty plates. Recipes are written down. And a new recipe is tasted by the head chef before it goes on the menu.
  12. ImranSo the raw tables are the crates in the cold room.
  13. NadiaExactly. That's ELT: extract and load the raw data first, then transform it inside the warehouse with SQL. The older way, ETL, transformed data before loading it, so if the logic had a bug, the raw data was gone.
  14. NadiaAt Mangrove, a loader copies the till exports, the app's orders and the store list into raw schemas every hour. And the rule is that nobody edits a raw table, ever.
  15. ImranSo where does the cleaning happen?
  16. NadiaIn models. A model is a file with one select statement. A tool like dbt, the data build tool, wraps it in a create view or create table statement and runs it. dbt is open source, and there's a hosted version too.
  17. ImranAnd how do models find each other?
  18. NadiaThrough two functions. Source points at a raw table you declared once in a YAML file. Ref points at another model. Models never type a table name.
  19. ImranWhy does that matter? A table name is a table name.
  20. NadiaBecause dbt reads every file and sees who depends on whom. That gives it a DAG, a directed acyclic graph, so it builds models in the right order. And the same code builds into your own development schema while you work, and into production at night.
  21. NadiaIf you can see your screen, the first diagram shows the layers from top to bottom. If not, picture shelves: sources at the top, then staging, intermediate and marts, and the dashboard at the bottom.
  22. ImranWalk me down the shelves.
  23. NadiaStaging is one model per raw table. Rename columns, cast types, turn dollars stored as text into whole cents, trim spaces, one case for codes, drop re-sent copies. No joins and no business rules.
  24. ImranAnd intermediate?
  25. NadiaThe joins that more than one table needs. Here it's one model that puts till sale lines and delivered app order lines into one shape, and leaves cancelled orders out.
  26. ImranAnd the marts are the star schema from last episode. The fact table at one row per sale line, with store, article and date dimensions around it.
  27. NadiaRight. And the dashboard reads only marts. Never raw tables, never staging.
  28. ImranHere's my guess, then. If the app sends the status as delivered with a capital D, and sometimes with a trailing space, I'd fix it in the dashboard filter. That's where people see it.
  29. NadiaThat's the trap. Then every dashboard needs the same fix, and they drift apart. Fix it once, in the staging model, and everything below sees one clean value.
  30. ImranOkay. What about how the tables are stored?
  31. NadiaThat's the materialisation. A view stores nothing and runs its query every time someone reads it. A table is rebuilt from scratch on every run. An incremental model builds everything once, then only adds or updates the new rows.
  32. ImranWhy not tables everywhere? Fewer moving parts.
  33. NadiaSize. Mangrove's stores make about four hundred thousand checkouts a day, at roughly four lines each. That's about one point six million sale lines a day, and roughly one point two billion over two years.
  34. ImranAnd a table rebuilds all one point two billion every night to add one day.
  35. NadiaExactly. The second diagram shows it. On top, one long bar, rebuilt every night. Below it, the incremental version: old rows left alone, and only a small window at the end reprocessed.
  36. ImranWhy a window? Why not only the new rows?
  37. NadiaBecause tills upload late. They keep selling when the internet drops and send the sales hours or days later.
  38. NadiaSo two rules. Filter on when the row was loaded. Never on when the sale happened. And reprocess a lookback window, say three days, with a unique key, so a reprocessed row replaces its old copy instead of doubling.
  39. ImranWhat goes wrong if you filter on sale time?
  40. NadiaA store uploads Saturday's sales on Monday afternoon. Monday morning's sales are already in. The model asks for sales newer than the newest one it has, and Saturday is older. So Saturday never arrives, until someone rebuilds the whole table.
  41. ImranAnd nobody gets an error.
  42. NadiaNo error at all. And the cost of doing it right is small: three days of lookback is about six point four million rows a night, around half of one percent of the table.
  43. ImranLet's talk tests. I think of a test as code that checks code.
  44. NadiaIn dbt, a data test is a query that looks for bad rows. Zero rows back, it passes. Any rows back, those rows are the evidence.
  45. ImranThat's neat. You can run the same query and look at the failures.
  46. NadiaThere are four built-in ones, declared in the YAML file next to the model. Unique, not null, accepted values, and relationships, which checks that every key points at a real row in another table.
  47. ImranAnd the heading in that file is data tests?
  48. NadiaIn dbt one point eight and later, yes. Older versions call it tests.
  49. ImranWhat about nulls? If a store ID is empty, does relationships catch it?
  50. NadiaNo. Unique, relationships and accepted values all skip nulls. Catching nulls is the not null test's job. That's why key columns get both unique and not null. Together they prove the grain.
  51. NadiaThe third diagram shows a build. Build a model, then run its tests. Zero rows, and the next models build. Rows back on an error-level test, and everything downstream is skipped.
  52. ImranSo the dashboard shows stale data?
  53. NadiaIt keeps the last good build. Yesterday's right number beats today's wrong one. For rules worth watching that shouldn't stop the morning's numbers, you set the test to warn.
  54. ImranAnd how do you know the raw data arrived at all?
  55. NadiaSource freshness. You name the column that records load time and set two limits. For the tills, warn after six hours and fail after twenty-four. Scheduling and alerts come later in the course.
  56. NadiaNow the story from the start. Tomás's old dashboard ran on one six-hundred-line SQL query inside the BI tool, copied into three reports. We moved it into models, and the first build failed three tests.
  57. ImranThe duplicates were one.
  58. NadiaYes. Unique on the till line key returned eighteen thousand four hundred rows: re-sent days. Staging now keeps the latest copy of each till, receipt and line.
  59. ImranSecond?
  60. NadiaRelationships from sale lines to the store dimension returned two thousand one hundred and forty rows. Two stores opened on the first of October and weren't in the store list yet. The old query's inner join quietly dropped their sales from the city totals.
  61. ImranOuch. And the third?
  62. NadiaAccepted values on the app order status. In September the app team started sending picked up for click-and-collect orders. The old query kept only delivered, so those sales were missing.
  63. ImranThree real problems, and the old query hid every one.
  64. NadiaAnd now the relationships test will catch the next new store on its first day.
  65. ImranWhat about changes? If Tomás edits a model, how do we stop him breaking Monday?
  66. NadiaEverything lives in Git. You change a model on a branch and open a pull request. A GitHub Actions job builds the changed models and everything downstream into a temporary schema, and runs their tests.
  67. ImranAnd a human reviewer?
  68. NadiaReads the logic and compares a few totals with production, like last week's sales by city. A change meant to tidy status codes shouldn't move sales by four percent.
  69. ImranAnd documentation? Nobody writes it.
  70. NadiaHere it sits in the same YAML files as the tests, so it gets updated with the model. dbt builds a small website from it, with a lineage graph. It answers two questions: where does this number come from, and what breaks if I change this.
  71. NadiaIn the Do it section you build the path yourself. Declare the sources with freshness. Write a staging model that cleans the till export and keeps the latest copy of each line. Union the two channels, with a channel prefix on the key. Then build the fact incrementally, add the tests, run a build and open a pull request.
  72. ImranWhy the prefix?
  73. NadiaBoth sources number their lines from one. Unique passes in each staging model and fails after the union. The prefix makes till line one and app line one different keys.
  74. ImranSo, questions for my next review. Which layer does this logic belong in? What's the grain, and which test proves it? If tills upload two days late, does the incremental model still catch them?
  75. NadiaAdd one more. When this test fails at five in the morning, who sees it, and what do they do?
  76. NadiaHere's one to think about. A pull request changes the staging model for app orders to drop test orders placed by staff accounts. What should CI build and test? Take a second.
  77. NadiaThe changed model and everything downstream of it: the intermediate union, the fact table, and all their tests. Then someone compares weekly app sales with production, to check the drop is the size they expected.
  78. ImranMy three takeaways, then. One: load raw data untouched and do all the reshaping in SQL models, in layers, so each rule lives in one place.
  79. ImranTwo: big fact tables go incremental, filtered on load time, with a lookback window and a unique key.
  80. ImranThree: a test is a query that returns bad rows. Test every key with unique and not null.
  81. NadiaOne nuance on the third. Unique and not null prove the grain, but add relationships and accepted values where a wrong value would change a number. That's what found the missing stores and the missing pickups.
  82. NadiaNext time: three teams, three definitions of sales. We write the one definition everyone agrees on.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Data-quality checks

Duplicates, missing values, orphans and impossible values, each as one row with a count.

Open in the SQL Lab
Next stopMetrics everyone agrees on
Was this lesson helpful?
Questions and discussion

Loading the discussion…