Learning Platform
Глоссарий Troubleshooting
Урок 07.01 · 28 мин
Средний
dbt-utilsPackagesMacrosProduction patterns

dbt-utils в глубину: production patterns

dbt-utils — самый используемый dbt package. К моменту middle-уровня вы видели его базовые macros: dbt_utils.surrogate_key, dbt_utils.unique_combination_of_columns, generic tests. Но реальная production-ценность — в advanced patterns: union_relations, get_column_values для dynamic SQL, deduplicate с правильной стратегией, grant_select_to для governance.

Этот урок — про эти patterns. Каждый я разбираю с production gotchas, edge cases, и реальными примерами кода. Версия dbt-utils 1.3+ (2026).


generate_surrogate_key — основа всех PK

-- Создание stable PK из нескольких колонок
{{ dbt_utils.generate_surrogate_key(['customer_id', 'order_date', 'product_id']) }}

Возвращает MD5 hash от concatenated values. Это deterministic — те же inputs дают тот же hash.

Use cases:

  1. Composite PK для fact-таблиц без natural single-column key.
  2. Surrogate key для dimensions, где natural key может меняться (например, email клиента — может быть отозван, переиздан).
  3. Idempotent merge для incremental models — surrogate как unique_key.
-- models/marts/fct_orders.sql
SELECT
    {{ dbt_utils.generate_surrogate_key(['order_id', 'line_item_id']) }} AS pk,
    order_id,
    line_item_id,
    customer_id,
    product_id,
    quantity,
    unit_price
FROM {{ ref('stg_order_items') }}

Gotchas:

  1. NULL handling. dbt_utils 1.0+ заменяет NULL на sentinel-строку (‘dbt_utils_surrogate_key_null’) перед hashing. Coллизии возможны если в данных есть точно такая строка — крайне маловероятно, но defensive design в чувствительных случаях:
{{ dbt_utils.generate_surrogate_key([
    'COALESCE(customer_id::text, \'NO_CUSTOMER\')',
    'COALESCE(order_date::text, \'NO_DATE\')'
]) }}

Явные COALESCE дают более явный intent для readers.

  1. Type ambiguity. Без явных cast’ов: 42 (int) и '42' (string) могут давать разные hashes в зависимости от warehouse. Стандартизируйте через cast:
{{ dbt_utils.generate_surrogate_key([
    'customer_id::text',
    'order_date::date::text'
]) }}
  1. Order matters. ['a', 'b']['b', 'a']. Фиксируйте order через convention в проекте.

  2. Не используйте для security. MD5 — не cryptographic hash для модели угроз. Surrogate keys — для identity, не integrity.


unique_combination_of_columns — composite uniqueness test

Generic test, проверяет уникальность комбинации колонок.

# models/marts/fct_orders.yml
models:
  - name: fct_orders
    data_tests:
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns:
            - order_id
            - line_item_id

Это должно быть на всех fact-таблицах где PK составной. unique-тест dbt-core тестирует одну колонку.

Production pattern: использовать в snapshot tests:

snapshots:
  - name: customers_snapshot
    data_tests:
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns:
            - customer_id
            - dbt_valid_from

Гарантирует что не появляются дубли версий с одинаковым timestamp.

Gotchas:

  • Performance: на больших таблицах — GROUP BY все combination_of_columns + COUNT > 1 — expensive. Используйте where config для subset:
data_tests:
  - dbt_utils.unique_combination_of_columns:
      combination_of_columns: [order_id, line_item_id]
      config:
        where: "order_date >= current_date - 30"

Тест проверяет только последние 30 дней — быстрее. Полный run раз в неделю.

  • NULL handling: NULL в одной из колонок может скрыть дубль (NULL != NULL в SQL). Если есть NULL — COALESCE с sentinel или отдельный not_null тест.

deduplicate — выбираем правильную стратегию

ROW_NUMBER и ранжирующие функции — база deduplicate dbt-i: первый тур по dbt_utils

dbt_utils.deduplicate(relation, partition_by, order_by) — самый частый use case в staging models.

-- models/staging/stg_customers.sql
{{ dbt_utils.deduplicate(
    relation=source('app', 'customers'),
    partition_by='customer_id',
    order_by='updated_at DESC, _loaded_at DESC'
) }}

dbt компилит в:

SELECT * FROM (
    SELECT *,
      ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC, _loaded_at DESC) AS rn
    FROM raw.app.customers
)
WHERE rn = 1

На DuckDB можно сделать через QUALIFY (быстрее, без subquery):

