ref(): соединяем модели в граф
ref() — это главная функция dbt. Без неё dbt — это просто набор SQL-файлов с CLI-обёрткой. С ref() — это полноценный transformation framework.
В этом уроке мы разберём, что именно делает ref(), почему это так важно, и как его использовать на практике.
Что делает ref()
Когда вы пишете внутри модели:
SELECT *
FROM {{ ref('stg_orders') }}
WHERE amount > 100
dbt при компиляции делает две вещи одновременно:
- Подставляет полное имя таблицы на место
{{ ref('stg_orders') }}:SELECT * FROM "dev"."main"."stg_orders" WHERE amount > 100 - Добавляет ребро в DAG зависимостей: «текущая модель зависит от
stg_orders».
Эти две вещи — подстановка имени + регистрация зависимости — и делают ref() особенным.
Почему не просто хардкод имени
«А почему бы просто не написать SELECT * FROM dev.main.stg_orders?» — частый вопрос джунов. Несколько причин:
1. Environment portability
Хардкод имени работает в одном environment. Но dev.main.stg_orders в dev — это prod.main.stg_orders в prod. Без ref() модель пришлось бы переписывать для каждого окружения.
С ref() dbt сам подставляет правильное имя в зависимости от target:
target: dev->"dev"."main"."stg_orders"target: prod->"analytics"."public"."stg_orders"
Один и тот же SQL работает везде.
2. Автоматическое построение DAG
Без ref() зависимости между моделями приходится описывать вручную (в Airflow это set_upstream). С ref() dbt строит DAG из самого SQL: парсит {{ ref('X') }} и добавляет ребро. На 500 моделях это переход от ручного управления зависимостями к автоматическому.
3. Топологический порядок выполнения
dbt run/build выполняет модели в правильном порядке (parents -> children). Без ref() dbt не знает, что от чего зависит, и пришлось бы ручкой указывать порядок.
4. dbt run —select X+ работает только с ref()
Команда dbt run --select stg_orders+ запускает stg_orders и всё, что от неё зависит. Без ref() dbt не знает, что зависит — этот функционал не работает.
5. lineage UI и docs
dbt docs serve рисует lineage-диаграммы. Они генерируются из ref()-зависимостей. Без ref() — пустой граф.
Минимальный пример: parent + child
Создадим два связанных модели.
models/staging/stg_orders.sql:
SELECT
100 AS order_id, 1 AS customer_id, 50.00 AS amount, '2026-01-01'::date AS order_date
UNION ALL SELECT 101, 2, 75.50, '2026-01-15'::date
UNION ALL SELECT 102, 1, 120.00, '2026-02-01'::date
UNION ALL SELECT 103, 3, 30.00, '2026-02-05'::date
models/marts/orders_large.sql:
{{ config(materialized='table') }}
SELECT
order_id,
customer_id,
amount,
order_date
FROM {{ ref('stg_orders') }}
WHERE amount > 50
Запустим:
dbt run --select orders_large+
Стоп, опечатка: нам нужно build с верхней модели:
dbt run --select +orders_large
Префикс + слева означает «эта модель и все upstream». Вывод:
06:55:11 Found 2 models, 408 macros
06:55:11
06:55:11 Concurrency: 4 threads (target='dev')
06:55:11
06:55:11 1 of 2 START sql view model main.stg_orders ......... [RUN]
06:55:11 1 of 2 OK created sql view model main.stg_orders .... [OK in 0.03s]
06:55:11 2 of 2 START sql table model main.orders_large ...... [RUN]
06:55:11 2 of 2 OK created sql table model main.orders_large . [OK in 0.04s]
Заметили? Сначала stg_orders (view), потом orders_large (table). dbt сам определил порядок из {{ ref('stg_orders') }} в orders_large.
Проверяем в DuckDB:
SELECT * FROM orders_large;
-- ┌──────────┬─────────────┬────────┬─────────────┐
-- │ order_id │ customer_id │ amount │ order_date │
-- ├──────────┼─────────────┼────────┼─────────────┤
-- │ 101 │ 2 │ 75.50│ 2026-01-15 │
-- │ 102 │ 1 │ 120.00│ 2026-02-01 │
-- └──────────┴─────────────┴────────┴─────────────┘
Две строки (amount > 50). Отлично.
Что в compiled SQL
Откроем compiled:
cat target/compiled/learning_models/models/marts/orders_large.sql
SELECT
order_id,
customer_id,
amount,
order_date
FROM "dev"."main"."stg_orders"
WHERE amount > 50
{{ ref('stg_orders') }} заменилось на "dev"."main"."stg_orders". Если бы target был prod с другой базой/схемой — здесь стояло бы её имя.
run-версия добавляет DDL:
create or replace table "dev"."main"."orders_large"
as (
SELECT
order_id,
customer_id,
amount,
order_date
FROM "dev"."main"."stg_orders"
WHERE amount > 50
);
Это то, что DuckDB реально получил.
DAG: визуализация зависимостей
Сейчас у нас DAG из двух нод:
Это можно увидеть в dbt docs:
dbt docs generate
dbt docs serve
Откроется http://localhost:8080. Найдите orders_large в левой панели, кликните на иконку графа в правом нижнем углу — увидите визуальный lineage.
Цепочка из трёх моделей
Реальные проекты — это цепочки и сети. Добавим intermediate-слой.
models/intermediate/int_orders_with_customers.sql:
SELECT
o.order_id,
o.customer_id,
o.amount,
o.order_date,
c.first_name,
c.last_name
FROM {{ ref('stg_orders') }} o
LEFT JOIN {{ ref('stg_customers') }} c USING (customer_id)
models/marts/orders_revenue.sql:
{{ config(materialized='table') }}
SELECT
customer_id,
first_name,
last_name,
COUNT(*) AS order_count,
SUM(amount) AS total_revenue
FROM {{ ref('int_orders_with_customers') }}
GROUP BY 1, 2, 3
ORDER BY total_revenue DESC
И обновлённый stg_customers.sql (вы уже создавали в первом уроке):
SELECT 1 AS customer_id, 'Alice' AS first_name, 'Smith' AS last_name
UNION ALL SELECT 2, 'Bob', 'Jones'
UNION ALL SELECT 3, 'Carol', 'Davis'
Запустим:
dbt run
Вывод (порядок!):
1 of 4 START sql view model main.stg_customers ................. [RUN]
2 of 4 START sql view model main.stg_orders .................... [RUN]
1 of 4 OK created sql view model main.stg_customers ............ [OK in 0.03s]
2 of 4 OK created sql view model main.stg_orders ............... [OK in 0.03s]
3 of 4 START sql view model main.int_orders_with_customers ..... [RUN]
3 of 4 OK created sql view model main.int_orders_with_customers [OK in 0.04s]
4 of 4 START sql table model main.orders_revenue ................ [RUN]
4 of 4 OK created sql table model main.orders_revenue ........... [OK in 0.05s]
dbt:
- Запустил
stg_customersиstg_ordersпараллельно (threads=4, оба не имеют зависимостей) - После того как оба готовы — запустил
int_orders_with_customers(зависит от обоих) - После него —
orders_revenue(зависит от intermediate)
Это и есть «топологическая сортировка DAG»: параллельность где возможна, последовательность где требуется.
Граф зависимостей
Что важно понять:
int_orders_with_customersимеет два upstream: stg_customers и stg_orders. Оба должны быть готовы.orders_revenueимеет один upstream: int_orders_with_customers. Дальше до stg_* — транзитивно.
dbt вычислил это сам из ref()-вызовов. Никто не писал «orders_revenue depends on int_orders_with_customers».
ref() в Jinja — это функция
ref() — это Jinja-функция (а не специальный SQL-синтаксис). Можно использовать в выражениях:
-- Условный ref в зависимости от target
SELECT * FROM
{% if target.name == 'prod' %}
{{ ref('orders_prod_only') }}
{% else %}
{{ ref('orders_dev_only') }}
{% endif %}
Или в цикле:
-- UNION нескольких моделей
{% set models = ['stg_orders_us', 'stg_orders_eu', 'stg_orders_asia'] %}
{% for model in models %}
SELECT *, '{{ model }}' AS region FROM {{ ref(model) }}
{% if not loop.last %} UNION ALL {% endif %}
{% endfor %}
При компиляции это развернётся в три SELECT с UNION ALL. И dbt всё ещё построит правильные зависимости — три ребра в DAG, по одному на каждый ref().
ref() парсится в фазу parse, ещё до выполнения. Если вы пишете {{ ref(some_dynamic_var) }}, где some_dynamic_var зависит от runtime (например, результат run_query()) — dbt не сможет определить зависимость. Используйте ref() со строковым литералом или с переменной, известной на parse.
ref() с двумя аргументами: cross-project
В dbt 1.6+ есть dbt Mesh — возможность ссылаться на модели из другого dbt-проекта:
SELECT * FROM {{ ref('marketing_project', 'campaigns') }}
Первый аргумент — имя другого dbt-проекта (определённого как dependency в packages.yml), второй — модель в нём.
Для junior это за пределами скоупа — упоминаем только для понимания, что увидите в больших корпоративных проектах. Самостоятельно вряд ли понадобится в течение первого года.
dbt Mesh: cross-project ref, model groups и governanceЧто НЕ может ref()
Несколько ограничений:
1. ref() работает только для моделей, sources и snapshots
Нельзя сделать {{ ref('some_table_that_was_not_created_by_dbt') }}. Для внешних таблиц (загруженных Fivetran/Airbyte) — нужно использовать {{ source(...) }} (модуль 5 курса).
2. ref() работает только для существующих моделей
Если модель 'X' не существует в проекте — dbt parse упадёт с ошибкой:
Compilation Error in model my_model
Model 'model.learning_models.my_model' (models/marts/my_model.sql) depends on a node named 'X' which was not found
3. Циклические зависимости запрещены
Если A.sql имеет ref('B'), а B.sql имеет ref('A') — dbt parse упадёт:
Compilation Error
Found a cycle: model.learning_models.A --> model.learning_models.B --> model.learning_models.A
DAG (как название говорит) — Directed Acyclic.
4. ref() парсится на parse-фазе, а не runtime
{% set model_name = "some_value_from_db" %} -- НЕ работает
SELECT * FROM {{ ref(model_name) }}
Если model_name определяется через run_query() (выполнение SQL во время компиляции), dbt не сможет определить зависимость на parse. Решение — использовать литералы или Jinja-переменные, известные на parse.
ref() vs source()
ref() — для моделей, которые dbt создаёт.
source() — для таблиц, которые в warehouse уже есть и dbt их не создаёт (raw от Fivetran/Airbyte).
-- ref: ссылка на dbt-модель
FROM {{ ref('stg_orders') }}
-- source: ссылка на raw таблицу
FROM {{ source('jaffle_shop', 'orders') }}
Источники объявляются в sources.yml. Подробно — модуль 5 курса.
Принципиально: ref() создаёт зависимость «dbt-модель X зависит от dbt-модели Y». source() создаёт зависимость «dbt-модель X зависит от external-source Z (которую dbt не строит)». Оба попадают в DAG, но обрабатываются по-разному.
Что делает ref() при dbt parse
Чтобы окончательно разобраться: dbt при parse делает примерно следующее (псевдокод Python):
manifest = {"nodes": {}, "edges": []}
for model_file in glob("models/**/*.sql"):
model_id = basename(model_file).replace(".sql", "")
sql_content = read_file(model_file)
# Находим все {{ ref('...') }} через Jinja parse
ref_calls = jinja_parse_refs(sql_content)
# Регистрируем модель и её зависимости
manifest["nodes"][model_id] = {
"sql": sql_content,
"depends_on": [refd_name for refd_name in ref_calls]
}
for refd_name in ref_calls:
manifest["edges"].append((refd_name, model_id))
# Топологическая сортировка
execution_order = topological_sort(manifest["edges"])
# manifest.json записывается в target/
write_json("target/manifest.json", manifest)
То есть весь DAG строится из самого SQL через Jinja-парсинг. Никаких отдельных DAG-файлов, никакой ручной декларации зависимостей. Это и есть фундаментальная идея dbt — код моделей одновременно является и графом.
Попробуй сам
Создайте цепочку из четырёх моделей:
-
stg_payments.sql(staging) — синтетические оплаты:SELECT 1 AS payment_id, 100 AS order_id, 50.00 AS amount UNION ALL SELECT 2, 101, 75.50 UNION ALL SELECT 3, 102, 120.00 -
int_orders_with_payments.sql(intermediate):SELECT o.order_id, o.customer_id, o.amount AS order_amount, p.amount AS paid_amount FROM `{{ ref('stg_orders') }}` o LEFT JOIN `{{ ref('stg_payments') }}` p USING (order_id) -
customer_revenue.sql(mart):`{{ config(materialized='table') }}` SELECT customer_id, COUNT(*) AS payment_count, SUM(paid_amount) AS total_paid FROM `{{ ref('int_orders_with_payments') }}` GROUP BY 1 -
Запустите
dbt run. Посмотрите порядок выполнения — должен быть stg_* -> int_* -> mart. -
Запустите
dbt docs generate && dbt docs serve, откройтеhttp://localhost:8080, найдитеcustomer_revenueи посмотрите его lineage — увидите полную цепочку. -
Удалите одну модель из ref() — например,
int_orders_with_payments. Запуститеdbt parse— увидите Compilation Error.