dbt defined flat: the five incremental strategies and unique_key, snapshots and their two strategies, generic against singular tests and the four built-ins, unit tests, macros and Jinja's two passes, dbt_project.yml, targets, slim CI and state:modified, hooks, var and env_var, adapters, the materialized view materialization, full refresh, on_schema_change, tags and meta, dbt retry, defer and clone, plus the ten one-liners every dbt screen opens with.
What are dbt's incremental strategies? How do append, merge, delete+insert, insert_overwrite and microbatch differ, and which adapters support which?
Why they ask this
Incremental models are where dbt projects spend their money, and the strategy decides correctness under late data and reruns.
Say this
The strategy is how dbt writes a new batch into an existing table. append inserts the rows and never updates. merge matches on unique_key and updates or inserts. delete+insert deletes rows whose key appears in the batch, then inserts the batch. insert_overwrite replaces whole partitions. microbatch, from dbt 1.9, splits the run into time windows and replaces each one. The docs list support per adapter: BigQuery has merge, insert_overwrite and microbatch; Snowflake has append, merge, delete+insert and microbatch.
The reasoning
append is the cheapest: `INSERT INTO target SELECT ...`. It is correct only when the source never resends a row, because duplicates are not detected. A rerun of the same window doubles the rows.
merge is `MERGE INTO target USING batch ON target.key = batch.key WHEN MATCHED UPDATE WHEN NOT MATCHED INSERT`. It is idempotent per key and handles late corrections. Its cost is the match, which on large tables is a full scan unless the warehouse can prune, which is why `incremental_predicates` exists.
delete+insert is two statements: delete every target row whose key is in the batch, then insert the batch. It avoids MERGE on engines where MERGE is slow or absent and handles duplicate keys inside the batch, which merge rejects on some engines.
insert_overwrite replaces partitions rather than rows. On BigQuery it needs a `partition_by` config and replaces every partition the batch touches, so the batch must be the full content of those partitions. microbatch does the same by time: it reads `event_time` from the source, runs one query per `batch_size` window from `begin`, and replaces each window. `lookback` defaults to 1 batch to catch late rows.
The docs set merge as the default for Snowflake and BigQuery and append as the default for Postgres and Redshift. Databricks and Spark add insert_overwrite; Databricks also has replace_where. Choosing a strategy the adapter does not support fails at compile time.
The answer most people give
"Incremental means dbt only processes new rows, so it is always idempotent." The filter in `is_incremental()` selects the rows; the strategy decides what happens to them. With append, a rerun duplicates every row it reprocesses.
They’ll ask next
A fact table receives corrections to rows from three days ago. Which strategy handles them, and which config lets the merge prune to recent partitions?
What is unique_key on an incremental model? What does each strategy do with it, and what happens when it is missing or not unique?
Why they ask this
It is the one config that turns an incremental model from an append log into a correct upsert, and it is misunderstood as a constraint.
Say this
unique_key is a column or list of columns that identifies a row for the purpose of updating it. merge uses it as the match condition. delete+insert uses it to find rows to delete. append and insert_overwrite ignore it. dbt does not enforce uniqueness; if two batch rows share the key, merge fails on engines that reject multiple matches, and if it is omitted, the docs say merge behaves as an insert.
The reasoning
It is a model config: `{{ config(materialized='incremental', unique_key='order_id') }}` or `unique_key=['order_id', 'line_no']` for a composite. dbt compiles it into the `ON` clause of the merge or the `WHERE key IN (SELECT key FROM batch)` of the delete.
Without it, merge has nothing to match on, so every batch row is inserted. The docs call this out: merge with no unique_key is an append. Teams discover this when a model labelled merge has duplicated for months.
It is not a constraint. The warehouse does not check it, and dbt does not test it unless you add a `unique` test on the same columns. If the batch contains two rows with the same key, Snowflake's MERGE raises a nondeterministic merge error; BigQuery fails similarly. delete+insert tolerates it, which is one reason to choose it.
The key must be stable across runs. A surrogate built from a hash of the natural key, `dbt_utils.generate_surrogate_key`, is the common choice, because a key that changes between runs means the old row is never matched.
The answer most people give
"Setting unique_key makes dbt deduplicate the model." It tells the strategy how to match rows. The batch itself must already be unique on the key, and a test is what verifies the result.
They’ll ask next
A merge model fails with a nondeterministic merge error on Snowflake. What does the batch contain, and what are the two fixes?
What is a dbt snapshot, and what columns does it add? What is the difference between the timestamp strategy and the check strategy?
Why they ask this
Snapshots are dbt's SCD Type 2, and the strategy choice decides whether a change is ever detected.
Say this
A snapshot records how a source table's rows change over time, producing a Type 2 table with dbt_valid_from, dbt_valid_to, dbt_scd_id and dbt_updated_at. The timestamp strategy uses an updated_at column: a row is new when its timestamp is later than the stored one. The check strategy compares a list of columns, or all, and opens a new version when any differ. Since dbt 1.9 snapshots are defined in YAML.
The reasoning
Each `dbt snapshot` run selects the source and compares it with the snapshot table on unique_key. For changed rows it sets dbt_valid_to on the old version and inserts a new version with dbt_valid_from at run time. Unchanged rows are untouched. The current version has a null dbt_valid_to unless `dbt_valid_to_current` is set to a sentinel, a 1.9 option.
timestamp is cheaper and more precise: one column comparison, and the source's own change time becomes dbt_valid_from. It needs a reliable updated_at. A source that updates rows without touching the timestamp silently loses history.
check compares values. `check_cols: ['status', 'address']` or `check_cols: all`. It works on sources with no reliable timestamp, at the cost of comparing every listed column per run and of using run time, not change time, as the version boundary.
Deleted rows are ignored by default. `hard_deletes: invalidate` closes the version when the row disappears; `hard_deletes: new_record`, from 1.9, inserts a row flagged deleted. Snapshots run on a schedule, so a row that changes twice between runs records one change.
The answer most people give
"A snapshot is a backup copy of the table." It is a Type 2 history table with validity columns, built by comparing successive runs. A full copy would be a table materialization.
They’ll ask next
A source table has updated_at but a nightly job rewrites addresses without updating it. Which strategy do you choose, and what do you lose?
What does the --full-refresh flag do? Which materializations does it affect, and how do you protect a model from it?
Why they ask this
It is the flag that drops a table people spent three days building, and its scope is wider than incrementals.
Say this
--full-refresh makes dbt drop and rebuild incremental models and seeds instead of adding to them. Inside the model, is_incremental() returns false, so the filter is skipped and the whole source is read. Tables and views are rebuilt every run anyway, so it changes nothing for them. A model config of full_refresh: false makes that model ignore the flag.
The reasoning
An incremental model normally compiles to an insert or merge into the existing table. With `--full-refresh`, dbt issues a `CREATE OR REPLACE` or a drop and create, and the select runs without the `is_incremental()` branch. The table is rebuilt from the beginning of history.
Seeds are the other target. A seed normally loads with a truncate and insert. With full refresh, dbt drops and recreates the table, which is what you need after a column type or name changes in the CSV.
`is_incremental()` is true only when the relation already exists, the model is incremental, and full refresh is not requested. That third condition is why the flag works without the model author doing anything.
Protection is per model: `{{ config(full_refresh=false) }}` makes dbt skip the drop even when the flag is passed, which is standard on large event tables where a rebuild costs hours. The CI pattern is to run `--full-refresh` only in a dedicated job with the model list spelled out.
The answer most people give
"Full refresh re-runs every model from scratch." Views and tables are always rebuilt. The flag changes behaviour only for incremental models and seeds, and only those without full_refresh set to false.
They’ll ask next
A column type changed in an incremental model and the merge fails. Is full refresh the right fix, and what does it cost on a 2 TB table?
What is the on_schema_change config on an incremental model? What are its four values, and what does none of them do?
Why they ask this
Schema drift is the commonest way an incremental model breaks, and the config's limits are as important as its options.
Say this
on_schema_change tells an incremental model what to do when the columns of the new batch differ from the existing table. The values are ignore, the default, which leaves the table alone and drops new columns from the insert; fail, which stops the run; append_new_columns, which adds new columns; and sync_all_columns, which adds new and removes missing columns. None of them changes a column's type or backfills values into old rows.
The reasoning
With ignore, a new source column never reaches the target. The run succeeds and the column is silently absent, which is why teams set fail in production: a schema change should be a decision, not a surprise.
append_new_columns issues an `ALTER TABLE ADD COLUMN` for each new column before the insert. Existing rows get null in the new column. Columns removed from the source stay in the table.
sync_all_columns adds new columns and drops columns no longer in the select. Dropping a column is destructive, so it suits staging tables and not tables other teams query directly.
The docs are explicit that type changes are out of scope. A column that moves from integer to string fails on insert under every setting, and the fix is a full refresh or a manual alter. Backfilling the new column's history also needs a full refresh.
The answer most people give
"sync_all_columns keeps the table in sync with the source, including types and old rows." It only adds and removes columns. Types are untouched and new columns are null for every row that existed before.
They’ll ask next
The source adds a `discount` column and the model runs with the default setting. What is in the target, and what two steps make the history correct?
What is dbt's materialized view materialization? How does it differ from a table, and what does on_configuration_change control?
Why they ask this
It is the newest materialization and the one where dbt hands refresh to the warehouse, which changes what a run means.
Say this
materialized='materialized_view', available since dbt 1.6, creates a warehouse object that the engine keeps up to date itself: a materialized view on Postgres, Redshift and BigQuery, a dynamic table on Snowflake, a materialized view or streaming table on Databricks. A table is rebuilt only when dbt runs. on_configuration_change decides what dbt does when the model's config differs from the live object: apply, the default, alters it; continue warns and skips; fail stops the run.
The reasoning
A table materialization is `CREATE TABLE AS SELECT`, fresh at run time and stale afterwards. A materialized view is created once and then refreshed by the engine, on its own schedule or on demand, so freshness no longer depends on the dbt schedule.
Each engine implements it differently, and the config follows. Snowflake dynamic tables take a `target_lag` such as '10 minutes' and a warehouse. BigQuery materialized views have restrictions on the query shape, no window functions and limited joins, and refresh incrementally. Postgres needs an explicit `REFRESH MATERIALIZED VIEW`, which dbt runs on each `dbt run` unless configured otherwise.
on_configuration_change matters because dropping and recreating a materialized view loses its state and may be expensive. `apply` issues alters where the engine allows them and falls back to replace where it does not. `continue` and `fail` let you keep a hand on that.
The trade is control for freshness. The engine decides when to refresh and bills for it, and the query is constrained to what it can maintain. It suits simple aggregations that dashboards hit constantly, not complex marts.
The answer most people give
"A materialized view in dbt is just a table that refreshes faster." The engine owns the refresh and restricts the query. dbt only creates and alters the object.
They’ll ask next
A Snowflake dynamic table model has target_lag of one hour. What happens at `dbt run` when nothing changed, and what happens when target_lag changes?
Tests (generic, singular, dbt-utils, unit tests)Packages
What is the difference between a generic test and a singular test in dbt? How is each written, and when does a test pass?
Why they ask this
The two test kinds are the whole data-quality surface of dbt, and candidates often know only the YAML half.
Say this
A generic test is a parameterised query defined once as a test block macro and applied to many columns or models through YAML. A singular test is a single SQL file in the tests directory written for one case. Both compile to a query that returns failing rows, and a test passes when the query returns zero rows.
The reasoning
The four built-in generic tests, `unique`, `not_null`, `accepted_values` and `relationships`, are generic tests dbt ships. You apply one with two lines of YAML under a column. A custom generic test is a macro in a `{% test my_test(model, column_name) %}` block that returns a select of bad rows.
A singular test is `tests/assert_revenue_non_negative.sql` containing `select * from {{ ref('fct_orders') }} where amount < 0`. It is used once, for a rule too specific to parameterise. If you write the same singular test twice, the docs suggest promoting it to a generic one.
Both are run by `dbt test`, and by `dbt build` after the models they depend on. The result is pass, warn or fail by the row count, with `severity`, `warn_if` and `error_if` thresholds configurable per test. `store_failures` writes the failing rows to a table for inspection.
Packages such as dbt_utils and dbt_expectations add dozens more generic tests, from `unique_combination_of_columns` to `expect_column_values_to_be_between`, which is why most projects write few of their own.
The answer most people give
"Generic tests are the built-in ones and singular tests are the ones you write." You can write generic tests too. The distinction is parameterised and reusable against one-off SQL.
They’ll ask next
You need the same rule, no negative amounts, on six models. Which test kind do you write, and what is its signature?
Tests (generic, singular, dbt-utils, unit tests)Models & ref/source
What are dbt's four built-in generic tests, and what does each one check? How do you write the relationships test?
Why they ask this
They are asked by name, and the relationships test is the one candidates cannot write from memory.
Say this
unique checks that no value of a column repeats. not_null checks that no value is null. accepted_values checks that every value is in a given list. relationships checks that every value exists in a column of another model, a foreign key check. relationships is written with to: ref('other_model') and field: the column there.
The reasoning
`unique` compiles to a group by with `having count(*) > 1`. On a surrogate key it is the primary key test. Null values are not counted as duplicates on most adapters, which is why `unique` and `not_null` are usually declared together.
`not_null` compiles to `where column is null`. `accepted_values` takes `values: ['placed', 'shipped', 'returned']` and an optional `quote: false` for numeric lists. It is the test for status columns and the first to fail when a source adds a state.
`relationships` is the referential integrity test: `- relationships: {to: ref('dim_customers'), field: customer_id}` under `orders.customer_id` fails for every order whose customer_id has no match. Warehouses do not enforce foreign keys, so this test is the enforcement.
Each accepts a `config` block for `severity`, `where` to filter the rows tested, and `store_failures`. Beyond the four, `dbt_utils` adds `unique_combination_of_columns` for composite keys and `accepted_range` for numeric bounds.
The answer most people give
"unique and not_null on the primary key are enough." They prove the key is clean; they say nothing about whether foreign keys resolve or whether status values are valid. All four exist because each catches a different failure.
They’ll ask next
An orders model passes unique and not_null on order_id but the revenue report is wrong. Which of the other two tests would most likely have caught it?
Tests (generic, singular, dbt-utils, unit tests)CI/CD & slim CI (state:modified)
What is a dbt unit test, and how does it differ from a data test? How is one defined, and when does it run?
Why they ask this
Unit tests arrived in dbt 1.8 and interviewers use them to see whether you follow the tool or just run it.
Say this
A unit test checks a model's SQL logic against fixed input rows and expected output rows, with its refs and sources mocked. A data test checks real data in the warehouse after a run. Unit tests are defined in YAML under unit_tests with given and expect blocks, and run with dbt test or during dbt build before the model is built. They are supported on SQL models from dbt 1.8.
The reasoning
A data test asks "is the table correct". A unit test asks "is the transformation correct", and it answers without production data. You give three input rows and the expected two output rows; dbt builds the model's query over the mocks and compares.
The YAML names the model, then `given:` lists each `input: ref('stg_orders')` with `rows:` inline, from a CSV or from a SQL fixture. `expect:` lists the rows the model must produce. Columns not mentioned are null. `overrides` can pin macros and vars such as `env_var`.
In `dbt build`, unit tests run after the upstream models exist and before the model under test is built. A failing unit test stops a bad model from being materialised. `dbt test --select test_type:unit` runs them alone. They need the upstream relations to exist for type inference, so an empty-schema CI job runs `dbt run --empty` first.
They suit models with branching logic: case expressions, window functions, date arithmetic. They do not run on Python models, and they are slow to maintain on wide tables, so the convention is to unit-test the logic-heavy intermediate models and data-test the marts.
The answer most people give
"A unit test is a data test on a small sample." A unit test replaces the inputs with fixtures and checks exact output. A data test on a sample still reads real tables and checks a property, not a result.
They’ll ask next
A model computes a customer's lifetime tier with a nested case expression. What would the unit test's given and expect blocks contain, and what does `--empty` do for it in CI?
What is a macro in dbt? How is it defined and called, and what is adapter.dispatch for?
Why they ask this
Macros are how dbt projects stop repeating SQL, and dispatch is the part that makes packages work across warehouses.
Say this
A macro is a reusable block of Jinja and SQL defined in a .sql file under the macros directory with a macro tag, and called with double braces anywhere dbt renders templates. It can take arguments and return text or a value. adapter.dispatch lets one macro name resolve to a different implementation per adapter, so the same call compiles to Snowflake SQL on Snowflake and BigQuery SQL on BigQuery.
The reasoning
The definition is `{% macro cents_to_dollars(column_name, precision=2) %} round({{ column_name }} / 100, {{ precision }}) {% endmacro %}`. The call inside a model is `{{ cents_to_dollars('amount_cents') }}`. At compile time dbt replaces the call with the rendered text.
Macros can also do work rather than emit SQL. `run_query` executes a statement and returns a result table, which is how a macro can read a list of partitions. Such macros must guard with `{% if execute %}`, because at parse time queries are not run and `run_query` returns nothing.
`adapter.dispatch('my_macro')` looks for `snowflake__my_macro`, then `default__my_macro`, by adapter prefix. dbt_utils is built on this: `dbt_utils.date_trunc` is one name with a per-warehouse body. You can also override a package's dispatch with `dispatch` config in dbt_project.yml.
Macros can be run standalone with `dbt run-operation macro_name --args '{...}'`, which is how teams script grants, vacuum calls or cleanup. Materializations themselves are macros, which is why custom materializations are possible.
The answer most people give
"A macro is a stored procedure in the warehouse." It runs in dbt at compile time and produces text. Nothing is created in the database unless the rendered SQL does so.
They’ll ask next
A macro that lists partitions with run_query returns nothing during `dbt parse` and breaks the DAG. Which flag is missing, and why does dbt parse before it runs?
What is Jinja, and how does dbt use it? Why does dbt render a model twice, and what is the execute flag?
Why they ask this
Every strange dbt behaviour, from empty query results in macros to the DAG, follows from the two-pass compile.
Say this
Jinja is a Python templating language. dbt wraps each model's SQL in Jinja so it can contain expressions, control flow and macro calls, and renders it to plain SQL before sending it to the warehouse. dbt renders at parse time to discover ref and source calls and build the DAG, with execute false and no queries run, then again at run time with execute true. Code that needs query results must check execute.
The reasoning
Jinja gives three things: expressions in `{{ }}`, statements in `{% %}` such as `for` and `if`, and comments in `{# #}`. `{{ ref('stg_orders') }}` is a Jinja expression that calls dbt's ref function and renders to a fully qualified table name.
Parse time is the first pass. dbt renders every model only to collect `ref`, `source` and `config` calls, from which it builds the DAG and the manifest. No SQL is executed. `run_query` returns None, `this` is defined, but anything that depends on the database is empty.
Run time is the second pass, model by model in DAG order. Now `execute` is true, queries run, and the rendered SQL is wrapped by the materialization and sent. A macro that loops over query results needs `{% if execute %}` so the parse pass does not fail on an empty result.
The two-pass design is why refs must be literal strings and not computed at run time, and why the DAG cannot depend on data. It is also why dbt can show you lineage without touching the warehouse. `dbt compile` writes the rendered SQL to the target directory for inspection.
The answer most people give
"Jinja runs in the warehouse." The warehouse never sees Jinja. dbt renders it to SQL first, twice, and only the second rendering is executed.
They’ll ask next
A model's ref is built from a variable set by a query result. Why does dbt refuse to draw the dependency, and what is the compliant pattern?
What is dbt_project.yml, and what does it configure? How does the models block set configuration by folder, and which config wins?
Why they ask this
It is the file every project starts with, and the precedence rule is a reliable discriminator.
Say this
dbt_project.yml is the project's root configuration: its name, version, the profile it connects with, the paths for models, seeds, tests and macros, project-wide vars, run hooks, and default configs for models and seeds by folder. The models block mirrors the folder tree, so a materialization set under models.my_project.staging applies to every model in that folder. A config in the model file itself overrides the YAML, and a property file overrides the project file.
The reasoning
The required keys are `name`, `profile`, and the `config-version: 2`. `model-paths`, `seed-paths`, `snapshot-paths`, `macro-paths` and `test-paths` default to the conventional folders. `require-dbt-version` pins the engine range.
The `models:` block is keyed by project name, then by folder. `models: my_project: staging: +materialized: view` sets every staging model to a view. The plus sign marks a config rather than a folder name. Schemas, tags, and any model config can be set this way.
`vars:` holds project variables read with `var()`. `on-run-start` and `on-run-end` hold SQL or macro calls that run once per invocation. `seeds:` configures column types and schemas for seeds. `dispatch` overrides which package's macros a dispatch call searches first.
Precedence runs from most specific to least: a `config()` block in the model file, then the property YAML file next to the model, then dbt_project.yml. A materialization set in all three resolves to the model file's. Package projects have their own dbt_project.yml, and the root project can override their configs from its own.
The answer most people give
"dbt_project.yml holds the connection credentials." Credentials live in profiles.yml, outside the repo. dbt_project.yml names which profile to use.
They’ll ask next
A model is a view in dbt_project.yml, a table in its properties file, and incremental in its config block. What is built, and why is that order sensible?
What is a target in dbt? Where is it defined, how do you switch it, and how does a model read it?
Why they ask this
Targets are how one project runs against dev, CI and prod, and the Jinja variable is what makes environment-aware SQL possible.
Say this
A target is one named connection configuration under a profile's outputs in profiles.yml: the warehouse, account, credentials, database, schema and threads. The profile's target key names the default, and --target on the command line switches it. Inside a model, the target object exposes target.name, target.schema, target.database and target.type for conditional logic.
The reasoning
profiles.yml holds profiles; each profile has `outputs:` with one entry per target, usually `dev`, `ci` and `prod`, and a `target:` key naming the default. dbt_project.yml names the profile, so the project never contains credentials.
Switching is `dbt run --target prod`. In dbt Cloud, environments play the same role and the target name is set per environment. Each target sets its own `schema`, so a developer's `dev` target writes to `dbt_alice` while prod writes to `analytics`.
Models read the target through Jinja: `{% if target.name == 'prod' %} ... {% else %} ... limit 1000 {% endif %}` is the common pattern to sample in dev. The `generate_schema_name` macro uses `target.schema` to decide where models land, and overriding it is how teams get custom schema layouts.
`threads` on the target controls how many models dbt runs concurrently. The `type` is the adapter name, and one profile may hold targets on different warehouses, which is how a migration can be tested.
The answer most people give
"A target is the table a model writes to." That is the relation. A target is the connection and environment the whole run uses.
They’ll ask next
Developers' models land in their own schemas but a shared staging schema is required for sources. Which macro do you override, and what does it read from the target?
What are pre-hooks, post-hooks, on-run-start and on-run-end in dbt? When does each run, and what are they used for?
Why they ask this
Hooks are where projects put grants, audit rows and cleanup, and the timing of each kind matters.
Say this
pre-hook and post-hook are SQL statements or macro calls configured on a model, seed or snapshot and run immediately before and after that node is built, by default in the same transaction on adapters that have transactions. on-run-start and on-run-end are set in dbt_project.yml and run once at the start and end of the whole invocation. Typical uses are grants, inserting audit rows, and refreshing stats.
The reasoning
`{{ config(post_hook="grant select on {{ this }} to role reporter") }}` runs after the model is created. `{{ this }}` resolves to the model's relation. Hooks may be a list and run in order. `pre_hook` runs before the create, for example to drop a stale temp table.
On Postgres and Redshift, hooks run inside the model's transaction unless wrapped in `before_begin()` or `after_commit()`. On Snowflake and BigQuery there is no multi-statement transaction, so each hook is its own statement. That distinction decides whether a failed hook rolls back the model.
`on-run-start` runs before any node, typically to create a run log table. `on-run-end` runs after all nodes and has access to the `results` variable, a list of each node's status and timing. That is how teams write a run summary row or send a failure notification.
Grants have a dedicated config since dbt 1.2: `grants: {select: ['reporter']}` on a model, which dbt applies with the right syntax per adapter and revokes when removed. It replaces most post-hook grants and is the first thing to suggest when a candidate reaches for a hook.
The answer most people give
"A post-hook runs after the whole dbt run." That is on-run-end. A post-hook runs after one node, once per node that carries it.
They’ll ask next
A grant in a post-hook on Postgres succeeds but the model's create fails afterwards. Does the grant persist, and how does that differ on Snowflake?
What are var and env_var in dbt? Where does each read from, how do defaults work, and how are secrets handled?
Why they ask this
They are the two ways configuration enters a project, and the secrets rule is a safety question.
Say this
var reads a project variable: from the --vars command-line argument first, then from the vars block in dbt_project.yml, then the default given as its second argument. env_var reads an environment variable, with an optional default. Variables whose names begin with DBT_ENV_SECRET are scrubbed from logs and may be used only in profiles.yml and packages.yml, not in models.
The reasoning
`{{ var('start_date', '2020-01-01') }}` in a model renders the value. `dbt run --vars '{"start_date": "2026-10-01"}'` overrides it for one run, which is how a backfill window is passed from an orchestrator. A var with no value and no default fails at compile.
`vars:` in dbt_project.yml can be global or scoped under a project or package name, so a package's variable can be set by the root project. Variables are strings or YAML values; a date passed on the command line arrives as a string.
`{{ env_var('DBT_SCHEMA_SUFFIX', '') }}` reads the process environment. It is the standard way to make profiles.yml portable: `password: "{{ env_var('DBT_PASSWORD') }}"`. env_var is not allowed in `vars` resolution at parse in some contexts, and an unset variable with no default fails.
Secrets: a name prefixed `DBT_ENV_SECRET_` is masked in logs and compiled artifacts, and dbt refuses to render it inside a model so a password cannot end up in compiled SQL. `DBT_ENV_CUSTOM_ENV_` prefixed variables are copied into the manifest metadata, which is how a CI system stamps the run with its build id.
The answer most people give
"var and env_var are interchangeable." var is project configuration resolved from YAML and the command line; env_var is the process environment. Only env_var is appropriate for credentials, and only with the secret prefix.
They’ll ask next
An orchestrator must run the same model for one day at a time over a year. Which mechanism passes the day, and what does the model's where clause look like?
What is a dbt adapter? What does it implement, and how does a project use one?
Why they ask this
Adapters are why dbt has no connectors of its own, and the question checks whether you know where warehouse-specific behaviour lives.
Say this
An adapter is a plugin that lets dbt talk to one data platform: dbt-snowflake, dbt-bigquery, dbt-postgres, dbt-databricks. It implements the connection, how to run queries, the SQL dialect for each materialization, and the adapter-prefixed macros that dispatch resolves. A project uses one by installing it alongside dbt Core and naming its type in profiles.yml.
The reasoning
dbt Core knows nothing about any warehouse. The adapter supplies a connection manager, a credentials class with the fields profiles.yml expects, and a set of macros such as `snowflake__create_table_as` that the materializations call through `adapter.dispatch`.
That is why features differ by platform. Incremental strategies, the materialized view materialization, `grants`, and column-level contracts each exist only where the adapter implements them. The docs list per adapter what is supported.
Adapters are versioned with dbt Core and installed as `pip install dbt-snowflake`, which pulls in dbt-core. dbt Labs maintains the major ones; others are community or vendor maintained. The dbt Fusion engine ships its adapters inside the engine rather than as separate packages.
The `adapter` object is available in Jinja: `adapter.get_columns_in_relation(this)` lists columns, `adapter.get_relation` checks existence, and `target.type` tells a macro which adapter is running. That is how a single project can hold conditional SQL for two warehouses.
The answer most people give
"The adapter extracts data from the source into the warehouse." dbt moves no data. The adapter is a connection and dialect layer for running transformations inside one platform.
They’ll ask next
A package's macro works on Snowflake and fails on Redshift with a syntax error. Which part of the adapter mechanism is missing, and how do you add it?
What are tags and meta in dbt? How is each set, and what is each one for?
Why they ask this
Both appear in every YAML file, and interviewers want the difference between selection and documentation.
Say this
tags are labels for selecting nodes: dbt run --select tag:nightly runs every model carrying the tag. meta is a free-form dictionary of metadata attached to a node, column or source, surfaced in the docs and the manifest, and used for ownership, PII flags or downstream tooling. Both are set in config blocks, property YAML or dbt_project.yml, and both inherit down folders.
The reasoning
`{{ config(tags=['nightly', 'finance']) }}` or `tags: [nightly]` in YAML. Tags accumulate: a folder tag in dbt_project.yml plus a model tag both apply. Tests inherit the tags of the model they test, so `--select tag:finance` runs the finance tests too.
Selection is the purpose. Orchestrators split a project into `tag:hourly` and `tag:daily` jobs. `--exclude tag:deprecated` keeps old models from running. Tags are also the usual way to run a client's subset in a multi-tenant project.
`meta: {owner: 'payments-team', contains_pii: true}` describes the node. dbt does nothing with it by itself, but it appears in `manifest.json` and in the docs site. Catalog tools, alerting, and custom macros read it: a macro can loop over the graph and apply masking to every column with `meta.pii`.
Since dbt 1.10 the docs ask for meta and tags under `config:` in YAML rather than at the top level. Selection on meta works through `config.meta.owner:payments-team`, so meta can also drive selection when a tag would be too coarse.
The answer most people give
"tags and meta are both just documentation." Tags drive selection and job splitting. meta is the documentation and machine-readable metadata half.
They’ll ask next
Every model owned by the payments team must run in its own job and show the owner in the catalog. Which goes in tags, which in meta, and what is the select string?
CI/CD & slim CI (state:modified)Environments, profiles & targets
What is slim CI in dbt? What does the state:modified selector compare, and what role does defer play?
Why they ask this
Slim CI is the standard dbt CI design, and the state mechanism is what makes a pull request build cost minutes rather than hours.
Say this
Slim CI builds only the models a pull request changed, plus their downstream dependents, instead of the whole project. state:modified compares the current project's manifest with a saved production manifest and selects nodes whose definition changed. --defer makes unselected upstream refs resolve to the production relations, so the changed models can be built in a CI schema without rebuilding their parents.
The reasoning
The command is `dbt build --select state:modified+ --defer --state ./prod-artifacts`. The `--state` folder holds `manifest.json` from the last production run, downloaded from storage or fetched by dbt Cloud. `state:modified+` is every changed node and everything downstream.
Modified means the compiled definition differs: the SQL body, a config, a column contract, a macro the node uses, or its database relation name. Sub-selectors narrow it: `state:modified.body`, `state:modified.configs`, `state:modified.macros`, `state:modified.contract`. `state:new` picks nodes absent from the old manifest.
Defer solves the parents problem. If `fct_orders` changed but `stg_orders` did not, CI builds `fct_orders` in the CI schema, and `{{ ref('stg_orders') }}` resolves to production's `stg_orders`. The parents are never built. `--favor-state` prefers production even when a CI copy exists.
The costs are a dependency on fresh production artifacts and a CI schema that must be cleaned up. dbt Cloud does the cleanup automatically; dbt Core teams do it with an on-run-end macro. A macro change can mark hundreds of models modified, which is the usual reason a slim run is suddenly not slim.
The answer most people give
"Slim CI runs the changed files." It runs changed nodes by compiled definition, not by git diff, and includes everything downstream. A macro edit selects every model that calls it.
They’ll ask next
A one-line change to a shared macro turns a two-minute CI run into forty minutes. Why, and what selector would limit it to models whose SQL body changed?
CI/CD & slim CI (state:modified)Environments, profiles & targets
What does --defer do in dbt, and what is dbt clone? How do they differ, and when do you use each?
Why they ask this
They are the two ways of borrowing production state, and the difference between a pointer and a copy matters for cost and safety.
Say this
--defer makes references to nodes you are not building resolve to the relations recorded in another environment's manifest, so a dev or CI run reads production parents without copying them. dbt clone, since dbt 1.6, creates the selected nodes in the current schema as zero-copy clones of the relations in the state manifest, on warehouses that support cloning, and as views pointing at them elsewhere. Defer is a pointer at run time; clone is a copy you then own.
The reasoning
`dbt run --select my_model --defer --state prod/` compiles `ref('parent')` to production's parent table when `parent` is not selected and does not exist in the current target. The model you build lands in your schema; nothing else is created. It is the mechanism behind slim CI and behind building one model in dev.
`dbt clone --select state:modified+ --state prod/` runs `CREATE TABLE ... CLONE` on Snowflake and Databricks and a table copy on BigQuery, producing real objects you can test, alter and point dashboards at. Views are recreated as views over the production relation.
Clone suits promoting a blue-green deployment, seeding a staging environment from production in seconds, or giving a developer a full copy of the marts without recomputing them. Defer suits building a small subset cheaply.
Both depend on the state manifest being current. A stale manifest defers to a relation that may no longer exist. Clones are cheap at creation but diverge as production changes, so they are a snapshot, not a link.
The answer most people give
"--defer copies production tables into dev." It copies nothing. It rewrites refs to point at production. dbt clone is the one that creates objects.
They’ll ask next
You want a staging environment that is a full copy of production marts, refreshed each morning in seconds. Which command, and what does it do for models materialized as views?
What does dbt retry do? What does it read, which commands does it work with, and what does it not do?
Why they ask this
It is the operational command that saves an hour after a transient failure, and candidates confuse it with rerunning.
Say this
dbt retry, available since dbt 1.6, reruns the previous invocation from the point of failure. It reads run_results.json from the target directory, selects the nodes that errored, were skipped or did not run, and runs them with the same command and flags. It works for build, run, test, seed, snapshot, compile and clone. It does not rerun nodes that succeeded and does not fix the cause.
The reasoning
After `dbt build` fails because a warehouse connection dropped at model 40 of 120, the first 39 succeeded and 80 are errored or skipped. `dbt retry` reads the results file, selects those 80, and continues as if the run had never stopped.
It reuses the original command's selection and flags, which it also reads from the artifacts, so you do not retype them. If the project changed between the failure and the retry, the retry still runs the original selection against the new code.
`--state` can point at a different target directory, which is how an orchestrator retries a run whose artifacts were uploaded elsewhere. In dbt Cloud the same feature is the "rerun from failure" button on a job.
It is not a fix for deterministic failures. A model with a SQL error fails again. And because the successful nodes are not rebuilt, a retry after a long gap can build children against parents that are hours older than the children's own sources expect.
The answer most people give
"dbt retry reruns the whole command with retries on each query." It reruns only the nodes that did not succeed, once, from the saved results. Query-level retries belong to the adapter's connection settings.
They’ll ask next
A nightly build failed at 3 a.m. on a transient error and you retry at 9 a.m. Which nodes run, and what freshness risk does that create?
The opening question of every dbt interview; the answer must name the T and the two things dbt adds to SQL.
Say this
dbt is a transformation tool. It takes SQL select statements with Jinja, renders them, wraps each in the DDL for a chosen materialization, and runs them in dependency order inside the warehouse. It adds to plain SQL what software has: version control, a dependency graph derived from code, tests, documentation and environments. It moves no data in or out of the warehouse.
The reasoning
The problem is that analytical SQL used to live in scheduled scripts and BI tools with no tests, no lineage and no review. dbt makes each transformation a file in a repository, declares its dependencies through `ref`, and builds the graph from them.
A model is a select. dbt decides whether it becomes a view, a table, an incremental insert or nothing at all, and generates the statement. The warehouse does the work; dbt is a compiler and a scheduler of statements.
Around the models sit tests that return bad rows and YAML documentation that becomes a browsable site with lineage. Seeds hold small reference data, snapshots keep history, and macros carry shared logic. Together they are what people mean by analytics engineering.
dbt Core is the open-source command-line tool. dbt Cloud hosts it with a scheduler, an IDE, CI integration, a semantic layer and a catalog. The dbt Fusion engine, announced in 2025, is a Rust rewrite of the compiler with SQL understanding.
The answer most people give
"dbt is an ETL tool." It performs no extraction or loading. It is the T, run inside a warehouse that something else has already loaded.
They’ll ask next
In one sentence each: what does dbt do that a folder of SQL scripts on a cron cannot?
Is dbt an ETL or an ELT tool, and why does the distinction matter?
Why they ask this
A one-word answer that reveals whether you understand where dbt sits in a pipeline.
Say this
dbt is the T in ELT. Data is extracted and loaded into the warehouse by other tools, and dbt transforms it there, in place, using the warehouse's compute. It is not ETL, because it does not extract or load, and because the transformation happens after loading rather than in flight.
The reasoning
In ETL, data is transformed on the way in by an engine outside the warehouse, Informatica or Spark, and lands in its final shape. In ELT, raw data lands first, and transformations run as SQL inside the warehouse. dbt only makes sense in the second design.
The distinction matters for architecture. Someone must own E and L: Fivetran, Airbyte, a Spark job, a COPY statement. dbt's sources declare where that landed data is, and `dbt source freshness` checks that it is arriving, but dbt never fetches it.
It matters for cost too. Every dbt transformation is a warehouse query, billed by the warehouse. The optimisation levers are the ones the warehouse offers: incremental models, clustering, materialization choice.
And it matters for history. Because raw data is kept, a transformation can be fixed and rerun from the raw layer. ETL designs that transform in flight have to re-extract.
The answer most people give
"dbt is ETL because it transforms data." The T is shared. The question is where and when: dbt transforms after loading, inside the warehouse, with no E or L of its own.
They’ll ask next
Name the tool or statement that performs the E and L in a stack you have used, and say what dbt declares about its output.
What is a model in dbt, and what happens when it runs?
Why they ask this
The unit of everything in dbt; the answer should include the file, the select and the materialization.
Say this
A model is a single select statement in a .sql file under the models directory, optionally with Jinja, whose file name becomes the name of the relation it builds. When it runs, dbt renders the Jinja, wraps the select in the DDL of its materialization, view by default, and executes it in the warehouse after the models it refs.
The reasoning
`models/marts/fct_orders.sql` builds a relation called `fct_orders` in the target schema. The file contains only a select; dbt supplies the `CREATE VIEW AS` or `CREATE TABLE AS`. There is no insert statement to write.
The model's properties, description, columns, tests and configs, live in a YAML file beside it. Config can also sit in a `{{ config() }}` block at the top of the SQL file.
Running it means: render Jinja, resolve each `ref` to a relation, pick the materialization, generate the statements, run pre-hooks, run the statements, run post-hooks, record the result. Models run in DAG order, with up to `threads` of them concurrently.
Since dbt 1.3, a model can also be a Python file returning a DataFrame, on Snowflake, Databricks and BigQuery, executed on the platform's Python runtime. Most models remain SQL.
The answer most people give
"A model is a table." It is the code that produces a relation. Whether that relation is a view, a table or an incremental table is the materialization, not the model.
They’ll ask next
A model file is named stg_orders.sql and is configured as ephemeral. What exists in the warehouse after `dbt run`?
What is the ref function in dbt, and why do you use it instead of a table name?
Why they ask this
ref is the single most important function in dbt, and the three reasons for it are expected in order.
Say this
ref takes a model name and returns the fully qualified name of the relation that model builds in the current target. You use it instead of a table name because it builds the dependency graph, so dbt knows the order to run; it follows the environment, so the same code reads dev tables in dev and prod tables in prod; and it lets dbt rename or relocate a model without editing its consumers.
The reasoning
`select * from {{ ref('stg_orders') }}` compiles to `select * from analytics.dbt_alice.stg_orders` in Alice's dev target and `analytics.prod.stg_orders` in production. The code never names a schema.
At parse time dbt collects every ref call to draw an edge from the referenced model to this one. That graph is the DAG, and it is why dbt can run in the right order, select `+model` with its ancestors, and show lineage in the docs.
A hardcoded table name breaks all three: dbt cannot see the dependency and may run the consumer first, dev reads production, and a rename silently breaks readers. The `dbt_project_evaluator` package flags any model that reads a table without ref or source.
The two-argument form `ref('other_project', 'model')` reads a model from an installed package or another project in dbt Mesh. `ref` also accepts a `v=` argument for versioned models.
The answer most people give
"ref is just a shortcut for the table name." It is also the dependency declaration and the environment switch. Dropping it loses the DAG and points dev at prod.
They’ll ask next
A model reads `raw.orders` directly instead of through ref or source. What three things does dbt lose for that model?
What is a source in dbt, and what does declaring one give you?
Why they ask this
Sources are the boundary between dbt and whatever loaded the data, and freshness checks live on them.
Say this
A source is a declaration in YAML of a raw table that something other than dbt loaded, grouped under a source name with its database and schema. Models read it with source('name', 'table'). Declaring it gives lineage from raw data, one place to change the raw location per environment, tests on raw columns, and freshness checks through loaded_at_field and dbt source freshness.
The reasoning
The YAML is `sources: - name: shopify database: raw schema: shopify tables: - name: orders`. A staging model then reads `{{ source('shopify', 'orders') }}`, which compiles to `raw.shopify.orders`. The database and schema can differ per target.
Freshness is the distinctive feature. With `loaded_at_field: _loaded_at` and `freshness: {warn_after: {count: 12, period: hour}, error_after: {count: 24, period: hour}}`, `dbt source freshness` queries the max of that column and compares it with now. It is how dbt notices that the loader stopped.
Sources appear as the leftmost nodes in the lineage graph, and tests can be declared on their columns, so a `not_null` on a raw key fails before any model runs. `--select source:shopify+` runs everything downstream of one source.
The convention is one staging model per source table, doing only renaming and casting. Every other model then reads refs, and the raw layer is touched in exactly one place.
The answer most people give
"A source is where dbt extracts data from." dbt extracts nothing. A source is a declared raw table that is already in the warehouse.
They’ll ask next
The loader has been down for six hours and no model failed. Which dbt command would have noticed, and what YAML does it need?
EvergreenTests (generic, singular, dbt-utils, unit tests)
What is a test in dbt, and how does dbt decide whether it passed?
Why they ask this
The simplest dbt question with a precise answer: a query, and zero rows.
Say this
A test is a select query that returns the rows which violate a rule. dbt runs it against the warehouse and counts the result. Zero rows is a pass; any rows is a failure, or a warning if the test's severity is set to warn or a threshold allows it. Tests are either generic, applied through YAML, or singular, written as one SQL file.
The reasoning
`not_null` on `order_id` compiles to `select * from fct_orders where order_id is null`. If the query returns nothing, the data obeys the rule. There is no special test engine; it is SQL and a count.
Severity and thresholds refine the verdict. `severity: warn` reports but does not fail the run. `error_if: '>100'` and `warn_if: '>0'` let a few bad rows warn and many fail. `store_failures: true` saves the bad rows to a table named after the test for debugging.
`dbt test` runs tests alone; `dbt build` runs each model's tests right after the model, and stops downstream models when a test errors, so bad data does not propagate. `--select test_type:data` or `test_type:unit` chooses the kind.
Unit tests, from dbt 1.8, are the other family: they test a model's logic on fixture rows rather than real data, and pass when the output matches the expectation exactly.
The answer most people give
"A test passes when it returns true." It passes when the query returns zero rows. A test written to return the good rows fails on every good table.
They’ll ask next
You write a singular test that selects orders with amount greater than zero. Why does it fail on perfect data, and what is the fix?
What is a seed in dbt, and what should and should not be a seed?
Why they ask this
Seeds are small and often misused, so the question is really about judgment on what belongs in version control.
Say this
A seed is a CSV file in the seeds directory that dbt loads into a table with dbt seed. It is for small, static, version-controlled reference data: country codes, a mapping of status codes to labels, a list of test accounts. It is not for data that changes often, is large, or comes from a source system, because every change is a commit and the load is a slow insert.
The reasoning
`seeds/country_codes.csv` becomes a table `country_codes` in the target schema. Models read it with `ref('country_codes')`, and it appears in the DAG like a model. `dbt seed` truncates and reloads it; `--full-refresh` drops and recreates it, which is needed when a column changes.
Column types are inferred from the CSV, which guesses wrong on zip codes and ids with leading zeros. `seeds: my_project: country_codes: +column_types: {code: varchar(2)}` in dbt_project.yml fixes the type.
The load is row-by-row inserts in batches, so seeds of more than a few thousand rows are slow and the docs advise against them. Data that belongs to a source system should be loaded by the loader and declared as a source.
The right test for a seed is "would I want a pull request review when this changes". Mapping tables and allow lists pass that test. Yesterday's exchange rates do not.
The answer most people give
"A seed is how you load data into the warehouse with dbt." It is for tiny reference files in the repo. Real loading is the job of a loader, and dbt reads its output as a source.
They’ll ask next
A team wants to seed a 2 million row customer list. What goes wrong, and what should they do instead?
What is the DAG in dbt, how is it built, and how do you select parts of it?
Why they ask this
The DAG is what dbt runs; knowing it is derived, not declared, is the point.
Say this
The DAG is the directed acyclic graph of every node in the project: sources, seeds, snapshots, models, tests and exposures, with an edge for each ref or source call. dbt builds it at parse time by rendering the Jinja and recording the calls, so it is derived from code rather than declared. The graph decides run order, and selectors such as +model, model+ and @model choose subsets.
The reasoning
There is no file that lists dependencies. `{{ ref('stg_orders') }}` in `fct_orders` is the edge. Remove the ref and the edge disappears. That is why hardcoded table names are dangerous: they are invisible edges.
Acyclic is enforced. If two models ref each other, parse fails with a cycle error. The graph is stored in `manifest.json` and drawn in the docs site as lineage.
Selection is graph syntax. `+fct_orders` is the model and all ancestors; `fct_orders+` is the model and all descendants; `+fct_orders+` is both. `2+fct_orders` limits depth. `@fct_orders` is the model, its descendants and all their ancestors, used to rebuild a model's children correctly. Set operators combine: a space is union, a comma is intersection.
The DAG also drives parallelism. dbt runs any nodes whose parents are complete, up to the `threads` setting, so a wide graph finishes faster than a chain of the same size.
The answer most people give
"You define the DAG in dbt_project.yml." Nothing there lists dependencies. The DAG comes entirely from ref and source calls inside the models.
They’ll ask next
You changed `stg_orders` and want to rebuild everything that depends on it, including the other parents those children need. Which selector, and why not plain `stg_orders+`?
Every project runs it first, and interviewers check whether you know what it installs and from where.
Say this
dbt deps installs the packages listed in packages.yml or dependencies.yml into the dbt_packages directory. A package is another dbt project, with macros, models and tests, pulled from the dbt Package Hub, a git repository or a local path. Its macros become callable and its models become part of your DAG. Since dbt 1.7 a package-lock.yml pins the resolved versions.
The reasoning
`packages.yml` lists entries like `- package: dbt-labs/dbt_utils version: [">=1.1.0", "<2.0.0"]`. `dbt deps` resolves versions against the hub, downloads each package, and writes it under `dbt_packages/`, which is gitignored. Nothing runs without this step, and CI does it first.
A package is a full dbt project. dbt_utils is macros only; a Fivetran source package ships staging models for a connector; dbt_project_evaluator ships models that audit your project. Their models run as part of your `dbt run` unless you disable them with `+enabled: false` under the package name.
Macros are called with the package prefix, `{{ dbt_utils.generate_surrogate_key([...]) }}`. Package variables are set from your dbt_project.yml under `vars:` scoped to the package name.
The lock file records the exact version and commit that resolved, so every environment installs the same code. `dependencies.yml` is the newer file that also holds dbt Mesh project dependencies; packages.yml remains valid.
The answer most people give
"dbt deps installs the Python dependencies of dbt." That is pip. dbt deps installs dbt packages, which are other dbt projects, into dbt_packages.
They’ll ask next
CI fails with an unknown macro error for dbt_utils even though packages.yml lists it. Which step is missing, and what folder would it have created?
Evergreendbt Core vs CloudSemantic layer & metrics
What is the difference between dbt Core and dbt Cloud?
Why they ask this
A positioning question, and the answer should separate the engine from the service around it.
Say this
dbt Core is the open-source command-line tool: you install it, run it on your own machines or orchestrator, and manage profiles, scheduling, CI and documentation hosting yourself. dbt Cloud is dbt Labs' hosted service around the same engine: a browser IDE, a job scheduler, environments, slim CI on pull requests, hosted docs and catalog, the semantic layer API, and access control. The project code is the same in both.
The reasoning
With Core you own the operations. Airflow, Dagster or cron runs `dbt build`, you store manifests for slim CI yourself, you host the docs site, and credentials live in profiles.yml or environment variables on your runners.
Cloud supplies those pieces. Jobs run on a schedule or on a pull request with deferral handled for you, and environments replace profiles.yml. The IDE compiles and previews in the browser, Explorer provides the catalog, and the semantic layer serves metrics to BI tools through an API.
The engine difference arrived in 2025. dbt Cloud runs the dbt Fusion engine, a Rust rewrite that understands SQL and validates it before running. Core users can adopt Fusion too under its own licence. Project syntax is unchanged.
The choice is about team size and platform ownership. Teams with an orchestrator and platform engineers often stay on Core; teams of analytics engineers without that support buy Cloud for the scheduler and CI.
The answer most people give
"dbt Cloud is a different, more powerful dbt." The project code and models are identical. Cloud adds the hosting, scheduling, IDE, catalog and semantic layer around the engine.
They’ll ask next
A team on Core wants slim CI on every pull request. Name the two things they must build that Cloud would provide.