Skip to content

11. Configuration and precedence

dbt lets you configure a model in four places. Most of the time this is convenient. Occasionally it is baffling, and this chapter is about the occasions.

BROADEST NARROWEST
────────────────────────────────────────────────────▶
1 dbt_project.yml by folder
2 a .yml properties file per resource
3 {{ config() }} in the model file
4 CLI flags --full-refresh, --target
# 1 — dbt_project.yml
models:
jaffle_shop:
+materialized: view # everything
marts:
+materialized: table # everything in marts/
+tags: ['nightly']
# 2 — models/marts/_marts.yml
models:
- name: customers
config:
materialized: incremental
unique_key: customer_id
-- 3 — models/marts/customers.sql
{{ config(materialized='table') }}

The winner is the model file. customers materialises as a table, despite YAML saying incremental and the project saying table-by-folder.

models:
jaffle_shop: # the project name, always first
staging: # models/staging/
+materialized: view
stripe: # models/staging/stripe/
+schema: stripe_raw

The first key must be the project name, and the rest mirror your directory tree exactly. A typo means dbt silently configures a folder that does not exist — no error, no effect, and a confusing afternoon.

Check what actually applied:

Terminal window
dbt ls --select customers --output json

The genuinely surprising part. Different config types combine differently.

SCALARS replaced
materialized, unique_key, schema
→ narrowest wins, outright
LISTS CLOBBERED, not merged
tags
→ narrowest wins; broader entries LOST
DICTS merged key by key
meta
→ keys combine; conflicts go to narrowest
HOOKS APPENDED
pre-hook, post-hook
→ all of them run, broad to narrow

The list case catches everyone:

dbt_project.yml
models:
jaffle_shop:
marts:
+tags: ['nightly']
-- customers.sql
{{ config(tags=['finance']) }}

The model’s tags are ['finance']. The nightly tag is gone. Not merged — replaced. So dbt build --select tag:nightly silently skips this model, and the nightly job stops building it. Nothing errors.

The fix is to restate:

{{ config(tags=['nightly', 'finance']) }}

Hooks are the opposite — they accumulate, so a project-level post-hook and a model-level one both run, project first. That asymmetry is worth remembering: tags replace, hooks accumulate.

Three parts to a relation name, and each is computed rather than declared.

database . schema . identifier
│ │ │
│ │ └── the alias, defaulting
│ │ to the filename
│ └── generate_schema_name()
└── generate_database_name()

The most-asked question in dbt.

models:
jaffle_shop:
marts:
+schema: marts

With a profile schema of dbt_sarah, you expect marts. You get:

dbt_sarah_marts

dbt’s default is <target_schema>_<custom_schema>. The logic is in a built-in macro:

{% macro generate_schema_name(custom_schema_name, node) -%}
{%- set default_schema = target.schema -%}
{%- if custom_schema_name is none -%}
{{ default_schema }}
{%- else -%}
{{ default_schema }}_{{ custom_schema_name | trim }}
{%- endif -%}
{%- endmacro %}

This is deliberate and correct for development. Two developers with +schema: marts would otherwise write to the same relation and overwrite each other. Concatenation gives dbt_sarah_marts and dbt_james_marts.

But in production it gives analytics_marts when you wanted marts. The standard override:

-- macros/generate_schema_name.sql
{% macro generate_schema_name(custom_schema_name, node) -%}
{%- set default_schema = target.schema -%}
{%- if target.name == 'prod' and custom_schema_name is not none -%}
{{ custom_schema_name | trim }}
{%- elif custom_schema_name is none -%}
{{ default_schema }}
{%- else -%}
{{ default_schema }}_{{ custom_schema_name | trim }}
{%- endif -%}
{%- endmacro %}

Production gets clean names; development stays isolated. Defining a macro with this name in your project overrides dbt’s own — that is how all of dbt’s built-in macros work.

Two warnings. Check target.name, not target.schema — the schema differs per developer and the check will not do what you want. And be careful in CI: a CI target that takes the production branch of this macro will build straight into production. Gate on the exact target name and nothing looser.

The relation name defaults to the filename. alias changes it:

{{ config(alias='customer_summary') }}

customers.sql becomes customer_summary. Two models with the same alias in the same schema silently collide — one overwrites the other, and dbt reports success both times.