-- DuckDB-specific
SELECT * FROM raw.app.customers
QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC, _loaded_at DESC) = 1

Через dispatch override (см. модуль 05) можно сделать dbt_utils.deduplicate использовать QUALIFY на DuckDB.

Production gotchas:

  1. order_by стабильность. Если у duplicate rows одинаковый updated_at, ROW_NUMBER может выбирать произвольную row при разных runs. Добавляйте tie-breaker:
order_by='updated_at DESC, _loaded_at DESC, customer_id'  -- 3-уровневый ORDER BY
  1. partition_by для composite. Один column обычно: partition_by='customer_id'. Многие: partition_by='customer_id, region_id' (строка, не список).

  2. Deduplicate vs distinct. dbt_utils.deduplicate — на основе window function, возвращает full row first occurrence. SQL DISTINCT — на основе всех колонок, может вернуть unexpected results если есть NULL или type differences.


get_column_values — dynamic SQL

dbt_utils.get_column_values(table, column) — возвращает список уникальных значений колонки. Используется для PIVOT, dynamic columns, conditional logic.

-- models/marts/sales_by_country.sql
{% set countries = dbt_utils.get_column_values(
    table=ref('dim_countries'),
    column='country_code',
    order_by='country_code'
) %}

SELECT
    product_id,
    {% for c in countries %}
      SUM(CASE WHEN country_code = '{{ c }}' THEN revenue ELSE 0 END) AS revenue_{{ c }}
      {%- if not loop.last %},{% endif %}
    {% endfor %}
FROM {{ ref('fct_sales') }}
GROUP BY product_id

5 стран в dim_countries -> 5 колонок revenue_*. Новая страна добавлена в next dbt run — schema extended automatically.

Production gotchas:

  1. execute flag: dbt_utils handle это правильно, но если вызывать в parse-time loop — может возвращать empty (placeholder). Compile output надо проверять.

  2. Performance в parse-time: каждый dbt compile отправляет SELECT DISTINCT в warehouse. Для маленьких dim — fine, для huge — overhead. Cache через var() / target conditional.

  3. Limit max для безопасности:

{% set countries = dbt_utils.get_column_values(
    table=ref('dim_countries'),
    column='country_code',
    order_by='country_code',
    max_records=100  -- защита: если больше 100 уникальных, ошибка
) %}

Без max_records если в dim вдруг появилось 10000 стран (corrupt data) — будет 10000 SUM CASE WHEN — model compile упадёт или будет невероятно медленным.

  1. Default value на случай empty:
{% set countries = dbt_utils.get_column_values(
    table=ref('dim_countries'),
    column='country_code',
    default=['US']  -- если 0 строк или parse-time
) %}

Не критично, но safer для edge cases.


union_relations — объединение source-таблиц

dbt_utils.union_relations — UNION ALL множества relations с автоматическим matching колонок.

-- models/staging/stg_all_orders.sql
{{ dbt_utils.union_relations(
    relations=[
        source('app_us', 'orders'),
        source('app_uk', 'orders'),
        source('app_eu', 'orders')
    ],
    source_column_name='_source'  -- добавит колонку с именем relation
) }}

Что делает:

  1. Берёт schema каждого relation.
  2. Находит union колонок (по имени).
  3. Делает SELECT с COALESCE / CAST на каждую относительную колонку, NULL для missing.
  4. UNION ALL результатов.
  5. Опционально добавляет колонку _source с именем relation.

Compile output:

SELECT
    customer_id,
    order_id,
    amount,
    NULL AS region_specific_field,
    'app_us.orders' AS _source
FROM raw.app_us.orders

UNION ALL

SELECT
    customer_id,
    order_id,
    amount,
    region_specific_field,
    'app_uk.orders' AS _source
FROM raw.app_uk.orders

Production patterns:

  1. Multi-region source consolidation. Каждая региональная app пишет в свою schema, dbt union-ит в central staging.

  2. Schema evolution support. Если в US появилась new колонка, а UK ещё нет — union_relations corrupted без явных types. Использовать include config:

{{ dbt_utils.union_relations(
    relations=[
        source('app_us', 'orders'),
        source('app_uk', 'orders')
    ],
    include=['order_id', 'customer_id', 'amount']  -- only these columns, ignore others
) }}

Это explicit contract: ‘мы union-им только эти 3 колонки, новые в US/UK игнорируются until added to include list’.

  1. Exclude config: симметрично, можно skip определённые колонки:
{{ dbt_utils.union_relations(
    relations=[...],
    exclude=['_loaded_at', '_etl_run_id']
) }}

Skip technical metadata columns.

