Chapter 12 · Metaprogramming

Custom Materializations

A materialization decides what "building a model" means. Writing one moves logic out of every model and into one reusable strategy. In this chapter you'll build scd2, a small but complete Type 2 materialization for PostgreSQL, and look at every line that dbt requires along the way.

~40 min read Builds on: Internals ch. 07

When a materialization is the right tool

NeedBetter tool
Different SELECT logicA macro (ch. 06–08)
Slightly different incremental SQLA strategy override (ch. 10) or incremental_predicates
Standard Type 2 history from a sourcedbt snapshots (built in)
A new build procedure (several statements, special DDL, swaps, validation)A custom materialization

Snapshots are dbt's built-in SCD2, so why build your own? Snapshots are separate resources, they add dbt_valid_from/dbt_valid_to columns, and they work on their own schedule. A materialization lets an ordinary model keep history using your own column names and rules, and it's the clearest way to learn how materializations work.

Anatomy

{% materialization my_strategy, adapter='postgres' %}     {# or default=true for all adapters #}

  {#- 1. relations & config -#}
  {%- set target_relation   = this.incorporate(type='table') -%}
  {%- set existing_relation = load_cached_relation(this) -%}
  {%- set some_option       = config.get('some_option', 'x') -%}

  {#- 2. pre-hooks -#}
  {{ run_hooks(pre_hooks, inside_transaction=False) }}
  {{ run_hooks(pre_hooks, inside_transaction=True) }}

  {#- 3. the work — exactly one statement named 'main' -#}
  {% call statement('main') %}
      … uses {{ sql }} (the model's compiled SELECT) …
  {% endcall %}

  {#- 4. post-hooks & commit -#}
  {{ run_hooks(post_hooks, inside_transaction=True) }}
  {% do adapter.commit() %}
  {{ run_hooks(post_hooks, inside_transaction=False) }}

  {#- 5. tell dbt what now exists (updates the relation cache) -#}
  {{ return({'relations': [target_relation]}) }}
{% endmaterialization %}
  • Name and adapter. dbt registers this as the macro materialization_my_strategy_postgres. A model uses it with config(materialized='my_strategy').
  • sql is the model's compiled SELECT, and it's available only inside materializations.
  • statement('main') is required. The runner reads its result for the run summary and run_results.json.
  • The return value lists the relations the materialization created or replaced, so dbt's cache stays correct for later nodes.

Designing scd2

Contract with the model:

  • The model selects the current state of each entity, one row per business key, including a row_hash.
  • Config unique_key names the business key (one column, to keep the example readable).
  • The materialization adds and maintains valid_from, valid_to and is_current.
  • Keys that disappear from the source are closed when invalidate_hard_deletes=true.
scd2 materialization flow Target exists?load_cached_relation No → create tableall rows current Yes → temp tablemodel SELECT Close changed / goneupdate valid_to Insert new versionsno current row
Figure 12.1 — Two paths. The incremental path closes versions first, then inserts a new version for every key that has no current row.

The materialization

{% materialization scd2, adapter='postgres' %}

  {%- set unique_key      = config.require('unique_key') -%}
  {%- set hash_column     = config.get('hash_column', 'row_hash') -%}
  {%- set hard_deletes    = config.get('invalidate_hard_deletes', false) -%}
  {%- if unique_key is not string -%}
      {{ exceptions.raise_compiler_error("scd2: unique_key must be a single column name") }}
  {%- endif -%}

  {%- set target_relation   = this.incorporate(type='table') -%}
  {%- set existing_relation = load_cached_relation(this) -%}
  {%- set tmp_relation      = make_temp_relation(target_relation) -%}
  {%- set full_refresh      = should_full_refresh() -%}

  {{ run_hooks(pre_hooks, inside_transaction=False) }}
  {{ run_hooks(pre_hooks, inside_transaction=True) }}

  {%- if existing_relation is not none and existing_relation.type != 'table' -%}
      {#- a view (or other) with our name is in the way -#}
      {% do adapter.drop_relation(existing_relation) %}
      {%- set existing_relation = none -%}
  {%- endif -%}

  {% if existing_relation is none or full_refresh %}

      {%- if existing_relation is not none -%}
          {% do adapter.drop_relation(existing_relation) %}
      {%- endif -%}
      {% call statement('main') %}
          create table {{ target_relation }} as
          select src.*,
                 now()            as valid_from,
                 null::timestamptz as valid_to,
                 true             as is_current
          from ( {{ sql }} ) src
      {% endcall %}

  {% else %}

      {% call statement('build_tmp') %}
          {{ get_create_table_as_sql(True, tmp_relation, sql) }}
      {% endcall %}

      {% call statement('main') %}
          -- 1. close versions whose attributes changed
          update {{ target_relation }} t
             set valid_to = now(), is_current = false
            from {{ tmp_relation }} s
           where t.{{ unique_key }} = s.{{ unique_key }}
             and t.is_current
             and t.{{ hash_column }} is distinct from s.{{ hash_column }};

          {% if hard_deletes -%}
          -- 2. close versions whose key disappeared from the source
          update {{ target_relation }} t
             set valid_to = now(), is_current = false
           where t.is_current
             and not exists (select 1 from {{ tmp_relation }} s
                              where s.{{ unique_key }} = t.{{ unique_key }});
          {%- endif %}

          -- 3. insert a new current version for every key without one (new + just closed)
          insert into {{ target_relation }}
          select s.*, now(), null::timestamptz, true
          from {{ tmp_relation }} s
          where not exists (select 1 from {{ target_relation }} t
                             where t.{{ unique_key }} = s.{{ unique_key }}
                               and t.is_current);
      {% endcall %}

  {% endif %}

  {{ run_hooks(post_hooks, inside_transaction=True) }}
  {% do adapter.commit() %}
  {{ run_hooks(post_hooks, inside_transaction=False) }}

  {{ return({'relations': [target_relation]}) }}

{% endmaterialization %}

Line-by-line notes

PieceWhy
config.require('unique_key')Fails at compile time with a clear message if the model forgot it
this.incorporate(type='table')this has no type yet. The materialization says what it will be
make_temp_relationOn Postgres this makes a uniquely named temporary table (no schema), dropped automatically at the end of the session
get_create_table_as_sql(True, …)Reuses dbt's own dispatched DDL, so True means a temporary table, written correctly for the adapter
One statement('main') with several SQL statementspsycopg2 runs them in order in one call, inside the open transaction. The row count reported is from the last statement
Close first, then insert where there's no current rowChanged keys become "keys without a current version" after step 1, so one insert covers both new and changed keys
adapter.commit() between the hook groupsSame transactional contract as the built-in materializations

Known limitations (and how to address them)

  • Column order: insert … select s.*, … assumes the model's columns haven't changed. Add a schema check with adapter.get_columns_in_relation on both relations and fail if they differ, as on_schema_change='fail' does.
  • Composite keys: accept a list and generate the join with the scd2_key_join macro from chapter 06.
  • Time: now() is the transaction start time, the same for every row in the run, which is usually what you want. Use a config if you need the effective date to come from the source data.
  • Grants and docs: built-in materializations also call apply_grants and persist_docs. Add them if you use those configs.

Using it

{{ config(
    materialized='scd2',
    unique_key='salesterritorykey',
    invalidate_hard_deletes=true,
    post_hook="{{ record_dim_stats(scd2=false) }}"
) }}

select
    salesterritorykey,
    salesterritoryregion,
    salesterritorycountry,
    salesterritorygroup,
    {{ row_hash(['salesterritoryregion', 'salesterritorycountry', 'salesterritorygroup']) }} as row_hash
from {{ ref('dimsalesterritory') }}
$ dbt run -s dimterritory_history --vars '{"run_id": 1}'     # first run: create table
$ dbt run -s dimterritory_history --vars '{"run_id": 2}'     # no changes: 0 rows inserted
#   change a territory name in stg.salesterritory, rebuild dimsalesterritory, then:
$ dbt run -s dimsalesterritory dimterritory_history --vars '{"run_id": 3}'

Debugging a materialization

Run with --debug. Every statement the materialization executes is logged with its connection name, so you can follow BEGIN → temp table → updates → insert → COMMIT exactly. If something goes wrong, it's usually a missing main statement, a missing return value, or a commit in the wrong place.


Key takeaways

  • A materialization is a {% materialization name, adapter=… %} block that receives sql and must run statement('main') and return its relations.
  • Follow the built-in transaction contract: hooks outside, hooks inside, main, hooks inside, commit, hooks outside.
  • Reuse dbt's dispatched building blocks (get_create_table_as_sql, make_temp_relation, load_cached_relation).
  • Consider snapshots or strategy overrides first. Write a materialization when you need a new build procedure.

Exercises

1 · Add the schema guard

Before the updates, fail if the temp table's column names differ from the target's (ignoring the three SCD columns).

Answer
{% set src_cols = adapter.get_columns_in_relation(tmp_relation) | map(attribute='name') | list %}
{% set tgt_cols = adapter.get_columns_in_relation(target_relation) | map(attribute='name')
                  | reject('in', ['valid_from', 'valid_to', 'is_current']) | list %}
{% if src_cols != tgt_cols %}
  {{ exceptions.raise_compiler_error("scd2: columns changed: " ~ src_cols ~ " vs " ~ tgt_cols) }}
{% endif %}

Put this after build_tmp. Comparing ordered lists also catches reordered columns.

2 · What does run_results say?

In the incremental path, what will rows_affected in run_results.json show?

Answer

The count from the last statement in main, which is the INSERT. The closed versions aren't included. Use a post-hook like record_dim_stats, or separate statements with load_result, if you need both numbers.