Skip to main content
dbt beginner Lesson 3 of 10

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.

Frequently Asked Questions

What is a source in dbt?
A declaration of a table dbt did not build — something a loader like Fivetran or Airbyte writes into the warehouse. Declaring it lets models reference it with `source()`, puts it at the head of the lineage graph, and makes it eligible for freshness checks.
What is the difference between source() and ref()?
`ref()` points at a model dbt builds and creates a dependency edge to it. `source()` points at a table dbt does not build and only records where the data entered the project. Using `source()` outside a staging model is the most common way lineage becomes unreadable.
How does dbt source freshness work?
It runs `select max(loaded_at_field)` on each source and compares the age of that value against your `warn_after` and `error_after` thresholds. It queries the source table directly, so it can run before your models and stop a pipeline from rebuilding on stale input.
Should freshness failures fail the pipeline?
Run freshness as its own step before the build. A warning is worth surfacing without blocking, but an error usually should stop the run — rebuilding marts on yesterday's data quietly publishes wrong numbers, which is worse than publishing nothing.