dbt Interview Questions
Reviewed by Mark Dickie · Last updated
dbt (data build tool) is a transformation framework that lets analytics engineers build, test, and document the T in ELT using nothing but SQL and Jinja, with the warehouse doing the compute. Interviews probe how ref() and source() wire models into a dependency DAG, when to choose each materialization (view, table, incremental, ephemeral), how tests and snapshots guard data quality and history, and where incremental builds go wrong. These questions cover the model that separates someone who has run dbt run from someone who understands what dbt compiles it into.
At a glance
| Questions | 15 |
|---|---|
| Difficulty | 2–5 of 5 |
| Formats | Multiple choice, Multiple answer, True / false, Code output, Find the bug, Short answer, Flashcard, Ordering, Fill in the blank |
What you'll review
- ref function
- materialization choice
- incremental models
- ephemeral models
- dbt build
- source freshness
- model config
- jinja templating
- generic tests
- incremental strategy
- scd type 2
- state deferral
- seeds
- dependency resolution
- source function
Practice questions
dbt/refs-sources/ref-function
In a dbt model you write select * from {{ ref('stg_orders') }} instead of select * from analytics.stg_orders. What does using ref() give you that the hard-coded name does not?
Options
- dbt builds the dependency DAG from
ref()calls, so it ordersstg_ordersbefore this model and resolves the table to the correct schema/database per target ref()caches the upstream result in memory so the query runs fasterref()is purely cosmetic; it compiles to the identical SQL with no behavioural differenceref()runs the upstream model again every time this model is queried
Show answer
ref() is what lets dbt understand your project. Each ref('stg_orders') becomes an edge in the DAG, so dbt builds upstream models first and resolves the relation to the correct database and schema for whichever target you run against — dev locally, prod in deployment. A hard-coded analytics.stg_orders gives dbt no dependency information and freezes the name to one environment. It does no caching and never re-runs upstream models.
ref() is the backbone of dbt. At parse time dbt records every ref('stg_orders') as an edge in the project DAG, which lets it (1) build models in dependency order — stg_orders is guaranteed to exist before any model that refs it — and (2) resolve the relation to the right database/schema for the active target, so the same code points at your dev schema locally and the prod schema in deployment. A hard-coded analytics.stg_orders has neither property: dbt can't see the dependency, so it may build in the wrong order, and the name is frozen to one environment. ref() does no caching and doesn't re-run upstream models (b, d are wrong); it isn't cosmetic (c). It compiles to a fully-qualified relation name, but the value is the dependency graph and environment-aware resolution behind it.
dbt/models/materialization-choice
A model is materialized as a view. What is the main tradeoff compared to materializing it as a table?
Options
- A view stores no data and is cheap/instant to build, but recomputes its query on every read; a table persists results so reads are fast but the build costs storage and compute
- A view always returns stale data, while a table is always live
- A view can hold more rows than a table because it is not stored on disk
- There is no difference in query performance; the choice only affects naming
Show answer
A view stores only the query, so it builds instantly, costs no storage, and always reflects current upstream data — but every read re-runs the underlying SQL, which is slow for expensive logic. A table persists the computed rows via CREATE TABLE AS SELECT: builds cost compute and storage and the data is only as fresh as the last run, but reads are a fast scan. Use views for light, infrequently-read models and tables for expensive logic read by many consumers.
A view materialization issues a CREATE VIEW — it stores only the query definition, not the result set. Building it is near-instant and uses no storage, and it always reflects current upstream data, but every downstream select re-executes the underlying SQL, so reads of an expensive transformation are slow and repeated. A table materialization runs CREATE TABLE AS SELECT, persisting the computed rows: builds cost compute and storage and the data is only as fresh as the last dbt run, but reads are a cheap scan of stored results. The rule of thumb: views for light staging/transient models that are read infrequently, tables for expensive logic read by many downstream consumers or BI tools. Option b mislabels freshness (views are more live, not stale); c and d are simply false.
dbt/models/incremental-models
An incremental model wraps its filter in {% if is_incremental() %} where event_at > (select max(event_at) from {{ this }}) {% endif %}. On a normal incremental run, what does this achieve?
Options
- It transforms only rows newer than the latest already in the table, then merges/inserts just those rows — avoiding a full rebuild of the whole history
- It drops and fully rebuilds the table from all source rows on every run
- It deletes the existing table and replaces it with only the new rows, losing history
is_incremental()is always false, so thewhereclause never applies
Show answer
On a normal incremental run, is_incremental() is true (the table already exists and it isn't a full refresh), so the compiled model selects only source rows newer than the max timestamp already stored — {{ this }} is the model's own existing table — and merges or inserts just that slice using the configured strategy. This avoids re-transforming the entire history every run, which is the reason incremental models exist for large fact tables. The first run and --full-refresh runs build from all rows.
is_incremental() returns true only when the target table already exists and the run is not a --full-refresh. On such runs dbt compiles the where clause so the model selects only source rows newer than the max timestamp already persisted ({{ this }} refers to the model's own existing relation), then applies the configured incremental_strategy (append, merge, delete+insert, etc.) to fold just those new rows into the existing table. That is the entire point of incremental models: process a small recent slice instead of re-transforming the full history every run, which is essential for large event/fact tables. On the first run (or under --full-refresh) is_incremental() is false, the filter is omitted, and the table is built from all rows. Options b and c describe a table materialization or a destructive rebuild; d is wrong because the function is true on subsequent runs.
dbt/models/ephemeral-models
A model is configured materialized='ephemeral'. How does dbt make its logic available to downstream models, and what is a key limitation?
Options
- dbt creates no database object; it inlines the model's SQL as a CTE into each downstream model that refs it — so you cannot select from it directly in the warehouse
- dbt builds a temporary table that is dropped after the run, queryable only during that run
- dbt builds a permanent view in a hidden schema that only dbt can read
- Ephemeral models are skipped entirely and produce no output anywhere
Show answer
An ephemeral model builds no database object. When a downstream model refs it, dbt inlines its compiled SQL as a CTE at the top of that model's query. This keeps small, reusable logic out of the warehouse namespace. The tradeoff: because no relation exists, you cannot select from an ephemeral model directly in the warehouse or a BI tool, its tests only run through consumers, and long ephemeral chains compile into large nested CTE stacks that are harder to debug.
An ephemeral model is never materialized as a view or table — dbt builds no relation for it at all. Instead, when a downstream model refs it, dbt interpolates the ephemeral model's compiled SQL as a common table expression (CTE) at the top of that downstream model's query. This keeps tidy, reusable logic out of the warehouse namespace and avoids cluttering it with trivial intermediate objects. The limitations follow directly: because there's no object, you can't query the ephemeral model directly (in a BI tool or ad-hoc), tests on it run only in the context of consumers, and deeply nested ephemeral chains can produce large, hard-to-debug compiled CTE stacks. Option b describes a temp table (not how ephemeral works), c invents a hidden view, and d is false — the logic does run, just inlined.
dbt/deployment/dbt-build
Which statements correctly describe how dbt build differs from running dbt run and dbt test separately? Select all that apply.
Options
dbt buildinterleaves work per-node: it runs a model, then runs that model's tests, before moving downstream — rather than building every model first and testing everything afterward- If a model's test fails during
dbt build, downstream models that depend on it are skipped, stopping bad data from propagating dbt buildalso handles seeds and snapshots in the same DAG-ordered command, not just modelsdbt buildskips running tests entirely; it is just a faster alias fordbt rundbt buildignores the DAG and executes nodes in alphabetical order
Show answer
dbt build runs seeds, snapshots, models, and tests as one DAG-ordered command, interleaving them node by node: it builds a model, runs that model's tests, and only then moves downstream. A failing test skips the dependent models, so bad data does not propagate — the key advantage over a separate dbt run then dbt test, where everything is built before any test runs. It always respects the DAG and never executes alphabetically or skips tests.
dbt build is a single DAG-aware command that executes seeds, snapshots, models, and tests together in dependency order, interleaving them node-by-node (a, c). Crucially, after building a node it runs the tests attached to that node before proceeding downstream, so a failing test causes dbt to skip the dependent models rather than build them on top of bad data (b). That early-stop behaviour is the main reason build is preferred in CI/production over a separate dbt run followed by dbt test — in the split workflow, every model is built first, so failing tests are discovered only after bad data has already been written and propagated downstream. Option d is wrong (build very much runs tests), and e is wrong (it strictly respects the DAG, never alphabetical order).
dbt/testing/source-freshness
dbt source freshness checks how recently raw source tables were loaded by querying a loaded_at_field (or warehouse metadata) and comparing it to warn_after/error_after thresholds — it is about the freshness of external sources, not of dbt's own models.
Show answer
True. dbt source freshness is about raw external sources, not dbt's own models. It reads each source's loaded_at_field (or warehouse metadata) to find the latest load time and compares the gap to the warn_after/error_after thresholds declared on the source, warning or erroring when ingestion has gone stale. Teams run it before a build to catch a broken upstream load early. It tells you nothing about how current your models are — that depends on your last run.
True. Source freshness is a property you declare on source definitions in a .yml file, not on models. dbt looks at a loaded_at_field column (or, on supported warehouses, table metadata) to find the most recent load time for each source table, then compares the gap to now against the warn_after and error_after thresholds you configure, emitting a warning or error when raw data has gone stale. This catches a broken or late upstream ingestion (an ETL job that stopped running) before you waste a build transforming stale data — a common pattern is to run dbt source freshness at the start of a pipeline and abort if it errors. It says nothing about whether your dbt models are up to date; that depends on when you last ran them. Because it targets sources, it has no notion of ref() — it is checking the inputs to your project, not its internal nodes.
dbt/project-structure/model-config
A model file models/marts/dim_customers.sql begins with the config below, and dbt_project.yml separately sets marts: +materialized: view. When you run this model, how is it materialized?
{{ config(
materialized='table'
) }}
select * from {{ ref('stg_customers') }}Options
- As a table — the in-model
config()block overrides the directory-level setting in dbt_project.yml - As a view — dbt_project.yml always wins over in-file config
- Build error: materialization is declared in two places
- As an ephemeral model, because conflicting configs cancel out
Show answer
As a table — the in-model `config()` block overrides the directory-level setting in dbt_project.yml
dbt merges configuration from multiple layers with a clear precedence: a config() block inside the model itself is the most specific and wins over properties.yml configs, which win over dbt_project.yml directory-level configs, which win over project defaults. Here the model's own config(materialized='table') is more specific than the marts: +materialized: view set in dbt_project.yml, so the model is built as a table. dbt does not error on a 'conflict' (option c) — layered config with overrides is the intended design, letting you set a sensible default for a whole folder and override the exceptions per model. Options b and d invert or invent the rule. The practical takeaway: set broad defaults in dbt_project.yml, then reach for an in-model config() block only for the specific models that need to differ.
dbt/jinja-macros/jinja-templating
What SQL does this Jinja compile to (whitespace aside)?
{% set payment_methods = ['card', 'cash'] %}
select
order_id,
{% for pm in payment_methods %}
sum(case when method = '{{ pm }}' then amount end) as {{ pm }}_amount{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('payments') }}
group by 1Options
- select order_id, sum(case when method = 'card' then amount end) as card_amount, sum(case when method = 'cash' then amount end) as cash_amount from <payments> group by 1
- select order_id, sum(case when method = 'card' then amount end) as card_amount, sum(case when method = 'cash' then amount end) as cash_amount, from <payments> group by 1 (with a trailing comma)
- select order_id, {{ pm }}_amount from <payments> group by 1 — the loop variable is left un-rendered
- A parse error, because you cannot define a list with
{% set %}in a model
Show answer
select order_id, sum(case when method = 'card' then amount end) as card_amount, sum(case when method = 'cash' then amount end) as cash_amount from <payments> group by 1
This is the canonical Jinja-in-dbt pattern: {% set %} defines a list, and the {% for %} loop fans it out into one aggregate column per element. Each iteration renders sum(case when method = '<pm>' then amount end) as <pm>_amount, substituting the loop value. The {% if not loop.last %},{% endif %} guard emits a comma after every column except the final one — loop.last is true only on the last iteration — so you get a clean comma-separated list with no dangling trailing comma before from (which would be a SQL syntax error, the trap in option b). The result is two pivot-style columns, card_amount and cash_amount. Option c misunderstands that Jinja renders the loop variable; d is wrong because {% set %} lists are fully supported. This loop.last comma trick is one of the most common reasons people use Jinja in dbt at all.
dbt/testing/generic-tests
Given this schema.yml, the id column of stg_orders contains a NULL and a duplicated value. What is the result of dbt test --select stg_orders?
models:
- name: stg_orders
columns:
- name: id
tests:
- unique
- not_nullOptions
- Both tests run and both FAIL: the not_null test returns the NULL row and the unique test returns the duplicated value (each failing test reports its offending rows)
- Only the first test (unique) runs; dbt stops at the first failure
- Both tests PASS, because generic tests only check column types, not values
- A compile error, because a column cannot have two tests
Show answer
Both tests run and both FAIL: the not_null test returns the NULL row and the unique test returns the duplicated value (each failing test reports its offending rows)
unique and not_null are built-in generic tests. dbt compiles each into a select that returns the failing rows — not_null returns rows where id is null, unique returns values appearing more than once via a group by ... having count(*) > 1. A generic test passes when that query returns zero rows and fails when it returns one or more. With both a NULL and a duplicate present, the two tests run independently and each fails, reporting its own offending rows; dbt does not stop after the first failure (b) — every selected test runs so you see the full picture. The tests check values, not just types (c is wrong), and a column may carry any number of tests (d is wrong). Generic tests are the cheapest, most common data-quality guardrail in dbt; dbt build would additionally use these failures to skip downstream models.
dbt/deployment/incremental-strategy
This incremental model is meant to keep one current row per order_id, but late-arriving updates to existing orders produce duplicate order_ids in the table. What is the root cause?
{{ config(
materialized='incremental',
incremental_strategy='merge'
) }}
select
order_id,
status,
updated_at
from {{ ref('stg_orders') }}
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}Options
- The config has no
unique_key, so themergestrategy has no key to match on and simply appends new rows — an updated order arrives as a second row instead of replacing the existing one is_incremental()should beis_full_refresh(); the filter is inverted{{ this }}is invalid inside an incremental model and silently resolves to nothingmergeis not a real incremental strategy; onlyappendexists
Show answer
The config has no unique_key, so the merge strategy has no key to match on and simply appends new rows — an updated order arrives as a second row instead of replacing the existing one
The merge (and delete+insert) strategy needs a unique_key to know which existing rows a new batch should update. Without it, dbt has no match condition, so merge degenerates into an insert-only operation: when a previously-seen order_id arrives with a newer updated_at, it passes the updated_at > max filter and is appended as a brand-new row instead of overwriting the old one — hence the duplicates. The fix is to add unique_key='order_id' to the config so the merge updates the matching row in place (and inserts genuinely new orders). Option b is wrong — is_incremental() is correct here, gating the filter so it only applies on incremental runs. Option c is false: {{ this }} is the standard self-reference to the model's existing relation. Option d is false: merge is a first-class incremental strategy on warehouses that support it. The general lesson: an incremental model that must keep one row per entity is incomplete without a unique_key.
dbt/snapshots-history/scd-type-2
What problem do dbt snapshots solve, and how does the timestamp strategy differ from the check strategy?
Show answer
Snapshots capture how a mutable source row changes over time, building a slowly changing dimension (SCD type 2) so you keep history that the source itself overwrites. dbt adds bookkeeping columns — typically dbt_valid_from and dbt_valid_to (and dbt_scd_id) — so each version of a row is a separate record with a validity window; the current version has a null dbt_valid_to. The two strategies decide how dbt detects a change. The timestamp strategy trusts an updated_at column: if the source's updated_at is newer than the recorded one, dbt closes the old row and inserts a new version. The check strategy compares the actual values of a specified list of columns (or all columns) between source and snapshot, creating a new version whenever any of them differs — used when the source has no reliable updated_at. Timestamp is cheaper and preferred when a trustworthy timestamp exists; check is the fallback that diffs columns directly.
Snapshots exist because source systems mutate rows in place — an order's status flips from 'pending' to 'shipped' and the old value is lost. A snapshot persists each version as a row with validity columns (dbt_valid_from/dbt_valid_to), giving you SCD type 2 history you can query as-of any point in time. The timestamp strategy detects change by watching a reliable updated_at column — newer timestamp means a new version — and is cheap and preferred. The check strategy diffs the values of named (or all) columns between source and the latest snapshot row, opening a new version on any difference; it's the fallback when no trustworthy update timestamp exists. A strong answer names SCD type 2, the validity columns, and the timestamp-vs-check distinction.
dbt/deployment/state-deferral
In a CI pipeline, how do state:modified selection and --defer let you test only the models you changed without rebuilding the entire project ('slim CI')?
Show answer
dbt writes a manifest.json artifact describing every node and its compiled SQL. By saving the production manifest as a baseline, CI can run with --select state:modified (comparing against that baseline via --state path/to/prod/manifest), which selects only the nodes that changed plus, with the + operator, their downstream children. That builds just the affected slice instead of the whole DAG. The catch is that a changed model usually refs unchanged upstream models that don't exist in the CI schema. --defer solves that: for any ref to a node not selected for the current run, dbt resolves the relation to the deferred environment (production) instead of the empty CI schema, so the modified models read real upstream prod tables without you having to build them. Together they make CI fast and cheap — build and test only the diff, defer everything else to prod — which is the standard slim-CI pattern.
Slim CI rests on two features. State comparison: dbt's manifest.json is a full description of the project, so saving the production manifest as a baseline lets --select state:modified --state <prod-manifest> pick out only nodes whose definition changed (and, with +, their downstream dependents) — you build the diff, not the whole project. Deferral: a changed model still refs unchanged upstream models that were never built in the ephemeral CI schema; --defer makes those refs resolve to the production relations instead, so the modified models can read real upstream data without rebuilding it. Together they cut CI time and warehouse cost dramatically. A strong answer names the manifest baseline, state:modified, and how --defer redirects unbuilt refs to prod.
dbt/project-structure/seeds
What is a dbt seed, and when should (and shouldn't) you use one?
Show answer
A seed is a CSV file in the project's seeds/ directory that dbt loads into the warehouse as a table when you run dbt seed. Because it lives in version control, it's referenced downstream with ref('my_seed') just like a model and participates in the DAG. Seeds are meant for small, relatively static, business-defined lookup data that you want versioned alongside your code — things like country-code mappings, a list of internal test accounts to exclude, status-code descriptions, or a category taxonomy. They are not an ingestion tool: you should not use seeds for large datasets or for raw event/transactional data, because CSVs in git bloat the repo, load slowly, and aren't how production data should arrive. That kind of data belongs in proper sources loaded by an extract/load tool and declared with source(). Rule of thumb: a seed is fine if a human would happily edit it in a spreadsheet and it has at most a few thousand rows.
Seeds turn small, static CSVs in seeds/ into warehouse tables via dbt seed, version-controlled and usable downstream with ref(). The interview point is knowing the fit: good for small business-owned lookup/mapping tables (country codes, exclusion lists, category mappings), bad for large or raw transactional data, which belongs in source()-declared tables loaded by an EL tool. A common follow-up contrasts seeds (ref) with sources (source): both are inputs, but seeds are tiny git-tracked CSVs you own, while sources are externally-loaded raw tables you only point at. The 'would a human edit this in a spreadsheet?' heuristic is the tell of real understanding.
dbt/refs-sources/dependency-resolution
Order the phases dbt goes through when you invoke dbt run, from first to last.
Put these in order
- Parse the project: read all files, render Jinja, and resolve
ref()/source()calls - Build the DAG and compute the order of nodes from the resolved dependencies
- Compile each selected model into final, warehouse-ready SQL in target/
- Execute the compiled SQL against the warehouse in dependency order
- Write run artifacts (run_results.json, manifest.json) recording what happened
Show answer
dbt run proceeds in five phases. First it parses the project, rendering Jinja and resolving every ref()/source(). Next it builds the dependency DAG and sorts the nodes. Then it compiles each selected model into plain SQL under target/ without touching the warehouse. Only then does it execute that SQL against the database in dependency order so upstream tables exist first. Finally it writes artifacts like manifest.json and run_results.json that record the graph and each node's status.
A dbt invocation is a pipeline. First dbt parses the whole project — reading every model, macro, and yml file and rendering Jinja enough to discover each ref()/source() call (this is where the manifest of nodes is assembled). It then builds the dependency graph (DAG) from those resolved references and topologically sorts it to decide execution order. Next it compiles the selected models, fully rendering their Jinja into plain SQL written under target/compiled/ — at this point there is concrete SQL but nothing has touched the warehouse yet. Only then does it execute that SQL against the database, materializing models in dependency order so upstream tables exist before downstream ones run. Finally it writes artifacts (manifest.json, run_results.json) capturing the graph and per-node timing/status, which power docs, state comparison, and slim CI. Mixing up parse-before-compile or execute-before-compile is the common mistake; SQL must be compiled before it can run.
dbt/refs-sources/source-function
To select from another dbt model and register the dependency in the DAG, you call {{ _____('stg_orders') }}. To select from a raw, externally-loaded table declared in a .yml file, you instead call {{ _____('jaffle_shop', 'orders') }}.
Show answer
To select from another dbt model and register the dependency in the DAG, you call {{ **ref**('stg_orders') }}. To select from a raw, externally-loaded table declared in a .yml file, you instead call {{ **source**('jaffle_shop', 'orders') }}.
ref() and source() are the two ways a dbt model declares an input, and both add the dependency to the DAG. ref('model_name') points at another model in your project — dbt builds it first and resolves it to the right schema/database per target. source('source_name', 'table_name') points at a raw, externally-loaded table that you've declared under a sources: block in a .yml file; the two arguments are the source group and the table within it. The discipline is to wrap every raw table in a source() (giving you a single place to document it, test it, and check freshness) and to chain all model-to-model dependencies through ref() — never hard-code a database table name in a model, or you lose dependency tracking and environment-aware resolution.
Related interview questions
Practice this for real
CodePrep turns your target job description into an adaptive quiz from a bank of tagged questions, scores your answers, and resurfaces the topics you miss.