Tree-sitter и static parsing: зачем dbt парсит SQL без Jinja
Когда dbt парсит модель customers.sql, его задача — извлечь метаданные: какие ref(), какие source(), какие config(), какие включаемые macros. Эти метаданные попадают в Manifest и используются для построения DAG.
Очевидное решение — выполнить Jinja-рендеринг с execute=False и парсить результат. Но это медленно: 50-200мс на модель × 1000 моделей = 50-200 секунд только на parse. На производительных проектах это критический боттлнек.
Tree-sitter — это альтернатива: статический парсинг SQL+Jinja без выполнения Jinja-кода. В этом уроке мы разберём что такое tree-sitter, как он используется в dbt-core, какие модели он умеет обрабатывать, какие нет, и почему это даёт 30-100x ускорение.
Что такое tree-sitter
Tree-sitter — это incremental parser generator, изначально написанный для Atom editor (потом перенят VS Code, Neovim, GitHub). Его use case в editor — syntax highlighting и code navigation на больших файлах: парсинг должен быть быстрым (миллисекунды) и поддерживать incremental updates (когда вы добавляете символ, переparsить нужно только окрестность).
Технически tree-sitter — это:
- Parser generator — описываете grammar (типа BNF), tree-sitter генерирует парсер на C.
- Runtime library — runtime на C с bindings для многих языков (Python, JavaScript, Rust).
- Grammar repositories —
tree-sitter-python,tree-sitter-javascript,tree-sitter-sql,tree-sitter-jinja2, …
В dbt-core используется специальный grammar — tree-sitter-jinja-stripper (репозиторий dbt-labs/tree-sitter-jinja-stripper). Это grammar, который понимает Jinja внутри SQL и может извлекать specific patterns.
Что делает tree-sitter в dbt-core
Tree-sitter не пытается выполнить Jinja. Он парсит исходник модели как AST (abstract syntax tree) и матчит specific patterns:
Jinja: expressions, statements, comments (dbt I){{ ref('foo') }}— pattern для ref calls{{ source('schema', 'name') }}— pattern для sources{{ config(materialized='table') }}— pattern для config calls{{ var('my_var') }}— pattern для var calls- Comments
{# ... #}— игнорируются - Conditional
{% if ... %}— игнорируется (но содержимое внутри парсится)
Из этих matches извлекается список refs, sources, configs модели — без рендеринга, без выполнения Python кода macros.
# Псевдокод того что делает tree-sitter wrapper в dbt
def extract_metadata(raw_code: str) -> ModelMetadata:
tree = tree_sitter.parse(raw_code, grammar='jinja-sql')
refs = []
sources = []
configs = {}
for node in tree.walk():
if matches_ref_pattern(node):
refs.append(extract_ref_args(node))
elif matches_source_pattern(node):
sources.append(extract_source_args(node))
elif matches_config_pattern(node):
configs.update(extract_config_args(node))
return ModelMetadata(refs=refs, sources=sources, configs=configs)
Реальный код в dbt-core живёт в core/dbt/parser/manifest.py и связанных файлах, использует Python tree_sitter package + dbt’s wrapper.
Сколько это ускоряет
Сравнение для модели среднего размера (~100 строк):
| Approach | Time per model | 1000 моделей |
|---|---|---|
| Full Jinja render (execute=False) | 50-200 мс | 50-200 сек |
| Tree-sitter static parse | 0.5-2 мс | 0.5-2 сек |
30-100x speedup на typical production workload. На больших проектах с 5000+ моделей это разница между dbt parse за минуту и за час.
Но не каждая модель пригодна для tree-sitter parsing. Если static parser не справляется — dbt fallback’ится на full Jinja render.
Когда tree-sitter справляется
Правило: если все аргументы ref/source — это string literals, и нет ref внутри loops/dynamic expressions — tree-sitter справляется. Если есть variables, functions, comprehensions — fallback.
В типичных production проектах 80-95% моделей парсятся через tree-sitter. Это даёт большую часть ускорения.
Как tree-sitter интегрирован в parser pipeline
# core/dbt/parser/manifest.py (упрощённо)
class ManifestLoader:
def _load_node(self, file: AnyFile):
raw_code = file.contents
# Step 1: Try tree-sitter static parsing
try:
metadata = static_parser.parse(raw_code)
# Got refs, sources, configs without Jinja
node = build_model_node(file, metadata)
self.manifest.nodes[node.unique_id] = node
return
except StaticParsingError:
pass # fallback
# Step 2: Fallback — full Jinja render
node = self._render_node_fully(file)
self.manifest.nodes[node.unique_id] = node
StaticParsingError — это когда tree-sitter не справился (нашёл dynamic expressions в ref args). dbt-core гладко fallback’ится без шума пользователю. Но в --debug логах будет видно static parser failure on model X, falling back to full render.
Tree-sitter и partial parsing
Partial parsing (следующий урок) — это отдельная оптимизация: при изменении одного файла не пере-парсим всё. Tree-sitter — про скорость parse одной модели. Они независимы и работают вместе:
- Cold start без partial parse: tree-sitter сильно ускоряет. Парсит ~5000 моделей за 5-15 сек вместо 5-15 минут.
- Warm start с partial parse: даже без tree-sitter было бы быстро (msgpack load), но tree-sitter ускоряет parse тех файлов, что изменились.
В dbt Fusion (модуль 12) parsing полностью на Rust — там tree-sitter уступает место SDF compiler с собственным parsing pipeline. Но идея та же — static analysis вместо Jinja render.
execute flag: parse phase vs execute phase (dbt I)Ограничения static parsing
Senior должен понимать ограничения tree-sitter — это критично для дебага «почему модель не находится в DAG».
-
Dynamic refs не извлекаются автоматически. Пример:
`{% for table in get_tables_via_macro() %}` SELECT * FROM `{{ ref(table) }}` `{% endfor %}`table— переменная, tree-sitter pessimistically не извлекает ref. Fallback на full render. -
String concatenation в args:
`{{ ref('stg_' ~ entity) }}`'stg_' ~ entity— это expression, не literal. Tree-sitter не извлекает. Fallback. -
Refs внутри SQL comments:
-- TODO: use ref('staging_users') eventually SELECT * FROM `{{ ref('raw_users') }}`Tree-sitter может ошибочно поднять
ref('staging_users')из комментария. На самом деле dbt-core grammar обрабатывает это корректно —--comments стрипаются перед поиском patterns. Но это типичный gotcha для custom parsers. -
Custom Jinja-функции, которые возвращают строки:
`{% set my_ref = make_ref('foo') %}` `{{ my_ref }}`Tree-sitter не выполняет custom функции. Fallback.
Дебаг static parsing failures
Senior может проверить, какие модели падают на static parser и fallback’ятся:
DBT_LOG_LEVEL=debug dbt parse 2>&1 | grep "static parser"
В выводе:
[debug] 2026-05-19 ... static parser failure on model.my_project.dynamic_model, falling back to full render
[debug] 2026-05-19 ... static parser failure on model.my_project.macro_ref, falling back to full render
Если у вас сотни таких сообщений — parse phase медленнее, чем мог бы быть. Reduction strategy:
-
Rewrite dynamic refs в explicit list. Вместо
for table in get_tables():`{% for table in ['foo', 'bar', 'baz'] %}` `{{ ref(table) }}` `{% endfor %}`Tree-sitter видит string literals в loop body, может справиться (на новых версиях dbt).
-
Move dynamic logic в
for_eachconfigs (1.10+):- name: my_model config: for_each: - { name: 'foo' } - { name: 'bar' }Parser обрабатывает на уровне YAML — статически.
-
Принять fallback как trade-off. На 50 dynamic моделей это +5-10 сек parse. Часто not worth refactor.
Tree-sitter в действии: пример
Возьмём модель:
-- models/marts/dim_customers.sql
{{ config(materialized='table', schema='marts') }}
{% if target.name == 'prod' %}
SELECT * FROM {{ ref('stg_customers_prod') }}
{% else %}
SELECT * FROM {{ ref('stg_customers_dev') }} LIMIT 1000
{% endif %}
JOIN {{ source('crm', 'accounts') }} USING (account_id)
Tree-sitter AST (упрощённо):
file
├── jinja_statement: config(materialized='table', schema='marts')
│ └── extracted: configs = {materialized: 'table', schema: 'marts'}
├── if_block
│ ├── condition: target.name == 'prod'
│ ├── then_branch:
│ │ └── jinja_expr: ref('stg_customers_prod')
│ │ └── extracted: refs.append(RefArgs(name='stg_customers_prod'))
│ └── else_branch:
│ └── jinja_expr: ref('stg_customers_dev')
│ └── extracted: refs.append(RefArgs(name='stg_customers_dev'))
├── sql_text: "JOIN"
└── jinja_expr: source('crm', 'accounts')
└── extracted: sources.append(SourceArgs(name='accounts', source_name='crm'))
Результат tree-sitter parse:
ModelMetadata(
refs=[
RefArgs(name='stg_customers_prod'),
RefArgs(name='stg_customers_dev'),
],
sources=[
SourceArgs(source_name='crm', name='accounts'),
],
configs={'materialized': 'table', 'schema': 'marts'},
)
Обе ref’ы добавлены — pessimistic. DAG получит зависимость на обе модели stg_customers_prod и stg_customers_dev. Execution выберет одну based on target.name. Это правильное поведение — нельзя предсказать target.name в parse time.
Time: ~1 мс на этой модели. Full Jinja render был бы ~50 мс.
Попробуй сам
- Включите debug log на parse:
DBT_LOG_LEVEL=debug dbt parse 2>&1 | tee parse.log - Найдите static parser activity:
grep "static parser" parse.log | head -20 - Найдите fallback cases:
grep "falling back to full render" parse.log - Если у вас большой проект — замерьте time с и без static parser:
# С static parser time dbt parse # Without (если доступен флаг — в dbt-core 1.11 есть DBT_PARTIAL_PARSE=false, но static parser всегда on) - Откройте
core/dbt/parser/в клонированном dbt-core. Файлы связанные с tree-sitter —core/dbt/parser/search.py,core/dbt/parser/manifest.py. Используется packagedbt-extractor(на Rust!), который wrapped вокруг tree-sitter.