Dbt macros
Профессиональные Data Engineering Agent Skills для разработки AI Agentic Data Platform
npx -y skills add ivanshamaev/de-agent-skills --skill dbt_macrosAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
dbt Jinja macros — macro authoring fundamentals, Jinja syntax (blocks/filters/tests), context variables (this/target/adapter/execute), run_query, adapter.dispatch for cross-database macros, generate_schema_name, hooks, custom materializations, dbt-utils patterns
SKILL.md
21.3 KB, as published. Nobody here has run it
dbt Macros & Jinja
When to Use
Activate this skill when the task involves:
- Writing dbt macros from scratch or improving existing ones
- Using Jinja control flow, filters, and variables in dbt SQL
- Implementing cross-database macros with
adapter.dispatch - Dynamic SQL generation via
run_query/execute - Overriding built-in dbt macros (
generate_schema_name,generate_alias_name) - Writing macros that call
adaptermethods (schema introspection, DDL) - Organizing macros across files and packages
Jinja Fundamentals
Three Delimiters
{{ expression }} {# outputs a value into SQL #}
{% statement %} {# control flow: for, if, set, do — produces no output #}
{# comment #} {# stripped at compile time #}
Variables
{% set payment_methods = ["credit_card", "bank_transfer", "gift_card"] %}
{% set threshold = 1000 %}
{% set label = "high_value" if threshold > 500 else "low_value" %}
Whitespace Control
Jinja preserves whitespace by default. Use - to strip it:
{%- set x = 1 -%} {# strip left AND right #}
{{- column_name -}} {# strip around expression #}
Filters
{{ column | upper }} -- COLUMN
{{ column | lower }} -- column
{{ column | replace(" ", "_") }} -- col_name
{{ items | join(", ") }} -- a, b, c
{{ items | list | length }} -- 3
{{ value | default("unknown") }} -- 'unknown' if value is falsy
{{ items | reject("equalto", "x") | list }} -- removes "x" from list
{{ text | trim }} -- strips leading/trailing whitespace
{{ value | int }} -- cast to Python int
{{ value | string }} -- cast to string
Control Flow
{# if/elif/else #}
{% if target.name == "prod" %}
limit 1000000
{% elif target.name == "dev" %}
limit 1000
{% else %}
limit 100
{% endif %}
{# for loop with loop variable #}
{% for col in columns %}
{{ col }}{%- if not loop.last -%},{%- endif %}
{% endfor %}
{# loop.index (1-based), loop.index0 (0-based), loop.first, loop.last #}
{% for col in columns %}
{{ loop.index }}: {{ col }}
{% endfor %}
Macro Basics
-- macros/cents_to_dollars.sql
{% macro cents_to_dollars(column_name, scale=2) %}
({{ column_name }} / 100.0)::numeric(16, {{ scale }})
{% endmacro %}
Call in a model:
-- models/stg_payments.sql
select
payment_id,
{{ cents_to_dollars('amount') }} as amount_usd,
{{ cents_to_dollars('refund_amount', scale=4) }} as refund_usd
from {{ ref('raw_payments') }}
Compiled output:
select
payment_id,
(amount / 100.0)::numeric(16, 2) as amount_usd,
(refund_amount / 100.0)::numeric(16, 4) as refund_usd
from analytics.raw_payments
Macro Returning a Value
Use return() to return a value for use in {% set %}:
{% macro is_incremental_model() %}
{{ return(model.config.materialized == 'incremental') }}
{% endmacro %}
{% if is_incremental_model() %}
-- incremental-only logic
{% endif %}
do Tag — Side-Effect Calls
{% do log("Starting transform for " ~ model.name, info=true) %}
{% do adapter.create_schema(api.Relation.create(database=target.database, schema=target.schema ~ "_tmp")) %}
Context Variables
this — Current Relation
-- The current model's fully-qualified relation:
{{ this }} -- "analytics"."dbt_prod"."orders"
{{ this.database }} -- "analytics"
{{ this.schema }} -- "dbt_prod"
{{ this.identifier }} -- "orders"
{{ this.include(database=false) }} -- "dbt_prod"."orders"
Use this to reference the current table in incremental models:
{% if is_incremental() %}
where event_at > (select max(event_at) from {{ this }})
{% endif %}
target — Warehouse Connection
{{ target.name }} -- "dev" | "prod" | "ci"
{{ target.schema }} -- "dbt_ivan"
{{ target.database }} -- "analytics"
{{ target.type }} -- "postgres" | "snowflake" | "bigquery" | "spark"
{{ target.threads }} -- 4
Use to branch logic by environment:
{% if target.name == "prod" %}
{{ config(materialized="table") }}
{% else %}
{{ config(materialized="view") }}
{% endif %}
model — Node Metadata
{{ model.name }} -- "stg_orders"
{{ model.config.schema }} -- "staging"
{{ model.config.materialized }} -- "incremental"
{{ model.unique_id }} -- "model.my_project.stg_orders"
{{ model.fqn }} -- ["my_project", "staging", "stg_orders"]
{{ model.tags }} -- ["daily", "finance"]
{{ model.refs }} -- list of ref() calls in this model
execute — Compile vs. Execute Guard
execute is True only when dbt is running the model (not during parsing or dbt compile). Always wrap run_query and DDL calls in {% if execute %}:
{% if execute %}
{% set results = run_query("select distinct region from dim_geo") %}
{% set regions = results.columns[0].values() %}
{% else %}
{% set regions = [] %}
{% endif %}
select
order_id,
{% for region in regions %}
sum(case when region = '{{ region }}' then revenue end) as {{ region | lower | replace(' ', '_') }}_revenue
{%- if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('fct_orders') }}
group by 1
var() — Project Variables
{# dbt_project.yml:
vars:
lookback_days: 90
start_date: "2023-01-01"
#}
{{ var('lookback_days') }} -- 90
{{ var('lookback_days', default=30) }} -- 30 if not defined
-- Override at runtime:
-- dbt run --vars '{"lookback_days": 30}'
env_var() — Environment Variables
{{ env_var('DBT_SCHEMA_PREFIX', 'dbt_') }} -- value or default
{{ env_var('DATABASE_PASSWORD') }} -- required; raises if absent
run_query — Dynamic SQL Execution
run_query sends SQL to the warehouse and returns an agate Table result. Always guard with {% if execute %}.
agate Table Access
{% set query %}
select distinct
payment_method,
count(*) as cnt
from {{ ref('stg_payments') }}
group by 1
order by 2 desc
{% endset %}
{% if execute %}
{% set results = run_query(query) %}
{% set methods = results.columns['payment_method'].values() %}
{% set counts = results.columns['cnt'].values() %}
{% else %}
{% set methods = [] %}
{% set counts = [] %}
{% endif %}
| Access Pattern | Code |
|---|---|
| Column by index | results.columns[0].values() |
| Column by name | results.columns['col_name'].values() |
| Single value | results.rows[0][0] |
| Row count | results | length |
| Print table | results.print_table() |
DDL / DML Macros (non-SELECT)
{% macro truncate_staging(table_name) %}
{%- set schema = target.schema ~ "_staging" -%}
{% if execute %}
{% do run_query("truncate table " ~ schema ~ "." ~ table_name) %}
{% do log("Truncated " ~ schema ~ "." ~ table_name, info=true) %}
{% endif %}
{% endmacro %}
Only run during dbt run / dbt build, skip on dbt compile and dbt docs generate:
{% if execute and flags.WHICH in ('run', 'build') %}
{% do run_query("delete from " ~ this ~ " where created_at < current_date - 90") %}
{% endif %}
adapter Methods
Schema Introspection
{# Get all columns in a relation #}
{%- set cols = adapter.get_columns_in_relation(this) -%}
{% for col in cols %}
{{ col.name }} ({{ col.data_type }}, nullable={{ col.is_nullable }})
{% endfor %}
{# Check if a relation exists before referencing it #}
{%- set rel = adapter.get_relation(
database=target.database,
schema=target.schema,
identifier='my_table') -%}
{% if rel is not none %}
-- table exists, safe to select from it
select * from {{ rel }}
{% endif %}
DDL Operations
{# Create a schema #}
{% do adapter.create_schema(
api.Relation.create(database=target.database, schema=target.schema ~ "_audit")
) %}
{# Drop a relation #}
{%- set stale = adapter.get_relation(database=target.database, schema=target.schema, identifier='old_table') -%}
{% if stale is not none %}
{% do adapter.drop_relation(stale) %}
{% endif %}
{# Rename a relation #}
{% do adapter.rename_relation(from_relation, to_relation) %}
adapter.dispatch — Cross-Database Macros
The core pattern for writing macros that behave differently per SQL dialect:
-- macros/safe_divide.sql
{% macro safe_divide(numerator, denominator) -%}
{{ return(adapter.dispatch('safe_divide', 'my_project')(numerator, denominator)) }}
{%- endmacro %}
-- Default: works on PostgreSQL, Trino, DuckDB
{% macro default__safe_divide(numerator, denominator) %}
case when {{ denominator }} = 0 then null
else {{ numerator }}::float / {{ denominator }}
end
{% endmacro %}
-- BigQuery uses SAFE_DIVIDE
{% macro bigquery__safe_divide(numerator, denominator) %}
SAFE_DIVIDE({{ numerator }}, {{ denominator }})
{% endmacro %}
-- Spark SQL
{% macro spark__safe_divide(numerator, denominator) %}
case when {{ denominator }} = 0 then null
else {{ numerator }} / {{ denominator }}
end
{% endmacro %}
Dispatch resolution order for postgres:
my_project.postgres__safe_dividemy_project.default__safe_divide
Override a Package Macro via dbt_project.yml
# dbt_project.yml
dispatch:
- macro_namespace: dbt_utils
search_order: ['my_project', 'dbt_utils']
- macro_namespace: dbt
search_order: ['my_project', 'my_org_macros', 'dbt']
With this config, dbt_utils.generate_surrogate_key will first look for my_project.postgres__generate_surrogate_key before falling back to dbt_utils.
Cross-Database Built-in Macros (dbt.*)
These are the dbt namespace macros that abstract SQL dialect differences:
-- Date / time
{{ dbt.date_trunc("month", "created_at") }} -- date_trunc('month', created_at)
{{ dbt.dateadd("day", 7, "created_at") }} -- created_at + interval '7 day'
{{ dbt.datediff("start_date", "end_date", "day") }} -- end_date::date - start_date::date
{{ dbt.current_timestamp() }} -- now() / current_timestamp / getdate()
{{ dbt.last_day("created_at", "month") }} -- last day of month
-- Types
{{ dbt.type_string() }} -- TEXT (PG) / VARCHAR (Snowflake) / STRING (BQ)
{{ dbt.type_timestamp() }} -- TIMESTAMP
{{ dbt.type_bigint() }} -- BIGINT
{{ dbt.type_int() }} -- INT
{{ dbt.type_float() }} -- FLOAT
{{ dbt.type_numeric() }} -- NUMERIC(28,6)
{{ dbt.type_boolean() }} -- BOOLEAN
-- Casting
{{ dbt.safe_cast("user_id", dbt.type_bigint()) }} -- cast(user_id as BIGINT)
{{ dbt.cast("amount", dbt.type_numeric()) }} -- cast(amount as NUMERIC(28,6))
-- Strings
{{ dbt.concat(["first_name", "' '", "last_name"]) }} -- first_name || ' ' || last_name
{{ dbt.hash("order_id") }} -- md5(cast(order_id as varchar))
{{ dbt.length("email") }} -- length(email)
{{ dbt.replace("status", "'_'", "' '") }} -- replace(status, '_', ' ')
{{ dbt.split_part("full_name", "' '", 1) }} -- split_part(full_name, ' ', 1)
-- Aggregates
{{ dbt.listagg("tag", "','", "order by tag") }} -- array_to_string(array_agg(tag order by tag), ',')
{{ dbt.any_value("description") }} -- any(description)
{{ dbt.bool_or("is_active") }} -- bool_or(is_active)
Built-in Override Macros
generate_schema_name — Custom Schema Logic
Override to prevent dbt from prefixing custom schemas with the target schema:
-- macros/get_custom_schema.sql
{% macro generate_schema_name(custom_schema_name, node) -%}
{%- set default_schema = target.schema -%}
{%- if custom_schema_name is none -%}
{{ default_schema }}
{%- elif target.name == 'prod' -%}
{# In prod: use custom_schema_name directly, no prefix #}
{{ custom_schema_name | trim }}
{%- else -%}
{# In dev: prefix with target.schema to isolate environments #}
{{ default_schema }}_{{ custom_schema_name | trim }}
{%- endif -%}
{%- endmacro %}
generate_alias_name — Custom Alias Logic
{% macro generate_alias_name(custom_alias_name=none, node=none) -%}
{%- if custom_alias_name is not none -%}
{{ custom_alias_name | trim }}
{%- else -%}
{{ node.name }}
{%- endif -%}
{%- endmacro %}
generate_database_name
{% macro generate_database_name(custom_database_name=none, node=none) -%}
{%- if custom_database_name is none -%}
{{ target.database }}
{%- else -%}
{{ custom_database_name | trim }}
{%- endif -%}
{%- endmacro %}
Production-Quality Macro Patterns
1. Dynamic PIVOT
-- macros/pivot.sql
{% macro pivot(column, values, alias=true, agg='sum', then_value=1, else_value=0) %}
{% for v in values %}
{{ agg }}(case when {{ column }} = '{{ v }}' then {{ then_value }} else {{ else_value }} end)
{%- if alias %} as {{ v | lower | replace(' ', '_') | replace('-', '_') }}{% endif %}
{%- if not loop.last %},{% endif %}
{% endfor %}
{% endmacro %}
-- Usage
select
order_id,
{{ pivot('payment_method', ['credit_card', 'bank_transfer', 'gift_card'], agg='sum', then_value='amount') }}
from {{ ref('stg_payments') }}
group by 1
2. Union All Tables by Pattern
-- macros/union_relations.sql
{% macro union_relations(relations, exclude=[], column_override={}) %}
{% set cols_list = [] %}
{% for rel in relations %}
{% set rel_cols = adapter.get_columns_in_relation(rel) %}
{% for col in rel_cols %}
{% if col.name | lower not in (exclude | map('lower') | list) %}
{% do cols_list.append(col.name | lower) %}
{% endif %}
{% endfor %}
{% endfor %}
{% set all_cols = cols_list | unique | list | sort %}
{% for rel in relations %}
{% set rel_cols = adapter.get_columns_in_relation(rel) | map(attribute='name') | map('lower') | list %}
select
{% for col in all_cols %}
{% if col in rel_cols %}
{{ col }}
{% else %}
null as {{ col }}
{% endif %}
{%- if not loop.last -%},{%- endif %}
{% endfor %}
from {{ rel }}
{% if not loop.last %}
union all
{% endif %}
{% endfor %}
{% endmacro %}
3. Audit Columns Macro
-- macros/audit_columns.sql
{% macro audit_columns() %}
current_timestamp as dbt_loaded_at,
'{{ invocation_id }}' as dbt_invocation_id,
'{{ model.unique_id }}' as dbt_model_id
{% endmacro %}
-- models/marts/fct_orders.sql
select
order_id,
customer_id,
total,
{{ audit_columns() }}
from {{ ref('int_orders') }}
4. Grant Permissions After Build
-- macros/grants.sql
{% macro grant_select(role, schema=none) %}
{% set target_schema = schema or target.schema %}
{% if execute and target.name == 'prod' %}
{% set sql %}
grant usage on schema {{ target.database }}.{{ target_schema }} to role {{ role }};
grant select on all tables in schema {{ target.database }}.{{ target_schema }} to role {{ role }};
grant select on all views in schema {{ target.database }}.{{ target_schema }} to role {{ role }};
{% endset %}
{% do run_query(sql) %}
{% do log("Granted SELECT on " ~ target_schema ~ " to " ~ role, info=true) %}
{% endif %}
{% endmacro %}
Use in dbt_project.yml post-hook:
models:
my_project:
marts:
+post-hook: "{{ grant_select(role='bi_reader') }}"
5. Idempotent Schema + Table Creation
{% macro create_if_not_exists(schema, table_name, ddl_body) %}
{% set rel = adapter.get_relation(
database=target.database,
schema=schema,
identifier=table_name
) %}
{% if rel is none %}
{% set sql %}
create table {{ target.database }}.{{ schema }}.{{ table_name }} (
{{ ddl_body }}
)
{% endset %}
{% do run_query(sql) %}
{% do log("Created " ~ schema ~ "." ~ table_name, info=true) %}
{% else %}
{% do log(schema ~ "." ~ table_name ~ " already exists, skipping", info=true) %}
{% endif %}
{% endmacro %}
6. Column Existence Guard
-- macros/column_exists.sql
{% macro column_exists(relation, column_name) %}
{%- set cols = adapter.get_columns_in_relation(relation) | map(attribute='name') | map('lower') | list -%}
{{ return(column_name | lower in cols) }}
{% endmacro %}
-- models/stg_events.sql
select
event_id,
event_type,
{% if column_exists(source('raw', 'events'), 'user_agent') %}
user_agent,
{% endif %}
created_at
from {{ source('raw', 'events') }}
Hooks and Operations
on-run-start / on-run-end
# dbt_project.yml
on-run-start:
- "{{ logging.log_run_start() }}"
on-run-end:
- "{{ grant_select(role='reporter') }}"
- "{{ logging.log_run_end() }}"
pre-hook / post-hook on Models
models:
my_project:
staging:
+pre-hook:
- "{{ truncate_if_exists(this) }}"
+post-hook:
- "analyze {{ this }}"
- "{{ grant_select(role='analysts') }}"
Operations — Run Macros Without Models
# Run a macro directly (no model output)
dbt run-operation grant_select --args '{role: bi_team}'
dbt run-operation truncate_staging --args '{table_name: stg_events}'
Documenting Macros
# macros/schema.yml
macros:
- name: cents_to_dollars
description: Converts integer cents to decimal dollars.
arguments:
- name: column_name
type: column name (string)
description: Column containing the cent value.
- name: scale
type: integer
description: Decimal places in the output. Default is 2.
- name: safe_divide
description: >
Divides numerator by denominator, returning NULL when denominator is 0.
Cross-database via adapter.dispatch.
arguments:
- name: numerator
type: expression
description: The dividend expression.
- name: denominator
type: expression
description: The divisor expression.
Anti-Patterns
-
Calling
run_querywithout{% if execute %}— runs duringdbt compileanddbt docs generate, causing unintended warehouse queries. Always guard with{% if execute %}. -
Hardcoding schema names in macros —
analytics.my_tablebreaks across environments. Use{{ this }},{{ target.schema }}, or{{ ref() }}instead. -
Using Python string quoting for SQL values —
{{ cents_to_dollars(amount) }}(no quotes) treatsamountas a Jinja variable (undefined). Always quote column names:{{ cents_to_dollars('amount') }}. -
Writing adapter-specific SQL in generic macros —
date_part(...)works on Postgres but breaks on BigQuery. Usedbt.date_trunc()anddbt.dateadd()built-ins, oradapter.dispatch. -
One macro file per macro — clutters the
macros/directory. Group related macros by domain:macros/dates.sql,macros/grants.sql,macros/schema_overrides.sql. -
Overusing Jinja where SQL suffices — Jinja adds cognitive overhead. If the logic can be a CTE or window function, keep it in SQL. Use macros only for genuinely reusable or cross-database patterns.
-
Using
flags.WHICHchecks as the only protection —flags.WHICHis internal API and may change. Prefer{% if execute %}as the primary guard; useflags.WHICHonly when you need command-specific behavior. -
Not returning from macros meant to produce a value —
{% macro foo() %} ... {% endmacro %}always returns a string (including whitespace). Use{{ return(value) }}for macros that return scalars or lists used in{% set %}. -
Ignoring whitespace in generated SQL — extra blank lines and spaces make compiled SQL hard to read and debug. Use
{%- -%}whitespace control, especially inside loops. -
Not documenting macros in
schema.yml— undocumented macros become mystery functions. Document all public macros with arguments and descriptions.
References to Consult When Needed
- dbt Jinja macros overview:
docs.getdbt.com/docs/build/jinja-macros - dbt Jinja context variables:
docs.getdbt.com/reference/dbt-jinja-functions adapter.dispatchreference:docs.getdbt.com/reference/dbt-jinja-functions/dispatch- Cross-database built-ins:
docs.getdbt.com/reference/dbt-jinja-functions/cross-database-macros run_queryreference:docs.getdbt.com/reference/dbt-jinja-functions/run_query- Jinja template designer docs:
jinja.palletsprojects.com/en/3.1.x/templates/ - dbt-utils package:
github.com/dbt-labs/dbt-utils