dbt

dbt Custom Materializations: How to Build Your Own Strategy

2026-04-19
NicheeLab Editorial Team

A dbt materialization is the execution strategy that decides how a model is built. In addition to built-in choices like table, view, and incremental, you can implement a custom materialization tailored to your requirements.

This article walks through design principles for safety and portability that follow the official documentation, a minimal implementation, and operational gotchas — along with points that are likely to show up on the Analytics Engineer exam.

The Big Picture of Custom Materializations

A materialization is defined as a special Jinja block and selected from the model side via config(materialized='name'). dbt compiles the model SQL, then runs the materialization block with that SQL and the target relation (this) as inputs.

The safe baseline pattern is: build into a temp table → swap via rename → return the artifact. This avoids exposing half-finished state.

  • Location: macros/materializations/*.sql
  • Naming: {% materialization name, adapter='default' %} ... {% endmaterialization %}
  • Runtime context: this (output relation), sql (compiled SQL), target, project, config, and more
  • Return value: return({'relations': [relation1, ...]}) must be returned

Execution flow (from compile to swap)

model.sqlJinja/SQL compilematerializationuses: this, sqlcreate temp table from sqldrop/rename atomic swapfinal relationExecution flow (from compile to swap)

Design Principles and Exam-Relevant Angles

The Analytics Engineer exam tends to ask about the differences between built-in and custom materializations, when to use which, and how dependencies and grants are handled. When designing, prioritize portability and observability on failure.

In particular, do not mix processing that belongs in on-run-end hooks or post-hooks inside the materialization body, and keep grant application clearly separated. This separation of concerns matters.

  • Portability: avoid hardcoding warehouse-specific syntax; prefer create_table_as / create_view_as / the adapter API whenever possible
  • Atomicity: use a temp → rename swap to suppress exposure of intermediate state
  • Observability: use statement() and log() so errors leave a trace
  • Separation of concerns: stage DDL (creation), DCL (grants), and metadata application separately
TypeUpdate ModeTypical Use CasesWatch Out For
view (built-in)RecomputedLightweight aggregation and explorationGrants depend on the underlying tables
table (built-in)RebuiltStable delivery and cases with many downstream dependenciesBuild time and storage cost grow
incremental (built-in)Delta / mergeEvent-driven or daily-partitioned dataKey management and a recomputation strategy are required
customArbitrary (swap / split / combined)Optimized for business requirementsTesting and rollback design are key

Minimal Implementation: A Swap-Style Table Materialization

The most general-purpose and safe custom approach writes the compiled SQL to a temp table, then renames it onto the production relation once it is complete. It uses helper macros and the adapter API that work across most adapters.

Below is a minimal implementation, along with an example of how to opt in from the model.

  • Use make_temp_relation to safely generate the temp relation name
  • Execute DDL together inside a statement() block
  • Do not forget return({'relations': [target]}) — it is required for catalog propagation

macros/materializations/swap_table.sql and an example model

-- macros/materializations/swap_table.sql
{% materialization swap_table, adapter='default' %}
  {% set target = this %}
  {% set tmp = make_temp_relation(target) %}

  {# Drop any pre-existing temp table #}
  {% do adapter.drop_relation(tmp) %}

  {# Create into the temp table (compiled SQL = sql) #}
  {% call statement('create_tmp', fetch_result=False) %}
    {{ create_table_as(tmp, sql) }}
  {% endcall %}

  {# Drop the old table and swap #}
  {% do adapter.drop_relation(target) %}
  {% do adapter.rename_relation(tmp, target) %}

  {# Return the artifact #}
  {{ return({'relations': [target]}) }}
{% endmaterialization %}

-- models/fct_orders.sql
{{ config(materialized='swap_table') }}
select *
from {{ ref('stg_orders') }}
where order_status != 'CANCELLED';

Operational Design: Dependencies, Grants, and Error Handling

Operationally, hooks, grant application, and cleanup-on-failure design matter. Side-effect work such as ANALYZE/OPTIMIZE in post-hooks should be kept separate from the materialization body so that re-runs are safer.

Define grants in the model config, and where possible call apply_grants at the end of the materialization. For adapters that do not support it, fall back to a post-hook.

  • Concentrate DDL optimization in post-hooks to isolate side effects
  • Emit stage logs with log('message', info=True)
  • If you leave tmp tables around on failure, decide on a naming convention and TTL up front
  • Fix the drop/rename order so it remains safe under full-refresh as well

Adapter Differences and Dispatch Strategy

When you want warehouse-specific optimizations, keep the default implementation and override only the adapters that need it. Materialization blocks branch via the adapter argument.

In this shape, unsupported adapters inherit the default while supported adapters can override with minimal diffs.

  • Always provide a default to guarantee backward compatibility
  • Keep adapter-specific DDL contained inside statement() blocks
  • Extract shared logic into separate macros to improve reusability

Adapter-specific override example (conceptual)

{% materialization swap_table, adapter='default' %}
  {# Shared swap implementation #}
  ...
{% endmaterialization %}

{% materialization swap_table, adapter='snowflake' %}
  {# Only describe the diff — for example, add CLUSTER BY for Snowflake #}
  ...
{% endmaterialization %}

Validation and Troubleshooting

Verify a new materialization on a small model, and confirm behavior under both dbt run -m model_name --full-refresh and a normal run.

When trouble strikes, inspect the generated SQL, the statement logs, and any leftover tmp relations. A missing relations array in the return value affects the catalog and dependency graph.

  • Use dbt ls -s model_name to confirm the target
  • Inspect the generated SQL first with dbt compile
  • Verify RENAME/DROP privileges on the warehouse side in advance
  • Set up a CI smoke test against a small dataset

Check Your Understanding

Analytics Engineer

問題 1

You implemented a custom dbt materialization. After setting config(materialized='swap_table') on the model and running it, the artifact is created, but the relation is not recognized by the documentation or dependency resolution. Which is the most likely cause?

  1. The materialization does not return return({'relations': [target]})
  2. model.sql does not use is_incremental()
  3. No post-hook is defined
  4. The adapter argument is set to a specific adapter instead of default

正解: A

A custom materialization must return a list of relations once it finishes. Without return({'relations': [target]}), the artifact does not propagate to the catalog or the dependency graph. The presence or absence of is_incremental() and post-hooks is not the direct cause. Setting adapter to something other than default still works, as long as it matches the active adapter.

Frequently Asked Questions

When should I replace incremental with a custom materialization?

When the standard incremental delta/merge behavior is not enough and you need to atomically express specific business logic — period-based swaps, commit-after-two-phase validation, simultaneously swapping multiple tables, and so on. Always check first whether the built-in materializations can do the job.

Should grants be applied inside the materialization or in a post-hook?

For re-runnability and separation of concerns, either call apply_grants with the model's grants config at the end of the materialization, or move grants to a post-hook. Pick one based on your environment and adapter support, and stick with it consistently.

What do I do when renaming a temp table fails (due to locks or permissions)?

First check DROP/RENAME privileges and schedule jobs to avoid contention. If that is unavoidable, branch within statement() to fall back to a CREATE OR REPLACE strategy.

Check what you learned with practice questions

Practice with certification-focused question sets

無料で問題を解いてみる
Author

NicheeLab Editorial Team

NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.


Related articles
dbt

dbt Models: SQL-Defined Transformation Units (2026)

Model fundamentals — SELECT-based definitions, naming, refs,...

dbt

dbt Analytics Engineering Exam: Complete Guide (2026)

Pass the AE Certification — scope, weighting, sample questio...

dbt

dbt Cloud vs dbt Core: Feature & Cost Comparison (2026)

Honest comparison of dbt Cloud vs. dbt Core — IDE, scheduler...

dbt

dbt Project Structure: models/seeds/macros Layout (2026)

Recommended dbt project layout — models, seeds, macros, snap...

dbt

dbt_project.yml Explained: Every Config (2026)

Every dbt_project.yml setting that matters — paths, vars, ma...

Browse all dbt articles (101)
© 2026 NicheeLab All rights reserved.