Gotchas:

  • Type coercion. Если amount в US это NUMERIC(10,2), в UK — INTEGER, union_relations попытается cast. Может lose precision. Лучше align types в staging upstream.
  • Column ordering: relations с одинаковыми именами но разным order — union обрабатывает по names. Если разные names — добавляется NULL для missing.

grant_select_to — production governance

RBAC: ролевая модель доступа — почему grant_select_to это не просто utility

dbt_utils.grant_select_to — даёт SELECT permission на model после CREATE. Critical для production governance.

# dbt_project.yml
models:
  myproject:
    +post-hook: "{{ dbt_utils.grant_select_to(grantees=['analyst_role', 'bi_service']) }}"

После каждого dbt run — модель получает GRANT SELECT TO listed roles. Без это автоматики:

  • Аналитики не могут читать новые модели (нужен manual GRANT каждый раз).
  • При recreate (table materialization) permissions могут сбрасываться.
-- Compile result для DuckDB / Postgres / Snowflake (через dispatch)
GRANT SELECT ON {{ this }} TO analyst_role;
GRANT SELECT ON {{ this }} TO bi_service;

Production patterns:

  1. Per-environment grants:
models:
  myproject:
    prod:
      +post-hook: "{{ dbt_utils.grant_select_to(grantees=['prod_analyst', 'bi_prod']) }}"
    dev:
      +post-hook: "{{ dbt_utils.grant_select_to(grantees=['developer']) }}"

Dev и prod имеют разные grant lists.

  1. Per-model grants:
-- models/marts/restricted_finance_data.sql
{{ config(
    materialized='table',
    post_hook=\"{{ dbt_utils.grant_select_to(grantees=['finance_team_only']) }}\"
) }}

Sensitive models имеют restrictive grants — overrides project-level.

  1. dbt 1.5+ grants config (alternative):
+grants:
  select: ['analyst_role', 'bi_service']

Это native dbt feature, без dbt_utils. Часто preferred для new projects (proper grant lifecycle: incrementally added vs revoked at recreate).

Gotchas:

  • DuckDB: GRANT не имеет смысла (single-user). grant_select_to silently no-op или error в зависимости от version.
  • BigQuery: используется IAM, не SQL GRANT. dbt_utils версия для BigQuery адаптирована.
  • Snowflake: проверьте что role exists перед GRANT. dbt не валидирует — GRANT на non-existent role упадёт.

Прочие dbt-utils gem’ы

dbt_utils.star — SELECT * без указанных колонок:

SELECT
    {{ dbt_utils.star(
        from=ref('stg_orders'),
        except=['_loaded_at', '_source_file']
    ) }}
FROM {{ ref('stg_orders') }}

Полезно когда хотите все колонки кроме technical metadata.

dbt_utils.pivot — table pivot:

SELECT
    customer_id,
    {{ dbt_utils.pivot(
        column='order_status',
        values=dbt_utils.get_column_values(ref('fct_orders'), 'order_status'),
        agg='sum',
        then_value='order_total'
    ) }}
FROM {{ ref('fct_orders') }}
GROUP BY customer_id

Generates SUM(CASE WHEN order_status = 'X' THEN order_total END) AS X для каждого status.

dbt_utils.date_spine — generate range дат:

{{ dbt_utils.date_spine(
    datepart='day',
    start_date='cast(\\'2024-01-01\\' as date)',
    end_date='cast(\\'2026-12-31\\' as date)'
) }}

Использует на JOIN’ах для filling missing dates в trend analysis.

dbt_utils.expression_is_true — generic test для произвольного SQL boolean:

columns:
  - name: amount
    data_tests:
      - dbt_utils.expression_is_true:
          expression: "amount >= 0"

Альтернатива singular tests, но проще декларативно.


Production project layout

В большом проекте dbt-utils используется везде. Рекомендую:

  1. packages.yml:
packages:
  - package: dbt-labs/dbt_utils
    version: [">=1.3.0", "<2.0.0"]

Pin major version. dbt-utils minor versions backwards compatible, major могут ломать.

  1. dbt deps в CI:
dbt deps
dbt parse
dbt build

Без dbt deps — packages not installed, всё ломается.

  1. Override registry: если override package macros (через search_order), документируйте в OVERRIDES.md:
| Macro | Original package | Override reason | Last reviewed |
|---|---|---|---|
| deduplicate | dbt_utils | DuckDB QUALIFY optimization | 2026-05-19 |
| grant_select_to | dbt_utils | Add custom role mapping | 2026-04-10 |

Попробуй сам

