execute flag: parse phase vs execute phase
Это самый «нелогичный» урок Jinja для junior. Многие dbt-разработчики работают год-два, не понимая, почему в их макросе с run_query() иногда возвращается None и ломается компиляция. Корень — в том, что dbt проходит по шаблонам ДВА раза: сначала на этапе парсинга проекта, потом — при запуске. Без понимания этой механики вы будете писать код, который работает «через раз».
Почему два прохода
Чтобы построить DAG, dbt должен сначала прочитать все модели и понять зависимости — кто на кого ссылается через {{ ref() }} и {{ source() }}. Это parse phase. Происходит до того, как любая модель была собрана и любой SQL выполнен.
На parse phase dbt:
- Сканирует все
.sql,.yml,.csvв проекте. - Парсит Jinja в каждом файле, чтобы выявить
ref()иsource(). - Строит DAG зависимостей.
- Валидирует, что все ссылки разрешаются.
Дальше идёт execute phase: dbt в правильном порядке запускает CREATE TABLE ... AS ... для каждой модели, и здесь же второй раз парсит шаблоны — но теперь с полным контекстом (target, warehouse-connection, реальные таблицы).
Это и есть parse vs execute. И вот в чём подвох: на parse phase warehouse-соединения нет. dbt не подключается к DuckDB/Postgres/Snowflake, пока не пришло время реально выполнять SQL.
Что такое execute flag
Переменная execute — это специальная булева переменная в Jinja-контексте dbt. Она показывает, на какой фазе сейчас исполняется шаблон.
- На parse phase:
execute = false. - На execute phase:
execute = true.
Проверка: {% if execute %} — «выполни этот блок только на execute phase».
Зачем это нужно? Потому что некоторые функции не работают на parse phase: они требуют warehouse-connection, которого ещё нет.
Главный пример: run_query
run_query(sql) — функция, которая отправляет SQL в warehouse и возвращает результат. Используется, чтобы построить динамический SQL на основе данных в реальной таблице.
Канонический пример: получить список колонок таблицы, чтобы динамически собрать SELECT:
-- models/marts/marts__dynamic_select.sql
{% set cols_query %}
select column_name
from information_schema.columns
where table_name = 'orders'
{% endset %}
{% set results = run_query(cols_query) %}
{% if execute %}
{% set column_names = results.columns[0].values() %}
{% else %}
{% set column_names = [] %}
{% endif %}
select
{% for col in column_names %}
{{ col }}{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('stg_jaffle__orders') }}
Что здесь критично:
run_query(cols_query)на parse phase возвращает None, потому что нет warehouse-connection. Если попытаемся вызватьresults.columns[0].values()без проверкиexecute— упадёт сAttributeError: NoneType has no columns.{% if execute %}пропускает обработку результата на parse phase, ставит заглушкуcolumn_names = [].- На execute phase
run_queryвозвращает результат,column_namesзаполняется реальными колонками. - На parse phase цикл for пройдётся по пустому списку — output будет невалидным SQL, но это не важно: на parse phase SQL и не запускается.
Полный паттерн {% if execute %}
Канонический шаблон работы с run_query:
{% set query %}
select distinct status from {{ ref('stg_jaffle__orders') }}
{% endset %}
{% set results = run_query(query) %}
{% if execute %}
{% set statuses = results.columns[0].values() %}
{% else %}
{% set statuses = [] %}
{% endif %}
select
customer_id,
{% for s in statuses %}
sum(case when status = '{{ s }}' then 1 else 0 end) as count_{{ s }}
{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('stg_jaffle__orders') }}
group by customer_id
Это dynamic pivot — pivot, где набор значений не захардкожен, а извлекается из самой таблицы. На parse phase цикл пустой, на execute — генерирует реальный SQL.
Запомните паттерн: каждый раз, когда используете run_query для получения данных, оборачивайте обработку результата в {% if execute %} + fallback пустой коллекции. Иначе на parse phase упадёте с None-ошибкой.
Когда execute true, когда false
| Команда dbt | execute |
|---|---|
dbt parse | false |
dbt list | false |
dbt compile | true (для всех моделей) |
dbt run | true (на этапе выполнения каждой модели) |
dbt test | true |
dbt build | true |
dbt docs generate | смешано (parse для DAG, execute для catalog) |
Ключевая мысль: compile уже execute. dbt compile ходит в warehouse за метаданными для адаптера и выполняет run_query-вызовы. Поэтому в скомпилированный SQL уже подставляются результаты.
dbt parse — особая команда, чисто для построения manifest без обращения к warehouse. Полезна в CI для проверки синтаксиса.
Тонкость: что run_query возвращает
run_query возвращает объект AgateTable (agate — Python-библиотека табличных данных). У него есть:
.columns— список объектов колонок.results.columns[0]— первая колонка..column_names— список имён колонок..rows— список строк.len(results)— количество строк (но только на execute!).
Чтобы получить значения одной колонки как Python-список:
{% set values = results.columns[0].values() %}
Или через rows и индекс:
{% set values = [] %}
{% for row in results.rows %}
{% do values.append(row[0]) %}
{% endfor %}
Оба работают идентично.
Альтернатива run_query: dbt_utils.get_column_values
В пакете dbt_utils есть готовая макрос для частого случая «получить уникальные значения колонки»:
{% set statuses = dbt_utils.get_column_values(
table=ref('stg_jaffle__orders'),
column='status'
) %}
Внутри она делает run_query и обрабатывает execute правильно. Возвращает пустой список на parse phase, реальный — на execute. Если ваш случай — просто получить уникальные значения, используйте get_column_values, а не свой run_query. Меньше boilerplate, меньше шансов забыть {% if execute %}.
Когда execute НЕ нужен
Не все макросы требуют execute. Главное правило: execute нужен только там, где есть run_query или другой warehouse-доступ.
Простые operation типа арифметики, конкатенации строк, работы с переменными — не зависят от execute:
{# Не нужен execute — нет warehouse-обращения #}
{% set lookback = var('lookback_days', 7) %}
{% set table_name = ref('orders') %}
{% set columns_str = ['a', 'b', 'c'] | join(', ') %}
Эти строки работают одинаково на parse и execute.
Распространённая ошибка #1: использовать results без execute
{# WRONG #}
{% set results = run_query('select count(*) from orders') %}
{% set count = results.columns[0].values()[0] %}
select '{{ count }}' as cnt
{# Падает на parse: NoneType has no attribute 'columns' #}
Исправление:
{# RIGHT #}
{% set results = run_query('select count(*) from orders') %}
{% if execute %}
{% set count = results.columns[0].values()[0] %}
{% else %}
{% set count = 0 %}
{% endif %}
select '{{ count }}' as cnt
Распространённая ошибка #2: тянуть данные на parse
Иногда нужно понять, что в источнике X записей, и от этого что-то менять. Соблазн:
{# WRONG: пытаемся выполнить SELECT на parse, когда warehouse недоступен #}
{% set count = run_query('select count(*) from raw_orders').columns[0].values()[0] %}
{% if count > 1000000 %}
-- большой объём
{% else %}
-- маленький
{% endif %}
Это работает на dbt compile и dbt run, но на dbt parse и dbt list падает. Хуже: некоторые CI делают dbt parse перед dbt run — и тут вы получаете сюрприз.
Исправление — оборачивать всё, что требует warehouse, в {% if execute %}:
{% set count = 0 %}
{% if execute %}
{% set results = run_query('select count(*) from raw_orders') %}
{% set count = results.columns[0].values()[0] %}
{% endif %}
{% if count > 1000000 %}
-- ...
{% endif %}
Распространённая ошибка #3: ожидать, что переменная сохранится между моделями
Нет, не сохранится. Каждая модель — отдельный Jinja-контекст. {% set x = 5 %} в model A не виден в model B.
Если нужна общая переменная — используйте var() (из dbt_project.yml), env_var(), или macro, который возвращает значение.
Tradeoff: насколько dynamic делать модели
run_query — мощная, но опасная вещь:
- Плюс: SQL не хардкодит список значений, автоматически адаптируется.
- Минус: компиляция модели зависит от данных — если завтра в warehouse появится новый status, SQL изменится, нужен
dbt run. В CI это может приводить к нестабильным diff. - Минус: лишнее обращение к warehouse при каждой компиляции. На больших проектах удлиняет
dbt compile.
Junior-правило: используйте run_query только когда реально нужен динамизм. Захардкоженный список (['completed', 'pending', 'returned']) обычно нормально — его легко обновить.
Попробуй сам
Напишите модель marts__status_pivot.sql, которая:
- Через
dbt_utils.get_column_valuesполучает список уникальных значенийstatusизstg_jaffle__orders. - Делает pivot: каждая строка = customer_id, колонки = count в каждом status.
Скелет:
{% set statuses = dbt_utils.get_column_values(
table=ref('stg_jaffle__orders'),
column='status'
) %}
select
customer_id,
{% for s in statuses %}
sum(case when status = '{{ s }}' then 1 else 0 end) as count_{{ s }}
{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('stg_jaffle__orders') }}
group by customer_id
Проверьте, что на dbt compile модель правильно собирается. Затем выполните dbt parse — не должно быть ошибок (потому что get_column_values корректно обрабатывает execute=false внутри).
Итоги
- dbt делает два прохода по шаблонам: parse phase (без warehouse) и execute phase (с warehouse).
execute— boolean,falseна parse,trueна execute.run_queryвозвращает None на parse phase. Всегда обрабатывай результат под{% if execute %}с fallback.dbt parse,dbt list— только parse.dbt compile,dbt run,dbt test,dbt build— parse + execute.dbt_utils.get_column_values— готовая абстракция над частым случаемrun_query. Используй её вместо ручногоrun_query, когда возможно.- Tradeoff: dynamic SQL даёт гибкость ценой стабильности. Хардкод проще и предсказуемее.
В следующем уроке — последний кусок Jinja-фундамента: log() и приёмы отладки через target/compiled и target/run.