dbt courseLesson 9 of 9
dbt course · Lesson 9 of 9
Top 50 dbt Interview Questions and Answers
Top 50 dbt interview questions with answers: models and materializations, incremental models, tests, snapshots, Jinja and macros, and CI/CD.
On this page
These 50 dbt interview questions cover models and materializations, incremental strategies, tests, snapshots, sources, Jinja and macros, project design, CI/CD and governance. They are grouped from beginner (freshers) to advanced (experienced). Each one starts with the answer you would give in the first 20 seconds; open the full answer for the detailed explanation, an example, how it shows up in real work, common mistakes and the follow-up questions interviewers ask.
Question list
All 50 questions
- What is dbt, and where does it fit in ELT?
- dbt project structure: dbt_project.yml and profiles.yml
- ref() vs source()
- Materializations: view vs table vs incremental vs ephemeral vs materialized_view
--full-refresh: when and why- Seeds
- The four built-in generic data tests
- Singular data tests
- Jinja basics in dbt
- Project layering: staging, intermediate and marts
- Naming and style conventions
- dbt run vs build vs test vs compile
- Generating documentation and the lineage graph
- The analyses folder and dbt show
- Incremental models and is_incremental()
- unique_key and on_schema_change
- Snapshots: timestamp vs check strategy (See also Q12)
- Writing custom generic tests
- Unit tests
- Test severity, warn_if/error_if and store_failures
- Source freshness
- Writing macros
- var() and env_var()
- Packages: dbt_utils, dbt_expectations and packages.yml
- Hooks: pre-hook, post-hook, on-run-start/end
- Exposures
- Node selection syntax (+, @, tags, paths, state)
- Targets, environments and deployment
- dbt Core vs dbt platform (Cloud) vs Fusion engine / dbt v2
- Debugging with compiled SQL, logs and the target folder
- Custom schemas and generate_schema_name
- grants and persist_docs configs
- Orchestrating dbt (Airflow/Cosmos, dbt platform jobs, warehouse-native dbt projects)
- dbt artifacts: manifest.json and run_results.json
- Designing a test strategy across layers
- Refactoring a legacy SQL script into dbt models
- Common dbt anti-patterns
- Incremental strategies by adapter (append, merge, delete+insert, insert_overwrite, microbatch)
- The microbatch incremental strategy
- YAML-defined snapshots and hard-delete handling
- Model contracts
- Model versions, groups and access levels
- dbt Semantic Layer and MetricFlow (See also Q99)
- Slim CI with state:modified and
--defer - Speeding up slow dbt runs
- Python models
- Late-arriving data in incremental models (lookback windows)
- dbt Mesh: cross-project refs
- Cross-database macros and adapter.dispatch
- dbt clone and zero-copy CI environments
Beginner dbt interview questions (freshers)
1. What is dbt, and where does it fit in ELT?
Short answer
dbt (data build tool) is the “T” in ELT. You write transformations as SELECT statements (and optionally Python models), and dbt compiles them, works out dependencies from ref(), and materialises them in the warehouse as views, tables or incremental tables. It adds testing, documentation, lineage, environments and version control on top of SQL. It doesn’t extract or load data; it runs inside your warehouse or lakehouse.
Full answer: explanation, example and follow-ups
Detailed explanation
Where it sits
Sources --(Fivetran / Airbyte / Openflow / Lakeflow Connect / custom EL)--> raw tables in warehouse
raw --(dbt: staging -> intermediate -> marts)--> analytics-ready tables --> BI / ML / reverse ETL
What dbt gives you
- Modularity: each model is one SELECT in a
.sqlfile.ref('model')creates dependencies, so dbt builds a DAG and runs models in the right order, in parallel where possible. - Materialisations: declare how a model is persisted (view, table, incremental, ephemeral, materialized view) without writing DDL (see Materializations: view vs table vs incremental vs ephemeral vs materialized_view).
- Testing: data tests (unique, not_null, relationships, accepted_values, custom ones) and unit tests (see The four built-in generic data tests–Unit tests).
- Documentation and lineage: YAML descriptions plus auto-generated docs and DAG (see Generating documentation and the lineage graph).
- Jinja and macros: reusable, parameterised SQL (see Jinja basics in dbt, Writing macros).
- Environments: the same code deploys to dev, CI and prod through targets (see Targets, environments and deployment).
- Software engineering practices: Git, code review, CI/CD and slim CI (see Slim CI with state:modified and
--defer).
Flavours
- dbt Core: open-source CLI.
- dbt platform (formerly dbt Cloud): a managed service with IDE, scheduler, CI, Semantic Layer and Catalog.
- dbt Fusion engine: a newer Rust-based engine with fast parsing and SQL comprehension.
- Warehouse-native options: for example dbt projects deployed as objects in Snowflake, or dbt tasks in Databricks Lakeflow Jobs (see dbt Core vs dbt platform (Cloud) vs Fusion engine / dbt v2, Orchestrating dbt (Airflow/Cosmos, dbt platform jobs, warehouse-native dbt projects)).
What it isn’t: an ingestion tool, a scheduler on its own (Core needs orchestration), or a compute engine. All compute happens in the warehouse.
Example
-- models/marts/fct_orders.sql (Dialect: Snowflake SQL via dbt)
select
o.order_id,
o.customer_id,
o.order_date,
p.amount_usd
from {{ ref('stg_shop__orders') }} as o
left join {{ ref('int_order_payments') }} as p
on o.order_id = p.order_id
dbt build --select fct_orders+ compiles this to fully qualified table names, builds it after its upstream models, then runs its tests and its downstream models.
Real-world use case
An analytics team had 300 stored procedures with hidden dependencies and no tests. After moving to dbt, the lineage graph exposed the dependency chain, tests caught duplicate orders before dashboards did, and new analysts could contribute with pull requests.
Common mistakes and red flags
- Calling dbt an ETL tool that moves data.
- Hard-coding table names instead of using
ref(). - Thinking dbt has its own compute.
Follow-up questions
- What does dbt compile to? — Plain SQL (DDL/DML wrappers around your SELECT) executed by the adapter on the warehouse.
- What is an adapter? — A plugin (dbt-snowflake, dbt-databricks, dbt-bigquery…) translating dbt operations to a platform’s SQL.
2. dbt project structure: dbt_project.yml and profiles.yml
Short answer
dbt_project.yml defines the project: its name, profile to use, folder paths, and default configs for folders of models, seeds, snapshots and tests. profiles.yml (usually in ~/.dbt/, or supplied by the platform) holds connection details per target: account, user, auth method, role, warehouse, database and schema. Keep secrets out of the repo by using env_var() in profiles.
Full answer: explanation, example and follow-ups
Detailed explanation
Typical layout
my_dbt_project/
├─ dbt_project.yml
├─ packages.yml # external packages (see Q322)
├─ models/
│ ├─ staging/ # stg_ models + _sources.yml
│ ├─ intermediate/
│ └─ marts/
├─ seeds/ # small CSVs (see Q310)
├─ snapshots/
├─ tests/ # singular tests (see Q314)
├─ macros/
├─ analyses/
└─ target/ # compiled output, artifacts (git-ignored)
dbt_project.yml essentials
name,version,profile(which profile in profiles.yml),model-paths,macro-pathsand others.models:block with folder-level configs, using+prefixes for configs:+materialized,+schema,+tags,+persist_docsand so on.vars:for project variables (see var() and env_var()).on-run-start/on-run-endhooks (see Hooks: pre-hook, post-hook, on-run-start/end).- Config precedence, from most specific: in-model
config()> properties YAML >dbt_project.ymlfolder config.
profiles.yml essentials
- A profile name, then
target(the default) andoutputswith one entry per target (dev, ci, prod). - Adapter-specific keys. For Snowflake:
account,user,authenticatororprivate_key_path,role,warehouse,database,schema,threads. - Secrets come from
{{ env_var('DBT_SNOWFLAKE_PRIVATE_KEY_PATH') }}. Never commit them. - The dbt platform manages connections and credentials in its UI, so there is no local profiles.yml. Newer dbt versions also support a project-level
profiles.ymlor--profiles-dir.
Why the split matters: dbt_project.yml is shared and committed, so it describes what to build and how. profiles.yml is personal or per-environment, so it describes where and as whom. Keeping them separate lets the same project run unchanged on a laptop, in CI and in production, with only the target and credentials differing. Threads (parallel model builds) also live in the profile, because the right value depends on the warehouse each target uses.
Example
# dbt_project.yml
name: acme_analytics
version: "1.0.0"
profile: acme_snowflake
models:
acme_analytics:
staging:
+materialized: view
+schema: staging
intermediate:
+materialized: ephemeral
marts:
+materialized: table
+schema: marts
finance:
+tags: ["finance", "daily"]
# ~/.dbt/profiles.yml
acme_snowflake:
target: dev
outputs:
dev:
type: snowflake
account: "{{ env_var('DBT_SF_ACCOUNT') }}"
user: "{{ env_var('DBT_SF_USER') }}"
private_key_path: "{{ env_var('DBT_SF_KEY_PATH') }}"
role: TRANSFORMER_DEV
warehouse: TRANSFORM_XS
database: ANALYTICS_DEV
schema: "dbt_{{ env_var('DBT_USER_SUFFIX') }}"
threads: 8
Real-world use case
A team set +materialized: view for staging and table for marts once in dbt_project.yml. That removed hundreds of per-model config lines and made materialisation policy reviewable in one file.
Common mistakes and red flags
- Committing passwords in profiles.yml.
- Repeating configs in every model.
- Not knowing the config precedence order.
Follow-up questions
- What does
dbt debugdo? — Validates profile, connection and project setup. - Why are
+prefixes used? — To distinguish configs from folder names in the YAML tree.
3. ref() vs source()
Short answer
{{ source('shop', 'orders') }} points to raw tables loaded outside dbt, which are declared in a sources YAML file. {{ ref('stg_shop__orders') }} points to another dbt model, seed or snapshot. Both resolve to fully qualified relation names and build the DAG. ref() is environment-aware: it resolves to the dev, CI or prod schema of the target being built, and it controls build order.
Full answer: explanation, example and follow-ups
Detailed explanation
| Aspect | source() |
ref() |
|---|---|---|
| Points to | Raw/external tables (not built by dbt) | dbt nodes: models, seeds, snapshots |
| Declared in | sources: YAML (database, schema, tables, freshness, tests) |
Implicit (the model file itself) |
| Environment resolution | Same raw location for all targets (unless you template it) | Resolves to current target’s schema (or deferred prod; see Slim CI with state:modified and --defer) |
| DAG | Root nodes (green in lineage) | Edges between models |
| Extra features | Source freshness (see Source freshness), source tests, loaded_at_field |
Versioned refs ref('m', v=2), cross-project refs (see Model versions, groups and access levels, dbt Mesh: cross-project refs) |
Why never hard-code table names
- dbt can’t see the dependency, so it may build models in the wrong order.
- Environments break. Dev models would read production tables, or the reverse.
- Lineage, docs and selection (
+model) miss the edge. - Renaming or moving schemas means editing SQL everywhere.
Best practices
- Reference each source in exactly one staging model (
stg_<source>__<entity>). Everything downstream refs staging models, never sources directly (see Project layering: staging, intermediate and marts). - Put source definitions in
_<source>__sources.ymlnext to the staging models. - Add tests and freshness on sources to catch upstream issues early.
- Use
identifier:when the raw table name is ugly, and keep a clean logical name.
Example
# models/staging/shop/_shop__sources.yml
version: 2
sources:
- name: shop
database: RAW
schema: SHOP
loaded_at_field: _loaded_at
freshness:
warn_after: {count: 6, period: hour}
error_after: {count: 24, period: hour}
tables:
- name: orders
identifier: ORDERS_V2
- name: customers
-- models/staging/shop/stg_shop__orders.sql
select
id::number as order_id,
customer_id::number as customer_id,
status as order_status,
created_at::timestamp as ordered_at
from {{ source('shop', 'orders') }}
Real-world use case
A model selected from ANALYTICS.MARTS.DIM_CUSTOMER directly. In CI it read production data instead of the PR’s version and passed tests incorrectly. Switching to ref('dim_customer') made CI test the modified model.
Common mistakes and red flags
- Hard-coded relation names.
- Sources referenced from many models.
- Missing source declarations, so there is no freshness or lineage.
Follow-up questions
- Can sources point to different databases per environment? — Yes, by templating
database:withtarget.nameorvar(). - What does
refcompile to? —database.schema.identifierfor the relation in the active target.
4. Materializations: view vs table vs incremental vs ephemeral vs materialized_view
Short answer
A view stores only the SQL, so every query recomputes it: cheap to build, slower to read. A table is rebuilt fully on each run, which is fast to read and costly to rebuild. Incremental inserts or merges only new or changed rows into an existing table. Ephemeral models are inlined as CTEs into downstream models and never created. Materialized views (or dynamic tables on Snowflake) are maintained by the warehouse itself.
Full answer: explanation, example and follow-ups
Detailed explanation
| Materialization | Build cost | Query speed | Freshness | Use for |
|---|---|---|---|---|
view |
Near zero | Recomputed each query | Always current | Staging, light transforms, low-traffic models |
table |
Full rebuild each run | Fast | As of last run | Marts and dims of moderate size, heavy logic queried often |
incremental |
Only new/changed data | Fast | As of last run | Large event/fact tables (see Incremental models and is_incremental(), Incremental strategies by adapter (append, merge, delete+insert, insert_overwrite, microbatch)) |
ephemeral |
None (inlined CTE) | Depends on parent | N/A | Small reusable intermediate logic, not queried directly |
materialized_view |
Warehouse-managed refresh | Fast | Near-real-time (platform-dependent) | Simple aggregations on platforms with good MV support |
dynamic_table (dbt-snowflake) |
Snowflake-managed incremental/full refresh by target lag | Fast | Target lag | Declarative pipelines on Snowflake (see Snowpipe vs Snowpipe Streaming vs COPY INTO) |
Snapshots (SCD2) and seeds are separate node types (see Seeds, Snapshots: timestamp vs check strategy (See also Q12)).
Choosing
- Start with views for staging.
- Use tables for marts until build time or cost becomes a problem.
- Move to incremental when full rebuilds are too slow or expensive, typically large append-heavy facts.
- Use ephemeral sparingly. They can’t be queried or tested on their own, and deep chains create huge compiled SQL that is hard to debug.
- Use materialized views or dynamic tables when platform-managed freshness fits, and check the platform’s limitations (supported SQL, refresh cost).
Configuring: in dbt_project.yml by folder, in properties YAML, or in-model with {{ config(materialized='incremental') }}.
Example
-- models/marts/fct_page_views.sql
{{ config(
materialized = 'incremental',
unique_key = 'page_view_id',
incremental_strategy = 'merge',
cluster_by = ['view_date']
) }}
select page_view_id, user_id, page_id, viewed_at, viewed_at::date as view_date
from {{ ref('stg_web__page_views') }}
{% if is_incremental() %}
where viewed_at > (select dateadd('hour', -3, max(viewed_at)) from {{ this }})
{% endif %}
-- models/marts/agg_daily_signups.sql (dbt-snowflake dynamic table)
{{ config(materialized='dynamic_table', target_lag='30 minutes', snowflake_warehouse='TRANSFORM_S') }}
select signup_date, count(*) as signups
from {{ ref('stg_app__users') }}
group by signup_date
Real-world use case
A 4-billion-row page-views table rebuilt as a table took 50 minutes each hour. Converting it to incremental with a 3-hour lookback cut the run to 4 minutes, and the small dimension models stayed as tables.
Common mistakes and red flags
- Making everything incremental from day one.
- Long chains of ephemeral models.
- Heavy views queried by dashboards all day.
Follow-up questions
- Can you test an ephemeral model? — Not directly as a relation; test the downstream model instead.
- What’s a custom materialization? — A macro implementing your own build logic via
{% materialization %}blocks.
5. --full-refresh: when and why
Short answer
dbt run --full-refresh (or build) drops and rebuilds incremental models (and seeds) from scratch, ignoring is_incremental(). Use it after logic changes that must apply to history, incompatible schema changes, corrupted or duplicated data, or changes to the incremental key. For very large tables it’s expensive and slow, so you can protect models with full_refresh: false and backfill in ranges instead.
Full answer: explanation, example and follow-ups
Detailed explanation
When you need it
- Business logic changed for existing rows (new calculation, bug fix), and history must reflect it.
- Incompatible schema changes: type changes, renamed columns, or a changed
unique_key. - Data corruption: duplicates from a bad strategy, or missing data from a broken filter.
- Seeds whose column types changed.
- Snapshots aren’t affected by
--full-refresh. Don’t rebuild them; that would lose history.
Risks and costs
- Recomputes everything, which can take hours and cost a lot of credits on billion-row tables.
- May not be possible if source data has expired (retention-limited raw data, Kafka topics), because rebuilding loses history.
- Downstream consumers may see empty or partial tables mid-build. dbt typically builds into a temporary relation and swaps, depending on adapter and materialization, but long builds still matter.
Safeguards
{{ config(full_refresh = false) }}on critical large models makes dbt ignore--full-refreshfor them. Rebuilds then need a deliberate config change.- Run full refreshes on a larger warehouse or off-peak.
- For time-series models, prefer range backfills: microbatch
--event-time-start/end(see The microbatch incremental strategy), or a variable-driven date range. - Use zero-copy clones to test a full refresh first (see dbt clone and zero-copy CI environments).
Example
-- models/marts/fct_events.sql
{{ config(
materialized = 'incremental',
unique_key = 'event_id',
full_refresh = false -- protect: --full-refresh is ignored for this model
) }}
select * from {{ ref('stg_app__events') }}
{% if is_incremental() %}
where event_ts > (select max(event_ts) from {{ this }})
{% endif %}
# Rebuild one fixed model and everything downstream
dbt build --select fct_orders+ --full-refresh --target prod
Real-world use case
An engineer ran dbt run --full-refresh on the whole project in production. A 3-year events model was rebuilt from raw data that only had 13 months of retention, and 23 months of history were lost. Restoring it from Time Travel saved the day, and full_refresh: false is now mandatory on such models.
Common mistakes and red flags
- Project-wide full refresh in production.
- Not knowing source retention limits.
- Using full refresh as a routine fix for bad incremental logic.
Follow-up questions
- Does –full-refresh affect views? — Views are recreated every run anyway.
- How do you full-refresh just one model? —
--select model_name --full-refresh.
6. Seeds
Short answer
Seeds are small CSV files in the seeds/ folder that dbt seed loads as tables, version-controlled with the project. Downstream models ref() them like any model. Use them for small, slowly changing reference data that analysts own, such as country mappings, holiday calendars, account mappings or test fixtures. Don’t use them for large or frequently changing data.
Full answer: explanation, example and follow-ups
Detailed explanation
Good uses
- Mapping tables: status code to label, country code to region, GL account to reporting line.
- Static calendars and lists (holidays, fiscal periods), if not generated with SQL.
- Small lookup data needed for tests or demos.
Avoid them for
- Large data. Seeds load through INSERT statements batched by the adapter, which is slow beyond a few thousand rows, and they bloat the repo.
- Data owned or updated by other systems. Ingest those as sources.
- Sensitive data (PII, secrets), which would end up in Git history.
Configuring
column_typesto fix types (otherwise they are inferred), for examplezip_code: varcharto keep leading zeros.quote_columns,delimiter,+schema,+enabled.- Tests and descriptions in YAML, like models.
- Changing column types or removing columns may require
dbt seed --full-refresh.
Commands
dbt seedloads all seeds.dbt seed --select country_regionsloads one.dbt buildincludes seeds in DAG order.
Ownership tip: treat each seed like a small dataset with an owner. Name the owning team in the YAML description or meta, review changes through pull requests, and add tests for keys and allowed values. That turns ad-hoc spreadsheet mappings into governed reference data.
Example
country_code,country_name,region,is_eu
DE,Germany,EMEA,true
FR,France,EMEA,true
IN,India,APAC,false
US,United States,AMER,false
# seeds/_seeds.yml
seeds:
- name: country_regions
description: "Owned by Finance Ops; reviewed quarterly."
config:
column_types:
country_code: varchar(2)
is_eu: boolean
columns:
- name: country_code
data_tests: [unique, not_null]
-- models/marts/dim_customer.sql
select c.customer_id, c.country_code, r.region, r.is_eu
from {{ ref('stg_crm__customers') }} c
left join {{ ref('country_regions') }} r using (country_code)
Real-world use case
Finance kept a spreadsheet mapping 300 GL accounts to P&L lines, emailed monthly. Moving it into a seed with pull-request review gave version history and tests (unique accounts, accepted P&L lines), and the P&L mart now refs it directly.
Common mistakes and red flags
- 100,000-row seeds.
- Seeds with PII.
- Relying on type inference for codes with leading zeros.
Follow-up questions
- Where would larger reference data go? — Ingest as a source (stage + COPY, or EL tool).
- Are seeds rebuilt every run? —
dbt seedtruncates and reloads by default.
7. The four built-in generic data tests
Short answer
dbt ships four generic data tests that you attach to columns in YAML. unique means no duplicate values. not_null means no NULLs. accepted_values means values come from a list. relationships means every value exists in another model’s column, which checks referential integrity. Each compiles to a query returning failing rows, and the test fails if any rows come back.
Full answer: explanation, example and follow-ups
Detailed explanation
| Test | Checks | Typical use |
|---|---|---|
unique |
Column has no duplicate values | Primary/surrogate keys in every model |
not_null |
No NULLs | Keys, required business fields |
accepted_values |
Values ∈ values: list (optional quote: false) |
Status, type, currency codes |
relationships |
Each value exists in to: model’s field: |
FK from fact to dimension |
How tests work
- Each test compiles to a SELECT that returns failing rows. For example,
uniquegroups by the column and keepscount(*) > 1. dbt test(ordbt build) runs them. Zero rows means pass. Thresholds can be configured (see Test severity, warn_if/error_if and store_failures).- Since dbt 1.8, the YAML key is
data_tests:(the oldertests:key still works), which distinguishes them from unit tests (see Unit tests).
Useful configs
where:to test a subset (where: "order_date >= current_date - 30") for big tables.severity: warnfor soft rules.config: {store_failures: true}to keep failing rows in a table for debugging.name:to give tests readable names in logs.
Coverage guidance
- Every model: primary key
uniqueandnot_null. This is the minimum standard many teams enforce in CI. - Foreign keys:
relationships(or adbt_utilsordbt_expectationsequivalent withwherefilters for late-arriving dimensions). - Enumerations:
accepted_values. - Add packages for richer tests:
dbt_utils,dbt_expectations,elementary(see Packages: dbt_utils, dbt_expectations and packages.yml).
Example
# models/marts/_marts__models.yml
models:
- name: fct_orders
columns:
- name: order_id
data_tests: [unique, not_null]
- name: customer_id
data_tests:
- not_null
- relationships:
arguments:
to: ref('dim_customer')
field: customer_id
- name: order_status
data_tests:
- accepted_values:
arguments:
values: ['placed', 'shipped', 'delivered', 'returned', 'cancelled']
Newer dbt versions nest test parameters under arguments:. Older versions put to:, field: and values: directly under the test name. Use the form your dbt version expects.
Real-world use case
A new status, “partially_shipped”, appeared in the source. The accepted_values test failed in the nightly build before the dashboard refreshed, and the team added the value deliberately, with a mapping to reporting categories.
Common mistakes and red flags
- Models without primary-key tests.
relationshipsfailing on legitimate late-arriving dimensions withoutwhereor warn configs.- Not knowing tests return failing rows.
Follow-up questions
- How do you test a composite key? —
dbt_utils.unique_combination_of_columns. - How do you see failing rows? —
store_failures, or run the compiled test SQL fromtarget/.
8. Singular data tests
Short answer
A singular test is a SQL file in tests/ that selects rows violating a rule. If the query returns any rows, the test fails. Use singular tests for one-off business rules that don’t fit a reusable generic test, such as “refund amount can’t exceed the original payment” or “daily totals reconcile with the source”. They can ref() models and use Jinja.
Full answer: explanation, example and follow-ups
Detailed explanation
Characteristics
- Lives in
tests/(or the configuredtest-paths), as one SELECT per file. - Attached to every model it
refs, sodbt test --select fct_paymentsruns it. - Supports
{{ config(severity='warn', store_failures=true, tags=[...]) }}at the top of the file. - Results appear like other tests in logs and artifacts.
Good candidates
- Cross-column rules:
ship_date >= order_date. - Cross-model reconciliation: total revenue in the mart equals the sum in staging for the same day.
- Business invariants: one active subscription per customer.
- Anomaly guards: today’s row count isn’t below 50% of the 7-day average (or use packages or observability tools).
Singular vs generic
| Singular | Generic |
|---|---|
| One SQL file, specific to a case | Parameterised macro reused across columns/models |
| Quick to write | Needs a {% test %} macro (see Writing custom generic tests) |
| Harder to reuse | Applied in YAML anywhere |
If you write the same singular test three times with different tables, convert it to a generic test.
Writing them well
- Return identifying columns and the values that broke the rule, which makes debugging easy.
- Filter to relevant windows on big tables, and use
wherelogic for incremental data. - Name files descriptively:
assert_refunds_not_exceed_payments.sql.
Example
-- tests/assert_refunds_not_exceed_payments.sql (Dialect: Snowflake SQL via dbt)
{{ config(severity = 'error', store_failures = true) }}
with refunds as (
select payment_id, sum(refund_amount) as total_refunded
from {{ ref('fct_refunds') }}
group by payment_id
)
select p.payment_id, p.amount as paid_amount, r.total_refunded
from {{ ref('fct_payments') }} p
join refunds r on r.payment_id = p.payment_id
where r.total_refunded > p.amount
Sample: payment P1 was 100.00 and has refunds totalling 120.00. The test returns that row and fails, and the row is stored in the failures table for review.
Real-world use case
A singular reconciliation test comparing daily revenue in fct_orders against the payments provider’s settlement totals (as a source) caught a currency conversion bug on day one. It had affected 2% of orders.
Common mistakes and red flags
- Tests that return all rows, or no rows, because of inverted logic.
- Unbounded scans on huge tables.
- Copy-pasted singular tests that should be generic.
Follow-up questions
- How do you make a test warn instead of fail? —
config(severity='warn'). - Can singular tests be run alone? —
dbt test --select test_name(file name without .sql).
9. Jinja basics in dbt
Short answer
dbt uses Jinja templating to generate SQL before execution. {{ ... }} outputs expressions (ref(), source(), variables, macro calls). {% ... %} runs statements: if, for, set, and macro definitions. {# ... #} is a comment. Jinja runs at compile time, so it produces SQL text and doesn’t run per row in the warehouse.
Full answer: explanation, example and follow-ups
Detailed explanation
Syntax
| Syntax | Purpose | Example |
|---|---|---|
{{ expr }} |
Print value | {{ ref('dim_customer') }}, {{ var('start_date') }} |
{% stmt %} |
Logic | {% if target.name == 'prod' %}, {% for c in cols %} |
{# note #} |
Comment (not rendered) | {# remove after migration #} |
{%- -%} |
Whitespace control | Trim newlines in generated SQL |
| Filters | Transform values | {{ cols | join(', ') }}, {{ name | upper }} |
Useful dbt context
target.name,target.schema,target.database,target.type(adapter).this(the current relation),model.name,config.get('materialized').is_incremental(),execute(true during execution, false during parsing).run_query()to query the warehouse at compile time, for example to fetch distinct values. Guard it with{% if execute %}.log(),exceptions.raise_compiler_error().
Good uses
- Loops for repetitive SQL, such as pivoting known values or generating columns from a list.
- Environment-specific logic (sampling in dev).
- Macros for reusable expressions (see Writing macros).
Overuse warnings
- Heavy Jinja makes models hard to read and debug. Check the compiled SQL in
target/compiled/(see Debugging with compiled SQL, logs and the target folder). run_queryat compile time adds warehouse queries and slows parsing and compilation.- Prefer SQL features (window functions,
PIVOT) where the warehouse supports them.
Example
-- models/marts/fct_payments_pivoted.sql (Dialect: Snowflake SQL via dbt)
{% set methods = ['card', 'bank_transfer', 'wallet', 'gift_card'] %}
select
order_id,
{% for m in methods -%}
sum(case when payment_method = '{{ m }}' then amount else 0 end) as {{ m }}_amount
{%- if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('stg_payments') }}
{% if target.name == 'dev' %}
where created_at >= dateadd('day', -7, current_date) {# keep dev builds small #}
{% endif %}
group by order_id
This compiles to four sum(case ...) columns: card_amount, bank_transfer_amount, wallet_amount and gift_card_amount. In dev, a 7-day filter is added.
Real-world use case
A finance model had 48 nearly identical hand-written CASE columns for monthly buckets. Replacing them with a Jinja loop over a list cut the file from 400 lines to 40, and adding a new bucket became a one-line change.
Common mistakes and red flags
- Thinking Jinja runs per row.
run_querywithout anexecuteguard.- Unreadable Jinja-heavy models with no compiled-SQL review.
Follow-up questions
- What does
executeguard against? — During parsing, queries aren’t run;run_queryresults would be empty or fail. - How do you see rendered SQL? —
dbt compileand checktarget/compiled/....
10. Project layering: staging, intermediate and marts
Short answer
Staging models are one per source table. They rename, cast, apply light cleaning and dedupe, with no joins or business logic, and are usually views. Intermediate models combine and reshape staging models into reusable building blocks (joins, aggregations, pivots). Marts are business-facing facts and dimensions organised by domain, materialised as tables or incremental. Each layer only refs the layer before it.
Full answer: explanation, example and follow-ups
Detailed explanation
Layer responsibilities
| Layer | Naming | Contents | Materialization | Refs |
|---|---|---|---|---|
| Staging | stg_<source>__<entity> |
Rename, cast, standardise units/timezones, basic dedupe, one model per source table | View (or table if heavy) | source() only |
| Intermediate | int_<entity>_<verb> (e.g. int_orders_pivoted) |
Joins, re-graining, complex logic shared by marts | Ephemeral/view/table | ref() staging/intermediate |
| Marts | fct_<process>, dim_<entity>, or business names |
Kimball-style facts/dims or wide tables, by domain folder (finance, marketing) | Table/incremental | ref() intermediate/staging |
Folder structure
models/
├─ staging/
│ ├─ shop/ _shop__sources.yml, _shop__models.yml, stg_shop__orders.sql, ...
│ └─ stripe/ ...
├─ intermediate/
│ └─ finance/ int_payments_pivoted_to_orders.sql
└─ marts/
├─ finance/ fct_orders.sql, dim_customers.sql
└─ marketing/ fct_campaign_performance.sql
Why layer
- Single source of truth per source table. Cleaning happens once, so fixes propagate everywhere.
- Reusability. Intermediate logic is shared, not duplicated across marts.
- Clear ownership and testing. Staging tests check source assumptions, and mart tests check business rules (see Designing a test strategy across layers).
- Readable DAG. Lineage flows left to right without spaghetti.
Relation to medallion: staging is roughly bronze-to-silver cleaning, intermediate is silver logic, and marts are gold. Many teams map them directly (see What is the medallion (bronze/silver/gold) architecture?).
Anti-patterns to avoid: marts that ref sources directly, staging with joins, and the same entity cleaned in several places (see Common dbt anti-patterns).
Example
-- models/staging/shop/stg_shop__orders.sql
with source as (select * from {{ source('shop', 'orders') }}),
renamed as (
select
id::number as order_id,
customer_id::number as customer_id,
lower(trim(status)) as order_status,
convert_timezone('UTC', created_at)::timestamp_ntz as ordered_at,
total_cents / 100.0 as order_total
from source
)
select * from renamed
qualify row_number() over (partition by order_id order by ordered_at desc) = 1
-- models/marts/finance/fct_orders.sql
select o.order_id, o.customer_id, o.ordered_at, o.order_total, p.amount_paid
from {{ ref('stg_shop__orders') }} o
left join {{ ref('int_payments_pivoted_to_orders') }} p using (order_id)
Real-world use case
In a project of 900 models without layering, the same Salesforce account cleaning appeared in 14 places, with different results. Refactoring into one staging model per source table, plus shared intermediate models, removed the inconsistencies and cut the model count by a third.
Common mistakes and red flags
- Business logic in staging.
- Marts reading sources directly.
- Huge intermediate models that are really marts.
Follow-up questions
- Should staging dedupe? — Light, technical dedupe (exact duplicates from loaders) is common; business dedupe often belongs later.
- Where do you put SCD2 snapshots? — Snapshot raw sources; then stage/model from the snapshot.
11. Naming and style conventions
Short answer
Use consistent prefixes (stg_, int_, fct_, dim_), the double-underscore source separator (stg_stripe__charges), snake_case names, singular or plural nouns used consistently, and standard column suffixes (_id, _at for timestamps, _date, is_/has_ for booleans, _amount or a currency suffix). Write CTE-based models with an import → logic → final select pattern. Enforce it with SQLFluff (or dbt Fusion’s linting) and code review.
Full answer: explanation, example and follow-ups
Detailed explanation
Model naming
| Pattern | Meaning |
|---|---|
stg_<source>__<entity> |
Staging per source table (stg_salesforce__accounts) |
int_<entity>_<verb> |
Intermediate (int_orders_joined_payments) |
fct_<event/process> |
Facts (fct_orders, fct_page_views) |
dim_<entity> |
Dimensions (dim_customers) |
snap_<source>__<entity> |
Snapshots |
_<folder>__models.yml / _<source>__sources.yml |
Property files sorted to top of folder |
Column conventions
- Primary key:
<entity>_id(natural) and<entity>_keyor_sk(surrogate). Be consistent. - Timestamps
_at(created_at, in UTC), dates_date, booleansis_/has_. - Money:
_amountplus a currency column, or a currency suffix (_usd). Never ambiguous units. - No reserved words, no abbreviations nobody understands, and lower_snake_case.
SQL style
- Import CTEs at the top (
with orders as (select * from {{ ref('stg_shop__orders') }})), then logical CTEs, then a finalselect * from final. - Explicit column lists in marts, so contracts and diffs are clear.
- Leading or trailing commas used consistently, lowercase keywords (or uppercase, consistently), and four-space indentation.
- Use
usingor explicitonconsistently, and fully alias joined columns. - Comments explain why, not what.
Tooling
- SQLFluff with the
dbttemplater (orjinja), run in pre-commit and CI. dbt_project_evaluatorfor structural rules (marts referencing sources, missing tests or docs).- dbt Fusion offers built-in SQL comprehension and linting capabilities in its tooling (check the current feature set).
Example
-- models/marts/finance/fct_refunds.sql (Dialect: Snowflake SQL via dbt)
with
refunds as (
select * from {{ ref('stg_stripe__refunds') }}
),
charges as (
select * from {{ ref('stg_stripe__charges') }}
),
final as (
select
refunds.refund_id,
refunds.charge_id,
charges.order_id,
refunds.refund_amount_usd,
refunds.refunded_at,
refunds.refund_amount_usd = charges.charge_amount_usd as is_full_refund
from refunds
left join charges
on refunds.charge_id = charges.charge_id
)
select * from final
# .sqlfluff (excerpt)
# [sqlfluff]
# templater = dbt
# dialect = snowflake
# [sqlfluff:rules:capitalisation.keywords]
# capitalisation_policy = lower
Real-world use case
A team had created, create_ts, creation_date and created_at across models, plus mixed timezones. Adopting _at for UTC timestamps, enforced by SQLFluff rules and review, reduced analyst confusion and wrong-timezone bugs.
Common mistakes and red flags
- Inconsistent prefixes.
- Ambiguous money units.
- Style debates resolved in every PR instead of by tooling.
Follow-up questions
- Why the double underscore? — Separates source name from entity when either contains underscores.
- Should marts use
select *? — Finalselect * from finalis fine; explicit column lists insidefinalkeep outputs deliberate.
12. dbt run vs build vs test vs compile
Short answer
dbt run builds models only. dbt test runs data tests and unit tests on built objects. dbt build runs models, tests, seeds and snapshots together in DAG order, testing each node right after it builds and skipping downstream nodes when a test fails. dbt compile renders Jinja to SQL without executing the models (it may still run introspective queries). build is the usual choice for production and CI.
Full answer: explanation, example and follow-ups
Detailed explanation
| Command | Does | Use when |
|---|---|---|
dbt run |
Materialize models | Quick iteration on models; legacy pipelines |
dbt test |
Run tests (data + unit) | After a run, or to test without rebuilding |
dbt build |
Seeds → snapshots → models → tests in DAG order; failed test skips children | Production and CI (safest) |
dbt compile |
Render SQL into target/compiled |
Debugging Jinja, reviewing generated SQL |
dbt seed |
Load seeds | Seed changes |
dbt snapshot |
Run snapshots | Scheduled SCD2 capture |
dbt show |
Run a model/query preview, return rows | Quick inspection (see The analyses folder and dbt show) |
dbt docs generate |
Build catalog and docs artifacts | Documentation (see Generating documentation and the lineage graph) |
dbt source freshness |
Check source freshness | Before builds (see Source freshness) |
dbt ls |
List selected resources | Verify selectors |
dbt retry |
Re-run failed/skipped nodes from last run | After fixing a failure |
dbt clone |
Clone relations from another state | CI/dev setup (see dbt clone and zero-copy CI environments) |
dbt parse / dbt debug / dbt deps / dbt clean |
Parse project / check connection / install packages / remove target dirs | Setup and maintenance |
Why build is preferred
- Tests run immediately after each model, so a failing primary-key test on
stg_ordersstopsfct_ordersfrom building on bad data. That is a circuit breaker. - One command, one set of artifacts, and correct ordering of seeds and snapshots.
- Selection works the same way (
dbt build --select state:modified+).
Practical notes
--fail-fast(-x) stops at the first failure.--threadssets parallelism.--exclude-resource-type unit_testin production if unit tests ran in CI.dbt runfollowed bydbt testlets bad data reach downstream tables before tests run.
Example
# Local iteration
dbt run --select stg_shop__orders
dbt compile --select fct_orders # inspect target/compiled/.../fct_orders.sql
# CI and production
dbt build --select state:modified+ --defer --state ./prod-artifacts --target ci
dbt build --target prod --exclude-resource-type unit_test
# Fix and continue after a failure
dbt retry
Real-world use case
A nightly pipeline ran dbt run then dbt test. A duplicate-key bug in staging propagated into 40 downstream tables before the tests failed. Switching to dbt build meant the failing test skipped the dependants, so production kept yesterday’s correct data.
Common mistakes and red flags
- Using
runandtestseparately in production. - Thinking
compilenever queries the warehouse. - Not knowing about
dbt retry.
Follow-up questions
- What happens to downstream nodes when a model fails in build? — They’re skipped.
- Does
dbt buildrun source freshness? — No; rundbt source freshnessseparately.
13. Generating documentation and the lineage graph
Short answer
Describe models, columns, sources and macros in YAML (and in {% docs %} blocks for long text). dbt docs generate compiles the project and queries the warehouse for column types and statistics, writing manifest.json and catalog.json. dbt docs serve hosts a static site with documentation and an interactive DAG. dbt platform provides Catalog, formerly dbt Explorer, as the managed equivalent.
Full answer: explanation, example and follow-ups
Detailed explanation
What goes into docs
descriptionfor models, columns, sources, seeds, snapshots, macros and exposures.{% docs block_name %} ... {% enddocs %}in.mdfiles, reused with{{ doc('block_name') }}. Good for shared definitions such as “net revenue”.meta:fields (owner, tier, PII flags) show in docs and can drive automation.- Tests, contracts, materialisation, tags and lineage appear automatically.
Generation
dbt docs generateproducestarget/manifest.json(project graph and metadata),target/catalog.json(warehouse metadata: columns, types, stats), andindex.html.--staticproduces a single self-contained HTML file for easy hosting.--no-compileand--selectspeed it up on big projects.- Host the static files on internal object storage or a web server behind SSO, or use dbt platform Catalog.
Persisting docs to the warehouse: persist_docs writes descriptions into table and column comments, so catalogs (Snowflake Horizon, Unity Catalog, BI tools) show them (see grants and persist_docs configs).
Lineage graph
- Built from
refandsourceedges plus exposures. Sources are green, models blue, exposures orange in dbt docs. - Interactive selection uses the same syntax as the CLI (
+fct_orders+). - Column-level lineage is available in dbt platform Catalog and Fusion-powered tooling, but not in the classic static docs.
Keeping docs alive
- Require descriptions for marts and public models in CI (
dbt_project_evaluator, or a custom check over the manifest). - Use codegen to scaffold YAML.
- Document why and business meaning, not just column names.
Example
<!-- models/docs/finance_docs.md -->
{% docs net_revenue %}
Order total minus discounts, refunds and taxes, in USD at the daily FX rate on `ordered_at`.
Excludes cancelled orders. Owned by Finance Data ([email protected]).
{% enddocs %}
models:
- name: fct_orders
description: "One row per order. Grain: order_id."
meta:
owner: finance-data
tier: 1
columns:
- name: net_revenue_usd
description: "{{ doc('net_revenue') }}"
dbt docs generate --target prod
dbt docs serve --port 8081
Real-world use case
New analysts spent weeks asking which revenue column to use. Reusable doc blocks for 15 core metrics, plus persisted docs, made the definitions visible in dbt docs and in the BI tool’s field descriptions, and onboarding questions dropped noticeably.
Common mistakes and red flags
- Docs generated but never hosted or updated.
- Descriptions that repeat the column name.
- Not knowing what
catalog.jsoncontains.
Follow-up questions
- Does docs generate run models? — No; it compiles and queries metadata.
- How do you get column-level lineage? — dbt platform Catalog/Fusion, or external lineage tools parsing SQL.
14. The analyses folder and dbt show
Short answer
The analyses/ folder holds SQL files that use ref(), source() and Jinja but are only compiled, never materialised. They are good for version-controlled ad-hoc queries, audit queries and one-off reports. dbt show --select model (or --inline "select ...") runs a model’s SQL or an inline query and prints a preview of the rows, without building anything.
Full answer: explanation, example and follow-ups
Detailed explanation
analyses
dbt compilerenders them intotarget/compiled/.../analyses/, ready to paste into a SQL editor or BI tool.- They benefit from environment-aware refs. The same analysis works in dev or prod targets.
- Good uses:
- Reconciliation queries used during migrations (with
audit_helper; see Refactoring a legacy SQL script into dbt models). - Saved investigation queries (“why did revenue drop on 2026-03-02?”).
- Queries for BI tools that can’t use dbt directly.
- Documented, reviewed SQL that isn’t part of the DAG.
- Reconciliation queries used during migrations (with
- They appear in docs and lineage as analysis nodes.
dbt show
dbt show --select fct_orders --limit 20compiles and runs the model’s SELECT with a limit and prints the rows.dbt show --inline "select count(*) from {{ ref('fct_orders') }} where order_status = 'returned'"runs ad-hoc Jinja SQL.--output jsonfor scripts.- Used in the dbt platform IDE as “preview”, and the Fusion VS Code extension also offers previews.
- It runs a real query, so warehouse cost and data access apply. Limits are pushed into the query on most adapters, but complex models may still compute a lot.
When to promote an analysis to a model: when it is used repeatedly, needs tests, or feeds other work. Then make it a proper model with docs and tests.
Example
-- analyses/revenue_drop_2026_03_02.sql
select
o.order_status,
count(*) as orders,
sum(o.order_total) as revenue
from {{ ref('fct_orders') }} o
where o.ordered_at::date in ('2026-03-01', '2026-03-02')
group by 1
order by 3 desc
dbt compile --select revenue_drop_2026_03_02
dbt show --select fct_orders --limit 5
dbt show --inline "select order_status, count(*) from {{ ref('fct_orders') }} group by 1"
Real-world use case
During an incident review, the team stored the investigation queries as analyses in the PR. That gave a reviewable record of how the root cause was found, and a ready-made query when a similar alert fired a month later.
Common mistakes and red flags
- Expecting analyses to create tables.
- Using
dbt showon heavy models without considering cost. - Leaving important recurring logic as analyses without tests.
Follow-up questions
- Do analyses run in
dbt build? — No; they’re compiled only. - Can
dbt showpreview a CTE? — Use--inlinewith the CTE logic or compile and run pieces manually.
Intermediate dbt interview questions
15. Incremental models and is_incremental()
Short answer
An incremental model builds the full table on the first run, or with --full-refresh. On later runs it processes only new or changed rows and adds them to the existing table using the configured strategy. is_incremental() is true only when the target table exists, the model is incremental, and it isn’t a full refresh. Inside that block you filter the source using {{ this }}, the existing target table.
Full answer: explanation, example and follow-ups
Detailed explanation
Lifecycle
- First run: the target doesn’t exist, so
is_incremental()is false. dbt runs the full SELECT and creates the table. - Subsequent runs:
is_incremental()is true. The filter limits rows, for exampleupdated_at > max(updated_at) in {{ this }}. dbt writes the results to a temp relation, then appends, merges or deletes and inserts into the target, depending on the strategy (see Incremental strategies by adapter (append, merge, delete+insert, insert_overwrite, microbatch)). --full-refresh: dbt rebuilds from scratch (see--full-refresh: when and why).
Designing the filter
- Use a reliable, monotonic column: an
updated_atset by the source or loader, an ingestion timestamp, or an increasing ID. - Add a lookback window (
max(updated_at) - interval '3 hours') to catch late-arriving or clock-skewed rows. Combine it withunique_keyso reprocessed rows merge instead of duplicating (see Late-arriving data in incremental models (lookback windows)). - Use a subquery on
{{ this }}or a pre-computed variable. On some warehouses, push the filter so partition pruning applies. - The filter belongs to the source side. Avoid filters that depend on joins to the target.
Pitfalls
- No
unique_keywith a lookback creates duplicates under the append strategy. - Changing the model logic doesn’t rewrite historical rows. You need a full refresh or a backfill.
- Schema changes: new columns don’t appear without
on_schema_changehandling (see unique_key and on_schema_change). - Deletes in the source aren’t reflected unless you model them (soft-delete flags, or delete+insert per partition).
- Window functions over only the new rows give wrong results for metrics that need full history (running totals). Recompute affected keys, or keep those models as full tables.
Example
-- models/marts/fct_orders.sql (Dialect: Snowflake SQL via dbt)
{{ config(materialized='incremental', unique_key='order_id', incremental_strategy='merge') }}
with orders as (
select * from {{ ref('stg_shop__orders') }}
{% if is_incremental() %}
where updated_at >= (select dateadd('hour', -6, max(updated_at)) from {{ this }})
{% endif %}
)
select order_id, customer_id, order_status, order_total, ordered_at, updated_at
from orders
Walkthrough: if the target’s max updated_at is 2026-03-02 10:00, this run reads source rows updated since 04:00 and merges them on order_id. A row whose status changed at 09:30 updates in place.
Real-world use case
An incremental orders model used > against max(updated_at) with no lookback, and missed rows the loader committed late with earlier timestamps. Daily revenue was 0.3% low. Adding a 6-hour lookback with merge on order_id fixed it.
Common mistakes and red flags
- Using
current_dateinstead of the target’s state. - No lookback.
- Expecting logic changes to apply to old rows automatically.
Follow-up questions
- What is
{{ this }}? — The relation of the current model in the target (the existing table). - When does
is_incremental()return false? — First build, full refresh, or when materialization isn’t incremental.
16. unique_key and on_schema_change
Short answer
unique_key tells merge and delete+insert strategies which column(s) identify a row, so reprocessed rows update instead of duplicating. It must truly be unique in the model output. on_schema_change controls what happens when an incremental model’s columns change: ignore (default), fail, append_new_columns, or sync_all_columns, which adds and removes columns.
Full answer: explanation, example and follow-ups
Detailed explanation
unique_key
- A single column (
'order_id') or a list (['order_id', 'line_number']). - With duplicates in the batch, MERGE errors (“duplicate row detected” on Snowflake with ERROR_ON_NONDETERMINISTIC_MERGE, or nondeterministic results). Dedupe in the model with
qualify row_number() ... = 1. - NULLs in key columns never match in MERGE, so they insert duplicates each run. Ensure keys are non-null, and test them with
not_null. - Add
uniqueandnot_nulltests on the key, ordbt_utils.unique_combination_of_columnsfor composite keys.
on_schema_change options
| Option | Behaviour on new/removed columns |
|---|---|
ignore (default) |
New columns not added; removed columns cause NULLs or errors depending on SQL |
fail |
Error, forcing you to decide (full refresh or manual change) |
append_new_columns |
Add new columns (no backfill of old rows); never drop |
sync_all_columns |
Add new and drop removed columns; handles some type changes (adapter-dependent) |
Notes
- Newly added columns are NULL for historical rows. Backfill with a full refresh or targeted update if history matters.
- Column type changes are adapter-specific. Many require a full refresh.
- Model contracts (see Model contracts) make schema changes explicit and fail fast in CI.
- Snapshots handle schema changes differently: new columns are added, and removed columns stay.
Example
-- Dialect: Snowflake SQL via dbt
{{ config(
materialized = 'incremental',
incremental_strategy = 'merge',
unique_key = ['order_id', 'line_number'],
on_schema_change = 'append_new_columns'
) }}
select order_id, line_number, sku, quantity, unit_price, discount_pct, updated_at
from {{ ref('stg_shop__order_lines') }}
{% if is_incremental() %}
where updated_at >= (select dateadd('day', -1, max(updated_at)) from {{ this }})
{% endif %}
qualify row_number() over (partition by order_id, line_number order by updated_at desc) = 1
models:
- name: fct_order_lines
data_tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns: [order_id, line_number]
Real-world use case
After a source added discount_pct, an incremental model with on_schema_change: ignore silently never exposed it, and analysts thought discounts weren’t tracked. Switching to append_new_columns plus a one-time backfill of 90 days solved it.
Common mistakes and red flags
unique_keythat isn’t unique.- NULLable keys.
- Expecting new columns to be backfilled automatically.
Follow-up questions
- Does unique_key enforce uniqueness? — No; it’s used for matching. Tests enforce it.
- What happens with
sync_all_columnswhen a column is removed? — It’s dropped from the target table (data lost for that column).
17. Snapshots: timestamp vs check strategy (See also Q12)
Short answer
Snapshots implement SCD Type 2 on mutable source tables. Each dbt snapshot run compares the current source to the snapshot table and closes changed rows by setting dbt_valid_to, then inserts new versions. The timestamp strategy detects changes using an updated_at column, which is reliable and cheap. The check strategy compares listed columns (or all of them), and suits sources without a trustworthy updated timestamp.
Full answer: explanation, example and follow-ups
Detailed explanation
Columns dbt adds
dbt_scd_id: a hash identifying the version.dbt_updated_at: the timestamp used for the version.dbt_valid_from/dbt_valid_to: the validity window. The current row hasdbt_valid_toNULL, or a configured far-future value withdbt_valid_to_current.- Column names can be customised with
snapshot_meta_column_names(dbt 1.9+).
Strategies
| Strategy | Detects change by | Pros | Cons |
|---|---|---|---|
timestamp |
updated_at greater than the snapshot’s last value |
Efficient, simple | Requires a reliable updated timestamp that changes on every update |
check |
Comparing check_cols (list or all) |
Works without timestamps | Heavier comparisons; can miss changes if columns not listed; all captures noise columns |
Behaviour
- Snapshots capture state only when they run. Changes between runs (A → B → A within a day) are invisible. For complete history, use CDC (see Airflow executors: Local vs Celery vs Kubernetes), or AUTO CDC on Databricks (see AUTO CDC (formerly APPLY CHANGES) for SCD Type 1/2 in pipelines).
- Hard deletes in the source aren’t captured unless configured (see YAML-defined snapshots and hard-delete handling).
- Snapshot SQL should select from a source (or a light staging model), not a heavily transformed model. Keep it as raw as possible and transform downstream.
--full-refreshdoesn’t rebuild snapshots, because history is precious. Protect snapshot tables from accidental drops (grants, separate schema).
Example
# snapshots/snap_crm__customers.yml (dbt 1.9+ YAML snapshot)
snapshots:
- name: snap_crm__customers
relation: source('crm', 'customers')
config:
schema: snapshots
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
dbt_valid_to_current: "to_date('9999-12-31')"
-- Current state and point-in-time lookup (Dialect: Snowflake SQL)
select * from {{ ref('snap_crm__customers') }} where dbt_valid_to = to_date('9999-12-31');
select * from {{ ref('snap_crm__customers') }}
where '2026-02-15'::date >= dbt_valid_from and '2026-02-15'::date < dbt_valid_to;
Walkthrough: customer 7 moves from segment SMB to MID on 2026-03-01. After the next snapshot run, the SMB row gets dbt_valid_to = 2026-03-01 …, and a new MID row starts from that timestamp with dbt_valid_to = 9999-12-31.
Real-world use case
Sales commissions depended on each account’s tier at the time of sale. A daily snapshot of CRM accounts with the timestamp strategy let finance join each order to the tier valid at ordered_at, replacing manual spreadsheet lookups.
Common mistakes and red flags
- Snapshotting transformed models.
- Check strategy with
allincluding volatile columns such aslast_login. - Assuming snapshots catch every intermediate change.
Follow-up questions
- How often should snapshots run? — As often as you need history granularity, typically each load cycle.
- Can you backfill a snapshot? — Not natively; you’d need historical extracts or CDC data to reconstruct.
18. Writing custom generic tests
Short answer
A custom generic test is a {% test name(model, column_name, ...) %} Jinja block, usually in tests/generic/ or macros/, that returns failing rows for any model and column passed to it. Once defined, you apply it in YAML exactly like unique or not_null, with your own parameters. This turns repeated business rules into one tested, reusable definition.
Full answer: explanation, example and follow-ups
Detailed explanation
Anatomy
- Signature:
{% test test_name(model, column_name, arg1, arg2=default) %}.modelandcolumn_nameare injected by dbt. Model-level tests omitcolumn_name. - Body: a SELECT returning failing rows.
- YAML usage:
- test_name: {arg1: ..., arg2: ...}, nested underarguments:in newer versions.
Design tips
- Keep tests adapter-neutral using cross-database macros (
dbt.dateadd,dbt.datediff; see Cross-database macros and adapter.dispatch). - Support an optional
wherethrough the standard config rather than custom arguments. dbt applies thewhereconfig to themodelrelation automatically. - Return useful columns: the offending value and a key, for debugging.
- Document them in a YAML
macros:block, and unit-test complex ones with small fixtures. - Check packages before writing your own.
dbt_utils(expression_is_true,accepted_range,not_constant,recency) anddbt_expectationscover many cases (see Packages: dbt_utils, dbt_expectations and packages.yml).
Common custom tests
- Values within a range (
min_value,max_value, inclusive flags). - Positive or non-negative amounts.
- Column matching a regex (email, IBAN formats).
- Date not in the future.
- Row count within X% of another model.
Sharing them: put organisation-wide tests in an internal package, so every project uses the same definitions and tolerances, and changes to a rule happen in one reviewed place.
Example
-- tests/generic/not_in_future.sql
{% test not_in_future(model, column_name, grace_minutes=5) %}
select {{ column_name }} as offending_value, count(*) as n
from {{ model }}
where {{ column_name }} > {{ dbt.dateadd('minute', grace_minutes, dbt.current_timestamp()) }}
group by 1
{% endtest %}
# models/marts/_marts__models.yml
models:
- name: fct_orders
columns:
- name: ordered_at
data_tests:
- not_in_future:
arguments:
grace_minutes: 10
config:
severity: warn
Sample: one order with ordered_at two days in the future (a timezone bug) returns one failing group, and the test warns.
Real-world use case
Five teams had each written their own singular “no future timestamps” tests with inconsistent tolerances. A shared not_in_future generic test in the internal package standardised the rule across 140 timestamp columns.
Common mistakes and red flags
- Hard-coding table names inside generic tests.
- Adapter-specific SQL in shared tests.
- Reinventing tests packages already provide.
Follow-up questions
- Where can generic tests live? —
tests/generic/ormacros/(or a package). - How do you override a built-in test? — Define a test with the same name in your project; project definitions take precedence.
19. Unit tests
Short answer
Unit tests (dbt 1.8+) check a model’s transformation logic on small, hand-crafted input rows (given), comparing the output to expect. They run before the model is built, against mocked inputs, not production data. Data tests check the data that was actually built. Use unit tests for tricky SQL: CASE logic, window functions, date math, regex parsing and incremental logic.
Full answer: explanation, example and follow-ups
Detailed explanation
Structure (YAML unit_tests:)
model: the model under test.given: a list of inputs (ref('...')orsource('...')), each withrows, given inline as dicts, CSV, or SQL.expect: the expected output rows. Only the columns you list are compared.overrides: mockvars,env_varsor macros (for exampleis_incremental: true) to test incremental branches.
Unit vs data tests
| Aspect | Unit test | Data test |
|---|---|---|
| Input | Fixed fixtures | Real built data |
| When | Before/without building model (in CI, dev) | After building |
| Checks | Logic correctness | Data quality/invariants |
| Speed/cost | Small, cheap queries (still run on warehouse) | Can scan large tables |
Running
dbt test --select test_type:unit, or as part ofdbt build(unit tests run before the model is materialised).- In CI, run unit tests for modified models (
--select state:modified,test_type:unit; see Slim CI with state:modified and--defer). - Production runs often exclude them (
--exclude-resource-type unit_test) to save time, since logic was already verified in CI.
Limitations
- They still need a warehouse connection, because fixtures are compiled into SQL and executed there.
- Only direct inputs are mocked. You test one model at a time.
- Some adapter-specific types or functions need care in fixtures. Introspective macros that query the database may need overrides.
Example
# models/marts/_fct_orders__unit_tests.yml
unit_tests:
- name: order_status_bucketing_and_net_amount
model: fct_orders
given:
- input: ref('stg_shop__orders')
rows:
- {order_id: 1, customer_id: 10, order_status: "delivered", order_total: 100.00, discount: 10.00}
- {order_id: 2, customer_id: 11, order_status: "returned", order_total: 50.00, discount: 0.00}
- {order_id: 3, customer_id: 12, order_status: "cancelled", order_total: 20.00, discount: 0.00}
expect:
rows:
- {order_id: 1, status_bucket: "completed", net_amount: 90.00}
- {order_id: 2, status_bucket: "reversed", net_amount: 0.00}
- {order_id: 3, status_bucket: "reversed", net_amount: 0.00}
- name: incremental_branch_filters_old_rows
model: fct_orders
overrides:
macros:
is_incremental: true
given:
- input: this
rows: [{order_id: 1, updated_at: "2026-03-01 10:00:00"}]
- input: ref('stg_shop__orders')
rows:
- {order_id: 1, updated_at: "2026-03-01 09:00:00"}
- {order_id: 5, updated_at: "2026-03-02 08:00:00"}
expect:
rows:
- {order_id: 5}
The second test assumes the model’s incremental filter has no lookback window, so the older row is excluded. Adjust the fixtures to match your model’s actual lookback.
Real-world use case
A refactor of revenue bucketing logic broke the “returned” handling, but data tests passed because the data was valid, just wrong. A unit test with three fixture rows would have caught it, so the team now requires unit tests for any mart with CASE-heavy business logic.
Common mistakes and red flags
- Confusing unit tests with data tests.
- Huge fixtures copied from production.
- Running unit tests in every production run unnecessarily.
Follow-up questions
- Can you test ephemeral inputs? — Mock the direct refs of the model; ephemeral models referenced are mocked like refs.
- What does
input: thismean? — Mocks the current state of the incremental model’s existing table.
20. Test severity, warn_if/error_if and store_failures
Short answer
severity: error (default) fails the run when a test fails. severity: warn only logs a warning. warn_if and error_if set thresholds on the failing-row count (for example ">10"), so a few known bad rows warn while many fail. store_failures: true saves failing rows to a table in the warehouse (or store_failures_as: view) so you can investigate after the run.
Full answer: explanation, example and follow-ups
Detailed explanation
How thresholds evaluate
- dbt counts failing rows (
fail_calc, defaultcount(*)). - With
severity: error, it first checkserror_if(default!= 0). If that is true, the test errors. Otherwise it checkswarn_if(default!= 0) and warns if true. - With
severity: warn, it only checkswarn_if. fail_calccan be customised, for examplesum(case when ... then 1 else 0 end).
Typical policies
| Situation | Config |
|---|---|
| Primary key uniqueness | severity: error (default) |
| Known legacy dirty data, tolerate up to N | error_if: ">100", warn_if: ">0" |
| Soft business rule / monitoring only | severity: warn |
| Non-critical source checks | severity: warn + alerting |
store_failures
- Writes failing rows to
<target schema>_dbt_test__audit.<test_name>by default (configurableschema). - Overwritten each run. Snapshot or copy it if you need history.
- Watch for PII exposure: failure tables contain real rows, so restrict access to the audit schema.
store_failures_as: ephemeral | table | view.
Behaviour in builds
- In
dbt build, a failing error test skips downstream models of the tested model, which prevents bad data from propagating (a circuit breaker). Warnings don’t skip. --warn-errorand--warn-error-optionscan promote warnings to errors in CI.
Example
models:
- name: stg_crm__contacts
columns:
- name: email
data_tests:
- not_null:
config:
severity: error
warn_if: ">0"
error_if: ">500"
store_failures: true
where: "created_at >= dateadd('day', -30, current_date)"
- name: phone_country_code
data_tests:
- accepted_values:
arguments:
values: ['+1', '+44', '+91', '+49']
config:
severity: warn
Real-world use case
A CRM source had 120 historical contacts with NULL emails that couldn’t be fixed. The not_null test was set to warn above 0 and error above 500. That kept the nightly run green while still alerting the team if a new bug caused a spike.
Common mistakes and red flags
- Everything on warn, so tests are ignored.
store_failuresexposing PII broadly.- Not knowing that error tests skip downstream nodes in build.
Follow-up questions
- How do you alert on warnings? — Parse
run_results.json(see dbt artifacts: manifest.json and run_results.json) or use observability packages/platform notifications. - What’s
limiton tests? — Caps returned failing rows for performance.
21. Source freshness
Short answer
dbt source freshness checks how recently each source table was loaded. It compares max(loaded_at_field) (or warehouse metadata, where the adapter supports it) against warn_after and error_after thresholds defined in the sources YAML. Results go to sources.json. Running it before transformations lets you stop or flag a pipeline when upstream data is stale, instead of publishing stale dashboards.
Full answer: explanation, example and follow-ups
Detailed explanation
Configuration
loaded_at_field: a timestamp column set by the loader (for example_fivetran_synced,_loaded_at). Prefer load time over business event time.freshness: {warn_after: {count, period}, error_after: {count, period}}, set at source or table level (tables inherit).filter:limits the scan on huge tables (filter: "_loaded_at >= dateadd(day, -3, current_date)").- Without
loaded_at_field, some adapters (Snowflake, BigQuery, Redshift, Databricks) can use table metadata (last altered time). Check adapter support. freshness: nulldisables freshness for specific tables, such as static ones.
Running it
dbt source freshness(all), or--select "source:shop".- Results: pass, warn or error per table, with the max loaded time and age.
- In dbt platform jobs, a “run source freshness” step. In orchestrators, a task before
dbt build. - Selection by freshness:
dbt build --select "source_status:fresher+"builds only models downstream of sources that got new data since the last check (needs previous state artifacts).
Using results
- Fail fast: an error on a critical source stops downstream builds, or alerts.
- Publish freshness to dashboards (“data as of”), and to the data catalog.
- Feed SLA monitoring (see What are the dimensions of data quality?).
Example
# models/staging/payments/_payments__sources.yml
sources:
- name: payments
database: RAW
schema: STRIPE
loaded_at_field: _loaded_at
freshness:
warn_after: {count: 2, period: hour}
error_after: {count: 6, period: hour}
tables:
- name: charges
freshness:
filter: "_loaded_at >= dateadd('day', -2, current_timestamp())"
- name: refunds
- name: currency_codes
freshness: null
dbt source freshness --select "source:payments"
dbt build --select "source_status:fresher+" --state ./prod-artifacts
Real-world use case
An EL connector silently stopped syncing on a Friday night, and the Monday revenue dashboard showed a flat weekend. After adding source freshness with error_after: 6 hours, as a gating step that pages on-call, the next connector failure was caught within hours.
Common mistakes and red flags
- Using business event time as
loaded_at_field(late events look stale). - Freshness configured but never run.
- Full scans of huge tables without
filter.
Follow-up questions
- Does freshness block
dbt buildautomatically? — No; you orchestrate it as a separate step and decide behaviour. - Freshness for models (not sources)? — Newer dbt versions add model freshness configs; otherwise use
dbt_utils.recencytests.
22. Writing macros
Short answer
Macros are reusable Jinja functions defined with {% macro name(args) %} ... {% endmacro %} in the macros/ folder. You call them in models as {{ name(args) }}. Use them for repeated SQL snippets (currency conversion, surrogate keys, standard audit columns), environment logic, and operations run with dbt run-operation. Keep them small, documented and tested.
Full answer: explanation, example and follow-ups
Detailed explanation
Kinds of macros
| Type | Example |
|---|---|
| Expression snippets | cents_to_dollars('amount_cents') |
| Column/list generators | Standard audit columns, generate column lists from adapter.get_columns_in_relation |
| Overrides of dbt behaviour | generate_schema_name (see Custom schemas and generate_schema_name), generate_alias_name |
| Operations | dbt run-operation grant_select --args '{role: analyst}' (DDL/admin tasks) |
| Hooks | Called from pre/post-hooks (see Hooks: pre-hook, post-hook, on-run-start/end) |
| Cross-database | adapter.dispatch implementations (see Cross-database macros and adapter.dispatch) |
Best practices
- Single purpose and clear arguments, with defaults where sensible.
- Document in YAML (
macros:withdescriptionandarguments). - Return SQL fragments, not whole models, so they stay composable.
- Whitespace control (
{%-) for clean compiled SQL. - Test through unit tests on models that use them, or dedicated test models.
- Prefer packages (
dbt_utils.generate_surrogate_key,dbt.dateadd) over writing your own. - Avoid deep macro nesting, which makes debugging painful.
return() vs output: macros normally render text. {{ return(value) }} returns a Python object (list or dict) to the caller, which is useful for helper macros that compute values.
Security: macros that build SQL from var() or arguments should only be used with trusted inputs (project config or orchestrator). Avoid passing raw user input into identifiers. Use adapter.quote() for identifiers where needed.
Example
-- macros/money.sql
{% macro cents_to_currency(column_name, scale=2) -%}
round(({{ column_name }})::number(38, 4) / 100, {{ scale }})
{%- endmacro %}
-- macros/audit_columns.sql
{% macro audit_columns() -%}
current_timestamp() as dbt_loaded_at,
'{{ invocation_id }}' as dbt_invocation_id
{%- endmacro %}
-- models/staging/stripe/stg_stripe__charges.sql
select
id as charge_id,
{{ cents_to_currency('amount') }} as amount,
currency,
{{ audit_columns() }}
from {{ source('stripe', 'charges') }}
Compiled output: round((amount)::number(38, 4) / 100, 2) as amount, plus the two audit columns with the run’s invocation ID.
Real-world use case
Currency conversion was copy-pasted into 30 models with slightly different rounding, which caused reconciliation differences of a few cents. A single macro made the logic consistent, and a later rounding change took one line.
Common mistakes and red flags
- Macros that hide whole business logic models.
- No documentation.
- Unquoted dynamic identifiers from untrusted input.
Follow-up questions
- How do you run a macro standalone? —
dbt run-operation macro_name --args '{...}'. - What’s
invocation_id? — A unique ID for the dbt run, useful for audit columns and lineage.
23. var() and env_var()
Short answer
{{ var('name', default) }} reads project variables. They are defined in dbt_project.yml under vars: and overridden at runtime with --vars '{"name": value}', which suits parameters such as start dates or feature flags. {{ env_var('NAME', default) }} reads environment variables, which suit secrets and environment-specific settings such as credentials and account names. In dbt platform, variables prefixed DBT_ENV_SECRET_ are masked in logs.
Full answer: explanation, example and follow-ups
Detailed explanation
var()
- Defined in
dbt_project.yml, globally or scoped to a package or project:vars: start_date: "2024-01-01" acme_analytics: enable_sampling: false - Overridden on the CLI:
dbt build --vars '{"start_date": "2026-01-01"}'. - Use it for business parameters (lookback days, backfill ranges, currency lists) and package configuration (packages often read vars).
- Missing vars without defaults raise compile errors, which is good for required parameters.
env_var()
- Reads OS environment variables at parse or compile time.
- Use it for credentials in profiles.yml, account identifiers, and environment-specific database names.
- Secrets: in dbt platform,
DBT_ENV_SECRET_*values are masked and only usable in profiles and Git config. Locally, rely on your secret manager to export them. - Changing env vars can trigger a full re-parse in some cases, since parsing depends on them.
Choosing
| Use | Prefer |
|---|---|
| Passwords, keys, tokens | env_var (secrets manager) |
| Target-specific connection info | env_var or profiles targets |
| Backfill dates, thresholds, flags | var |
| Logic depending on environment name | target.name (no var needed) |
Pitfalls
- Putting secrets in
vars(they appear inmanifest.jsonand logs). - String vs number types from the CLI.
--varsis YAML-parsed, so quote dates. - Overusing vars until models become unreadable configuration puzzles.
- Vars in SQL must be trusted input. Don’t pass end-user input into vars that are rendered into SQL.
Example
-- models/marts/fct_daily_kpis.sql
{% set lookback_days = var('kpi_lookback_days', 3) %}
select kpi_date, sum(revenue) as revenue, count(distinct customer_id) as buyers
from {{ ref('fct_orders') }}
where kpi_date >= dateadd('day', -{{ lookback_days | int }}, current_date)
group by kpi_date
export DBT_SF_ACCOUNT="acme-xy12345" # read via env_var in profiles.yml
dbt build --select fct_daily_kpis --vars '{"kpi_lookback_days": 30}'
The | int filter forces the var to an integer, so a malformed value fails at compile time instead of injecting arbitrary text into the SQL.
Real-world use case
A backfill used to need code edits to change hard-coded dates. Introducing var('backfill_start') and var('backfill_end') with safe defaults let on-call engineers run controlled backfills from the orchestrator without code changes.
Common mistakes and red flags
- Secrets in vars or Git.
- Unquoted dates in
--vars. - Vars used to switch whole model logic everywhere.
Follow-up questions
- Where are vars visible? — In compiled SQL, manifest and logs — never for secrets.
- What’s the precedence? — CLI
--varsoverride projectvars:; package-scoped vars apply within that package.
24. Packages: dbt_utils, dbt_expectations and packages.yml
Short answer
Packages are dbt projects you install as dependencies. List them in packages.yml (or dependencies.yml) with version ranges, then run dbt deps. dbt_utils adds core helper macros and tests (surrogate keys, date spines, unique_combination_of_columns, expression_is_true). dbt_expectations brings Great Expectations-style tests. Others cover specific sources (Fivetran packages), observability (elementary), audit (audit_helper) and codegen.
Full answer: explanation, example and follow-ups
Detailed explanation
Installing
packages.yml: hub packages (package: dbt-labs/dbt_utils,version: [">=1.3.0", "<2.0.0"]), Git packages (git:URL plusrevision:tag or commit), local packages (local: ../shared), and private packages (private:, for dbt platform).dbt depsinstalls intodbt_packages/(git-ignored) and writespackage-lock.ymlfor reproducible installs. Commit the lock file.
Common packages
| Package | Highlights |
|---|---|
dbt_utils |
generate_surrogate_key, date_spine, star, pivot, union_relations, deduplicate, tests: unique_combination_of_columns, expression_is_true, accepted_range, recency, equal_rowcount |
dbt_expectations (community fork maintained by Metaplane) |
expect_column_values_to_be_between, ..._to_match_regex, row count expectations, distribution checks |
audit_helper |
Compare old vs new model outputs (refactoring validation; see Refactoring a legacy SQL script into dbt models) |
codegen |
Generate source YAML and base models |
elementary |
Observability: test results, anomaly detection, reports |
dbt_project_evaluator |
Lints project structure against best practices |
| Source-specific packages | Pre-built staging/marts for SaaS sources |
Governance
- Pin version ranges. Unpinned Git branches break builds unexpectedly.
- Review the package code. It runs SQL in your warehouse with your role.
- Check adapter support: some packages implement macros only for certain warehouses.
- Watch the maintenance status. Some packages have moved or been forked, and dbt Fusion compatibility varies by package.
Example
# packages.yml
packages:
- package: dbt-labs/dbt_utils
version: [">=1.3.0", "<2.0.0"]
- package: metaplane/dbt_expectations
version: [">=0.10.0", "<0.11.0"]
- package: dbt-labs/audit_helper
version: [">=0.12.0", "<1.0.0"]
-- models/marts/dim_customer.sql
select
{{ dbt_utils.generate_surrogate_key(['source_system', 'customer_id']) }} as customer_key,
source_system, customer_id, customer_name, segment
from {{ ref('int_customers_unioned') }}
models:
- name: fct_orders
data_tests:
- dbt_utils.expression_is_true:
arguments:
expression: "net_amount <= gross_amount"
columns:
- name: net_amount
data_tests:
- dbt_expectations.expect_column_values_to_be_between:
arguments:
min_value: 0
max_value: 1000000
Real-world use case
A team wrote its own surrogate-key macro with inconsistent NULL handling. Switching to dbt_utils.generate_surrogate_key (which handles NULLs consistently and is cross-database) removed a class of join mismatches.
Common mistakes and red flags
- Unpinned package versions.
- Not committing the lock file.
- Installing packages without reviewing what they run.
Follow-up questions
- How do you use a macro from a package? — Prefix with the package name, e.g.
dbt_utils.star(...). - How do you share internal macros across projects? — Internal Git/private package, or dbt Mesh for models (see dbt Mesh: cross-project refs).
25. Hooks: pre-hook, post-hook, on-run-start/end
Short answer
Hooks run SQL around dbt operations. pre-hook and post-hook run before and after a specific model, seed or snapshot is built, for example setting session parameters or applying grants. on-run-start and on-run-end run once at the beginning and end of an invocation, for example creating audit tables or logging results. They can call macros and use context such as this and results.
Full answer: explanation, example and follow-ups
Detailed explanation
Where they’re defined
- Per model:
{{ config(pre_hook=[...], post_hook=[...]) }}, or in YAML ordbt_project.ymlfolder configs (+post-hook). - Project level:
on-run-start:andon-run-end:indbt_project.yml. - Hooks from folder configs and model configs are combined. Order matters, and they run in the model’s transaction context where applicable.
Typical uses
| Hook | Use |
|---|---|
pre-hook |
alter session set ... (timezone, query tag), temporary setup |
post-hook |
Legacy grants, ANALYZE, clustering/optimize statements, data masking application, audit inserts |
on-run-start |
Create audit schema/table, set run-level tags |
on-run-end |
Log results (status, timing per node) to an audit table; send summaries; vacuum/optimize maintenance |
Prefer native configs where available
- Grants: use the
grantsconfig (see grants and persist_docs configs) instead of post-hook GRANT statements. - Query tags (Snowflake): the
query_tagconfig. - Docs to warehouse comments:
persist_docs. - Clustering: adapter configs (
cluster_by,liquid_clustered_by).
Cautions
- Hooks run with dbt’s role, so make sure they’re safe and idempotent.
- Heavy post-hooks (OPTIMIZE on big tables each run) can dominate runtime.
- Hooks fail the model if they error, which may be surprising.
on-run-endruns even on failures. Useresultsto branch.
Example
# dbt_project.yml (excerpt)
on-run-start:
- "create table if not exists {{ target.database }}.audit.dbt_run_log (invocation_id string, node string, status string, execution_time float, logged_at timestamp)"
on-run-end:
- "{{ log_run_results(results) }}"
models:
acme_analytics:
marts:
+pre-hook: "alter session set timezone = 'UTC'"
-- macros/log_run_results.sql
{% macro log_run_results(results) %}
{% if execute and results | length > 0 %}
insert into {{ target.database }}.audit.dbt_run_log
values
{% for r in results -%}
('{{ invocation_id }}', '{{ r.node.unique_id }}', '{{ r.status }}', {{ r.execution_time }}, current_timestamp())
{%- if not loop.last %},{% endif %}
{% endfor %}
{% else %}
select 1
{% endif %}
{% endmacro %}
Node IDs and statuses come from dbt itself, not user input. Still, keep logged values to safe fields like these, and avoid logging free-text error messages unescaped.
Real-world use case
An on-run-end hook writing per-model status and timing to an audit table fed a dashboard of the slowest and most-failing models. That guided a performance sprint that cut nightly runtime by 35%.
Common mistakes and red flags
- Using hooks for grants when the
grantsconfig exists. - Expensive maintenance in every post-hook.
- Non-idempotent hooks.
Follow-up questions
- Do hooks run in
dbt compile? — No; they run during execution commands (run/build/seed/snapshot). - Can you disable inherited hooks for one model? — Override the config for that model (e.g. set to an empty list).
26. Exposures
Short answer
Exposures declare downstream uses of dbt models (dashboards, notebooks, ML models, applications, reverse-ETL syncs) in YAML, with depends_on, owner, type, maturity and URL. They appear in the docs lineage graph and can be selected (dbt build --select +exposure:weekly_revenue_dashboard). Before changing a model you can see who would be affected, and you can build everything a critical dashboard needs.
Full answer: explanation, example and follow-ups
Detailed explanation
Fields
name,label,type(dashboard,notebook,analysis,ml,application).owner(name and email, required),maturity(low, medium, high),url,description.depends_on: a list ofref(),source()ormetric()entries.tagsandmeta(for example a business-critical tier or SLA).
Uses
- Impact analysis:
dbt ls --select +exposure:executive_kpislists the upstream models. Conversely, before deprecatingfct_orders, check which exposures depend on it. - Targeted builds:
dbt build --select +exposure:executive_kpisbuilds and tests everything feeding a critical dashboard, for example in a priority job that runs first. - Ownership and communication: owners get notified of breaking changes, and deprecations are planned with them.
- Documentation: the docs site shows the exposure node at the right edge of the lineage graph.
Keeping them current
- Manually maintained exposures go stale. Some BI integrations (dbt platform with Tableau or Power BI, or catalog tools) can auto-generate them. Otherwise, add exposure updates to the dashboard release checklist.
- Lineage in catalogs (Unity Catalog, Snowflake Horizon, OpenLineage tools) can complement dbt exposures (see What is data lineage, and how is it captured?).
Example
# models/exposures.yml
exposures:
- name: executive_kpis
label: Executive KPI Dashboard
type: dashboard
maturity: high
url: https://bi.acme.example/dashboards/exec-kpis
description: "Daily revenue, orders, active customers for the leadership team."
owner:
name: BI Platform Team
email: [email protected]
depends_on:
- ref('fct_orders')
- ref('dim_customers')
- ref('agg_daily_kpis')
meta:
tier: 1
sla: "07:00 UTC"
dbt build --select +exposure:executive_kpis --target prod
dbt ls --select +exposure:executive_kpis --resource-type model
Real-world use case
Before renaming columns in dim_customers, an engineer checked exposures, found 3 tier-1 dashboards depending on it, and coordinated changes with their owners. A separate early-morning job builds +exposure:executive_kpis first, to protect the 07:00 SLA.
Common mistakes and red flags
- Exposures with no owner.
- Stale
depends_on. - Thinking exposures are executable objects (they’re metadata).
Follow-up questions
- Do exposures run anything? — No; they’re nodes for lineage and selection.
- Can exposures depend on metrics? — Yes,
metric('...')references where Semantic Layer metrics are defined.
27. Node selection syntax (+, @, tags, paths, state)
Short answer
--select and --exclude choose which nodes run. model+ means the model and its descendants, +model the model and its ancestors, and 2+model limits ancestors to depth 2. @model means the model, its descendants, and all ancestors of those descendants. Methods include tag:, path:, source:, exposure:, resource_type:, config.materialized:, test_type:, state:modified, and result:error. Spaces mean union, and commas mean intersection.
Full answer: explanation, example and follow-ups
Detailed explanation
Graph operators
| Syntax | Selects |
|---|---|
fct_orders |
Just the node |
fct_orders+ / +fct_orders |
Node + all descendants / ancestors |
fct_orders+1 / 2+fct_orders |
Depth-limited |
@fct_orders |
Node, descendants, and ancestors of descendants (everything needed to build descendants) |
+exposure:exec_kpis |
Everything upstream of an exposure |
Methods
| Method | Example |
|---|---|
tag: |
tag:finance |
path: / folder |
path:models/marts/finance or models/marts/finance |
source: |
source:stripe+ (everything downstream of the source) |
resource_type: |
resource_type:snapshot |
config.<key>: |
config.materialized:incremental |
test_type: |
test_type:unit, test_type:data |
state: |
state:modified, state:new, state:modified.body (needs --state) |
result: |
result:error+ (needs prior run_results in --state) |
source_status: |
source_status:fresher+ (see Source freshness) |
group: / access: / version: |
Governance-based selection |
package: |
package:dbt_utils |
| Wildcards | stg_stripe__* |
Set operations
- Union: space-separated,
--select "tag:finance tag:marketing". - Intersection: comma,
--select "tag:daily,config.materialized:incremental". - Exclusion:
--exclude "tag:deprecated".
YAML selectors (selectors.yml): named, reusable selection definitions such as --selector nightly_finance, with union, intersection and exclude blocks. They keep orchestration commands short and reviewable.
Indirect selection: when selecting models, attached tests are selected too (eager by default). --indirect-selection cautious only includes tests whose parents are all selected, and buildable and empty modes also exist.
Example
# Everything downstream of changed models, deferring unchanged parents to prod
dbt build --select "state:modified+" --defer --state ./prod-artifacts
# Re-run only what failed last time, plus its children
dbt build --select "result:error+ result:fail+" --state ./target
# Finance incremental models only
dbt run --select "tag:finance,config.materialized:incremental"
# All ancestors needed for an exposure, excluding heavy backfill models
dbt build --select "+exposure:executive_kpis" --exclude "tag:heavy_backfill"
# selectors.yml
selectors:
- name: nightly_finance
definition:
union:
- method: tag
value: finance
children: true
- exclude:
- method: tag
value: deprecated
Real-world use case
A monolithic nightly dbt build took 3 hours. Splitting it into YAML selectors per domain, with the critical +exposure:executive_kpis running first, let tier-1 dashboards refresh by 06:00 while lower-priority marts ran afterwards.
Common mistakes and red flags
- Confusing comma (intersection) with space (union).
state:selection without--state.- Not knowing indirect test selection.
Follow-up questions
- How do you preview a selection? —
dbt ls --select .... - What does
@add over+? — Ancestors of descendants, so descendants can actually build.
28. Targets, environments and deployment
Short answer
A target is a named connection configuration in a profile (dev, ci, prod), with its own database, schema, role, warehouse and threads. --target prod selects it at runtime. Use target.name in Jinja for environment-specific behaviour. Typical setup: developers build in personal dev schemas, CI builds in per-PR schemas, and production builds through an orchestrator using a service account.
Full answer: explanation, example and follow-ups
Detailed explanation
Typical environment design
| Environment | Where | Who/what runs | Data |
|---|---|---|---|
| Dev | ANALYTICS_DEV.dbt_<user> |
Developer locally/IDE | Sampled or deferred to prod |
| CI | ANALYTICS_CI.dbt_ci_pr_<n> |
CI on pull request | Slim CI with defer (see Slim CI with state:modified and --defer) |
| Staging/QA (optional) | ANALYTICS_STAGING |
Release branch | Full or near-full |
| Prod | ANALYTICS.<schemas> |
Orchestrator, service user/role | Full |
Practices
- Isolation: separate roles per environment (dev roles can’t write production schemas), and separate warehouses for cost control.
- Same code, different targets. Avoid branching logic on environment except for safe things: dev sampling, smaller warehouses, disabling expensive exports.
- Service accounts with key-pair or OAuth authentication for prod and CI. No personal credentials.
- Schema naming: customise
generate_schema_nameso prod uses clean schema names while dev prefixes them per user (see Custom schemas and generate_schema_name). - Promotion: merge to
main, then deploy (the orchestrator pulls the new code or image), then run. Use tags or releases for rollback. - Artifacts: save prod
manifest.jsonandrun_results.jsonfor Slim CI and monitoring (see dbt artifacts: manifest.json and run_results.json).
dbt platform specifics: environments (development, staging, production) are configured in the UI with credentials and deferral settings. Jobs run in deployment environments, and CI jobs trigger on PRs automatically.
Example
-- models/staging/app/stg_app__events.sql
select event_id, user_id, event_type, event_ts
from {{ source('app', 'events') }}
{% if target.name == 'dev' %}
where event_ts >= dateadd('day', -3, current_date) -- small dev builds
{% endif %}
# profiles.yml (excerpt) — prod output
prod:
type: snowflake
account: "{{ env_var('DBT_SF_ACCOUNT') }}"
user: SVC_DBT_PROD
private_key_path: "{{ env_var('DBT_SF_PROD_KEY_PATH') }}"
role: TRANSFORMER_PROD
warehouse: TRANSFORM_M
database: ANALYTICS
schema: core
threads: 16
Real-world use case
A developer accidentally ran dbt run against production with personal credentials, overwriting a mart with a half-finished change. Separating roles (dev roles have no write access to ANALYTICS) and running production only through the orchestrator’s service account made it impossible to repeat.
Common mistakes and red flags
- Developers with production write access.
- Different SQL logic per environment.
- Personal credentials in prod jobs.
Follow-up questions
- How do you handle dev data volumes? — Sampling with
target.name,--empty, or deferral to prod for upstream models. - How do you roll back a bad deploy? — Revert the commit and rerun; restore data via Time Travel/clone if needed.
29. dbt Core vs dbt platform (Cloud) vs Fusion engine / dbt v2
Short answer
dbt Core is the open-source Python CLI. You bring your own orchestration, CI and docs hosting. The dbt platform (formerly dbt Cloud) is the managed SaaS: IDE, Studio, scheduler, CI jobs, Semantic Layer APIs, Catalog, Canvas and Copilot features. The dbt Fusion engine is a Rust-based rewrite of the dbt engine. It parses projects much faster, understands SQL (column-level lineage, compile-time errors) and powers the VS Code extension and dbt v2 tooling.
Full answer: explanation, example and follow-ups
Detailed explanation
| Aspect | dbt Core | dbt platform | dbt Fusion engine |
|---|---|---|---|
| Form | Python package + CLI (Apache 2.0) | Managed SaaS | New engine (Rust); CLI, VS Code/LSP, used in dbt platform |
| Orchestration | Bring your own (Airflow, Dagster, cron, warehouse-native) | Built-in scheduler, job chaining, CI on PR | Same as host (CLI or platform) |
| IDE | Any editor | Studio IDE, Canvas (visual) | VS Code extension with live errors, lineage, previews |
| Semantic Layer | MetricFlow locally | Hosted APIs for BI/AI | Supported per release |
| SQL understanding | Templating only (Jinja → text) | Same + platform features | Parses SQL: column-level lineage, type/compile checks before running |
| Speed | Parse can be slow on large projects | Same engine historically | Much faster parse/compile |
| Licence | Apache 2.0 | Commercial | Source-available / proprietary components, with free tier usage terms |
| Compatibility | Baseline | Baseline | Most projects; some Jinja patterns, packages and adapters may need updates |
dbt v2 / naming notes
- dbt Labs has been aligning the product around dbt (the language and framework), the dbt platform, and Fusion. Expect “dbt Cloud” to be referred to as the dbt platform, and new major-version tooling to be Fusion-based.
- Deprecations: projects are encouraged to fix deprecation warnings (for example
tests:→data_tests:, and test arguments nested underarguments:) to be Fusion-ready.
Other ways to run dbt
- Warehouse-native: Snowflake dbt Projects on Snowflake (deploy and run dbt as Snowflake objects, scheduled with tasks), and Databricks dbt tasks in Lakeflow Jobs (see Orchestrating dbt (Airflow/Cosmos, dbt platform jobs, warehouse-native dbt projects)).
- Orchestrators: Airflow with Astronomer Cosmos, Dagster’s dbt integration, Prefect.
Choosing
- Core: cost-sensitive, strong platform team, an existing orchestrator, full control.
- Platform: faster setup, managed CI and scheduling, Semantic Layer APIs, and a less technical contributor base.
- Fusion: evaluate it for speed and developer experience. Test package and adapter compatibility first.
Example
# dbt Core
pip install dbt-core dbt-snowflake
dbt --version
dbt build --target prod
# Fusion CLI (installed separately; command name per current docs)
dbtf --version
dbtf build --target dev
The Fusion binary name, dbtf here, and its install steps may change between releases. Follow the current dbt docs.
Real-world use case
A 3,000-model project took about 4 minutes just to parse on dbt Core, which slowed every developer iteration. A Fusion pilot parsed it in seconds and surfaced column-level errors before any warehouse query. Two community packages had to be upgraded first.
Common mistakes and red flags
- Calling dbt Cloud the only way to schedule dbt.
- Assuming Fusion is a drop-in for every project without testing.
- Outdated naming without awareness of current terms.
Follow-up questions
- Can Core and platform coexist? — Yes; same project code, different execution environments.
- What’s column-level lineage good for? — Impact analysis of renaming/removing columns (see What is data lineage, and how is it captured?).
30. Debugging with compiled SQL, logs and the target folder
Short answer
When a model fails, look at target/compiled/ (rendered SELECT) and target/run/ (the full DDL/DML dbt actually executed). Run that SQL in the warehouse to reproduce the error. Check logs/dbt.log (debug-level details, adapter queries) and use --debug for verbose output. dbt compile --select model and dbt show let you inspect logic without materialising.
Full answer: explanation, example and follow-ups
Detailed explanation
Where things are
| Path | Contents |
|---|---|
target/compiled/<project>/models/...sql |
Jinja-rendered SELECT |
target/run/<project>/models/...sql |
Executed statements (CREATE/MERGE/INSERT wrappers) |
target/manifest.json, run_results.json |
Graph and results (see dbt artifacts: manifest.json and run_results.json) |
logs/dbt.log |
Detailed logs including adapter-issued queries |
Workflow
- Read the error message. dbt shows the node and the database error (for example “invalid identifier”, “duplicate row detected during DML action”).
- Open the compiled or run SQL, paste it into a SQL editor, and execute pieces of it (individual CTEs) to isolate the issue.
- Jinja problems:
dbt compile --select modelwith no warehouse writes. Add{{ log(var_value, info=True) }}to print values during compile. Checkexecuteguards. - Dependency or selection problems:
dbt ls --select ...anddbt list --resource-type model --output json. - Incremental logic: check
is_incremental()behaviour. The run SQL shows the temp table and MERGE. Reproduce with the existing target state. - Connection or auth:
dbt debug. - Performance: use the warehouse query profile for the specific query ID (Snowflake query tags help find dbt queries; see MERGE in Snowflake and the nondeterministic-merge error).
Useful flags
--debuggives verbose logs to the console.--log-level debug.--log-format jsonfor machine-readable logs (shipping to observability tools).--fail-faststops early when debugging cascades.--emptyruns a schema-only validation with zero rows.dbt show --select model --limit 10previews output (see The analyses folder and dbt show).
Typical errors and causes
| Error | Likely cause |
|---|---|
| Compilation Error: model not found | Typo in ref, disabled model, wrong package name |
| Duplicate row detected during DML (Snowflake MERGE) | Non-unique unique_key in batch (see unique_key and on_schema_change) |
| Object does not exist or not authorized | Missing grants, wrong target database/role, upstream not built (defer issue) |
| Cycle detected | Two models ref each other; break with intermediate model |
Example
-- Inspect a value at compile time
{% set cutoff = var('cutoff_date', '2026-01-01') %}
{{ log("fct_orders cutoff_date = " ~ cutoff, info=True) }}
select * from {{ ref('stg_shop__orders') }}
where ordered_at >= '{{ cutoff }}'
dbt compile --select fct_orders
cat target/compiled/acme_analytics/models/marts/finance/fct_orders.sql
dbt build --select fct_orders --debug --fail-fast
Real-world use case
An incremental model failed with “duplicate row detected” every few days. The run SQL showed the MERGE source, and running that SELECT revealed two rows per order_id when an order had two payment methods. The fix was aggregating payments before the join.
Common mistakes and red flags
- Debugging only the model file without the compiled SQL.
- Not knowing
target/runexists. - Changing code randomly instead of isolating CTEs.
Follow-up questions
- How do you tag dbt queries in Snowflake? —
query_tagconfig per model/project. - How do you debug a macro? —
dbt run-operationwithlog()statements, or compile a model that calls it.
31. Custom schemas and generate_schema_name
Short answer
By default, a model with +schema: marts builds into <target_schema>_marts, for example dbt_kumar_marts in dev or core_marts in prod. That concatenation prevents developers from colliding with production. To get clean schema names in prod (just marts) and prefixed ones in dev, override the generate_schema_name macro so its logic depends on target.name.
Full answer: explanation, example and follow-ups
Detailed explanation
Default behaviour
- No custom schema: the model goes into
target.schema. - With a custom schema:
target.schema ~ '_' ~ custom_schema. In dev,dbt_kumarplusmartsgivesdbt_kumar_marts. - Why: a custom schema shouldn’t let a developer write into the shared production schema.
Common override
- Prod: use the custom schema name as is (
marts,staging,finance). - Dev and CI: keep the prefix (
dbt_kumar_marts), or put everything in the dev schema.
Related macros
generate_database_name: similar logic for custom databases (+database).generate_alias_name: controls table names, for example adding version suffixes or stripping prefixes.
Cautions
- Changing the macro on an existing project moves where models are built. Old relations remain, so clean them up, and update grants and BI connections.
- Make sure CI schemas stay isolated (see Slim CI with state:modified and
--defer). - Avoid logic that lets non-prod targets write to production schemas.
- Document the convention. It’s a common source of “where did my table go?” confusion.
Interview angle: this question checks whether you understand that dbt’s defaults are designed for safety in multi-developer environments, and that you can customise behaviour by overriding built-in macros. The same override pattern applies to many other dbt behaviours.
Example
-- macros/generate_schema_name.sql
{% macro generate_schema_name(custom_schema_name, node) -%}
{%- set default_schema = target.schema -%}
{%- if custom_schema_name is none -%}
{{ default_schema }}
{%- elif target.name == 'prod' -%}
{{ custom_schema_name | trim }}
{%- else -%}
{{ default_schema }}_{{ custom_schema_name | trim }}
{%- endif -%}
{%- endmacro %}
| Target | target.schema | +schema |
Result |
|---|---|---|---|
| dev | dbt_kumar |
marts |
dbt_kumar_marts |
| ci | dbt_ci_pr_42 |
marts |
dbt_ci_pr_42_marts |
| prod | core |
marts |
marts |
| prod | core |
(none) | core |
Real-world use case
Production BI tools pointed to ANALYTICS.MARTS, but dbt kept building ANALYTICS.CORE_MARTS because of the default concatenation, so teams created manual views to bridge the gap. Overriding generate_schema_name for prod removed the bridge views.
Common mistakes and red flags
- Not knowing about the default concatenation.
- Overrides that let dev write to prod schemas.
- Changing the macro without migration and cleanup.
Follow-up questions
- Is there a built-in alternative? — dbt ships
generate_schema_name_for_envyou can call from your override. - How do you put all dev models in one schema? — Return
target.schemafor non-prod targets regardless of custom schema.
32. grants and persist_docs configs
Short answer
The grants config (for example +grants: {select: ['reporter_role']}) makes dbt apply and maintain privileges on each relation it builds. It also revokes privileges not listed, for the privilege types it manages. persist_docs: {relation: true, columns: true} writes model and column descriptions into the warehouse as table and column comments, so catalogs and BI tools show the documentation.
Full answer: explanation, example and follow-ups
Detailed explanation
grants
- Configure in
dbt_project.yml(folder level), YAML, or model config. Lists merge or override according to config inheritance. Use a+prefix on the privilege key (for example+select) to add to inherited grants rather than replace them. - dbt compares current grants with the desired ones after building, and issues GRANT and REVOKE statements. On Snowflake,
copy_grantscan also preserve grants on recreated objects. - Privilege names follow the adapter:
selecton Snowflake, BigQueryroles/bigquery.dataViewer, Databricksselect. - Prefer it to hand-written post-hook GRANTs, because it is declarative and idempotent (see Hooks: pre-hook, post-hook, on-run-start/end).
- Platform-level access patterns, such as Snowflake future grants (see Access-role vs functional-role design and future grants) or Unity Catalog schema-level grants (see Unity Catalog securables, privileges and inheritance), may cover the need without per-object grants. Choose one approach to avoid fights between dbt and the platform.
persist_docs
relation: truegives a table or view comment.columns: truegives column comments.- Adapter support varies. For example, column comments on Snowflake views have limitations, depending on version.
- Makes documentation visible in Snowsight, Unity Catalog, and BI tools that read comments.
- Write meaningful descriptions (see Generating documentation and the lineage graph). Persisting empty docs adds nothing.
Other related configs
query_tag(Snowflake): tag dbt queries for cost attribution.tagsandmeta: governance metadata, for examplemeta: {contains_pii: true}, which macros can use to apply masking policies (Snowflake tag or masking automation).- Adapter configs for policies, such as dbt-databricks column masks or row filters in newer versions. Check the adapter docs.
Example
# dbt_project.yml (excerpt)
models:
acme_analytics:
+persist_docs:
relation: true
columns: true
marts:
+grants:
select: ["REPORTER", "DATA_SCIENTIST"]
finance:
+grants:
+select: ["FINANCE_ANALYST"] # '+' adds to inherited list instead of replacing
staging:
+grants:
select: [] # no consumer access to staging
Real-world use case
After each full rebuild, BI users lost access to recreated tables because grants were applied manually. Moving to the grants config made access part of the code, and every rebuild reapplied the right roles automatically.
Common mistakes and red flags
- Mixing manual grants with dbt-managed grants (dbt revokes the extras).
- Granting to users instead of roles.
- Relying on persist_docs without writing descriptions.
Follow-up questions
- What happens to grants not in config? — dbt revokes them for the privilege types it manages on that relation.
- How do you apply masking from dbt? — Post-hooks/macros applying masking policies, or tag-based policies in the warehouse driven by
meta.
33. Orchestrating dbt (Airflow/Cosmos, dbt platform jobs, warehouse-native dbt projects)
Short answer
There are four main options. dbt platform jobs (scheduled, triggered by API or another job’s completion). Airflow, often with Astronomer Cosmos, which renders each dbt model as an Airflow task. Other orchestrators such as Dagster or Prefect. Warehouse-native execution: dbt Projects on Snowflake scheduled with tasks, or dbt tasks in Databricks Lakeflow Jobs. Choose based on where the rest of your pipeline is orchestrated and how much per-model visibility you need.
Full answer: explanation, example and follow-ups
Detailed explanation
| Option | How it runs dbt | Strengths | Trade-offs |
|---|---|---|---|
| dbt platform jobs | Managed runners; schedule, API, job-completion triggers | Low ops, CI integration, artifacts, Catalog | Cross-system orchestration needs API calls from your orchestrator |
Airflow + BashOperator/DbtCoreOperator |
One task runs dbt build |
Simple | Whole project as one task — coarse retries/visibility |
| Airflow + Cosmos | Parses project; one Airflow task (or task group) per model/test | Per-model retries, lineage in Airflow UI, mixes with other tasks | Large DAGs in Airflow; parse overhead; environment setup |
| Dagster | Assets mapped from dbt models | Asset-based lineage, partitions, freshness policies | Different paradigm to learn |
| Snowflake dbt Projects | dbt project deployed as a Snowflake object; EXECUTE DBT PROJECT scheduled by tasks |
No external runtime; governed in Snowflake | Snowflake-only; feature parity evolving |
| Databricks dbt task | dbt task in Lakeflow Jobs on job/serverless compute + SQL warehouse | Native with other Databricks tasks | Databricks-only |
Design principles
- Upstream dependencies: trigger dbt when the ingestion finishes (sensor, dataset or asset event, job-completion trigger), or gate on source freshness (see Source freshness). Avoid “run at 2am and hope”.
- Granularity: per-domain selectors or per-model tasks for retries and SLAs. Don’t make 2,000 Airflow tasks without need.
- Idempotency: dbt runs are naturally rerunnable. Pass date vars for backfills (see How do you make Airflow tasks idempotent and safe to retry?).
- Artifacts: persist
manifest.jsonandrun_results.jsonfor CI deferral and monitoring (see dbt artifacts: manifest.json and run_results.json). - Secrets: inject them through the orchestrator’s secret backend into env vars (see How do you manage secrets in pipelines?).
Example
# Runtime: Airflow 3.x + astronomer-cosmos
from datetime import datetime
from airflow.sdk import dag
from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig, RenderConfig
from cosmos.profiles import SnowflakePrivateKeyPemProfileMapping
profile_config = ProfileConfig(
profile_name="acme_snowflake",
target_name="prod",
profile_mapping=SnowflakePrivateKeyPemProfileMapping(conn_id="snowflake_dbt_prod"),
)
@dag(schedule="0 3 * * *", start_date=datetime(2026, 1, 1), catchup=False, tags=["dbt"])
def dbt_finance_nightly():
DbtTaskGroup(
group_id="finance",
project_config=ProjectConfig("/opt/airflow/dbt/acme_analytics"),
profile_config=profile_config,
render_config=RenderConfig(select=["tag:finance"]),
default_args={"retries": 2},
)
dbt_finance_nightly()
Import paths (airflow.sdk in Airflow 3, Cosmos profile mapping class names) depend on the installed versions. Check them against your Airflow and Cosmos releases.
Real-world use case
A team ran dbt build as one Airflow task, and any failure meant rerunning everything for 2 hours. With Cosmos, each model became a task. Failed models retried individually, and an upstream ingestion task gated the finance task group.
Common mistakes and red flags
- Time-based schedules without upstream dependencies.
- Per-model tasks for huge projects without considering scheduler load.
- Credentials baked into images.
Follow-up questions
- How do you trigger a dbt platform job from Airflow? — dbt Cloud provider operators/API call with job ID, then poll for completion.
- How do you backfill with dbt in Airflow? — Parameterise vars by logical date and run backfills (see How do you make Airflow tasks idempotent and safe to retry?).
34. dbt artifacts: manifest.json and run_results.json
Short answer
dbt writes JSON artifacts to target/. manifest.json describes the whole project: nodes, configs, dependencies, compiled SQL, docs and tests. run_results.json has per-node status, timing, messages and failure counts for the last command. catalog.json (from docs generate) has warehouse metadata, and sources.json has freshness results. They power Slim CI, observability dashboards, lineage tools and custom governance checks.
Full answer: explanation, example and follow-ups
Detailed explanation
| Artifact | Produced by | Key contents | Uses |
|---|---|---|---|
manifest.json |
Most commands (parse/compile/run/build) | Nodes (models, tests, seeds, snapshots, sources, exposures, metrics), depends_on, configs, tags, meta, descriptions, compiled code, checksums |
State comparison for Slim CI (see Slim CI with state:modified and --defer), lineage export, linting/governance checks |
run_results.json |
run, build, test, seed, snapshot, compile | Per-node status (success/error/fail/warn/skipped), execution_time, failures, message, adapter response (rows affected, query id) |
Monitoring, alerting, performance tracking (see Speeding up slow dbt runs) |
catalog.json |
docs generate |
Columns, data types, table stats from warehouse | Docs site, schema comparison |
sources.json |
source freshness |
Max loaded_at, age, status per source | Freshness dashboards, source_status selection |
semantic_manifest.json |
Parse (with semantic layer) | Semantic models and metrics | MetricFlow |
Practical patterns
- Upload after every production run to object storage or a warehouse table (an
on-run-endhook or orchestrator step). Keep history for trends. - Observability: load
run_resultsinto a table, then dashboard failure rates, slowest models and test failures by owner. Packages like elementary automate this. - Governance checks in CI: parse the manifest to enforce “every public model has a description, an owner in meta, and PK tests”.
- Lineage integrations: OpenLineage and catalog tools ingest the manifest.
- Schema versions: artifacts carry
metadata.dbt_schema_version. Write parsers that tolerate new fields across dbt versions.
Example
# Runtime: Python 3.12 — CI governance check over manifest.json
import json
import sys
from pathlib import Path
manifest = json.loads(Path("target/manifest.json").read_text())
nodes = manifest["nodes"]
tested_columns = {
(n.get("attached_node"), n.get("column_name"))
for n in nodes.values()
if n["resource_type"] == "test" and n.get("test_metadata", {}).get("name") in {"unique", "not_null"}
}
problems = []
for uid, node in nodes.items():
if node["resource_type"] != "model" or not node["path"].startswith("marts/"):
continue
if not node.get("description"):
problems.append(f"{uid}: missing description")
if not node.get("meta", {}).get("owner"):
problems.append(f"{uid}: missing meta.owner")
if not any(attached == uid for attached, _ in tested_columns):
problems.append(f"{uid}: no unique/not_null tests")
print("\n".join(problems) or "All mart models pass governance checks")
sys.exit(1 if problems else 0)
Real-world use case
A platform team loads run_results.json from every production run into a table. A weekly report lists each domain’s slowest models and most-failing tests by owner, which made performance and quality work visible to every domain team.
Common mistakes and red flags
- Not persisting artifacts, so Slim CI and history are impossible.
- Brittle parsers broken by new dbt versions.
- Ignoring
skippedstatuses in monitoring.
Follow-up questions
- Where is the compiled SQL in the manifest? —
compiled_codeper node (after compile/run). - How do you get query IDs per model? —
adapter_responseinrun_results.json(adapter-dependent, e.g. Snowflake query_id).
35. Designing a test strategy across layers
Short answer
Test where each assumption enters. Sources get freshness, plus key and accepted-value checks on raw data. Staging gets primary-key unique and not_null tests, type and format checks, and dedupe. Intermediate gets grain checks after joins (no fan-out). Marts get business rules, relationships, reconciliation against sources or finance totals, and contracts. Add unit tests for complex logic. Tune severity so failures are actionable.
Full answer: explanation, example and follow-ups
Detailed explanation
By layer
| Layer | Tests | Why |
|---|---|---|
| Sources | Freshness; unique/not_null on natural keys; accepted values on status codes |
Catch upstream breakage early, attribute blame correctly |
| Staging | PK unique + not_null on every model; type/format (regex, ranges) |
Guarantee clean foundation |
| Intermediate | Grain tests (unique_combination_of_columns) after joins; row count expectations vs inputs |
Prevent fan-out/duplication |
| Marts | Relationships to dims; business invariants (net <= gross); reconciliation singular tests; contracts on public models; recency |
Protect consumers and KPIs |
| Logic | Unit tests for CASE/window/incremental logic (see Unit tests) | Correctness independent of data |
| Exposures | Build/test +exposure: first in critical jobs |
Protect tier-1 assets |
Making tests effective
- Every model gets a PK test. This is the highest value per effort.
- Severity policy: errors for invariants that must stop the pipeline, warnings for monitoring. Review warnings weekly, or they become noise (see Test severity, warn_if/error_if and store_failures).
- Ownership: route test failures to the owners (in
metaor group owners), with alerts in team channels. - Cost control:
wherefilters on big tables (last N days), sampling, and running expensive reconciliation tests less often (tags). - Avoid test bloat: don’t test the same rule on every layer. Test once where the rule originates, and test outputs where consumers depend on them.
- Observability: anomaly detection (volume, freshness, distribution) through packages like elementary, or warehouse-native monitors, complements deterministic tests (see Data observability vs data quality testing).
CI vs production
- CI runs unit tests, plus data tests on modified models (Slim CI).
- Production runs data tests in
dbt build(circuit breaker), plus freshness before the build.
Example
# models/intermediate/_int__models.yml
models:
- name: int_orders_joined_payments
description: "Grain: one row per order_id (payments aggregated)."
data_tests:
- dbt_utils.unique_combination_of_columns:
arguments:
combination_of_columns: [order_id]
- dbt_utils.equal_rowcount:
arguments:
compare_model: ref('stg_shop__orders')
-- tests/assert_fct_orders_reconciles_with_payments.sql
{{ config(severity='error', tags=['reconciliation']) }}
select o.ordered_at::date as day, sum(o.amount_paid) as mart_paid, max(p.paid) as source_paid
from {{ ref('fct_orders') }} o
join (select paid_at::date as day, sum(amount) as paid
from {{ ref('stg_stripe__charges') }} where status = 'succeeded' group by 1) p
on p.day = o.ordered_at::date
where o.ordered_at >= dateadd('day', -7, current_date)
group by 1
having abs(sum(o.amount_paid) - max(p.paid)) > 0.01
The reconciliation test assumes orders and payments fall on the same day. Adjust the join for your settlement timing, or compare cumulative totals over a window.
Real-world use case
A team had 2,000 tests but still missed a fan-out bug that doubled revenue, because no test checked grain after a join. Adding grain tests to every intermediate model, and cutting 600 redundant not_null tests, improved both coverage and runtime.
Common mistakes and red flags
- Lots of not_null tests but no grain tests.
- Everything at warn severity.
- Testing only marts, so failures can’t be traced to sources.
Follow-up questions
- How do you measure coverage? — Share of models with PK tests, public models with contracts, and critical columns with rules (manifest analysis; see dbt artifacts: manifest.json and run_results.json).
- Tests vs observability tools? — Tests check known rules; observability detects unknown anomalies.
36. Refactoring a legacy SQL script into dbt models
Short answer
First get the legacy script running as one dbt model whose output matches the original. Then replace hard-coded tables with source() and ref(), and split the logic into staging models (one per source), intermediate CTEs or models, and a final mart. After each step, validate with row counts and audit_helper comparisons against the legacy output. Add tests and docs, then cut consumers over.
Full answer: explanation, example and follow-ups
Detailed explanation
Step by step
- Lift and shift. Copy the legacy SQL into a model (
legacy_revenue_report.sql) unchanged, except for wrapping it as a SELECT. Build it, and confirm it matches the legacy table. - Declare sources. Replace raw table names with
{{ source(...) }}, and add source YAML with freshness. - Create staging models. For each raw table, a
stg_model with renames, casts and cleaning. Point the legacy model’s CTEs at them. - Split the logic. Turn large CTEs or temp-table steps into intermediate models where they’re reusable or testable. Keep small CTEs inline.
- Final mart. Clean naming, explicit columns, a materialisation choice (table, incremental if large).
- Validate at each step, comparing the refactored output with the legacy output:
audit_helper.compare_relations(matching, missing or extra rows).compare_column_valuesper column to find which column differs.- Row counts, sums of key measures, distinct keys.
- Add tests: PK unique and not_null, relationships, business rules, plus unit tests for tricky CASE logic.
- Document and cut over: descriptions and exposures, then point BI to the new model, run both in parallel for a period, and retire the legacy job.
Handling legacy quirks
- Temp tables and procedural steps become models or CTEs. Row-by-row cursor logic becomes set-based SQL (window functions).
- UPDATE statements applied after insert become CASE expressions or later models.
- Hidden business rules (magic numbers, hard-coded IDs): extract them into seeds or vars with documentation (see Seeds).
- Known legacy bugs: decide explicitly whether to replicate them during validation (bug-for-bug), then fix them in a separate, documented change.
Example
-- analyses/compare_revenue_report.sql (uses audit_helper)
{% set old_relation = adapter.get_relation(database='LEGACY', schema='REPORTS', identifier='REVENUE_REPORT') %}
{% set new_relation = ref('fct_revenue_report') %}
{{ audit_helper.compare_relations(
a_relation = old_relation,
b_relation = new_relation,
primary_key = 'order_id'
) }}
Expected result summary: in_a, in_b and counts by match status. 100% in_a and in_b for the full row comparison means the refactor is faithful.
Real-world use case
A 1,800-line stored procedure, with 14 temp tables and 6 UPDATE passes, produced the monthly revenue report. Refactoring it into 9 staging, 4 intermediate and 1 mart model, validated with audit_helper at each step, surfaced two long-standing bugs. Finance approved the fixes separately after the faithful port was verified.
Common mistakes and red flags
- Rewriting everything at once without validation.
- Fixing bugs during the port without telling stakeholders.
- Leaving hard-coded table names.
Follow-up questions
- How do you migrate stored procedures with loops? — Replace with set-based SQL; for genuinely procedural needs, Python models or keep a procedure called by a hook temporarily.
- How long do you run old and new in parallel? — Through at least one full business cycle (e.g. month-end close).
37. Common dbt anti-patterns
Short answer
Common anti-patterns: hard-coded table names instead of ref()/source(); sources referenced from many models; business logic in staging; models without primary-key tests; giant 1,000-line models or deep ephemeral chains; incremental models without a unique_key or lookback; heavy views referenced everywhere; untested macros full of business logic; secrets in code; and running run then test instead of build.
Full answer: explanation, example and follow-ups
Detailed explanation
| Anti-pattern | Why it hurts | Fix |
|---|---|---|
| Hard-coded relations | Breaks DAG, environments, lineage | ref() / source() (see ref() vs source()) |
| Same source in many models | Inconsistent cleaning, hard to change | One staging model per source table (see Project layering: staging, intermediate and marts) |
| Marts referencing sources | Skips cleaning layer | Route through staging |
| No PK tests | Duplicates go unnoticed | unique + not_null everywhere (see The four built-in generic data tests) |
| Monster models | Unreadable, untestable, slow to debug | Split into intermediate models; CTE structure |
| Deep ephemeral chains | Huge compiled SQL; can’t debug pieces | Views/tables for important steps (see Materializations: view vs table vs incremental vs ephemeral vs materialized_view) |
| Incremental without unique_key/lookback | Duplicates or missed late data | Merge + lookback (see Incremental models and is_incremental(), Late-arriving data in incremental models (lookback windows)) |
| Everything incremental | Complexity without need | Tables until scale demands otherwise |
| Heavy views reused widely | Repeated computation, slow BI | Materialise as tables (see Speeding up slow dbt runs) |
| Jinja-heavy business logic in macros | Hidden logic, hard review | Keep logic in models; macros for small reusable snippets |
select * in marts |
Accidental schema changes reach consumers | Explicit columns, contracts for public models (see Model contracts) |
| Secrets in profiles/vars committed | Security risk | env_var + secret manager (see var() and env_var()) |
run then test in prod |
Bad data propagates before tests run | dbt build (see dbt run vs build vs test vs compile) |
| Full refresh as routine fix | Expensive; risky with limited source retention | Fix logic; targeted backfills; full_refresh: false (see --full-refresh: when and why) |
| No docs/owners | Nobody knows what models mean | Descriptions + meta.owner + CI checks (see dbt artifacts: manifest.json and run_results.json) |
| Circular logic via post-hooks writing to upstream tables | Hidden dependencies | Model dependencies explicitly |
| Dead models | Cost and confusion | Lineage + usage analysis; delete unused |
Automated detection
dbt_project_evaluatorflags structural issues: direct source references in marts, missing PK tests, model fanout, undocumented models.- SQLFluff for style.
- Manifest-based CI checks for team-specific rules.
- Warehouse usage data (query history, access history) to find unused models.
Cultural fixes: a PR template checklist (tests, docs, materialisation rationale), code owners per domain, and periodic cleanup sprints.
Example
-- Before (anti-patterns: hard-coded table, raw source in mart, select *, no dedupe)
select *
from RAW.SHOP.ORDERS o
join ANALYTICS.MARTS.DIM_CUSTOMER c on o.customer_id = c.customer_id
-- After (staging via ref, explicit columns, deterministic dedupe upstream)
select
o.order_id,
o.customer_id,
c.customer_segment,
o.ordered_at,
o.order_total
from {{ ref('stg_shop__orders') }} o
left join {{ ref('dim_customers') }} c
on o.customer_id = c.customer_id
Real-world use case
Running dbt_project_evaluator on a 1,400-model project flagged 230 direct source references in marts, 310 models without PK tests and 90 unused models. A quarter-long cleanup cut runtime by 25% and incident counts by about half.
Common mistakes and red flags
- Can’t name more than one anti-pattern.
- Defends hard-coded table names.
- No strategy for enforcing standards beyond hope.
Follow-up questions
- How do you find unused models? — Warehouse access history + exposures + BI usage metadata.
- What’s model fanout? — One model feeding very many direct children, often a sign intermediate models are missing.
Advanced dbt interview questions (experienced)
38. Incremental strategies by adapter (append, merge, delete+insert, insert_overwrite, microbatch)
Short answer
The incremental strategy decides how new rows land. append just inserts. merge upserts on a unique_key. delete+insert deletes target rows matching the new batch’s keys, then inserts. insert_overwrite replaces whole partitions. microbatch processes time-sliced batches by event_time. Supported strategies and defaults vary by adapter, so pick based on keys, partitioning, volume and the cost profile of the warehouse.
Full answer: explanation, example and follow-ups
Detailed explanation
| Strategy | How | Needs | Good for | Watch out |
|---|---|---|---|---|
append |
INSERT new rows | — | Immutable events, no reprocessing | Duplicates if lookback/retries |
merge |
MERGE on unique_key |
unique_key |
Upserts, late updates | Full target scans if unpruned; cost on huge tables |
delete+insert |
DELETE matching keys, INSERT batch | unique_key |
Large batch replacements; adapters where MERGE is slow | Two statements; key must cover all affected rows |
insert_overwrite |
Replace partitions present in batch | Partitioned target (BigQuery, Spark/Databricks) | Date-partitioned facts with full-day recompute | Partitions missing from batch are untouched |
replace_where (dbt-databricks) |
Delta REPLACE WHERE predicate |
incremental_predicates |
Deterministic ranges | Predicate must match data |
microbatch (dbt 1.9+) |
Independent batches by event_time and batch_size, using the adapter’s replace mechanism per batch |
event_time, begin, batch_size |
Large time-series, backfills, retries per batch (see The microbatch incremental strategy) | Requires clean event_time column |
Adapter notes (defaults can change, so check your adapter’s docs)
- Snowflake:
mergeis the default.append,delete+insert,insert_overwrite(full-table overwrite on Snowflake) andmicrobatchare supported. - Databricks:
mergeis the default for Delta.append,insert_overwrite,replace_whereandmicrobatchare supported. - BigQuery:
mergeis the default.insert_overwritewith partitions (static or dynamic) is often the cheapest. - Postgres and Redshift:
append,delete+insertandmerge(newer versions).
Performance lever: incremental_predicates. These add conditions to the MERGE or DELETE so the warehouse prunes the target, for example DBT_INTERNAL_DEST.order_date >= dateadd(day, -7, current_date). Without them, MERGE may scan the whole target to find matches.
Example
-- Dialect: Snowflake SQL via dbt
{{ config(
materialized = 'incremental',
incremental_strategy = 'merge',
unique_key = 'event_id',
incremental_predicates = [
"DBT_INTERNAL_DEST.event_date >= dateadd(day, -3, current_date)"
],
cluster_by = ['event_date']
) }}
select event_id, user_id, event_type, event_ts, event_ts::date as event_date
from {{ ref('stg_app__events') }}
{% if is_incremental() %}
where event_ts >= (select dateadd(day, -2, max(event_ts)) from {{ this }})
{% endif %}
The predicate window (3 days) must be at least as wide as the source lookback (2 days). Otherwise rows reprocessed from the lookback can’t find their existing matches, and they get inserted again as duplicates.
Real-world use case
A merge into a 20-billion-row events table took 40 minutes because each run scanned the whole target. Adding incremental_predicates limited to the last 3 days (matched to the lookback) cut it to 3 minutes on clustered data.
Common mistakes and red flags
appendwith lookbacks.insert_overwriteassuming partitions not in the batch get deleted.- Predicates narrower than the lookback.
Follow-up questions
- What’s
DBT_INTERNAL_DEST? — The alias dbt uses for the target table in generated MERGE/DELETE statements. - Can unique_key be composite? — Yes, a list of columns.
39. The microbatch incremental strategy
Short answer
incremental_strategy='microbatch' (dbt 1.9+) splits processing into independent time batches (hour, day, month or year) based on an event_time column. dbt filters the model’s inputs to each batch automatically, so you don’t write is_incremental() logic. It can rerun, retry or backfill specific batches with --event-time-start / --event-time-end, and use lookback for late data.
Full answer: explanation, example and follow-ups
Detailed explanation
Key configs
| Config | Meaning |
|---|---|
event_time |
Column on this model (and upstream models/sources) used to slice data |
batch_size |
hour, day, month, year |
begin |
Earliest timestamp for initial/full builds |
lookback |
Number of prior batches to reprocess each run (default 1) |
concurrent_batches |
Run batches in parallel when the adapter/model allows |
unique_key (some adapters) |
Used where the adapter implements batch replace via merge |
How filtering works
- Upstream models and sources that also declare
event_timeare auto-filtered to the batch window whenref’d orsource’d in a microbatch model. - Upstreams without
event_timeare read in full (for example dimensions). Useref('dim').render()-style options or setevent_timeon upstreams deliberately. - Each batch is written by the adapter’s mechanism for replacing that time range: delete+insert or insert_overwrite by time range, depending on the adapter. Reruns are therefore idempotent per batch.
Operations
- Normal run: processes the current batch plus
lookbackprevious batches. - Backfill:
dbt run --select fct_events --event-time-start "2026-01-01" --event-time-end "2026-02-01". - Retry:
dbt retryreruns only failed batches. - Logs show per-batch success or failure.
When to prefer it over classic incremental
- Large time-series facts, where predictable time-sliced work and easy backfills matter.
- Teams tired of hand-written
is_incremental()filters and lookback math. - It is less suitable when the natural key isn’t time-based or updates arrive without reliable event time (CDC upserts on keys are better served by
merge).
Example
-- models/marts/fct_sessions.sql (Dialect: Snowflake SQL via dbt 1.9+)
{{ config(
materialized = 'incremental',
incremental_strategy = 'microbatch',
event_time = 'session_start',
batch_size = 'day',
begin = '2025-01-01',
lookback = 2
) }}
select session_id, user_id, session_start, session_end, page_count
from {{ ref('int_web__sessions') }} -- auto-filtered per day if it declares event_time
# models/intermediate/_int__models.yml
models:
- name: int_web__sessions
config:
event_time: session_start
Real-world use case
A team spent hours backfilling a hand-written incremental model after a logic fix. With microbatch they ran a one-month backfill using --event-time-start/--event-time-end. Two failed days were retried with dbt retry, without touching the other 28.
Common mistakes and red flags
- Missing
event_timeon upstreams, which makes dbt read everything. - Wrong
batch_sizefor the data volume. - Using microbatch for key-based CDC upserts.
Follow-up questions
- Do you need
is_incremental()with microbatch? — No; filtering is automatic. - How is late data handled? — Via
lookbackbatches; very late data needs targeted backfills.
40. YAML-defined snapshots and hard-delete handling
Short answer
Since dbt 1.9, snapshots can be defined entirely in YAML (relation: plus config:), instead of the older Jinja {% snapshot %} blocks. Hard deletes are handled with hard_deletes. ignore (default) does nothing. invalidate closes the current row by setting dbt_valid_to. new_record inserts a new row flagged dbt_is_deleted = true, so deletions appear explicitly in history. The older invalidate_hard_deletes: true corresponds to invalidate.
Full answer: explanation, example and follow-ups
Detailed explanation
Why YAML snapshots
- Less boilerplate: no SQL file is needed when snapshotting a source or model directly.
- Configs live alongside other properties, such as descriptions and tests.
- If you need light transformations, point
relation:to an ephemeral or staging model, or keep the legacy SQL block style (still supported).
Hard-delete modes
hard_deletes |
What happens when a key disappears from source | History shows |
|---|---|---|
ignore |
Nothing; last version stays “current” forever | Deleted rows look active (misleading) |
invalidate |
dbt_valid_to set to snapshot time |
Row ends; no explicit delete marker |
new_record |
Close current row and insert a new row with dbt_is_deleted = true |
Explicit deletion event; reinsertion later creates another version |
Considerations
- Detecting deletes requires comparing the full source key set to current snapshot rows, which is more expensive on big tables. The source must be complete each run, not an incremental extract. Otherwise missing rows look like deletes.
- If the source is an incremental extract (only changed rows), hard-delete detection gives false deletes. Use CDC delete events instead.
- Downstream “current” logic: filter
dbt_valid_tois NULL (or the far-future date) andcoalesce(dbt_is_deleted, false) = falsewhen usingnew_record. - Other 1.9+ options:
snapshot_meta_column_namesto rename metadata columns, anddbt_valid_to_currentto use a far-future date instead of NULL, which simplifies BETWEEN joins.
Example
# snapshots/snap_billing__subscriptions.yml
snapshots:
- name: snap_billing__subscriptions
relation: source('billing', 'subscriptions')
description: "SCD2 history of subscriptions; deletions recorded explicitly."
config:
schema: snapshots
unique_key: subscription_id
strategy: check
check_cols: [plan_code, status, seats]
hard_deletes: new_record
dbt_valid_to_current: "to_timestamp('9999-12-31')"
-- models/marts/dim_subscription_current.sql
select *
from {{ ref('snap_billing__subscriptions') }}
where dbt_valid_to = to_timestamp('9999-12-31')
and coalesce(dbt_is_deleted, false) = false
Real-world use case
Cancelled subscriptions were hard-deleted in the billing database, and a snapshot with default settings kept them “active” forever, inflating MRR. Switching to hard_deletes: new_record made cancellations explicit, and the MRR model excluded rows marked deleted.
Common mistakes and red flags
- Hard-delete detection on incremental extracts.
- Forgetting the deleted flag in “current” filters.
- Assuming YAML snapshots require dbt 1.8.
Follow-up questions
- What if a deleted key comes back? — With
new_record, a new active version is inserted. - Can you convert legacy snapshot blocks to YAML? — Yes; move configs to YAML and point
relationto the source/model.
41. Model contracts
Short answer
A model contract (contract: {enforced: true}) declares the exact columns and data types a model must produce. dbt checks the compiled output against them before building and fails if they don’t match. On supported platforms it can also apply constraints in the DDL (not_null, primary_key, check, foreign_key), with enforcement depending on the platform. Use contracts on public, consumer-facing models to stop accidental breaking changes.
Full answer: explanation, example and follow-ups
Detailed explanation
What enforcement does
- Before building, dbt compares the model’s output columns (names, order-independent, and types) with the YAML definition. A mismatch is an error.
- It builds the table with explicit DDL (column types) instead of
CREATE TABLE AS SELECTtype inference, on supported materialisations (table, incremental). Views get the type check only. - Constraints:
not_nullis enforced on most warehouses.primary_key,uniqueandforeign_keyare often informational (Snowflake, BigQuery, Databricks), so use data tests for real enforcement.checkis enforced on Postgres and Databricks (Delta CHECK constraints; see Identity columns and constraints in Delta tables), and not on Snowflake.
Contracts in a governance setup
- Combine them with access levels (public, protected, private), groups and model versions (see Model versions, groups and access levels). Breaking changes to a public contracted model require a new version, and dbt detects breaking contract changes against the previous state in CI (with
state:modified). - Contracts are the producer side of a data contract (see What are data contracts, and how do you enforce them?). Consumers rely on stable columns and types.
Trade-offs
- More YAML to maintain, because every column needs a type. Use codegen to generate it.
- Type names are adapter-specific (
number(38,2)vsdecimal(38,2)). Usedata_typevalues valid for your warehouse. - Use contracts for stable, shared models, not every staging model.
Example
# models/marts/finance/_finance__models.yml
models:
- name: fct_orders
access: public
config:
contract:
enforced: true
materialized: table
columns:
- name: order_id
data_type: number(38,0)
constraints:
- type: not_null
- type: primary_key
- name: customer_id
data_type: number(38,0)
constraints:
- type: not_null
- name: ordered_at
data_type: timestamp_ntz
- name: order_total
data_type: number(18,2)
data_tests:
- dbt_utils.unique_combination_of_columns:
arguments:
combination_of_columns: [order_id]
If someone changes order_total to return a float, or drops customer_id, dbt build fails with a contract error before the table is replaced.
Real-world use case
A finance mart’s amount column changed from number(18,2) to float during a refactor, causing rounding issues in a downstream reconciliation system. After contracts were enforced on all public finance models, a similar change was caught in CI two months later.
Common mistakes and red flags
- Assuming primary key constraints are enforced on Snowflake.
- Contracts on every model (maintenance burden).
- Not pairing contracts with versioning for breaking changes.
Follow-up questions
- What happens to views with contracts? — Column/type check only; constraints not applied.
- How do you handle an intentional breaking change? — Create a new model version and deprecate the old one (see Model versions, groups and access levels).
42. Model versions, groups and access levels
Short answer
Groups assign models to an owning team. Access levels control who can ref a model: private (same group only), protected (same project, the default), public (other projects through dbt Mesh). Model versions (versions: in YAML) let you publish a breaking change as v2 while v1 keeps working for consumers until its deprecation_date. Consumers use ref('model', v=1), or the latest version by default.
Full answer: explanation, example and follow-ups
Detailed explanation
Groups
groups:in YAML, each with a name and owner (name or email). Models setgroup: finance.- They enable private access: only models in the same group can ref the model.
Access levels
| Level | Who can ref |
|---|---|
private |
Models in the same group |
protected (default) |
Any model in the same project (and packages installed in it) |
public |
Any project, including downstream projects via dbt Mesh cross-project refs (see dbt Mesh: cross-project refs) |
Violations fail at parse time, so the rules are enforced, not just advised.
Versions
- Define
versions:withv:numbers. Each version can include or exclude columns relative to the base definition, or point to a different SQL file (defined_in). latest_version:sets what an unpinnedref('fct_orders')resolves to.- Relations are named
fct_orders_v1andfct_orders_v2by default. You can create a view or alias for the latest version. deprecation_date:makes dbt warn consumers who still ref old versions.- Combine with contracts: when a public contracted model changes in a breaking way, dbt in CI (state comparison) flags it unless you bump the version.
Lifecycle of a breaking change
- Add v2 with the new schema. Keep v1, and set
latest_version: 1initially if you want consumers to opt in. - Communicate, and let consumers migrate to
ref('fct_orders', v=2). - Switch
latest_versionto 2. - After the deprecation date, remove v1.
Example
groups:
- name: finance
owner:
name: Finance Data Team
email: [email protected]
models:
- name: fct_orders
group: finance
access: public
latest_version: 2
config:
contract: {enforced: true}
columns:
- name: order_id
data_type: number(38,0)
- name: order_total
data_type: number(18,2)
- name: currency
data_type: varchar
versions:
- v: 2
- v: 1
deprecation_date: 2026-06-30
columns:
- include: all
exclude: [currency]
-- Downstream model in a consuming project still on v1
select order_id, order_total from {{ ref('finance_project', 'fct_orders', v=1) }}
Real-world use case
The finance team needed to add a mandatory currency column and change amount semantics in fct_orders. They published v2, kept v1 for 90 days with a deprecation date, and three consuming teams migrated on their own schedules without broken dashboards.
Common mistakes and red flags
- Making everything public.
- Versioning without contracts.
- Never removing deprecated versions.
Follow-up questions
- Does private access affect the warehouse grants? — No; it’s a dbt ref-time rule. Warehouse access uses grants.
- How is
latest_versionresolved? — An unpinnedrefresolves to it.
43. dbt Semantic Layer and MetricFlow (See also Q99)
Short answer
The dbt Semantic Layer defines metrics once in YAML: semantic models with entities, dimensions and measures, plus metrics built on them. MetricFlow generates the correct SQL (joins, grains, filters) for any requested combination of metrics and dimensions. BI tools, notebooks and AI agents query metrics through the dbt platform’s Semantic Layer APIs, so “revenue” means the same thing everywhere.
Full answer: explanation, example and follow-ups
Detailed explanation
Building blocks
| Concept | Meaning |
|---|---|
| Semantic model | Maps a dbt model to entities (keys), dimensions (categorical, time), measures (aggregations) |
| Entity | Join key (primary, foreign, unique, natural) — MetricFlow uses entities to build joins |
| Dimension | Attributes to group/filter by; time dimensions with granularity |
| Measure | Aggregation of a column (sum, count, count_distinct, average, percentile…) |
| Metric | simple, ratio, derived, cumulative, conversion metrics built on measures |
| Saved query / export | Predefined metric queries, optionally materialised as tables |
How queries work
- A consumer requests
metrics=[revenue, orders], group_by=[metric_time__month, customer__segment]. - MetricFlow finds the semantic models, joins through entities (avoiding fan-out errors), applies filters, and generates SQL for the warehouse.
- Interfaces include the dbt platform Semantic Layer APIs (JDBC/ADBC, GraphQL), integrations (Tableau, Power BI, Google Sheets, Hex and others), and the MetricFlow CLI in development (
mf query,dbt sl query).
Open-source and ecosystem notes
- MetricFlow is open source (Apache 2.0, relicensed in 2025). The hosted query APIs are part of the dbt platform.
- dbt Labs is part of the Open Semantic Interchange (OSI) initiative, which aims at portable semantic definitions across tools. Warehouse-native semantic layers also exist, such as Snowflake semantic views and Databricks metric views (see What is a semantic/metrics layer?).
- YAML spec changes: newer dbt versions have been simplifying the semantic layer YAML (for example defining metrics closer to models). Check your version’s spec.
Design tips
- Build semantic models on clean marts at clear grains.
- Define measures with explicit aggregations and descriptions. Use
agg_time_dimension. - Prefer ratio and derived metrics over pre-computed ratios, which avoids averaging averages.
- Test metric outputs against known reports during migration.
Example
# models/marts/finance/_finance__semantic.yml
semantic_models:
- name: orders
model: ref('fct_orders')
defaults:
agg_time_dimension: ordered_at
entities:
- name: order
type: primary
expr: order_id
- name: customer
type: foreign
expr: customer_id
dimensions:
- name: ordered_at
type: time
type_params:
time_granularity: day
- name: order_status
type: categorical
measures:
- name: order_total
agg: sum
- name: order_count
expr: order_id
agg: count_distinct
metrics:
- name: revenue
label: Revenue
type: simple
type_params:
measure: order_total
- name: average_order_value
label: Average Order Value
type: ratio
type_params:
numerator: revenue
denominator: order_count
dbt sl query --metrics revenue,average_order_value --group-by metric_time__month,order__order_status
Real-world use case
Three BI tools computed “average order value” differently: a mean of daily averages in one, excluding cancellations in another. Defining it once as a ratio metric in the Semantic Layer and connecting the tools to it removed a long-running dispute between finance and marketing.
Common mistakes and red flags
- Defining metrics on messy staging models.
- Pre-aggregated averages.
- Treating the semantic layer as only a BI feature, without governance.
Follow-up questions
- How does MetricFlow avoid fan-out? — It uses entity relationships and aggregates measures at their semantic model grain before joining.
- Semantic layer vs a wide mart table? — Wide tables fix one grain/filter set; the semantic layer answers flexible combinations consistently.
44. Slim CI with state:modified and --defer
Short answer
Slim CI builds and tests only what a pull request changed, plus its descendants, using --select state:modified+ with --state pointing to the production manifest.json. --defer makes refs to unchanged, unbuilt parents resolve to production relations instead of the CI schema. CI then validates changes in minutes without rebuilding the whole project.
Full answer: explanation, example and follow-ups
Detailed explanation
How it works
- Production runs save artifacts (
manifest.json). CI downloads the latest production manifest. In dbt platform, deferral to a production environment is built in. - dbt compares the PR’s manifest with production’s.
state:modifiedfinds nodes whose SQL, configs, macros they depend on, or YAML properties changed.state:newfinds added nodes. dbt build --select state:modified+ --defer --state prod_artifacts/ --target cithen:- builds the modified models and their descendants into a CI schema (often PR-specific, such as
dbt_ci_pr_123); - lets unchanged parents that aren’t in the selection defer to production relations, so they don’t need building;
- runs tests on what was built.
- builds the modified models and their descendants into a CI schema (often PR-specific, such as
- Clean up CI schemas after merge or on a schedule.
Refinements
- Sub-selectors:
state:modified.body,.configs,.relation,.persisted_descriptions,.macros,.contract, for finer control. --favor-state: prefer production relations even if a CI relation exists.dbt clone: zero-copy clone production relations of modified incremental models into CI first, so incremental logic runs against realistic existing data (see dbt clone and zero-copy CI environments).- Empty builds:
--empty(dbt 1.8+) builds with zero rows (limit 0) to validate SQL and schema cheaply. - Contract checks: breaking changes to contracted public models are flagged when compared with state (see Model contracts).
- Unit tests: run them for modified models in CI (
test_type:unit).
Why it matters
- Full CI builds of large projects take hours and cost a lot. Slim CI often takes minutes.
- Developers get fast feedback, and reviewers see test results on the PR.
Example
# .github/workflows/dbt_ci.yml (excerpt)
jobs:
dbt-ci:
runs-on: ubuntu-latest
env:
DBT_SF_ACCOUNT: ${{ secrets.DBT_SF_ACCOUNT }}
DBT_SF_USER: ${{ secrets.DBT_SF_CI_USER }}
DBT_SF_KEY_PATH: /tmp/ci_key.p8
DBT_CI_SCHEMA: dbt_ci_pr_${{ github.event.number }}
steps:
- uses: actions/checkout@v4
- run: pip install dbt-snowflake
- run: echo "${{ secrets.DBT_SF_CI_PRIVATE_KEY }}" > /tmp/ci_key.p8
- run: ./scripts/download_prod_manifest.sh prod-artifacts/ # from object storage
- run: dbt deps
- run: dbt build --select state:modified+ --defer --state prod-artifacts --target ci
The CI profile’s schema reads env_var('DBT_CI_SCHEMA'), so each PR builds into its own schema. The private key is written to a temporary file on the ephemeral runner and is never logged.
Real-world use case
A 1,200-model project’s CI rebuilt everything in 2.5 hours. Slim CI with deferral cut typical PR checks to 6–10 minutes, and the warehouse cost of CI dropped by over 90%.
Common mistakes and red flags
- A stale production manifest, which gives wrong modified detection.
- No
--defer, so CI tries to build every parent. - Never cleaning CI schemas.
Follow-up questions
- Where does the prod manifest come from? — Saved after each prod run (object storage, artifact store) or provided by dbt platform.
- What if a macro changes? —
state:modifiedincludes nodes that depend on changed macros.
45. Speeding up slow dbt runs
Short answer
First find the bottleneck. Compare model timings from run_results.json and the critical path in the DAG. Then convert slow full-table rebuilds to incremental (with pruning predicates), tune expensive SQL and data layout (clustering or partitioning), raise threads to use DAG parallelism, right-size or split warehouses, reduce unnecessary models and tests, and parse faster with partial parsing or Fusion.
Full answer: explanation, example and follow-ups
Detailed explanation
Diagnose
run_results.jsongives execution time per node. Sort to find the top 20. Track trends over runs (see dbt artifacts: manifest.json and run_results.json).- Warehouse query history and query profiles show spill, scans, joins and queueing (see MERGE in Snowflake and the nondeterministic-merge error, Troubleshooting a slow Databricks job end to end).
- Critical path: total runtime is bounded by the longest dependency chain, not total model time. Optimise models on that chain first.
- Parse and compile time: is dbt itself slow before queries start?
Levers
| Area | Actions |
|---|---|
| Materializations | Incremental for large facts; incremental_predicates; microbatch for time-series (see Incremental models and is_incremental()–The microbatch incremental strategy) |
| SQL | Filter early, avoid select * on wide tables, remove repeated heavy CTEs, avoid exploding joins, use QUALIFY instead of self-joins |
| Data layout | Clustering keys (Snowflake), partition/cluster (BigQuery), liquid clustering (Databricks) on filter/join columns |
| Parallelism | threads (8–32 typical) — DAG width limits benefit; warehouse concurrency must match |
| Compute | Bigger warehouse for heavy models via snowflake_warehouse config; separate warehouses per workload; Gen2/adaptive options |
| DAG design | Break long chains; avoid huge views queried repeatedly downstream (materialize as table); remove dead models |
| Tests | where filters on big tables, sample-based tests, run expensive tests less frequently |
| Selection | Split nightly runs by domain/priority; source_status:fresher+ to skip unchanged |
| Parsing | Partial parsing (default), avoid run_query at parse, Fusion engine |
Views as a hidden cost: a view referenced by 10 downstream models recomputes 10 times. If it is heavy, materialise it as a table.
Example
-- Heavy model routed to a larger warehouse only for itself (dbt-snowflake)
{{ config(
materialized = 'incremental',
unique_key = 'event_id',
snowflake_warehouse = 'TRANSFORM_L',
cluster_by = ['event_date'],
incremental_predicates = ["DBT_INTERNAL_DEST.event_date >= dateadd(day, -3, current_date)"]
) }}
select * from {{ ref('int_events_enriched') }}
{% if is_incremental() %}
where event_date >= dateadd(day, -2, current_date)
{% endif %}
# Runtime: Python 3.12 — top 10 slowest nodes from run_results.json
import json
from pathlib import Path
results = json.loads(Path("target/run_results.json").read_text())["results"]
slowest = sorted(results, key=lambda r: r.get("execution_time", 0), reverse=True)[:10]
for r in slowest:
print(f'{r["execution_time"]:8.1f}s {r["status"]:8} {r["unique_id"]}')
Real-world use case
A 4-hour nightly run had a critical path through a heavy view referenced by 12 models. Materialising that view as an incremental table, raising threads from 4 to 16, and giving three heavy models a larger warehouse cut the run to 70 minutes.
Common mistakes and red flags
- Upsizing the warehouse for everything.
- Raising threads beyond warehouse capacity, which causes queueing.
- Ignoring the critical path.
Follow-up questions
- How many threads? — Enough to use DAG width without queueing; tune with warehouse size/multi-cluster.
- What is partial parsing? — dbt re-parses only changed files using
partial_parse.msgpack.
46. Python models
Short answer
A dbt Python model is a .py file defining def model(dbt, session) that returns a DataFrame. It runs on the warehouse’s Python runtime: Snowpark on Snowflake, PySpark on Databricks, BigQuery DataFrames or Dataproc on BigQuery. It participates in the DAG with dbt.ref() and dbt.source(). Use it for logic SQL handles poorly, such as ML scoring, complex parsing or statistical functions. Keep most transformations in SQL.
Full answer: explanation, example and follow-ups
Detailed explanation
Structure
dbt.config(materialized="table", packages=[...])sets configs (packages are platform-specific).dbt.ref("model")anddbt.source("src", "table")return platform DataFrames.- Return a DataFrame. dbt writes it with the chosen materialisation (table or incremental, with
dbt.is_incrementalanddbt.this). - Python models can’t use Jinja, and macros don’t apply inside them.
Platform execution
| Platform | Runtime | Notes |
|---|---|---|
| Snowflake | Snowpark stored procedure | Packages from Snowflake Anaconda channel/artifact repository; warehouse compute (Snowpark-optimized for memory-heavy) |
| Databricks | PySpark on cluster/serverless | submission_method (all-purpose, job cluster, serverless); Python dependencies configured per platform |
| BigQuery | BigQuery DataFrames or Dataproc Serverless | Configuration via adapter docs |
Good uses
- Applying a pre-trained ML model to score rows (with UDFs or vectorised functions).
- Complex text, parsing or NLP with Python libraries.
- Statistical routines unavailable in SQL.
- Prototyping logic with pandas-like APIs (Snowpark pandas, PySpark pandas API).
Cautions
- Cost and speed: Python execution can be slower and more expensive than SQL. Pulling data to pandas (
to_pandas()) on large tables risks memory problems. - Testing: unit tests for Python models are limited compared with SQL. Rely on data tests and modular Python functions tested separately.
- Debugging and observability are harder: logs live in the platform’s procedure or job logs.
- Team skills and code review standards must cover both languages.
Example
# models/marts/ml/fct_churn_scores.py (dbt-snowflake Python model, Snowpark)
import snowflake.snowpark.functions as F
def model(dbt, session):
dbt.config(materialized="table", packages=["scikit-learn==1.5.2"])
features = dbt.ref("int_customer_churn_features")
# Simple rules-based score as a stand-in for a registered model call
scored = features.with_column(
"churn_score",
F.least(F.lit(1.0),
F.col("days_since_last_order") / F.lit(180.0) * F.lit(0.6)
+ F.col("support_tickets_90d") / F.lit(10.0) * F.lit(0.4)),
).select("customer_id", "churn_score", F.current_timestamp().alias("scored_at"))
return scored
In practice you would call a model registered in the platform’s model registry, for example as a UDF or a registry inference method. The score formula here is illustrative. Check the package pin against the versions available in your Snowflake channel.
Real-world use case
A team scored 20 million customers nightly with a registered churn model. A dbt Python model calls the model’s inference function on Snowpark and writes fct_churn_scores. Tests check score ranges, and an exposure links the CRM sync.
Common mistakes and red flags
- Using Python models for simple joins.
to_pandas()on large tables.- Expecting Jinja inside Python models.
Follow-up questions
- Can Python models be incremental? — Yes on supported adapters, using
dbt.is_incrementalanddbt.this. - Can SQL models ref Python models? — Yes; they’re normal DAG nodes.
47. Late-arriving data in incremental models (lookback windows)
Short answer
Rows can arrive after the model has already processed their time window: delayed loaders, offline devices, back-dated corrections. Use a lookback window in the incremental filter that reprocesses recent days, combined with merge on unique_key (or partition replacement), so reprocessed rows replace rather than duplicate. Filter on load time instead of event time where possible, and run periodic wider backfills for very late data.
Full answer: explanation, example and follow-ups
Detailed explanation
Filter column choice
| Filter on | Pros | Cons |
|---|---|---|
Ingestion/load timestamp (_loaded_at) |
Catches all newly arrived rows regardless of event time | Needs a reliable loader timestamp; target partitions by event date get touched across many days |
Event timestamp (event_ts) |
Aligns with partitions/clustering | Late rows with old event_ts are missed without lookback |
| Both: load-time filter + event-date predicates for pruning | Accurate and efficient | Slightly more complex |
Lookback design
- Pick a window covering the observed lateness, for example the p99.9 of arrival delay (see How do you build data quality checks into a pipeline?).
where event_ts >= (select max(event_ts) from {{ this }}) - interval '3 days'.- Pair it with merge on
unique_key(see Incremental strategies by adapter (append, merge, delete+insert, insert_overwrite, microbatch)). With append, a lookback creates duplicates. - Make
incremental_predicatesat least as wide as the lookback, to keep MERGE pruning correct. - Microbatch:
lookback: Nbatches (see The microbatch incremental strategy).
Aggregated models
- For daily aggregates, recompute whole affected days. Use
insert_overwriteordelete+insertby date, or a merge on(date, dimension keys). - Find the affected days from the newly arrived rows (
select distinct event_date from new_rows), and recompute only those days.
Very late data
- A scheduled wider backfill (weekly, last 30 days) with a var-driven lookback.
- Monitoring that counts rows arriving beyond the lookback, by comparing load time with event time. Alert when it’s non-zero.
- Document the guarantee (“numbers final after 3 days”).
Example
-- models/marts/agg_daily_device_metrics.sql (Dialect: Snowflake SQL via dbt)
{{ config(
materialized = 'incremental',
incremental_strategy = 'delete+insert',
unique_key = ['metric_date', 'device_id']
) }}
{% set lookback_days = var('device_lookback_days', 3) | int %}
with new_rows as (
select * from {{ ref('stg_iot__readings') }}
{% if is_incremental() %}
where _loaded_at >= (select dateadd('day', -{{ lookback_days }}, max(max_loaded_at)) from {{ this }})
{% endif %}
),
affected_days as (
select distinct reading_ts::date as metric_date from new_rows
)
select
r.reading_ts::date as metric_date,
r.device_id,
avg(r.temperature_c) as avg_temperature_c,
count(*) as readings,
max(r._loaded_at) as max_loaded_at
from {{ ref('stg_iot__readings') }} r
join affected_days d on r.reading_ts::date = d.metric_date
group by 1, 2
Rows recently loaded define which days are affected. Each of those days is recomputed from all its readings, so late readings for 5 days ago update that day’s averages correctly.
Real-world use case
Field devices uploaded readings up to 2 days late when offline. Daily averages computed from on-time data were wrong for about 8% of device-days. Switching to load-time filtering, plus full recomputation of affected days, made the daily metrics match the full rebuild.
Common mistakes and red flags
- Append plus lookback, creating duplicates.
- Event-time filter only.
- Merging partial aggregates instead of recomputing affected days.
Follow-up questions
- How do you choose the lookback? — Measure arrival delay distribution; balance with cost.
- What if lateness exceeds the window? — Periodic backfills and monitoring of beyond-window arrivals.
In depth: How do you handle late-arriving data?
48. dbt Mesh: cross-project refs
Short answer
dbt Mesh splits a monolithic dbt project into several domain-owned projects. Downstream projects reference upstream public models with {{ ref('finance', 'fct_orders') }}, declared in dependencies.yml. That relies on public access, contracts and versions (see Model contracts, Model versions, groups and access levels), and in dbt platform on cross-project metadata (Catalog). Teams then own, deploy and govern their domains independently while sharing stable interfaces.
Full answer: explanation, example and follow-ups
Detailed explanation
Why split
- Monoliths with thousands of models suffer slow parsing and CI, unclear ownership, merge conflicts, and every change risking everything.
- Domain teams want autonomy over their code, schedules and reviews, which matches data mesh principles (see Data engineering for ML and GenAI: feature stores, embeddings, vector stores).
Mechanics
dependencies.ymlin the consumer project listsprojects: [{name: finance}].- Consumers
ref('finance', 'fct_orders')(optionally withv=2). Only public models are referencable. - Resolution: dbt platform resolves the upstream project’s production relations through metadata, with no need to rebuild them. For dbt Core, cross-project refs need a way to supply upstream metadata. Common Core alternatives are installing the upstream as a package (which brings its code), using sources pointing at the upstream’s published tables, or community plugins. Check current support.
- Lineage spans projects in dbt platform Catalog.
Design guidance
- Expose a small set of public, contracted, versioned models per domain, as its “data products”.
- Keep internals
protectedorprivate. - Agree on SLAs and freshness for public models (exposures or contracts plus scheduling dependencies).
- Avoid circular dependencies between projects. The project DAG must be acyclic.
- Orchestrate consumers after producers: job-completion triggers or freshness gating.
Alternatives
- One monorepo project with groups and access levels, which gives most governance benefits without splitting.
- Packages for shared macros (not models).
Example
# marketing_project/dependencies.yml
projects:
- name: finance
-- marketing_project/models/marts/fct_campaign_revenue.sql
select c.campaign_id, c.channel, sum(o.order_total) as attributed_revenue
from {{ ref('stg_ads__campaign_touches') }} c
join {{ ref('finance', 'fct_orders', v=2) }} o
on o.order_id = c.order_id
group by 1, 2
Real-world use case
A 2,500-model monolith with 60 contributors was split into five domain projects. Finance publishes eight public contracted models. Marketing and product reference them cross-project, CI per project dropped from 40 minutes to under 10, and ownership became clear.
Common mistakes and red flags
- Splitting projects without contracts, which leads to breaking changes downstream.
- Making most models public.
- Cyclic project dependencies.
Follow-up questions
- How do you prevent breaking downstream projects? — Contracts + versions + deprecation dates on public models.
- When not to use Mesh? — Small teams or projects where a single project with groups is simpler.
49. Cross-database macros and adapter.dispatch
Short answer
adapter.dispatch('macro_name', 'namespace') picks the implementation of a macro for the current adapter. It looks for snowflake__macro_name, databricks__macro_name and so on, and falls back to default__macro_name. dbt ships cross-database macros (dbt.dateadd, dbt.datediff, dbt.concat, dbt.type_string, dbt.safe_cast, dbt.split_part and more), so packages and multi-warehouse projects can write portable SQL.
Full answer: explanation, example and follow-ups
Detailed explanation
How dispatch works
- A generic macro calls
{{ return(adapter.dispatch('my_macro', 'my_package')(args)) }}. - dbt searches the namespaces, in order of the
dispatchconfig indbt_project.yml(so your project can override package implementations), for<adapter>__my_macro, then any parent adapter (for exampleredshift__falls back topostgres__), thendefault__my_macro. - The matching implementation renders SQL for that warehouse.
Built-in cross-database macros (in the dbt namespace)
- Date and time:
dateadd,datediff,date_trunc,last_day,current_timestamp. - Strings:
concat,split_part,length,replace,position,right,escape_single_quotes. - Types:
type_string,type_timestamp,type_int,type_numeric,cast,safe_cast. - Aggregation and sets:
listagg,bool_or,any_value,array_construct,array_append. - Others:
hash,cast_bool_to_text,except,intersect.
When to use
- Packages meant for many warehouses.
- Migrations between warehouses: write once, then run on both during cutover.
- Multi-cloud setups (some business units on Snowflake, others on Databricks).
- For a single-warehouse project, native SQL is usually clearer. Don’t over-abstract.
Overriding package behaviour
dispatch:
- macro_namespace: dbt_utils
search_order: ['acme_analytics', 'dbt_utils']
With that, your project’s snowflake__<macro> overrides the package’s version. That is useful when a package’s implementation is slow or wrong for your warehouse, without forking the package.
Example
-- macros/week_start.sql
{% macro week_start(date_expr) -%}
{{ return(adapter.dispatch('week_start', 'acme_analytics')(date_expr)) }}
{%- endmacro %}
{% macro default__week_start(date_expr) -%}
date_trunc('week', {{ date_expr }})
{%- endmacro %}
{% macro snowflake__week_start(date_expr) -%}
date_trunc('week', {{ date_expr }})::date -- respects WEEK_START session parameter
{%- endmacro %}
{% macro bigquery__week_start(date_expr) -%}
date_trunc({{ date_expr }}, week(monday))
{%- endmacro %}
-- models/marts/agg_weekly_orders.sql
select {{ week_start('ordered_at') }} as week_start, count(*) as orders,
{{ dbt.datediff("min(ordered_at)", "max(ordered_at)", "day") }} as active_span_days
from {{ ref('fct_orders') }}
group by 1
Real-world use case
During a Redshift-to-Snowflake migration, a team replaced dialect-specific date functions in 200 models with dbt.dateadd, dbt.datediff and a few dispatched macros. The same project ran on both warehouses for parallel validation until cutover.
Common mistakes and red flags
- Abstracting every function in a single-warehouse project.
- Forgetting the
default__implementation. - Not configuring dispatch search order when overriding package macros.
Follow-up questions
- What’s the dispatch fallback chain? — Adapter-specific → parent adapter → default.
- Where are built-in cross-db macros documented? — In dbt’s cross-database macros reference (dbt namespace).
50. dbt clone and zero-copy CI environments
Short answer
dbt clone --select ... --state prod_artifacts creates copies of selected production relations in the current target schema. It uses zero-copy clones where the warehouse supports them (Snowflake, Databricks Delta shallow clones, BigQuery table clones) and views otherwise. CI or dev then gets realistic, prod-like tables instantly, for example to test incremental models against existing data or to give blue-green deployments a starting point.
Full answer: explanation, example and follow-ups
Detailed explanation
Why clone
- Incremental models in CI: with only
--defer, a modified incremental model in CI has no existing table, so it runs the full-build branch, not the incremental logic. Cloning the production table first makesis_incremental()true and tests the real incremental path. - Dev environments: developers start with production-shaped data without rebuilding upstream models.
- Blue-green deployments: clone production into a staging schema, build changes there, validate, then swap (see Dev/test/prod environments and promoting data code).
- Fast rollback points: a clone snapshot before risky changes.
How it works
- Requires
--state(a production manifest), so dbt knows where production relations live. - For each selected node, dbt creates a clone if the adapter supports it, otherwise a view pointing to the production relation (
create view as select * from prod). - Snowflake:
CREATE TABLE ... CLONE, zero-copy, near-instant. - Databricks: shallow clone for Delta (see Shallow clone vs deep clone). Watch VACUUM on the source.
- BigQuery: table clones.
- Use
--full-refreshto recreate existing clones.
Typical CI sequence
dbt clone --select state:modified+,config.materialized:incremental --state prod-artifacts --target ci
dbt build --select state:modified+ --defer --state prod-artifacts --target ci
Cautions
- Clones carry production data into CI and dev, so apply the same access controls, masking and PII policies. Consider masked clones or subsets for dev.
- Storage costs grow as clones diverge from the source (copy-on-write). Drop CI schemas after merge.
- Shallow clones on Databricks can break if the source is vacuumed. Keep CI schemas short-lived.
Example
# 1) Clone prod versions of modified incremental models into the PR schema
dbt clone \
--select "state:modified+,config.materialized:incremental" \
--state ./prod-artifacts \
--target ci
# 2) Build and test modified models; unchanged parents defer to prod
dbt build --select "state:modified+" --defer --state ./prod-artifacts --target ci
# 3) Cleanup (e.g. scheduled job or PR close hook)
dbt run-operation drop_ci_schema --args '{schema_name: dbt_ci_pr_42}' --target ci
drop_ci_schema is a project macro that should refuse any schema name not matching the CI prefix pattern, so it can never drop production schemas.
Real-world use case
An incremental MERGE bug only appeared when the target already had data. CI never caught it because CI always built from scratch. Adding dbt clone for modified incremental models made CI exercise the incremental branch, and the next similar bug failed in the PR.
Common mistakes and red flags
- Assuming
--deferalone tests incremental logic. - Cloning PII into loosely controlled dev schemas.
- Never cleaning up clones.
Follow-up questions
- What does dbt clone do on adapters without cloning? — Creates views referencing production relations.
- Does cloning copy grants? — Depends on platform/config; apply
grantsconfig in the target.
Progress is saved in this browser only. No account needed.