generate_alias_name can be overridden the same way, which is how model versions get _v1 suffixes.

generate_database_name works identically, for warehouses where crossing databases makes sense — a raw database and an analytics database on Snowflake, say.

vars are separate from config, with their own precedence.

vars:
start_date: '2020-01-01'
jaffle_shop: # scoped to this project
payment_methods: ['card', 'bank_transfer']
1. --vars on the CLI highest
2. project-scoped vars
3. global vars in dbt_project.yml
4. the default in var('x', default) lowest

Project-scoped vars matter with packages. A package reading var('surrogate_key_treat_nulls_as_empty_strings') should get your setting for it, not a global collision with a name of your own. Scope by project name when a var is meant for one project.

password: "{{ env_var('DBT_PASSWORD') }}"
threads: "{{ env_var('DBT_THREADS', '4') | int }}"

Two things to know.

Without a default, a missing variable fails the run at parse time. That is usually what you want for a credential.

env_var returns a string, always. threads: "{{ env_var('DBT_THREADS') }}" sets threads to "8", not 8, and something downstream will object. Pipe through | int or | float.

Variables prefixed DBT_ENV_SECRET_ are scrubbed from logs. Use that prefix for anything sensitive.

models:
jaffle_shop:
+materialized: "{{ 'table' if target.name == 'prod' else 'view' }}"

Production builds tables; development builds views, which are instant. A common variant limits data volume:

select * from {{ ref('stg_events') }}
{% if target.name != 'prod' %}
where event_date >= dateadd(day, -7, current_date)
{% endif %}

This is the single highest-value configuration pattern in dbt. Iterating on a billion-row model against a week of data turns a twenty-minute feedback loop into twenty seconds, and it costs one conditional.

The risk is that dev and prod diverge enough that something passes locally and fails in production. Keep the difference to volume, never to logic.

on-run-start:
- "create schema if not exists {{ target.schema }}"
models:
jaffle_shop:
+pre-hook: "{{ logging.log_start(this) }}"
+post-hook:
- "grant select on {{ this }} to role reporter"
- "{{ logging.log_end(this) }}"
on-run-end:
- "{{ audit.write_run_summary() }}"
on-run-start once, before everything
├─ pre-hook before each model
│ MODEL
└─ post-hook after each model
on-run-end once, after everything

Hooks accumulate across levels, running broadest first. on-run-end runs even when models failed, which is what makes it right for audit logging and wrong for anything that assumes success.

When a model is not behaving as configured:

Terminal window
# what config actually resolved?
dbt ls --select customers --output json
# what will it become?
dbt compile --select customers
cat target/compiled/jaffle_shop/models/marts/customers.sql
# where did it land?
dbt run --select customers # the log prints the relation
# did the folder path match anything?
dbt ls --select path:models/marts

dbt ls --output json is the fastest answer — it prints the fully-resolved config for every selected node, which immediately shows whether your dbt_project.yml block applied at all.

If nothing explains it, delete target/partial_parse.msgpack and retry. Config changes should invalidate the cache, and occasionally do not.

  • Four places to configure, narrowest wins: project file, YAML, in-model config(), CLI flags.
  • dbt_project.yml keys must start with the project name and mirror the directory tree exactly. A typo configures nothing, silently.
  • Merge behaviour is not uniform: scalars replace, lists clobber, dicts merge, hooks append. A model-level tags wipes out folder-level tags and quietly drops the model from tag: selections.
  • Custom schemas concatenate — dbt_sarah + marts = dbt_sarah_marts. Right for development, wrong for production; override generate_schema_name gating on target.name, and make sure CI does not take the production branch.
  • Aliases can collide silently. Two models, one relation, both report success.
  • Vars resolve CLI → project-scoped → global → default. Scope by project name when packages are involved.
  • env_var returns a string — cast it. No default means a missing variable fails the run, which is right for secrets. DBT_ENV_SECRET_ prefixed variables are scrubbed from logs.
  • Conditioning on target.name to limit dev data volume is the highest-value pattern here. Vary volume, never logic.
  • Hooks run broadest first and accumulate; on-run-end runs even after failures.
  • dbt ls --output json shows fully-resolved config and is the fastest way to find out what actually applied.

Next: state, artifacts and slim CI — using manifest.json to build only what changed.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source