Sources and Freshness Checks
Declare raw tables as sources so lineage starts at the loader, then let dbt source freshness fail the build when data stops arriving.
A source tells dbt about a table it did not create. That does two things: models can point at it by name rather than by literal schema, and the lineage graph gains a starting node — so a dashboard traces all the way back to the loader that produced its input.
Declaring sources
# models/staging/_sources.yml
version: 2
sources:
- name: raw
description: Landed by the nightly loader from the bookshop application database.
schema: main
tables:
- name: raw_customers
description: One row per registered customer.
columns:
- name: id
description: Primary key.
- name: raw_orders
description: One row per order, including pending ones.
loaded_at_field: ordered_at
freshness:
warn_after: { count: 12, period: hour }
error_after: { count: 24, period: hour }
Now models reference it through source():
-- models/staging/stg_orders.sql
select
id as order_id,
customer_id,
ordered_at::date as ordered_at,
status,
amount
from {{ source('raw', 'raw_orders') }}
where status != 'pending'
dbt run --select stg_orders
10:04:22 Running with dbt=1.9.1
10:04:22 Found 5 models, 2 seeds, 1 source, 431 macros
10:04:22
10:04:22 1 of 1 START sql view model main.stg_orders .................... [RUN]
10:04:22 1 of 1 OK created sql view model main.stg_orders ............... [OK in 0.05s]
10:04:22
10:04:22 Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1
“1 source” is new in the parse line. The compiled SQL resolves it the same way ref does:
cat target/compiled/bookshop/models/staging/stg_orders.sql
select
id as order_id,
customer_id,
ordered_at::date as ordered_at,
status,
amount
from "bookshop"."main"."raw_orders"
where status != 'pending'
The gain is not the SQL — it is that schema: main lives in one YAML file. When the loader
moves to a raw_prod schema, one line changes and every staging model follows.
The rule that keeps lineage honest
Only staging models may call source(). One staging model per source table, and
everything downstream goes through ref.
-- models/marts/daily_revenue.sql
from {{ source('raw', 'raw_orders') }} -- wrong: skips the cleanup layer
from {{ ref('stg_orders') }} -- right
The first version works and quietly costs you the thing sources are for. The mart now
depends on raw column names, so a rename in the loader breaks a file nobody expected to
touch, and the where status != 'pending' filter that everyone assumes is applied is not.
Checking whether data arrived
dbt source freshness
10:09:47 Running with dbt=1.9.1
10:09:47 Found 5 models, 2 seeds, 1 source, 431 macros
10:09:47
10:09:47 Concurrency: 4 threads (target='dev')
10:09:47
10:09:47 1 of 1 START freshness of raw.raw_orders ....................... [RUN]
10:09:47 1 of 1 WARN freshness of raw.raw_orders ........................ [WARN in 0.04s]
10:09:47
10:09:47 Finished running 1 source in 0 hours 0 minutes and 0.09 seconds (0.09s).
10:09:47
10:09:47 Done.
A warning, because the newest ordered_at in the seed data is older than 12 hours. dbt ran
one query to decide that:
select
max(ordered_at) as max_loaded_at,
convert_timezone('UTC', current_timestamp()) as snapshotted_at
from "bookshop"."main"."raw_orders"
Push the data further into the past and it escalates:
10:12:30 1 of 1 START freshness of raw.raw_orders ....................... [RUN]
10:12:30 1 of 1 ERROR STALE freshness of raw.raw_orders ................. [ERROR STALE in 0.04s]
10:12:30
10:12:30 Done.
10:12:30 Encountered an error:
FreshnessError: Source freshness check failed for raw.raw_orders
max_loaded_at: 2026-01-12 00:00:00+00:00
snapshotted_at: 2026-09-09 10:12:30+00:00
age: 5865.2 hours
error_after: 24 hours
A non-zero exit code with the numbers attached. In a pipeline this is the step that stops you rebuilding marts on data that stopped arriving three days ago — the failure mode that otherwise looks like “revenue is down”.
Where freshness belongs in a pipeline
dbt source freshness || exit 1
dbt build
10:15:02 1 of 1 START freshness of raw.raw_orders ....................... [RUN]
10:15:02 1 of 1 PASS freshness of raw.raw_orders ........................ [PASS in 0.04s]
10:15:02 Done.
10:15:03 Found 5 models, 2 seeds, 4 data tests, 1 source, 431 macros
10:15:03 1 of 9 START seed file main.raw_customers ...................... [RUN]
...
10:15:04 Done. PASS=9 WARN=0 ERROR=0 SKIP=0 TOTAL=9
Set thresholds per table, not per project — a daily customer export and a streaming order feed have nothing in common:
tables:
- name: raw_orders
loaded_at_field: ordered_at
freshness:
warn_after: { count: 1, period: hour }
error_after: { count: 6, period: hour }
- name: raw_customers
loaded_at_field: updated_at
freshness:
warn_after: { count: 2, period: day }
error_after: { count: 7, period: day }
A table with no freshness block is skipped, which is the right setting for genuinely
static reference data.
Selecting by source
dbt run --select source:raw+
10:19:31 Found 5 models, 2 seeds, 1 source, 431 macros
10:19:31
10:19:31 1 of 4 START sql view model main.stg_customers ................. [RUN]
10:19:31 2 of 4 START sql view model main.stg_orders .................... [RUN]
10:19:31 1 of 4 OK created sql view model main.stg_customers ............ [OK in 0.04s]
10:19:31 2 of 4 OK created sql view model main.stg_orders ............... [OK in 0.04s]
10:19:31 3 of 4 START sql table model main.customer_orders .............. [RUN]
10:19:31 4 of 4 START sql table model main.daily_revenue ................ [RUN]
10:19:31 3 of 4 OK created sql table model main.customer_orders ......... [OK in 0.08s]
10:19:31 4 of 4 OK created sql table model main.daily_revenue ........... [OK in 0.08s]
10:19:31
10:19:31 Done. PASS=4 WARN=0 ERROR=0 SKIP=0 TOTAL=4
“Rebuild everything that comes from this loader” in one selector. source:raw.raw_orders+
narrows it to a single table’s descendants — useful when one feed backfills and the rest
did not change.
Testing sources
Sources take the same tests as models, and running them before the build catches a bad load before it propagates:
- name: raw_orders
loaded_at_field: ordered_at
columns:
- name: id
data_tests: [unique, not_null]
- name: amount
data_tests:
- not_null
dbt test --select source:raw
10:23:18 Found 5 models, 2 seeds, 7 data tests, 1 source, 431 macros
10:23:18
10:23:18 1 of 3 START test not_null_raw_raw_orders_amount ............... [RUN]
10:23:18 2 of 3 START test not_null_raw_raw_orders_id ................... [RUN]
10:23:18 3 of 3 START test unique_raw_raw_orders_id ..................... [RUN]
10:23:18 1 of 3 PASS not_null_raw_raw_orders_amount ..................... [PASS in 0.03s]
10:23:18 2 of 3 PASS not_null_raw_raw_orders_id ......................... [PASS in 0.02s]
10:23:18 3 of 3 PASS unique_raw_raw_orders_id ........................... [PASS in 0.03s]
10:23:18
10:23:18 Done. PASS=3 WARN=0 ERROR=0 SKIP=0 TOTAL=3
A duplicate primary key in the raw feed is the loader’s bug, and finding it here rather than in a mart saves an hour of tracing. Lesson 4 covers what these tests actually run.
Practice
1. Add a second source table and reference it from a new staging model.
- name: raw_payments
description: One row per payment attempt against an order.
loaded_at_field: paid_at
-- models/staging/stg_payments.sql
select
id as payment_id,
order_id,
method,
amount / 100.0 as amount
from {{ source('raw', 'raw_payments') }}
where status = 'success'
10:28:04 1 of 1 START sql view model main.stg_payments .................. [RUN]
10:28:04 1 of 1 OK created sql view model main.stg_payments ............. [OK in 0.04s]
10:28:04 Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1
Cents-to-pounds division in staging is the right place for it — do it once here rather than in every mart that touches payments.
2. Set error_after to 1 hour and run the freshness check.
10:30:55 1 of 1 ERROR STALE freshness of raw.raw_orders ................. [ERROR STALE in 0.04s]
FreshnessError: Source freshness check failed for raw.raw_orders
age: 5865.5 hours
error_after: 1 hours
The command exits non-zero, so dbt source freshness && dbt build stops here. Chaining
them with && is the smallest useful version of a data-quality gate.
3. Point a mart directly at a source and look at the DAG.
dbt ls --select source:raw+ --output path
models/staging/stg_customers.sql
models/staging/stg_orders.sql
models/marts/daily_revenue.sql
models/marts/customer_orders.sql
daily_revenue now appears as a direct child of the source rather than sitting behind
staging. It still builds, but the lineage no longer shows the cleanup step, and the docs
site will show a mart hanging off raw — which is how a project becomes hard to reason about
one shortcut at a time.
4. Remove loaded_at_field and run freshness.
10:36:41 Running with dbt=1.9.1
10:36:41 Found 5 models, 2 seeds, 1 source, 431 macros
10:36:41
10:36:41 Nothing to do. Try checking your model configs and model specification args
No error, no check — a source without loaded_at_field is silently skipped. Worth knowing,
because “freshness is configured” and “freshness is being checked” are different things, and
the output above is the only signal telling you which one you have.
Next: tests — the assertions that turn a pipeline into something you can trust.