Третий тип материализации, который должен знать junior — ephemeral. Это специфичный для dbt концепт: модель, которая существует только в коде, не материализуется в warehouse, а вместо этого инлайнится как CTE в downstream-моделях.
Ephemeral — мощный инструмент, но с серьёзными подводными камнями. Разбираемся, когда применять, когда нет, и почему deep nesting убивает производительность.
Что физически делает ephemeral
В отличие от view и table, ephemeral-модель не появляется в warehouse. Никакой CREATE VIEW или CREATE TABLE не выполняется. Вместо этого dbt при компиляции downstream-моделей берёт SQL ephemeral и встраивает его как Common Table Expression в начало запроса.
Ephemeral не создаётся в warehouse. dbt при компиляции downstream-моделей берёт SQL ephemeral и встраивает его как WITH-блок (CTE) в начало запроса. Можно сказать, ephemeral 'испаряется' в downstream SQL.
Из-за этого dbt run --select <ephemeral_model> ничего не делает в warehouse. Если ты запустишь это, увидишь сообщение типа “ephemeral model скомпилирован”. Но в DuckDB его нет — он существует только в скомпилированных файлах downstream-моделей.
Когда ephemeral полезен
Главный сценарий: приватный промежуточный шаг, который нужен только одной-двум downstream-моделям, и нет смысла материализовать его как отдельный объект в warehouse.
Пример: фильтрация sensitive data перед агрегацией.
-- models/intermediate/int_orders_filtered.sql
{{ config(materialized='ephemeral') }}
SELECT *
FROM {{ ref('stg_orders') }}
WHERE NOT is_test_order
AND status != 'cancelled'
Эта модель — просто фильтр. Если её материализовать как view, downstream-запрос всё равно раскроет её при выполнении. Если как table — лишняя физическая копия данных. Ephemeral — компромисс: SQL встраивается inline, никаких объектов в warehouse, и не загромождает schema.
Когда ephemeral хороший выбор:
- Промежуточная фильтрация / переименование, читается одной моделью.
- Helper-расчёты, которые логически приватные (например, расчёт фискального года, который потом используется в одной mart).
- Замена subquery — если хочется выделить кусок логики в отдельный файл для читабельности.
- Маленькая трансформация над staging, используемая 1-2 downstream.
Когда ephemeral плохой выбор
1. Многократное использование. Если ephemeral используется в 3+ downstream-моделях, SQL будет inlined в каждую — это значит, что warehouse будет повторять одни и те же вычисления столько раз, сколько обращений. На больших данных это убивает performance. Лучше view (хотя бы план кэшируется) или table.
2. Тяжёлые aggregations. Ephemeral с GROUP BY на миллион строк, используемый в трёх mart-моделях — это три GROUP BY вместо одного. Здесь нужен либо table, либо хотя бы view.
3. Тесты на модели. Ephemeral-модель нельзя протестировать обычными тестами, потому что её нет в warehouse. not_null, unique и пр. на ephemeral не работают — dbt пропустит их или выдаст ошибку. Если на модели должны быть тесты — это не ephemeral.
4. Deep nesting (вложенность 3+). Когда ephemeral A -> ephemeral B -> ephemeral C -> final mart, скомпилированный SQL mart-а превращается в монстра-CTE. Warehouse начинает плохо его планировать. Это самая частая ловушка.
Никогда не строй цепочки ephemeral -> ephemeral -> ephemeral. Каждый уровень добавляет nesting. На 4 уровнях query становится несколько килобайт SQL, query planner тратит на него секунды. Обнаружишь это только когда дашборды начнут тормозить.
Deep nesting в действии
Чтобы понять проблему, рассмотрим конкретный пример. Допустим, у нас цепочка:
stg_orders -> int_filter (ephemeral) -> int_join (ephemeral) -> int_aggregate (ephemeral) -> mart_revenue (table)
SQL каждой модели:
-- int_filter.sql (ephemeral)
SELECT * FROM {{ ref('stg_orders') }} WHERE NOT is_test
-- int_join.sql (ephemeral)
SELECT o.*, c.country FROM {{ ref('int_filter') }} o JOIN {{ ref('stg_customers') }} c ON ...
-- int_aggregate.sql (ephemeral)
SELECT country, SUM(amount) AS revenue FROM {{ ref('int_join') }} GROUP BY country
-- mart_revenue.sql (table)
SELECT * FROM {{ ref('int_aggregate') }} ORDER BY revenue DESC
Финальный скомпилированный SQL для mart_revenue:
CREATE OR REPLACE TABLE jaffle_shop.main.mart_revenue AS (
WITH int_filter AS (
SELECT * FROM jaffle_shop.main.stg_orders WHERE NOT is_test
),
int_join AS (
SELECT o.*, c.country
FROM int_filter o
JOIN jaffle_shop.main.stg_customers c ON o.customer_id = c.id
),
int_aggregate AS (
SELECT country, SUM(amount) AS revenue FROM int_join GROUP BY country
)
SELECT * FROM int_aggregate ORDER BY revenue DESC
);
На трёх уровнях это ещё нормально. На пяти — становится 200 строк CTE с непонятным флоу. На семи — отладка через target/compiled превращается в детектив.
Каждый ephemeral в downstream добавляет CTE-блок к финальному SQL. На 2-3 уровнях это нормально. На 5+ финальный SQL становится массивным, query planner тратит время на оптимизацию, отладка сложна.
Решение для deep nesting — материализовать промежуточный слой как table или view. Это разрывает цепочку: следующая модель читает уже из готового warehouse-объекта, не наследует все CTE.
Тесты на ephemeral не работают
Очень тонкий момент: обычные тесты не работают на ephemeral-моделях. Когда dbt пытается выполнить not_null-тест, он генерирует SQL вроде:
SELECT COUNT(*) FROM <model> WHERE <column> IS NULL
Подставляет имя модели. Но ephemeral-модели в warehouse нет, имя не существует. dbt в большинстве версий просто пропустит тест молча (или выдаст warning).
Это значит: если на модель должны быть data-quality тесты — она не должна быть ephemeral. Используй view (минимальный overhead, тесты работают) или table.
# models/_models.yml
version: 2
models:
- name: int_orders_filtered # это ephemeral
columns:
- name: order_id
tests:
- not_null # ЭТО НЕ ЗАПУСТИТСЯ
Тест в YAML будет, но запускаться не будет. Junior’ы попадают на это часто.
Ephemeral как замена subquery
Один из законных use case’ов — выделение complexity в отдельный файл для читабельности. Сравни:
-- БЕЗ ephemeral: subquery в mart
SELECT
country,
SUM(amount) AS revenue
FROM (
SELECT
o.amount,
c.country
FROM {{ ref('stg_orders') }} o
JOIN {{ ref('stg_customers') }} c ON o.customer_id = c.id
WHERE o.status != 'cancelled'
) sub
GROUP BY country
-- С ephemeral: логика вынесена
-- int_orders_joined.sql (ephemeral)
SELECT o.amount, c.country
FROM {{ ref('stg_orders') }} o
JOIN {{ ref('stg_customers') }} c ON o.customer_id = c.id
WHERE o.status != 'cancelled'
-- mart_revenue.sql (table)
SELECT country, SUM(amount) AS revenue
FROM {{ ref('int_orders_joined') }}
GROUP BY country
Второй вариант читабельнее: каждый шаг изолирован, можно ref на int_orders_joined из другой mart, если понадобится. Финальный скомпилированный SQL будет одинаковый.
Ephemeral отлично для “DRY” — когда логика повторяется в 1-2 моделях, и хочется не дублировать SQL. Но если повторяется в 3+ — лучше view, чтобы не повторять вычисления в warehouse каждый раз.
DuckDB-специфика для ephemeral
Ephemeral на DuckDB работает так же, как на других warehouse — это чисто компиляционный концепт dbt, warehouse его не знает. Но есть несколько нюансов:
- DuckDB CTE-планировщик агрессивный. Простой CTE inlined эффективно, на маленьких объёмах разница ephemeral vs view не заметна.
- На больших данных не злоупотребляй. DuckDB query planner — vectorized, но не магия. Длинный WITH с GROUP BY на каждом уровне всё равно медленнее, чем готовая table.
- target/compiled показывает всё. Чтобы посмотреть финальный SQL после ephemeral inlining — открой
target/compiled/<project>/models/.../mart_revenue.sql. Там все CTE раскрыты.
Команды для отладки ephemeral
# Компилируем downstream и смотрим, как ephemeral inlined
dbt compile --select mart_revenue
cat target/compiled/jaffle_shop/models/marts/mart_revenue.sql
В файле увидишь WITH int_orders_joined AS (…) — это и есть встроенная ephemeral-модель.
# Попытка запустить ephemeral напрямую
dbt run --select int_orders_joined
Output:
14:23:01 Concurrency: 4 threads
Done. PASS=0 WARN=0 ERROR=0 SKIP=0 TOTAL=0
Ноль операций — ephemeral не материализуется, и dbt run “ничего не делает” в warehouse, только парсит модель.
Попробуй сам
В своём проекте создай models/intermediate/int_test_ephemeral.sql:
{{ config(materialized='ephemeral') }}
SELECT
customer_id,
UPPER(email) AS email_upper
FROM {{ ref('stg_customers') }}
И mart, который её использует:
-- models/marts/test_mart.sql
{{ config(materialized='table') }}
SELECT *
FROM {{ ref('int_test_ephemeral') }}
WHERE email_upper LIKE '%@JAFFLE.COM'
Запусти:
dbt run --select test_mart
Только test_mart создастся в warehouse (как BASE TABLE). int_test_ephemeral — не создаётся.
Открой target/compiled/jaffle_shop/models/marts/test_mart.sql — увидишь WITH int_test_ephemeral AS (…) и финальный SELECT. Это и есть inline-материализация.
Что мы поняли
Ephemeral — материализация-CTE, которая не создаёт ничего в warehouse, а встраивается в downstream-модели как WITH-блок. Полезна для приватных промежуточных шагов с одним downstream и для выноса логики из subquery в отдельный файл. Главные ловушки: deep nesting (3+ уровня делают SQL монструозным), нерабочие тесты, повторные вычисления при использовании в нескольких downstream. Используй с осторожностью — view или table обычно безопаснее.
В следующем уроке разберём Golden Rule — эмпирическое правило, как выбрать материализацию для каждой модели.
CTE как SQL-конструкция