Developing dbt Models
This domain covers the full lifecycle of building, structuring, and configuring dbt models using SQL and Jinja. It is the heaviest domain on the exam at 46% of total weight, meaning roughly half of all questions test your ability to choose correct materializations, write valid Jinja, configure models properly, and apply project structure conventions. Expect scenario-based questions that require you to distinguish between similar options (e.g., merge vs. delete+insert, ref() vs. direct table reference) and to predict the behavior of configurations.
Materializations
A materialization defines how dbt persists a model's results in the data warehouse. dbt ships with five built-in materializations.
| Materialization | What it creates | Re-runs rebuild from scratch? | Supports incremental logic? | Best for |
|---|---|---|---|---|
| view | A database view | Yes (DDL every run) | No | Lightweight transformations, always-fresh logic |
| table | A physical table (DROP + CREATE) | Yes | No | Frequently queried, expensive views |
| incremental | A table, appended/merged over time | No (only new/changed rows) | Yes | Large, append-heavy datasets |
| ephemeral | A CTE injected into the referencing model | N/A (never persisted) | No | Intermediate logic not worth materializing |
| materialized view | A database-native materialized view | Depends on warehouse | No | Auto-refreshed aggregations (warehouse-managed) |
Default materialization is view unless overridden in dbt_project.yml or a model-level config block.
How to Set Materializations
-- Model-level config block (highest precedence)
{{ config(materialized='table') }}
SELECT ...
# dbt_project.yml (applies to entire folder)
models:
my_project:
marts:
+materialized: table
Precedence order (highest → lowest): model config block → individual model YAML → dbt_project.yml folder config.
Materialized Views
A materialized view is a warehouse-native object (not the same as a dbt table). Supported on Snowflake, BigQuery, Postgres, Redshift, and Databricks — but behavior differs. dbt does not manage the refresh schedule; the warehouse does. Use when you want warehouse-managed incremental refresh without custom dbt incremental logic.
Common Traps
- Assuming ephemeral models appear in the warehouse — they do not; they compile to CTEs inside the consuming model.
- Confusing materialized view (warehouse-native, auto-refreshed) with table (dbt drops and recreates every run).
- Assuming table is incremental — it is not; every run is a full rebuild.
- Forgetting that view is the default; a model with no config is a view.
- Believing ephemeral models can be referenced outside dbt (e.g., by a BI tool) — they cannot.
Worked Scenario
Requirement: A model runs on 500 M rows and is queried heavily by a BI tool, but the underlying source data is fully replaced daily. Choose the materialization.
Reasoning: incremental is not needed because the source is fully replaced, eliminating the "only process new rows" benefit. view would force the BI tool to re-execute 500 M rows on every query. Answer: table — full rebuild nightly, fast BI reads.
Incremental Models
Incremental models avoid full rebuilds by processing only new or changed rows on each run after the initial load.
Core Mechanics
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge'
) }}
SELECT *
FROM {{ source('raw', 'orders') }}
{% if is_incremental() %}
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}
is_incremental(): Jinja macro that returnstrueonly when the model already exists in the warehouse AND--full-refreshis not passed. On first run ordbt run --full-refresh, it returnsfalse, causing a full table build.{{ this }}: Refers to the current model's relation (schema + table name) in the warehouse, used insideis_incremental()blocks to query the existing table.
Incremental Strategies
| Strategy | How it works | Requires unique_key? | Best for |
|---|---|---|---|
| append | Inserts all new rows, no deduplication | No | Immutable event streams |
| merge | MERGE statement: updates matched rows, inserts new | Yes | Mutable records with a unique key |
| delete+insert | Deletes matching rows, then inserts | Yes | Warehouses without native MERGE (e.g., older Redshift) |
| insert_overwrite | Overwrites entire partitions | No (uses partitions) | BigQuery/Spark partition-based loads |
| microbatch | Processes data in time-based batches | Yes | Very large event tables with a defined event time |
Default strategy depends on the adapter: most adapters default to append when no unique_key is set; when unique_key is provided, most adapters default to merge.
unique_key Behavior
- When
unique_keyis a single column: pass a string —unique_key='order_id' - When
unique_keyis a composite key: pass a list —unique_key=['order_id', 'line_item_id'] - Without
unique_key, dbt cannot deduplicate and will useappendstrategy.
--full-refresh Flag
Running dbt run --full-refresh forces is_incremental() to return false for all incremental models, causing them to drop and recreate from scratch. Use when source data has changed retroactively.
Common Traps
- Forgetting
is_incremental()returnsfalseon the first run — the full dataset is loaded initially. - Using
appendstrategy withunique_key—unique_keyis ignored byappend. - Expecting
delete+insertto work without aunique_key— it will error. - Confusing
{{ this }}(current model's table) with{{ source() }}(source table). - Assuming
--full-refreshonly affects the targeted model — it affects all incremental models in the run unless--selectnarrows scope.
Decision tree for choosing incremental strategy:
Flowchart: choosing the correct incremental strategy based on data characteristics
Sign in free to read the full guide
10 more sections (about 2,133 words) continue below. The first domain's study guide is free for every dbt Analytics Engineering Certification learner with a free account.