В labs:

  1. Используйте deduplicate для staging customers с tie-breaker order_by.
  2. Создайте dynamic PIVOT через get_column_values для sales by category.
  3. Union 3 sources через union_relations с include list (только нужные колонки).
  4. Generate surrogate key для fct_orders на (order_id, line_item_id), добавьте unique_combination_of_columns тест.
  5. Setup grant_select_to в dbt_project.yml на ‘analyst_role’. На DuckDB будет no-op — проверьте что не падает.
  6. Override deduplicate для DuckDB через search_order (из модуля 05), используйте QUALIFY.

Это набор production patterns, которые применяются ежедневно.


Ключевые выводы

  1. generate_surrogate_key — composite PK через MD5. Cast и order matter.
  2. unique_combination_of_columns — composite uniqueness test. Используйте where для performance на больших tables.
  3. deduplicate — staging dedup. Tie-breaker order_by обязателен для стабильности.
  4. get_column_values — dynamic SQL для PIVOT. max_records для safety, default для edge cases.
  5. union_relations — multi-source consolidation. include для explicit contract, schema evolution support.
  6. grant_select_to — production governance. Per-env grants через target-conditional. dbt 1.5+ имеет native +grants config.
  7. Other gems: star (SELECT except), pivot, date_spine, expression_is_true.
  8. Pin major version в packages.yml. Document overrides в registry.
