dbt Interview Questions — Practice Real Data Build Tool Questions

Reviewed by Mark Dickie · Last updated

dbt is a command-line transformation framework that lets data teams write modular SQL transformations and run them inside their data warehouse. For an interview, you should know how dbt models relate through ref(), what each materialization type does (table, view, incremental, ephemeral, snapshot), how tests work at the column and model level, and when to reach for snapshots vs incremental models when data changes slowly over time. You should also be comfortable with Jinja macros, the standard project structure (dbt_project.yml, models/, snapshots/, macros/), and the difference between dbt Core and dbt Cloud.

What does a dbt interview typically test?

Interviewers focus on a few recurring areas:

AreaWhat gets asked
MaterializationsWhen to use incremental vs table vs view; how unique_key and merge logic work in incremental models
Model dependenciesHow ref() builds the DAG; why source() matters for raw-layer stability
TestingBuilt-in tests (not_null, unique, accepted_values, relationships); writing singular and generic tests
SnapshotsType 2 SCD logic; check_cols vs timestamp strategy
Macros and JinjaReusable SQL via macros; {{ }} interpolation; conditional logic with {% if %}
Project structureFolder conventions, staging vs marts layers, dbt_project.yml config overrides

How should you prepare for dbt interview questions?

  1. Build a small project end to end (staging models, a mart, at least one incremental model, and a snapshot) so you can talk through concrete decisions you made.
  2. Memorize the materialization matrix: which one recomputes fully, which one appends, which one merges, and which one never materializes at all.
  3. Practice explaining the DAG. How does ref() resolve at compile time, and what happens when a model fails upstream?
  4. Write a generic test from scratch. Being able to explain the difference between a singular test (a SQL query that returns failing rows) and a generic test (a parameterized macro) comes up often.
  5. Know the dbt Cloud vs Core split: what Cloud adds (the IDE, scheduler, semantic layer, observability dashboard) and where Core is the right call.

Key facts

  • Tarmac has 108 dbt interview questions on this topic, 10 of them on this page, at difficulty 2–5 of 5.
  • Tarmac last reviewed these dbt interview questions on 23 August 2026.

At a glance

Questions10 shown · 108 in the bank
Difficulty2–5 of 5
FormatsMultiple choice, Flashcard, Coding exercise, Code output, Ordering, Fill in the blank, Multiple answer, True / false, Find the bug, Short answer
Interactive1 run your code against tests, in the app

What you'll review

  1. ref function
  2. seeds
  3. table materialization
  4. model config
  5. dependency resolution
  6. source function
  7. dbt build
  8. source freshness
  9. incremental strategy
  10. scd type 2

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

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.

Why:

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/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.

Why:

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/models/table-materialization

You are building a dbt model that uses table materialization ({{ config(materialized='table') }}). Because a table-materialized model is fully dropped and rebuilt on every dbt run, the model's SQL must contain the complete transformation logic — there is no incremental state to rely on.#

Starter code

-- TODO: aggregate orders by customer, keep only those with total revenue > 500
SELECT
    customer_id,
    SUM(amount) AS total_revenue,
    COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
ORDER BY customer_id ASC;

Your solution must pass

  • visible_base_dataset

This one is written and run, not read. Solve it in the app and your code is executed against these tests and the hidden ones.

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

Show answer
As a table — the in-model `config()` block overrides the directory-level setting in dbt_project.yml
Why:

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/refs-sources/dependency-resolution

Order the phases dbt goes through when you invoke dbt run, from first to last.#

Put these in order

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.

Why:

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') }}.

Why:

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.

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

Pick every one that applies.

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.

Why:

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.#

Options

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.

Why:

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/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

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

Why:

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.

Why:

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.

Related interview questions

The other 98 questions

This page shows 10. A free account opens the rest and marks your answers, which a page of answers can't do. Miss one and it returns a few days later, then a week after that, until you stop missing it.

Start free

Free · the whole bank · 100 graded answers per 30 days · written feedback and job-ad quizzes on the paid plan

What moved, monthly

One email a month when the bulletin comes out: what moved in the markets we track, and the new question topics we published. Confirm your address to join. Unsubscribe any time.