dbt's superpower is its dependency graph (DAG). That graph is built from exactly two functions in your SQL: ref() and source(). Master these and the rest of dbt makes sense.
source()
{{ source('shopify', 'orders') }} — points at a raw table that arrived from a source system. The first arg is the source name (defined in YAML); the second is the table name.
Defining sources
In models/staging/shopify/_shopify__sources.yml:
version: 2
sources:
- name: shopify
schema: raw_shopify # where Fivetran/Airbyte lands the data
tables:
- name: orders
description: "Raw orders from Shopify"
columns:
- name: id
tests:
- unique
- not_null
- name: customers
description: "Raw customers from Shopify"
Once defined, models can reference it: FROM {{ source('shopify', 'orders') }} compiles to FROM raw_shopify.orders.
Why declare sources at all (instead of hardcoding FROM raw_shopify.orders):
- Freshness checks —
dbt source freshnesstells you if the source data is stale. - Source-level tests — uniqueness on the raw primary key catches Fivetran double-loads early.
- Lineage — source-to-staging dependencies show in
dbt docs. - Schema portability — change
raw_shopifytoraw_shopify_devin dev environment by tweaking the source YAML, not 50 SQL files.
ref()
{{ ref('stg_orders') }} — points at another dbt model. dbt resolves it to the full database.schema.stg_orders path based on the target environment.
ref() does three things:
- Resolves the table name at compile time.
- Declares a dependency so dbt knows to build stg_orders before this model.
- Builds the DAG — the global graph of model dependencies.
What the DAG buys you
The DAG is what makes dbt feel different from running SQL scripts:
- Right-order execution —
dbt runbuilds upstream models first. - Selective rebuilds —
dbt run --select stg_orders+rebuilds only models downstream of stg_orders. - Parallelism — dbt runs independent branches concurrently.
--threads 4runs 4 models at once. - Lineage visualization —
dbt docs generate && dbt docs serveproduces an interactive graph. - Impact analysis — "what breaks if I change column X" is answerable by walking the DAG.
A real DAG
sources:
raw.shopify.orders raw.shopify.customers
↓ ↓
stg_shopify__orders stg_shopify__customers
↓ ↓
└──────→ int_orders_enriched ←──┘
↓
fct_orders
↓
fct_daily_revenue
Every arrow is a ref() (or source() at the top). Change stg_shopify__customers and dbt knows int_orders_enriched, fct_orders, and fct_daily_revenue all need to rebuild.
How environments work
In profiles.yml:
my_profile:
target: dev
outputs:
dev:
database: analytics_dev
schema: dbt_anuj
prod:
database: analytics
schema: dbt
{{ ref('stg_orders') }} resolves differently per target:
--target dev→analytics_dev.dbt_anuj.stg_orders--target prod→analytics.dbt.stg_orders
Same SQL, different output destinations. This is how environment isolation works.
The "no hardcoded table names" rule
NEVER write:
SELECT * FROM analytics.dbt.stg_orders -- WRONG
ALWAYS write:
SELECT * FROM {{ ref('stg_orders') }} -- RIGHT
If you hardcode, you:
- Lose the DAG dependency (dbt won't know to build stg_orders first).
- Break environment portability (dev code accidentally points at prod).
- Lose lineage in docs.
- Get caught at code review by every senior person on the team.
When ref() and source() can fail
- Wrong model name —
ref('stg_order')(missing s). dbt errors at compile. - Cyclic dependency — A refs B, B refs A. dbt detects and refuses to run.
- Reference to a non-dbt table — use
source(), notref(). ref() is dbt-only.
Cross-project ref()
In multi-project setups (dbt Mesh / dbt Cloud), you can ref() across projects:
SELECT * FROM {{ ref('upstream_project', 'fct_orders') }}
Lets large orgs split warehouse code across teams while keeping lineage intact.
Takeaway
ref() and source() are the only ways to declare dependencies in dbt. They build the DAG; the DAG powers everything else (right-order execution, selective rebuilds, lineage, docs, impact analysis). Hardcoding table names defeats all of it.