В прошлом уроке мы поняли, что view — это сохранённый запрос без данных. Иногда этого хватает, иногда — нет. Table материализация даёт другой trade-off: dbt создаёт настоящую таблицу с данными. Запрос на материализацию выполняется один раз (при dbt run), а downstream-чтения становятся почти мгновенными.
Это второй после view самый используемый тип материализации. Понимать его — must для junior’а.
Что делает dbt при materialized=‘table’
dbt компилирует SQL модели в полный путь, оборачивает его в DDL:
CREATE OR REPLACE TABLE jaffle_shop.main.dim_customers AS (
SELECT ...
);
Warehouse выполняет: считает запрос, материализует результат в таблицу, заменяет старую. На DuckDB операция атомарная (либо успешно — старая полностью заменена, либо нет — старая жива).
dbt отправляет CREATE OR REPLACE TABLE в warehouse. Warehouse считает запрос, материализует результат в физические данные. Старая версия таблицы заменяется атомарно.
После создания таблицы любой downstream-SELECT — это просто чтение, без пересчёта SQL модели. Latency минимальна, потому что warehouse не повторяет JOIN/GROUP BY каждый раз.
Когда переходить от view к table
Самый частый триггер: запрос дорогой, downstream-обращений много. Несколько конкретных индикаторов:
1. Время выполнения SELECT > 5 секунд. Если view выполняется 5+ секунд, и на неё ссылаются больше 2 моделей — переводи в table. Пользователи замечают latency, аналитики бесятся от тормозящего BI.
2. Запрос содержит aggregation на больших таблицах. GROUP BY с millions of rows, оконные функции, многоступенчатые JOIN — кандидаты на table. Расчёт один раз, downstream — мгновенно.
3. Модель — mart, который читается дашбордами. Marts по конвенции — всегда table. Они потребляются множеством пользователей, latency критична. Свежесть достигается через регулярный dbt run.
4. Деревьвозможно глубокий dependency tree. Если ниже по DAG ещё 3 уровня моделей, материализовать промежуточный слой как table — это разорвать цепочку view->view->view->…, тем самым уменьшить compile time для warehouse.
View — для дешёвых staging-преобразований с редким downstream. Table — для дорогих aggregations с активным downstream-traffic'ом. Промежуточная зона — оценивай по конкретике.
Минусы table-материализации
1. Cтоит storage. На DuckDB — место на диске. На Snowflake — это деньги ($/TB/month). Для junior проекта обычно неважно, но в проде на больших данных учитывается.
2. Данные могут устареть. После dbt run таблица “заморожена”. Если raw обновился через час, table-mart всё ещё показывает старые данные. Решение: запускать dbt run по расписанию или включать в pipeline после loader-а.
3. Дорогой пересчёт при каждом run. Если таблица 10GB, и dbt run делается каждые 15 минут — это 40 пересчётов в час на 10GB данных. Здесь нужен incremental — он считает только дельту.
4. Атомарность не всегда полная. CREATE OR REPLACE TABLE на некоторых warehouse — это DROP + CREATE под капотом. Между этими операциями короткое окно, когда таблица пуста. На DuckDB это не проблема (single writer per file, атомарно), но на cloud warehouse’ах — иногда заметная.
На больших таблицах (10GB+) полный пересчёт через CREATE OR REPLACE TABLE может занимать минуты-часы и blocking-ить downstream. Это сигнал, что пора переходить на incremental: считать только новые/изменённые строки. Подробнее в следующем модуле.
Анатомия скомпилированного SQL для table
Возьмём типичный mart:
{{ config(materialized='table') }}
SELECT
c.customer_id,
c.email,
COUNT(o.order_id) AS orders_count,
SUM(o.amount) AS total_spent
FROM {{ ref('stg_customers') }} c
LEFT JOIN {{ ref('stg_orders') }} o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.email
После компиляции (в target/compiled/...):
SELECT
c.customer_id,
c.email,
COUNT(o.order_id) AS orders_count,
SUM(o.amount) AS total_spent
FROM jaffle_shop.main.stg_customers c
LEFT JOIN jaffle_shop.main.stg_orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.email
В target/run/... будет с обёрткой:
CREATE OR REPLACE TABLE jaffle_shop.main.dim_customers AS (
SELECT
c.customer_id,
c.email,
COUNT(o.order_id) AS orders_count,
SUM(o.amount) AS total_spent
FROM jaffle_shop.main.stg_customers c
LEFT JOIN jaffle_shop.main.stg_orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.email
);
Именно этот SQL отправляется в DuckDB и выполняется при dbt run.
Schema и database для table
По умолчанию таблица создаётся в target.schema из profiles.yml. Можно переопределить через config:
{{ config(
materialized='table',
schema='marts',
database='analytics'
) }}
SELECT ...
С учётом схема-комбинирования (схема в результате = target.schema + '_' + custom_schema), это создаст таблицу в analytics.<target.schema>_marts.dim_customers. На DuckDB смена database через config — это попытка использовать другой файл, обычно не нужно. На Snowflake/BigQuery — нормальный паттерн.
DuckDB-специфика для table
В DuckDB table — это самый распространённый сценарий, потому что:
- CREATE OR REPLACE TABLE атомарно. Single writer per file гарантирует, что downstream чтения видят либо старую, либо новую версию, но не промежуточное состояние.
- Storage в файле .duckdb. Каждая таблица занимает блоки в едином файле. Нет пер-таблиц storage cost — есть просто размер файла.
- TPC-DS performance отличный. DuckDB — vectorized engine, простые table-mart-запросы летают на single-machine миллиардами строк.
- PRAGMA threads контролирует параллелизм. Если CREATE TABLE долгий, можно через profiles.yml настроить threads: 8.
- No PARTITION BY. В отличие от BigQuery, DuckDB не имеет нативного partitioning. Если нужны big-data optimization — рассмотри external materialization (Parquet с partition-prefix).
В реальной работе на Snowflake/BigQuery table-материализация может использовать CLUSTER BY, PARTITION BY и другие фичи, которые DuckDB не поддерживает. На junior-проекте на DuckDB этим можно не заморачиваться — модели до десятков миллионов строк прекрасно живут без оптимизаций.
Команды для отладки table
# Запустить только одну модель как table
dbt run --select dim_customers
Output:
14:23:01 Concurrency: 4 threads
14:23:01 1 of 1 START sql table model main.dim_customers ......... [RUN]
14:23:02 1 of 1 OK created sql table model main.dim_customers .... [OK in 0.81s]
Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1
“sql table model” подтверждает материализацию. Время больше, чем у view, потому что нужно реально пересчитать.
Проверить в DuckDB:
SELECT table_name, table_type FROM information_schema.tables WHERE table_name = 'dim_customers';
Получишь BASE TABLE в table_type — это и есть полноценная таблица с данными.
Посмотреть размер:
SELECT COUNT(*) FROM dim_customers;
Полный пересчёт vs incremental: куда дальше
Table-материализация при каждом dbt run полностью пересоздаёт таблицу. Если в исходных данных миллион новых строк, а в твоей таблице уже было 100M строк — все 100M+1M пересчитываются заново.
Это не проблема для маленьких таблиц (менее 10M строк на DuckDB) или для models, где запрос быстрый. Но на больших фактах с историей за годы — это становится медленно.
Решение — incremental materialization, которая считает только дельту. Это следующий шаг сложности, и ему посвящён модуль 7. Пока просто запомни: table = полный пересчёт, incremental = дельта. Выбирай table, пока tot оs пересчёт укладывается в приемлемое время.
Попробуй сам
В своём проекте создай models/marts/dim_customers_demo.sql:
{{ config(materialized='table') }}
SELECT
customer_id,
first_name || ' ' || last_name AS full_name
FROM {{ ref('stg_customers') }}
Запусти:
dbt run --select dim_customers_demo
Проверь:
duckdb jaffle_shop.duckdb -c "SELECT table_name, table_type FROM information_schema.tables WHERE table_name='dim_customers_demo';"
Должен быть BASE TABLE. Запусти dbt run ещё раз — увидишь то же время, потому что dbt пересоздаёт таблицу с нуля. Это и есть “полный пересчёт”.
Затем поменяй на materialized='view'. Запусти. Проверь — будет VIEW. Сравни время выполнения dbt run между table и view: view быстрее, потому что только метаданные.
Что мы поняли
Table — материализация, при которой dbt создаёт физическую таблицу через CREATE OR REPLACE TABLE ... AS SELECT. Downstream-чтения мгновенные, но dbt run пересчитывает всё с нуля при каждом запуске. Используется для marts и тяжёлых intermediate-моделей. На DuckDB атомарна и быстра, на cloud warehouses — нужно думать о cost. Когда полный пересчёт становится медленным — следующий шаг это incremental, разберём в модуле 7.
В следующем уроке — ephemeral, материализация-CTE, которая не создаёт ничего в warehouse, но раскрывается inline в downstream-запросы.
Как DuckDB хранит таблицу физически — row groups и columnar storage