Проверка знанийKnowledge check
Команда использует dbt_utils.deduplicate с order_by='updated_at DESC'. Каждый dbt run результат немного отличается (разные строки выбираются как 'last version'). Что починить?
ОтветAnswer
Проблема: **tie-breaking instability**. У duplicate rows с одинаковым updated_at, ROW_NUMBER OVER (PARTITION BY ... ORDER BY updated_at DESC) выбирает произвольную row при каждом query — поведение non-deterministic.\n\nКогда это происходит:\n- Несколько UPDATE на одной row в одну секунду (источник пишет с секундной точностью).\n- Bulk INSERT с одинаковым updated_at для всех rows.\n- updated_at NULL для multiple duplicates (NULL не sortable normally).\n\nFix через multi-level ORDER BY:\n\n```sql\n{{ dbt_utils.deduplicate(\n relation=source('app', 'customers'),\n partition_by='customer_id',\n order_by='updated_at DESC, _loaded_at DESC, source_row_id DESC'\n) }}\n```\n\nTie-breakers:\n- **_loaded_at DESC**: если source meta (ETL audit) пишет timestamp загрузки строки в warehouse — у каждой row уникальный. Используем как secondary sort.\n- **source_row_id DESC**: если есть natural row ID (e.g., Postgres ctid, Snowflake stream offset), используем как ultimate tie-breaker.\n- **created_at DESC**: если updated_at равны но created_at разные — fallback на старшинство.\n- **PRIMARY KEY DESC**: last resort — PK обычно auto-incremental, unique.\n\nPrinciple: **ORDER BY должен be unique per partition** для deterministic results.\n\nЕсли никаких unique tie-breakers нет — это data quality issue. Source duplicates с identical content. Решение:\n\n1. **Investigate source**: почему duplicates? bug в ETL? race в application?\n2. **Add unique tie-breaker upstream**: например, добавить ETL row_id или ingestion_timestamp с microsecond precision.\n3. **Accept ambiguity**: если duplicates с identical content — выбор любой не matters. Documentation: 'тут duplicates от source, выбираем any, downstream должен быть idempotent'.\n\nDiagnostic query:\n\n```sql\nWITH dups AS (\n SELECT customer_id, updated_at, COUNT(*) AS cnt\n FROM source.customers\n GROUP BY 1, 2\n HAVING COUNT(*) > 1\n)\nSELECT * FROM dups LIMIT 100;\n```\n\nПоказывает (customer_id, updated_at) пары с duplicates. Это input для analysis: что общего у duplicates? unique поля?\n\nProduction approach:\n\n1. **Add multi-level ORDER BY** в deduplicate.\n2. **Test stability**: запустить deduplicate 5 раз подряд, сохранить результаты, compare. Если differ — ORDER BY ещё не stable.\n3. **Test для regression**:\n\n```sql\n-- tests/test_stg_customers_stable_dedupe.sql\n-- Compare 'last week run' state with current\n-- Differ -> tests fail, investigate\n```\n\nЭто catches instability в CI до prod.
Проверка знанийKnowledge check
dbt_utils.get_column_values используется для PIVOT по country_code. dim_countries содержит 250 стран, но в fct_sales за прошлый месяц только 30 имели sales. Какие проблемы могут возникнуть?
ОтветAnswer
Несколько потенциальных проблем:\n\n**Проблема 1: 250 пустых колонок в результате.**\n\nPIVOT generates SUM(CASE WHEN country_code = 'X' THEN revenue ELSE 0 END) AS revenue_X для каждой из 250 стран. Для 220 стран без sales — все CASE WHEN evaluates к 0, итог колонок 220 × 0 = бесполезные колонки.\n\nResult: широкая (250 колонок) sparse table. BI инструменты могут плохо handle.\n\nFix:\n\n**Option A**: Filter dim_countries для активных стран:\n\n```sql\n{% set active_countries = dbt_utils.get_column_values(\n table=ref('dim_countries'),\n column='country_code',\n where='is_active = true AND has_recent_sales = true'\n) %}\n```\n\nИспользуем where фильтр (если get_column_values поддерживает) или предварительно создаём ref-модель active_countries.\n\n**Option B**: Список из самих sales, а не dim:\n\n```sql\n{% set countries_with_sales = dbt_utils.get_column_values(\n table=ref('fct_sales'),\n column='country_code',\n where=\"date не меньше current_date - 30\"\n) %}\n```\n\nPivot только по странам с фактическими sales. Result: 30 колонок, не 250.\n\n**Проблема 2: Performance каждого compile.**\n\nКаждый dbt compile отправляет SELECT DISTINCT в warehouse. На fct_sales (миллионы rows) — медленнее dim_countries (250 rows).\n\nFix:\n\n**Option C**: Кеширование через intermediate model:\n\n```sql\n-- models/intermediate/int_countries_with_recent_sales.sql\n{{ config(materialized='table') }}\nSELECT DISTINCT country_code\nFROM {{ ref('fct_sales') }}\nWHERE date не меньше current_date - 30\nORDER BY 1\n```\n\n```sql\n-- В pivot модели:\n{% set countries = dbt_utils.get_column_values(\n table=ref('int_countries_with_recent_sales'),\n column='country_code'\n) %}\n```\n\nИнкрементальный refresh через cron — fast. PIVOT model компилирует от intermediate (250 -> 30 rows), не от fct_sales (millions).\n\n**Option D**: target-conditional. На dev — fake fixed list. На prod — real query:\n\n```sql\n{% if target.name == 'prod' %}\n {% set countries = dbt_utils.get_column_values(...) %}\n{% else %}\n {% set countries = ['US', 'UK', 'DE'] %} -- dev fixture\n{% endif %}\n```\n\nDev не отправляет query, быстрее iteration.\n\n**Проблема 3: max_records safety.**\n\nЕсли dim_countries пополняется automatically (новые страны добавляются), однажды может стать 1000+. PIVOT с 1000 колонок ломает warehouse limits (max columns per table).\n\nFix:\n\n```sql\n{% set countries = dbt_utils.get_column_values(\n table=ref('dim_countries'),\n column='country_code',\n max_records=100 -- explicit safety\n) %}\n```\n\nЕсли > 100 — error в compile, alert. Защита от runaway expansion.\n\n**Проблема 4: Schema evolution downstream.**\n\nЕсли набор стран меняется между runs (новая страна added), PIVOT model получает новую колонку. Downstream models / BI могут break:\n\n- BI dashboards с hard-coded column references — fail.\n- Downstream models с SELECT * — work, но adds noise.\n\nFix:\n\n**Option E**: Fixed schema через model contract (1.5+):\n\n```yaml\n# models/marts/sales_pivot.yml\nmodels:\n - name: sales_pivot\n config:\n contract:\n enforced: true\n columns:\n - name: customer_id\n - name: revenue_US\n - name: revenue_UK\n - name: revenue_DE\n # ... explicit list\n```\n\nЕсли PIVOT generates extra columns — contract violation, build fails. Predictable schema для downstream.\n\n**Best practice combination**:\n\n1. Intermediate ref-модель для active country list (отдельно от dim).\n2. max_records safety.\n3. Target-conditional для dev fast iteration.\n4. Documentation: 'PIVOT schema dynamic, может меняться. Downstream должен handle через SELECT * не fixed columns'.\n5. Optional: model contract для критичных downstream-dependents.\n\nProduction решение зависит от tolerance к schema changes. High-stability environment -> fixed schema через contract + dim-filtered. Adaptive environment -> dynamic PIVOT с safety guards.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 6. dbt_utils.deduplicate использует order_by='updated_at DESC'. Каждый dbt run результат немного отличается. Что чинить?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4