Skip to content
Learning Platform

Главная мысль: 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 живёт Jinja — тот же движок, что в Flask и Ansible. Именно Jinja отличает модель dbt от обычного .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() собирается DAG. Узлы — модели, sources, seeds, snapshots, тесты. Рёбра — зависимости. Направление — от upstream к downstream.

Во-вторых, на 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Хранит данные?Когда использовать
viewCREATE VIEW ... AS (select)Нет, только определениеЛёгкие staging-модели; данные всегда свежие, но запрос пересчитывается при каждом обращении
tableCREATE TABLE ... AS (select), полная пересборка при каждом runДа, целикомMart-таблицы для BI, тяжёлые джойны, которые дорого пересчитывать на чтении
incrementalПервый раз — как table; далее INSERT/MERGE только новых строкДа, дописываетсяБольшие append-only факты и события, где полный пересчёт слишком долог
ephemeralНичего; SELECT вставляется в потомков как CTEНет, объекта в базе нетПромежуточная логика, которую не нужно материализовать отдельно

view и table интуитивны: вьюха — это сохранённый запрос, таблица — материализованный снимок, пересобираемый целиком. ephemeral хитрее: в хранилище не появляется никакого объекта вообще. Вместо этого SELECT эфемерной модели dbt подставляет в SQL её потомков как CTE. Удобно для промежуточной логики, но у эфемерной модели нет своего объекта, на который можно посмотреть в базе, — это усложняет отладку.

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 новых строк. Самая быстрая, но не защищает от дублей: если строка прилетела дважды, она дважды и ляжет.
  • mergeMERGE по 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 на нашей платформе.

Ещё в направлении · Data Engineering

Все материалы направления →