The dbt source() Function: Referencing Raw Data Correctly
Declaring a source in a sources.yml file, as covered in dbt Sources, only gets you halfway there. The other half is actually using that declaration inside a model โ and thatโs what the source() function is for. It looks almost identical to ref() on the surface, but it points at a fundamentally different kind of object: a raw table your pipeline loaded, not a model dbt itself builds.
Basic Syntax
source() takes two arguments: the name of the source group, and the name of the table within it, both matching exactly what you declared in YAML.
-- models/staging/stg_orders.sqlselect id as order_id, customer_id, cast(created_at as date) as order_date, amount, statusfrom {{ source('raw', 'orders') }}where status != 'test'Given the source declaration:
sources: - name: raw schema: raw_app_data tables: - name: orderssource('raw', 'orders') compiles to a fully-qualified reference to raw_app_data.orders (plus whatever database is configured), resolved correctly for whatever environment the run is targeting.
Why source() Instead of a Direct Table Reference
You could write select * from raw_app_data.orders and it would run identically the first time. The difference shows up later:
- Lineage. dbtโs DAG and documentation site show
source('raw', 'orders')as a node with an edge into your staging model. A hardcoded table name is invisible to dbt entirely โ it doesnโt exist in the lineage graph, which makes impact analysis on schema changes much harder. - Freshness checks. Only tables registered as sources can be checked with
dbt source freshness, detailed in Source Freshness. - Testability. Column-level tests (
not_null,unique,accepted_values) can be attached to a sourceโs columns in YAML and run withdbt test, exactly like model tests. - Refactor safety. If the raw schema name changes, you update one YAML file. Every model using
source()picks up the change automatically; every model with a hardcoded reference needs a manual edit.
The One-Model-Per-Source-Table Convention
The pattern that scales cleanly: exactly one staging model per source table, and that staging model is the only place source() is called for that table.
raw source: orders โ โผstg_orders.sql โ the only model calling source('raw', 'orders') โ โผcustomer_orders.sql โ calls ref('stg_orders'), never touches the source directly โ โผsales_summary.sqlIf you later need to change how raw orders are cleaned โ a new status filter, a renamed column, a type cast fix โ thereโs exactly one file to edit, and every downstream model inherits the fix automatically through its ref() chain.
What Staging Models Typically Do With source()
A staging model is rarely a bare select *. Itโs the layer where light, mechanical cleanup happens โ renaming cryptic columns, casting types, filtering out test/internal records โ before anything business-logic-shaped happens downstream.
-- models/staging/stg_customers.sqlwith source as ( select * from {{ source('raw', 'customers') }}),
renamed as ( select id as customer_id, lower(trim(email)) as email, first_name || ' ' || last_name as full_name, cast(signup_date as date) as signup_date, is_deleted from source where is_deleted = false)
select * from renamedThis staging model is doing exactly one job: taking whatever shape the raw loader produced and normalizing it into something predictable that every downstream model can rely on without re-deriving the same cleanup logic repeatedly.
Multiple Sources in One Model โ When Itโs Actually Fine
The โonly staging models call source()โ rule has one common, legitimate exception: a staging model that needs to union near-identical tables from multiple regional or sharded sources before anything else touches the data.
-- models/staging/stg_orders_all_regions.sqlselect *, 'us' as region from {{ source('raw_us', 'orders') }}union allselect *, 'eu' as region from {{ source('raw_eu', 'orders') }}union allselect *, 'apac' as region from {{ source('raw_apac', 'orders') }}This is still consistent with the convention โ itโs a staging model, itโs the only place these particular sources are referenced, and everything downstream consumes the unioned result via ref().
source() Across Multiple Schemas in One Warehouse
Larger organizations frequently split raw data across several schemas or even several databases within the same warehouse account โ one for each upstream system, or one per region. source() handles this transparently as long as each source groupโs database and schema are declared correctly.
sources: - name: raw_us database: analytics_raw schema: us_east_app_data tables: - name: orders - name: raw_eu database: analytics_raw_eu schema: eu_west_app_data tables: - name: ordersselect *, 'us' as region from {{ source('raw_us', 'orders') }}union allselect *, 'eu' as region from {{ source('raw_eu', 'orders') }}Because both calls go through source(), the lineage graph correctly shows both raw tables as separate upstream nodes feeding this staging model, even though they live in physically different databases โ something a hardcoded cross-database reference would obscure entirely from dbtโs dependency tracking.
Debugging a Broken source() Call
If source('raw', 'orders') fails to compile, the error is almost always one of three things:
Compilation Error Source raw.orders not found- The source group name is wrong. Check the
name:field in yoursources.ymlmatches exactly โ itโs case-sensitive. - The table name is wrong or missing. The table must be explicitly listed under
tables:in the source block; dbt doesnโt infer available tables from the schema automatically. - The YAML file isnโt where dbt expects it. Source files need to live somewhere dbt scans โ typically under
models/, matching yourmodel-pathsconfig indbt_project.yml.
source() vs ref(): A Quick Mental Model
source() | ref() | |
|---|---|---|
| Points at | Raw table loaded outside dbt | A model dbt itself builds |
| Used in | Staging models only (by convention) | Any downstream model |
| Enables | Freshness checks, source-level tests | DAG dependency, environment portability |
| Declared in | sources.yml | Just the model file existing |
Summary
The source() function is what turns a raw, unmanaged table into a first-class, trackable node in your dbt project. Used consistently โ one staging model per source, everything downstream using ref() โ it keeps your entire lineage graph accurate and your project resilient to upstream schema drift. Skipping it in favor of hardcoded table names saves a few minutes today and costs hours the first time something breaks three layers downstream.