11. Configuration and precedence
dbt · 11. Configuration and precedencedbt 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.
The four places
Section titled “The four places” 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.ymlmodels: jaffle_shop: +materialized: view # everything marts: +materialized: table # everything in marts/ +tags: ['nightly']# 2 — models/marts/_marts.ymlmodels: - 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.
Folder nesting is directory nesting
Section titled “Folder nesting is directory nesting”models: jaffle_shop: # the project name, always first staging: # models/staging/ +materialized: view stripe: # models/staging/stripe/ +schema: stripe_rawThe 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:
dbt ls --select customers --output jsonMerge behaviour, which is not uniform
Section titled “Merge behaviour, which is not uniform”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 narrowThe list case catches everyone:
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.
Where a model actually lands
Section titled “Where a model actually lands”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()Custom schemas concatenate
Section titled “Custom schemas concatenate”The most-asked question in dbt.
models: jaffle_shop: marts: +schema: martsWith a profile schema of dbt_sarah, you expect marts. You get:
dbt_sarah_martsdbt’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.
Aliases
Section titled “Aliases”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.
Databases
Section titled “Databases”generate_database_name works identically, for warehouses where crossing databases makes sense — a raw database and an analytics database on Snowflake, say.
Variables
Section titled “Variables”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) lowestProject-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.
Environment variables
Section titled “Environment variables”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.
Configuring by target
Section titled “Configuring by target”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.
Hooks, in order
Section titled “Hooks, in order”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 everythingHooks 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.
Debugging config
Section titled “Debugging config”When a model is not behaving as configured:
# what config actually resolved?dbt ls --select customers --output json
# what will it become?dbt compile --select customerscat 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/martsdbt 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.
What to take from this chapter
Section titled “What to take from this chapter”- Four places to configure, narrowest wins: project file, YAML, in-model
config(), CLI flags. dbt_project.ymlkeys 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
tagswipes out folder-level tags and quietly drops the model fromtag:selections. - Custom schemas concatenate —
dbt_sarah+marts=dbt_sarah_marts. Right for development, wrong for production; overridegenerate_schema_namegating ontarget.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_varreturns 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.nameto limit dev data volume is the highest-value pattern here. Vary volume, never logic. - Hooks run broadest first and accumulate;
on-run-endruns even after failures. dbt ls --output jsonshows 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.