Главная мысль: dbt не выполняет SQL, он его собирает
Самое частое заблуждение новичка в dbt: «dbt — это инструмент, который гоняет мои запросы». Это не так, и пока эта неточность сидит в голове, половина поведения dbt выглядит магией. Корректная ментальная модель такая: dbt — это компилятор. Он берёт ваши файлы-модели, в которых SQL перемешан с шаблонами, разворачивает шаблоны в чистый SQL, выстраивает из ссылок граф зависимостей и только потом отдаёт уже готовые строки SQL в драйвер вашего хранилища. Всё тяжёлое выполнение — сканирование таблиц, джойны, агрегации — делает само хранилище: DuckDB, Postgres, Snowflake, ClickHouse. dbt не трогает ни байта ваших данных.
Из этой одной мысли выводится почти всё остальное. Если dbt — компилятор, то у него есть фаза разбора исходников, промежуточное представление (граф) и фаза генерации кода. Давайте пройдём по этому конвейеру так, как он реально устроен, а не как его рисуют в маркетинговых слайдах.
Модель — это SELECT, а не таблица
Файл модели — это .sql в папке models/, и внутри него лежит ровно один SELECT. Никаких CREATE TABLE, никаких INSERT, никаких DROP. Вы описываете что хотите видеть в результате, а не как это материализовать. Имя файла становится именем модели: stg_orders.sql — это модель stg_orders.
Но чистым SQL дело не ограничивается. Внутри SELECT живёт .sql-файла. Выражения в двойных фигурных скобках вычисляются и подставляются в текст, а блоки с управляющими конструкциями ({% if %}, {% for %}) задают условную и циклическую генерацию SQL.
-- models/staging/stg_orders.sql
select
id as order_id,
user_id as customer_id,
order_date,
status
from {{ source('jaffle', 'raw_orders') }}
{% if target.name == 'dev' %}
where order_date >= dateadd('day', -7, current_date)
{% endif %}
Здесь видно две роли Jinja сразу. source(...) — это вызов функции, которая вернёт полностью квалифицированное имя таблицы. А блок {% if %} физически вырежет или оставит фильтр where в зависимости от того, в каком окружении мы компилируем. В прод-сборке этой строки в финальном SQL не будет вообще — не «она проигнорируется на лету», а её там не окажется как текста.
Две фазы: parse и execute
Чтобы понять, откуда берётся DAG, нужно знать, что dbt проходит по вашим шаблонам дважды, и это две принципиально разные фазы.
Parse phase. dbt сканирует все .sql, .yml и .csv в проекте и парсит Jinja в каждом файле — но не ради генерации SQL, а ради того, чтобы выловить вызовы ref() и source(). На этой фазе нет подключения к хранилищу: dbt физически не соединён с DuckDB или Snowflake. В Jinja-контексте на этой фазе специальная переменная execute равна false. Поэтому функции, которым нужно реальное соединение (например run_query()), на parse phase возвращают None — это та самая «магия», на которую натыкаются все, кто пишет первый макрос. Результат фазы — построенный граф зависимостей и его сериализация в manifest.json.
Execute phase. Теперь dbt в правильном порядке проходит по графу, второй раз рендерит каждую модель — но уже с execute равным true и с живым соединением к хранилищу — и отправляет получившийся DDL на исполнение. Только здесь run_query() действительно ходит в базу.
Запомните это правило: зависимости вычисляются на parse phase без подключения к базе, а SQL исполняется на execute phase. Оно объясняет, почему dbt всегда знает порядок выполнения ещё до первого запроса.
ref() и source() строят граф
Ключевая функция — ref(). Вместо того чтобы жёстко прописывать имя таблицы, вы пишете {{ ref('stg_orders') }}. Это даёт dbt две вещи сразу.
Во-первых, на parse phase dbt видит вызов ref('stg_orders') внутри модели fct_orders и регистрирует ребро графа: stg_orders является родителем (upstream) для fct_orders. Так из всех ref() и source() собирается
Во-вторых, на execute phase тот же ref('stg_orders') подставит в текст полностью квалифицированное имя той физической таблицы или вьюхи, в которую stg_orders материализовалась в текущем окружении — что-то вроде "dev"."analytics"."stg_orders". Поэтому одна и та же модель в dev и prod ссылается на разные физические объекты, а вы не меняете ни строчки SQL: имя приходит из конфигурации профиля.
source() устроен похоже, но указывает на сырые таблицы, которые dbt не строит, а только декларирует в .yml. Это входные узлы графа — листья без родителей.
Граф даёт два практических следствия. Первое — порядок выполнения: dbt делает топологическую сортировку, поэтому родитель всегда соберётся раньше потомка, и вам не нужно вручную ничего упорядочивать. Второе — распараллеливание: модели, между которыми нет пути в графе, независимы, и dbt запускает их одновременно в несколько потоков (параметр threads в профиле). Узкое место — самая длинная цепочка зависимостей, а не общее число моделей.
Materializations: какой DDL генерируется
Модель — это всегда SELECT. А вот что dbt сделает с результатом этого SELECT, задаёт materialization. Это и есть генерация кода: одна и та же модель при разных materialization превращается в разный DDL. Materialization задаётся в config(...) модели или в dbt_project.yml.
| Materialization | Что генерирует dbt | Хранит данные? | Когда использовать |
|---|---|---|---|
view | CREATE VIEW ... AS (select) | Нет, только определение | Лёгкие staging-модели; данные всегда свежие, но запрос пересчитывается при каждом обращении |
table | CREATE TABLE ... AS (select), полная пересборка при каждом run | Да, целиком | Mart-таблицы для BI, тяжёлые джойны, которые дорого пересчитывать на чтении |
incremental | Первый раз — как table; далее INSERT/MERGE только новых строк | Да, дописывается | Большие append-only факты и события, где полный пересчёт слишком долог |
ephemeral | Ничего; SELECT вставляется в потомков как CTE | Нет, объекта в базе нет | Промежуточная логика, которую не нужно материализовать отдельно |
view и table интуитивны: вьюха — это сохранённый запрос, таблица — материализованный снимок, пересобираемый целиком. ephemeral хитрее: в хранилище не появляется никакого объекта вообще. Вместо этого SELECT эфемерной модели dbt подставляет в SQL её потомков как
Incremental: is_incremental() и стратегии
incremental — самая интересная materialization, потому что её поведение зависит от состояния. При первом запуске целевой таблицы ещё нет, и dbt строит её как обычную table. При последующих запусках таблица уже существует, и dbt должен дописать только новые строки, а не пересчитывать гигабайты заново.
Развилку задаёт макрос is_incremental(). Он возвращает true только когда выполнены все условия: целевая таблица уже существует, режим инкрементальный, и это не полный обновляющий запуск. Внутри его блока вы пишете фильтр, который отрезает уже загруженное.
-- models/marts/fct_events.sql
{{ config(materialized='incremental', unique_key='event_id') }}
select
event_id,
user_id,
event_type,
event_ts
from {{ ref('stg_events') }}
{% if is_incremental() %}
-- this filter runs only on incremental runs, not on the first full build
where event_ts > (select max(event_ts) from {{ this }})
{% endif %}
Здесь this — ссылка на саму строящуюся модель (её физическое имя). На первом запуске блок is_incremental() не попадает в SQL, и читается весь источник. На последующих — добавляется where, и сканируются только свежие события.
Что dbt делает с этими отобранными строками, задаёт incremental_strategy:
- append — просто
INSERTновых строк. Самая быстрая, но не защищает от дублей: если строка прилетела дважды, она дважды и ляжет. - merge —
MERGEпоunique_key: совпавшие строки обновляются, новые вставляются. Идемпотентно, поддерживает обновления задним числом. - delete+insert — сначала
DELETEстрок с совпадающимиunique_key, затемINSERTновой пачки. Результат как у merge, но через две операции; полезно на хранилищах без эффективного MERGE.
И здесь вылезает роль unique_key. Без него dbt не может понять, какие строки «те же самые», поэтому и merge, и delete+insert вырождаются в простой append с риском дублей. unique_key — это бизнес-ключ, по которому строка уникальна; именно он делает инкрементальную загрузку идемпотентной. Перезапустили перекрывающееся окно — дублей не будет, потому что совпавшие по ключу строки заменятся, а не задвоятся.
Что лежит в target/
Раз dbt — компилятор, у него есть «папка сборки». Это target/, и заглядывать в неё — лучший способ отладки, потому что там лежит ровно тот SQL, что ушёл в базу.
target/compiled/<project>/.../model.sql— чистый SQL после раскрытия Jinja. Всеref()заменены на имена таблиц,source()— на сырые таблицы, переменные подставлены, макросы развёрнуты,{% if %}и{% for %}разрешены. Это простоSELECT, без DDL. Его можно скопировать и выполнить в любом SQL-редакторе хранилища.target/run/<project>/.../model.sql— тот жеSELECT, но обёрнутый в DDL под выбранную materialization:CREATE VIEW ... AS,CREATE TABLE ... ASилиMERGE. Это финал — именно это исполняется на execute phase.manifest.json— сериализованный DAG: все узлы, рёбра, конфиги. Артефакт parse phase.run_results.json— что и за сколько выполнилось в последнем запуске.
Различие compiled против run — это ровно различие «что выбрать» против «как материализовать». Команда dbt compile останавливается после фазы компиляции и наполняет compiled/, ничего не исполняя в базе. Это бесплатный способ увидеть итоговый SQL до запуска. Тему артефактов target/ подробно разбираем в бесплатном курсе по dbt с песочницей на DuckDB прямо в браузере.
Тесты: SELECT, который ищет нарушителей
Тесты в dbt стоят на той же идее компиляции, и это элегантно. Тест — это тоже модель, тоже компилируется в SELECT, но с одним соглашением: запрос возвращает строки-нарушители. Прошёл тест или нет, определяется тривиально — по числу вернувшихся строк.
- Ноль строк — нарушителей нет, тест пройден.
- Хотя бы одна строка — это и есть нарушения; тест падает, а строки можно посмотреть.
Generic-тест not_null для колонки компилируется примерно в select * from model where column is null. Если вернулась хоть одна строка — значит, нашлись NULL, и тест красный. unique ищет ключи, встречающиеся больше одного раза. Singular-тест — это просто ваш .sql-файл в папке tests/ с произвольным SELECT, который должен вернуть пусто на здоровых данных. Скомпилированный SQL теста лежит всё в той же target/compiled/, так что вы всегда можете глазами проверить, что именно dbt проверяет, и выполнить этот запрос вручную.
Собираем картину
dbt — это конвейер компиляции, а не движок выполнения. Он парсит модели и из ref() и source() строит DAG (parse phase), затем в топологическом порядке рендерит каждую модель в чистый SQL и оборачивает его в DDL под нужную materialization (execute phase). view и table — снимок против сохранённого запроса; incremental дописывает данные, опираясь на is_incremental() и unique_key; ephemeral живёт как CTE внутри потомков. Всё, что ушло в базу, лежит в target/, а тесты — это SELECT, возвращающий нарушителей. Держа в голове связку «компилятор плюс DAG плюс materialization», вы перестаёте удивляться поведению dbt и начинаете им управлять.
Если хотите закрепить это руками, а не на словах — у нас есть полный бесплатный курс по dbt: runnable-песочница на DuckDB прямо в браузере, без облака и без подписок, вы сами собираете проект и видите содержимое target/ после каждого запуска. Он часть бесплатного направления Data Engineering на нашей платформе.