| Time | Block |
|---|---|
| 0:00 – 0:15 | 35.1 DRY: spotting repetition worth removing |
| 0:15 – 0:40 | 35.2 Macro basics: small scalar macros |
| 0:40 – 0:48 | Break |
| 0:48 – 1:20 | 35.3 A model-generating macro: growth_rates() |
| 1:20 – 1:43 | 35.4 run_query: configuration from seeds |
| 1:43 – 1:50 | Break |
| 1:50 – 2:08 | 35.5 Debugging and documenting macros |
| 2:08 – 2:30 | 35.6 Mini-project: DRY the CPI, GDP and rates models |
35.1 DRY: Spotting Repetition Worth Removing (15 min)
DRY means Don't Repeat Yourself: every piece of knowledge should have one authoritative home. In our project, repeated knowledge includes:
| Knowledge | Currently written in |
|---|---|
| "Gap-safe growth = compare with the value exactly n periods earlier, else NULL" | int_cpi__growth, int_gdp__growth |
| "Policy rates aggregate by last, prices by mean" | int_rates__monthly_long (a case) |
| "A period is in recession if it overlaps a seed band" | dim_date, and later the monthly/quarterly marts |
| "Which series form the rates panel" | vars.rate_series and seed_series.csv |
| "Percentage change = 100 × (new/old − 1)" | many models |
But DRY has a limit. Don't abstract two things just because their SQL looks similar. Abstract when they share meaning, so that a change to one should always change the other. A macro that hides simple SQL behind ten parameters is worse than the repetition it removed.
Test for a good macro: could you explain what it does to an economist in one sentence, without mentioning SQL?
35.2 Macro Basics: Small Scalar Macros (25 min)
A macro is a reusable piece of Jinja that returns text, usually a SQL fragment. You wrote one on Day 32 (generate_schema_name). Macros live in macros/.
Syntax
{% macro macro_name(arg1, arg2='default') %}
... Jinja + SQL text ...
{% endmacro %}
Call it anywhere with {{ macro_name(...) }}.
Example 1 — Percentage change
Create macros/economics.sql:
{% macro pct_change(new_value, old_value) -%}
(100.0 * ({{ new_value }} / nullif({{ old_value }}, 0) - 1))
{%- endmacro %}
{% macro annualise(ratio, periods_per_year) -%}
(100.0 * (power({{ ratio }}, {{ periods_per_year }}) - 1))
{%- endmacro %}
{% macro bp(pp_expression) -%}
({{ pp_expression }} * 100)
{%- endmacro %}
Use them:
select
month,
{{ pct_change('cpi', 'lag(cpi, 12) over (order by month)') }} as yoy,
{{ bp('yield_10yr - yield_2yr') }} as spread_bp
The arguments are strings of SQL, pasted into the template. The macro doesn't see data, only text (Day 33's "Jinja runs first").
nullif(x, 0) returns NULL when x is zero, so a zero base yields NULL instead of an error. It's a small robustness bonus now written once.
Example 2 — Monthly aggregation rule
{% macro monthly_value(value_col, date_col, agg_col) -%}
case
when {{ agg_col }} = 'last' then arg_max({{ value_col }}, {{ date_col }})
else avg({{ value_col }})
end
{%- endmacro %}
Example 3 — Recession overlap
Create macros/calendar.sql:
{% macro overlaps_recession(period_start, period_end) -%}
exists (
select 1
from {{ ref('seed_recession_bands') }} as _rb
where {{ period_start }} <= _rb.end_date
and {{ period_end }} >= _rb.start_date
)
{%- endmacro %}
In dim_date:
{{ overlaps_recession('date_day', 'date_day') }} as in_dated_recession
And for a month (used on Days 38–40):
{{ overlaps_recession('month', "cast(month + interval 1 month - interval 1 day as date)") }}
Because the macro calls ref('seed_recession_bands'), dbt records the dependency in the model that calls it, so the lineage graph stays correct.
Try it 35.2
Write a macro real_rate(nominal_col, inflation_col) returning the approximate real rate, and a second one real_rate_exact(...) using the Fisher equation (Day 1). Test both with dbt show --inline "select {{ real_rate('2.25', '3.0') }} as r, {{ real_rate_exact('2.25', '3.0') }} as r_exact".
Solution
{% macro real_rate(nominal_col, inflation_col) -%}
({{ nominal_col }} - {{ inflation_col }})
{%- endmacro %}
{% macro real_rate_exact(nominal_col, inflation_col) -%}
(100.0 * ((1 + {{ nominal_col }} / 100.0) / (1 + {{ inflation_col }} / 100.0) - 1))
{%- endmacro %}
Expected: r = -0.75, r_exact ≈ -0.728. The same numbers as Day 1, now as reusable SQL.
35.3 A Model-Generating Macro: growth_rates() (33 min)
Day 33's horizons loop lived inside int_cpi__growth. GDP needs the same logic with quarters. Let's write it once.
Create macros/growth_rates.sql:
{#
growth_rates(): gap-safe growth measures for any time series.
relation model/ref to read from
value_col column holding the level (e.g. 'cpi', 'real_gdp')
date_col period column (first day of month or quarter)
partition_by list of columns identifying each series (may be empty)
horizons list of {"name", "lag", "power"} (power = annualisation exponent)
step_months months per period: 1 for monthly, 3 for quarterly
keep_cols extra columns to carry through unchanged
A measure is NULL unless the comparison period is exactly lag × step_months earlier.
#}
{% macro growth_rates(relation, value_col, date_col, partition_by=[], horizons=[],
step_months=1, keep_cols=[]) %}
with _base as (
select * from {{ relation }}
),
_lagged as (
select
*,
{%- for l in horizons | map(attribute='lag') | unique %}
lag({{ value_col }}, {{ l }}) over _w as _{{ value_col }}_l{{ l }},
lag({{ date_col }}, {{ l }}) over _w as _{{ date_col }}_l{{ l }}{{ ',' if not loop.last }}
{%- endfor %}
from _base
window _w as (
{% if partition_by %}partition by {{ partition_by | join(', ') }}{% endif %}
order by {{ date_col }}
)
)
select
{{ date_col }},
{%- for c in partition_by + keep_cols %}
{{ c }},
{%- endfor %}
{{ value_col }},
{%- for h in horizons %}
case
when _{{ date_col }}_l{{ h.lag }}
= cast({{ date_col }} - interval {{ h.lag * step_months }} month as date)
then {{ annualise(value_col ~ ' / _' ~ value_col ~ '_l' ~ h.lag, h.power) }}
end as {{ h.name }}{{ ',' if not loop.last }}
{%- endfor %}
from _lagged
{% endmacro %}
New Jinja features here:
{# … #}at the top documents the macro for the next reader.partition_by + keep_cols: lists can be concatenated, as in Python.value_col ~ ' / _' ~ …:~is Jinja's string concatenation, used to build an argument for another macro (annualise). Macros can call macros.- Internal names start with
_(_base,_w,_cpi_l12) to avoid clashing with the caller's columns.
Put the horizons in vars
In dbt_project.yml:
vars:
monthly_horizons:
- {name: mom, lag: 1, power: 1}
- {name: ann_3m, lag: 3, power: 4}
- {name: ann_6m, lag: 6, power: 2}
- {name: yoy, lag: 12, power: 1}
quarterly_horizons:
- {name: real_qoq, lag: 1, power: 1}
- {name: real_qoq_ann, lag: 1, power: 4}
- {name: real_yoy, lag: 4, power: 1}
The CPI growth model becomes six lines
-- models/intermediate/cpi/int_cpi__growth.sql
-- Grain: month × geography × component. Gap-safe growth rates (%).
{{ growth_rates(
relation = ref('int_cpi__active'),
value_col = 'cpi',
date_col = 'month',
partition_by = ['geo', 'component'],
keep_cols = ['geo_code', 'geo_level'],
horizons = var('monthly_horizons'),
step_months = 1
) }}
dbt compile --select int_cpi__growth
dbt build --select int_cpi__growth+
Read the compiled SQL: it's the familiar Day 19 logic. Then re-run the regression analysis. Zero differences means the refactor preserved the numbers.
Column order note: the macro outputs
date, partitions, keep_cols, value, measures. If a downstream model usedselect *and relied on the old column order, it may break. That's one reason to prefer explicit column lists downstream.
35.4 run_query: Configuration from Seeds (23 min)
Day 33 left a known duplication: vars.rate_series repeats seed_series.csv. Jinja can run a query at compile time with run_query and use the result to write SQL.
Step 1 — Mark panel membership in the seed
Add a column to seeds/seed_series.csv:
series_id,series_name,source,frequency,monthly_agg,max_staleness_days,in_rates_panel
V39079,overnight_rate,boc,daily,last,10,true
FXCADUSD,cad_usd,boc,daily,mean,10,true
BD.CDN.2YR.DQ.YLD,yield_2yr,boc,daily,mean,10,true
BD.CDN.10YR.DQ.YLD,yield_10yr,boc,daily,mean,10,true
FEDFUNDS,fed_funds,fred,monthly,last,75,true
DCOILWTICO,wti,fred,daily,mean,15,true
CANLOLITOAASTSAM,oecd_cli,fred,monthly,mean,120,true
v41690973,cpi_all_items,statcan,monthly,last,75,false
v2062815,unemployment_rate,statcan,monthly,last,60,true
and type it in seeds/_seeds.yml (in_rates_panel: boolean). Run dbt seed --select seed_series.
Step 2 — A macro that reads the seed
Create macros/get_series_names.sql:
{% macro get_series_names(where=none) %}
{% set query %}
select series_name
from {{ ref('seed_series') }}
{% if where %}where {{ where }}{% endif %}
order by series_name
{% endset %}
{% if execute %}
{% set results = run_query(query) %}
{% set names = results.columns[0].values() %}
{% else %}
{% set names = [] %}
{% endif %}
{{ return(names) }}
{% endmacro %}
New ideas:
{% set query %} … {% endset %}captures a block of text into a variable.run_query(query)runs SQL against the target and returns a table..columns[0].values()extracts the first column as a list.executeisfalsewhile dbt is parsing the project (building the DAG, when the database may not even be ready) andtruewhen it actually runs. Always guardrun_querywith{% if execute %}.{{ return(names) }}makes the macro return a Python-like value (a list) instead of text.
Step 3 — Use it
-- models/intermediate/macro/int_rates__monthly.sql
-- depends_on: {{ ref('seed_series') }}
-- Grain: one row per month. Columns generated from seed_series (in_rates_panel = true).
{% set panel_series = get_series_names(where="in_rates_panel") %}
with wide as (
select
month,
{%- for s in panel_series %}
max(value) filter (where series_name = '{{ s }}') as {{ s }}{{ ',' if not loop.last }}
{%- endfor %}
from {{ ref('int_rates__monthly_long') }}
group by month
)
select
*,
overnight_rate - fed_funds as boc_fed_diff,
{{ bp('yield_10yr - yield_2yr') }} as spread_10_2_bp,
1 / cad_usd as usd_cad
from wide
The -- depends_on: comment is a recommended dbt pattern: it guarantees dbt registers the dependency on seed_series, so the seed is always built before this model.
Now remove rate_series from vars. Adding a series to the panel means one CSV row, the same file that already drives ingestion checks.
Trade-off:
run_querymakes compilation depend on the database. A model can't compile until the seed exists, and every compile runs a small query. It's worth it for configuration tables like this; don't use it for anything large.
Try it 35.4
Use get_series_names(where="frequency = 'daily'") inside dbt show --inline to print the daily series as a single comma-separated string.
Solution
dbt show --inline "select '{{ get_series_names(where=\"frequency = 'daily'\") | join(', ') }}' as daily_series"
35.5 Debugging and Documenting Macros (18 min)
Seeing what a macro produced
dbt compile --select modeland readtarget/compiled/...: always the first step.{{ log("panel series: " ~ panel_series, info=True) }}prints to the terminal while dbt runs. Remove it once things work.dbt run-operation <macro> --args '{...}'runs a macro on its own (Day 37 uses this).
Common errors
| Error | Usual cause |
|---|---|
'dict object' has no attribute 'lag' | a horizon missing a key (typo in vars) |
Syntax error near , or from | a trailing comma: check loop.last |
| Empty column list in compiled SQL | run_query used without execute, or the seed isn't built |
macro 'x' takes no keyword argument 'y' | argument name mismatch |
Documenting macros
Create macros/_macros.yml:
version: 2
macros:
- name: growth_rates
description: >
Gap-safe growth measures for any time series. A measure is NULL unless the
comparison period is exactly lag × step_months earlier. Annualisation via `power`.
arguments:
- name: relation
type: relation
description: "Model to read from (use ref())."
- name: horizons
type: list
description: "List of {name, lag, power}; see vars.monthly_horizons."
- name: step_months
type: integer
description: "1 for monthly data, 3 for quarterly."
- name: get_series_names
description: "Series names from seed_series, optionally filtered by a SQL condition."
- name: overlaps_recession
description: "True if [period_start, period_end] overlaps any band in seed_recession_bands."
Macro documentation appears on the docs site (dbt docs generate), next to the models that use it.
35.6 Mini-Project: DRY the CPI, GDP and Rates Models (23 min)
- GDP. Split
int_gdp__growthinto:int_gdp__levels(view): quarter,real_gdp,nominal_gdp,gdp_deflator(Day 20's first CTEs);int_gdp__growth:growth_rates(ref('int_gdp__levels'), 'real_gdp', 'quarter', horizons=var('quarterly_horizons'), step_months=3, keep_cols=['nominal_gdp', 'gdp_deflator']).
- Rates.
int_rates__monthly_longuses themonthly_valuemacro;int_rates__monthlyusesget_series_names. - Calendar.
dim_dateusesoverlaps_recession. - Everywhere: replace hand-written
100 * (a / b - 1)withpct_change. dbt buildsucceeds and the regression analysis shows no differences.- Count lines of SQL before and after:
git stash && find models -name "*.sql" | xargs cat | wc -l && git stash pop
find models macros -name "*.sql" | xargs cat | wc -l
Solution — int_gdp__levels and int_gdp__growth
-- models/intermediate/gdp/int_gdp__levels.sql
-- Grain: one row per quarter (Canada, SAAR). Levels in $ millions; deflator 100 = base year.
select
quarter,
max(value) filter (where prices like 'Chained%') as real_gdp,
max(value) filter (where prices = 'Current prices') as nominal_gdp,
100 * nominal_gdp / real_gdp as gdp_deflator
from {{ ref('stg_statcan__gdp') }}
where estimate = 'Gross domestic product at market prices'
and seasonal_adjustment = 'Seasonally adjusted at annual rates'
group by quarter
-- models/intermediate/gdp/int_gdp__growth.sql
-- Grain: one row per quarter. Gap-safe real GDP growth (%).
{{ growth_rates(
relation = ref('int_gdp__levels'),
value_col = 'real_gdp',
date_col = 'quarter',
keep_cols = ['nominal_gdp', 'gdp_deflator'],
horizons = var('quarterly_horizons'),
step_months = 3
) }}
100 * nominal_gdp / real_gdp references two aliases from the same select, a DuckDB convenience (Day 17). Deflator growth and the 4-quarter moving average can be added in a small downstream model (int_gdp__growth_extended), or by calling growth_rates a second time on the deflator.
Day 35 checklist
- I can tell repetition of knowledge (worth a macro) from coincidental similarity (not worth it)
- I write scalar macros with arguments and defaults, and macros that call macros
-
growth_rates()generates gap-safe measures for monthly and quarterly data fromvars - I use
run_querysafely (execute,depends_on) to drive SQL from seeds - I debug macros with compiled SQL and
log(), and document them in YAML
dbt build && cd .. && git add . && git commit -m "Day 35: macros, growth_rates, run_query from seeds" && git push