Write the model, or the macro that stops five models repeating themselves. Jinja is the part that goes wrong: the signal is knowing when a macro clarifies and when it hides the SQL somebody has to debug at 3am.
The question the whole subject turns on. A model file is not what runs — read the Jinja, predict the SQL, then check.
Macros that earn their keep
6
A macro is a text function that returns SQL. Writing one is easy; knowing when it clarifies and when it hides the query somebody has to debug at 3am is the signal.
Parse time vs run time
4
dbt renders your project twice, and half the confusing Jinja errors come from not knowing which pass you are in.
Writing the model itself
4
Conventions that survive a hundred models: what belongs in staging, where hooks fit, and what dbt does when SQL is not enough.
01 / 20
Macros & JinjaModels & ref/source
A model loops over three statuses to build three `sum(case when ...)` columns. Write out the SQL it compiles to, commas included.
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
4
11
20
cancelled
2026-03-01 12:00:00
The project
models/orders_by_status.sql
Three columns from one loop. Predict the compiled SQL, including the commas.
{% set statuses = ['shipped', 'pending', 'cancelled'] %}
select
customer_id,
{% for status in statuses -%}
sum(case when status = '{{ status }}' then amount else 0 end) as {{ status }}_amount
{%- if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('raw_orders') }}
group by 1
What gets run
seeddbt seed
build itdbt run --select orders_by_status
Why they ask this
The standard live-compile exercise. Loops are the most common Jinja in a real project, and `loop.last` comma handling is where people who have only read about Jinja come unstuck.
Say this
Three sum-case columns, comma-separated, with no comma after the last one — the `{% if not loop.last %}` guard is what prevents a trailing comma and a syntax error.
The reasoning
The loop body is emitted once per item with `{{ status }}` substituted, so three iterations produce three aggregate expressions. Everything outside the tags — the `select`, the `customer_id`, the `from` and the `group by` — is emitted verbatim exactly once.
The comma is the part being tested. A comma written plainly inside the loop body gives you a trailing comma before `from`, which is a syntax error on most warehouses. `{%- if not loop.last %},{% endif %}` emits it for every iteration but the last. Jinja's loop object also gives you `loop.first`, `loop.index` (1-based) and `loop.index0`, and `loop.first` is the alternative idiom — put the comma *before* each item except the first.
The whitespace markers are doing real work here too. `{% for ... -%}` strips the newline after the tag, and `{%- if ... %}` strips the whitespace before it, which is what keeps each generated column on its own line rather than scattered across a page of blank lines.
The judgment question that follows is whether the list should be hardcoded. A literal `{% set statuses = [...] %}` is honest and reviewable: the compiled SQL is stable, and a new status in the source silently gets no column — which may be exactly what you want, since a column appearing without a code change breaks every downstream consumer that selected explicitly. Driving the list from the data requires `run_query`, and buys you automatic columns at the cost of a model whose schema changes without a commit.
What dbt did — 2 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
build itdbt run --select orders_by_status
Compiled SQLmodels/orders_by_status.sql
select
customer_id,
sum(case when status = 'shipped' then amount else 0 end) as shipped_amount,
sum(case when status = 'pending' then amount else 0 end) as pending_amount,
sum(case when status = 'cancelled' then amount else 0 end) as cancelled_amount
from "analytics"."main"."raw_orders"
group by 1
The warehouse now holds
orders_by_status
customer_id
shipped_amount
pending_amount
cancelled_amount
10
120
0
0
11
0
30
20
The answer most people give
Writing the comma unconditionally inside the loop. It compiles to `... as cancelled_amount, from raw_orders`, and the error the warehouse returns points at `from`, which is nowhere near the actual mistake.
They’ll ask next
A fourth status appears in the source. What happens to this model, and is that the behaviour you want?
Your compiled SQL is correct but full of blank lines and stray indentation. What are `{%-` and `-%}` for, and when does whitespace stop being cosmetic?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
4
11
20
cancelled
2026-03-01 12:00:00
The project
models/loose_whitespace.sql
Every tag sits on its own line. Count the blank lines in the output.
select
order_id,
amount
from {{ ref('raw_orders') }}
where 1 = 1
{% if true %}
and status = 'shipped'
{% endif %}
{% set cutoff = 40 %}
{% if cutoff %}
and amount >= {{ cutoff }}
{% endif %}
What gets run
seeddbt seed
compile itdbt compile --select loose_whitespace
Why they ask this
Every dbt developer hits it in week one, and the answer shows whether you have actually read your own compiled SQL — which is the habit the interviewer is really checking for.
Say this
Jinja emits the whitespace around tags as literal text, so a tag alone on a line leaves a blank line behind. The dashes strip adjacent whitespace, and it matters most inside loops and anywhere your SQL ends up in a comment or a string.
The reasoning
A Jinja tag produces no output, but the newline and indentation surrounding it are ordinary template text and are emitted. So `{% if true %}` on its own line contributes an empty line to the compiled SQL. Ten tags in a model give you ten blank lines, and the SQL that was going to be your debugging artifact becomes something you scroll through.
`{%-` strips whitespace immediately before the tag and `-%}` strips it immediately after; `{{- ... -}}` does the same for expressions. Use them so that each generated fragment lands where you would have typed it.
It stops being cosmetic in three places. Inside a loop, where the accumulated whitespace multiplies by the iteration count and the output becomes genuinely unreadable. Anywhere the rendered text goes into a single-line context — a `--` comment, a hook string, a JSON config — where an injected newline changes the meaning or truncates it. And in anything hash-based: if you feed rendered SQL into a checksum or compare two compiled models, a whitespace difference is a difference.
The practical habit worth stating: the compiled file is the artifact you will debug from at 3am, so keep it readable on purpose. `dbt compile --select my_model && cat target/compiled/...` after writing any non-trivial Jinja takes ten seconds and is how you learn what your own template does.
What dbt did — 2 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
compile itdbt compile --select loose_whitespace
Valid SQL, and unreadable. Every tag left a blank line where it used to be.
Compiled SQLmodels/loose_whitespace.sql
select
order_id,
amount
from "analytics"."main"."raw_orders"
where 1 = 1
and status = 'shipped'
and amount >= 40
The answer most people give
"It does not matter, the warehouse ignores whitespace." The parser does; the human debugging a 400-line compiled model at 3am does not, and neither does a hook string that was supposed to be one line.
They’ll ask next
Where would stray whitespace actually change behaviour rather than just readability?
Your source has two billion rows and a local `dbt run` takes 40 minutes. How would you make dev runs cheap, and what is the risk?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
4
11
20
cancelled
2026-03-01 12:00:00
The project
models/orders_sampled.sql
The dev target is the one this ran under.
select
order_id,
customer_id,
amount
from {{ ref('raw_orders') }}
{% if target.name == 'dev' %}
where updated_at >= '2026-03-01 11:00:00'
{% endif %}
What gets run
seeddbt seed
run against devdbt run --select orders_sampled
Why they ask this
A practical question everyone hits, and the risk half is the discriminator: the obvious fix quietly makes dev and prod compile to different SQL, which is how a bug ships.
Say this
Branch on `target.name` to add a date filter or a limit in dev only. The risk is that you are now testing SQL you never run in production, so the branch has to restrict rows and nothing else.
The reasoning
`target` is available in Jinja and carries the current target's name, schema, database and threads. Wrapping a filter in `{% if target.name == 'dev' %}` gives you a model that reads a week of data locally and everything in production, from one file.
The rule that keeps it safe is that the branch may only reduce the rows scanned. Filter on a date, sample, or limit. The moment the branch changes a join, an aggregation or a column list, dev and production are two different models and your local testing proves nothing about the thing that runs at night.
Two better versions worth offering. Put the cutoff in a var — `{% if target.name == 'dev' %}where updated_at >= '{{ var("dev_start", "2026-01-01") }}'{% endif %}` — so it is tunable without editing models. Or push the limit up into the staging layer only, so every downstream model inherits a small dataset and no mart contains an environment branch at all. The second is the one I would argue for: one place to reason about, and the marts stay identical across environments.
The failure this prevents is worth naming because it is the reason people ask. Without it, engineers avoid running dbt locally, so their first real execution of a change is in CI or production. Cheap dev runs are not a convenience, they are what makes the feedback loop short enough that people test.
What dbt did — 2 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
run against devdbt run --select orders_sampled
The branch is live because target.name is 'dev'. Under a prod target the where clause disappears entirely.
Compiled SQLmodels/orders_sampled.sql
select
order_id,
customer_id,
amount
from "analytics"."main"."raw_orders"
where updated_at >= '2026-03-01 11:00:00'
The warehouse now holds
orders_sampled
order_id
customer_id
amount
3
10
70
4
11
20
The answer most people give
Adding `limit 1000` at the bottom of every model without a target guard. It ships to production, and a mart silently capped at a thousand rows is a bug nobody notices until a total is wrong.
They’ll ask next
Where would you rather put the limit — in every model, or only in staging? Argue it.
What is the difference between `{{ var('min_amount', 0) }}` and `{{ var('min_amount') }}`, and where do you set the value?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
4
11
20
cancelled
2026-03-01 12:00:00
The project
models/orders_filtered.sql
Two vars. One has a default of 0, the other defaults to none.
select
order_id,
amount
from {{ ref('raw_orders') }}
where amount >= {{ var('min_amount', 0) }}
{% if var('start_date', none) %}
and updated_at >= '{{ var('start_date') }}'
{% endif %}
What gets run
seeddbt seed
no vars passeddbt run --select orders_filtered
with varsdbt run --select orders_filtered --vars '{"min_amount": 40, "start_date": "2026-03-01 10:30:00"}'
Why they ask this
Vars are how backfills and parameterised runs are driven, so an interviewer asking about reprocessing will get here. The two-argument form's behaviour on a missing var is the specific thing being checked.
Say this
With a default, a missing var falls back silently; without one, dbt raises a compilation error and the run stops. Values come from the `vars:` block in dbt_project.yml or from `--vars` on the command line, which wins.
The reasoning
`var('name')` requires the variable to be defined somewhere or compilation fails — which is the right choice for something the run genuinely cannot proceed without. `var('name', default)` substitutes the default when it is missing, which is right for a knob with a sensible off position.
Precedence is command line over project file: `vars:` in `dbt_project.yml` sets the baseline, and `dbt run --vars '{"min_amount": 40}'` overrides it for that invocation. The CLI value is YAML, so the quoting matters — the JSON-ish form with the space after the colon is the one that works everywhere.
The pattern that makes them useful is the optional filter: `{% if var('start_date', none) %} and updated_at >= '{{ var("start_date") }}' {% endif %}`. With no var passed, the block disappears entirely and the model behaves normally. With one, you get a bounded run — which is how you backfill a date range without editing a model, and how a reprocessing job passes its window in.
Two cautions. A default of `none` is falsy and a default of `0` is falsy too, so `{% if var('min_amount', 0) %}` skips the block when someone explicitly passes zero — test against `is not none` when zero is a legitimate value. And vars are global to the run, not scoped to a model, so a name like `start_date` used by two unrelated models is one var driving both.
What dbt did — 3 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
no vars passeddbt run --select orders_filtered
min_amount fell back to 0 and the start_date block vanished. Every row is returned.
Compiled SQLmodels/orders_filtered.sql
select
order_id,
amount
from "analytics"."main"."raw_orders"
where amount >= 0
The warehouse now holds
orders_filtered
order_id
amount
1
50
2
30
3
70
4
20
with varsdbt run --select orders_filtered --vars '{"min_amount": 40, "start_date": "2026-03-01 10:30:00"}'
Same model file, different compiled SQL, different rows.
Compiled SQLmodels/orders_filtered.sql
select
order_id,
amount
from "analytics"."main"."raw_orders"
where amount >= 40
and updated_at >= '2026-03-01 10:30:00'
The warehouse now holds
orders_filtered
order_id
amount
3
70
The answer most people give
"They are the same, the second argument is just documentation." Without a default, a missing var is a hard compilation error — which is often what you want, and is the difference between a run that stops and a run that quietly filters on nothing.
They’ll ask next
You need to backfill March only. Would you use a var, a full refresh, or something else?
A brand-new incremental model fails on its very first run with "Table with name recent_orders does not exist". The guard is there. What went wrong?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
4
11
20
cancelled
2026-03-01 12:00:00
The project
models/recent_orders.sql
A brand-new project: recent_orders has never been built. Two characters are missing.
{{ config(materialized='incremental') }}
select
order_id,
amount,
updated_at
from {{ ref('raw_orders') }}
where 1 = 1
{% if is_incremental %}
and updated_at > (select max(updated_at) from {{ this }})
{% endif %}
What gets run
seeddbt seed
first rundbt run --select recent_orders
Why they ask this
It is a real error people lose an hour to, and it tests whether the candidate understands that a macro name without parentheses is an object rather than a call — a Jinja fact that explains several other confusing behaviours.
Say this
The parentheses are missing. `{% if is_incremental %}` tests the macro object itself, which is always truthy, so the branch is included on every run — including the first, when the relation it references does not exist yet.
The reasoning
`is_incremental` without parentheses does not call anything. Jinja resolves the name to the macro object, and any object is truthy, so the condition is unconditionally true. `is_incremental()` calls it and returns the boolean you wanted.
The symptom is the giveaway. The guard's whole job is to keep `{{ this }}` out of the first run, because on a fresh environment the target relation has not been created yet. When the guard is always true, the filter compiles in and the warehouse is asked to select from a table that does not exist. The error names the model itself, which is the confusing part — people read it as dbt failing to create the model rather than as the model referring to itself.
Reading the compiled SQL settles it in seconds, and this is the habit worth demonstrating: the branch is plainly there in `target/compiled/`, referencing `analytics.main.recent_orders` on a run that is supposed to be creating it. No amount of staring at the model file shows you that.
The same mistake has a quieter twin. `{% if is_incremental() %}` on a model that is *not* materialized incremental is always false, so the filter never applies and the model silently rebuilds in full every night. No error, no warning, just a bill. Both come from the same root: the guard's value depends on things outside the file, so verify it in the compiled output rather than assuming.
What dbt did — 2 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
first rundbt run --select recent_ordersfailed
The guard was supposed to keep this branch out of the first run. Read the compiled SQL to see why it did not.
dbt says
Runtime Error in model recent_orders (models/recent_orders.sql)
Catalog Error: Table with name recent_orders does not exist!
Did you mean "raw_orders"?
LINE 20: and updated_at > (select max(updated_at) from "analytics"."main"."recent_orders")
^
Compiled SQLmodels/recent_orders.sql
select
order_id,
amount,
updated_at
from "analytics"."main"."raw_orders"
where 1 = 1
and updated_at > (select max(updated_at) from "analytics"."main"."recent_orders")
The answer most people give
"The model needs to be built once before the incremental logic works." That is true of a correct incremental model and is exactly what the guard handles. The guard is not working here, and the reason is two missing characters.
They’ll ask next
What is the quieter version of this bug, where nothing fails but you pay for it every night?
Is `{% set cutoff = 40 %}` a SQL variable? What is the difference between that and declaring a variable in your warehouse's SQL dialect?
Why they ask this
It is the cleanest test of whether someone has the two-phase model in their head. People who think Jinja runs alongside SQL write templates that cannot work and cannot explain why.
Say this
No — `{% set %}` binds a name during compilation, and by the time SQL runs it has already been replaced by its literal text. The warehouse never sees a variable; it sees `40`.
The reasoning
Jinja runs first, entirely, and produces a string. `{% set cutoff = 40 %}` creates a template-local name, and `{{ cutoff }}` writes its text into the output. The compiled SQL contains the number 40 with no trace that a variable was involved. A warehouse-level variable — a session variable, a declared local, a bind parameter — exists at execution time and can change per execution.
The consequence is that Jinja cannot react to data. You cannot `{% set max_id = select max(id) from ... %}` and branch on it, because at compile time no query has run. Anything data-dependent has to go through `run_query`, which executes a query during compilation and is a genuinely different mechanism with its own parse-time caveats.
The other direction is just as important: SQL cannot see Jinja names. A `{% set %}` inside an `{% if %}` block that was not taken simply does not exist, and referencing an undefined name gives you an empty string rather than an error, which is how a filter quietly becomes `where amount >= `.
Where `{% set %}` earns its place is naming things that repeat in the template: a list to loop over, a column list, a threshold used three times, a relation built once and reused. It makes the template readable without adding anything the warehouse has to think about.
The answer most people give
"It is the same as declaring a variable, dbt just handles it for you." They exist in different phases. A Jinja name is gone before the warehouse is contacted, which is why nothing at execution time can change it.
They’ll ask next
You want a threshold that comes from a lookup table. Where can that logic live?
select
order_id,
{{ cents_to_currency('amount') }} as amount_gbp,
{{ cents_to_currency('amount', 0) }} as amount_rounded
from {{ ref('raw_orders') }}
What gets run
seeddbt seed
build itdbt run --select orders_priced
Why they ask this
The basic can-you-do-it question, and it exposes the common misconception in one line: people expect a macro to return a value when it returns text that becomes part of a query.
Say this
`{% macro cents_to_currency(column_name, precision=2) %} round({{ column_name }} / 100.0, {{ precision }}) {% endmacro %}` — it returns a string of SQL that gets pasted where you called it, not a number.
The reasoning
Macros live in `macros/`, are defined with `{% macro name(args) %}...{% endmacro %}`, take positional or keyword arguments with defaults, and are called with `{{ name(...) }}`. Whatever text sits between the tags is the return value, and it is spliced into the compiled SQL at the call site.
That the argument is a column *name* rather than a column *value* is the thing to say explicitly. You pass the string `'amount'` and the macro writes it into an expression; it never sees a row. This is why a macro cannot do anything conditional on data, and why the same macro works on a billion-row table for free.
Two practical details. Give arguments defaults where there is a sensible one, so the common call stays short. And be careful with whitespace: the newlines inside the macro body are part of the return value, which is why the compiled output has the expression on its own line — `{%- macro ... -%}` and trimming inside the body is what keeps generated SQL tidy.
The version of this that comes up in follow-ups is where to put the macro. A macro used by one model is usually better inlined; a macro used by ten is worth the indirection. And a macro that wraps a single warehouse function adds a layer for no benefit — the value is in the cases where the expression is long, repeated, or genuinely differs by adapter.
What dbt did — 2 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
build itdbt run --select orders_priced
Compiled SQLmodels/orders_priced.sql
select
order_id,
round(amount / 100.0, 2)
as amount_gbp,
round(amount / 100.0, 0)
as amount_rounded
from "analytics"."main"."raw_orders"
The warehouse now holds
orders_priced
order_id
amount_gbp
amount_rounded
1
0.5
1
2
0.3
0
3
0.7
1
4
0.2
0
The answer most people give
"It returns the converted amount." It returns SQL text. Nothing in a macro ever touches a row — the warehouse does that after the compiled query is sent.
They’ll ask next
Where would you draw the line between a macro worth writing and one that just hides the SQL?
A colleague has factored the whole fct_orders model into a macro called `build_fact()` used by four models. Is that good? What is your rule for when a macro helps?
Why they ask this
A judgment question that senior interviews use heavily, because dbt makes over-abstraction very easy and the resulting projects are unmaintainable in a way that is only visible at 3am.
Say this
Usually not. A macro should hide something mechanical and repeated — a cast, a surrogate key, an adapter difference — not the business logic somebody will need to read when the number is wrong.
The reasoning
The cost of a macro is that the SQL in the file is no longer the SQL that runs. That is a real price: reviewers cannot review it, newcomers cannot read it, and debugging requires compiling first. You pay that price willingly for something small and mechanical, and unwillingly for a hundred lines of business rules.
The rule I use has three parts. Macro it if it is repeated *and* mechanical — a timezone cast, a surrogate key, a money conversion, a cross-database date function. Macro it if it papers over an adapter difference, because that is exactly the kind of noise SQL should not be cluttered with. And macro it if it enforces a convention that must be identical everywhere, like how you hash a key.
Do not macro it if the logic is what a stakeholder would ask you to explain. Revenue recognition, deduplication rules, a join grain — those belong in a model, where the DAG documents them, tests can attach to them and lineage points at them. Four models sharing a fact-building macro almost always means there should be one model with four thin models on top of it, which is what dbt is for.
The tell that you have crossed the line: you cannot answer 'what does this model do?' without compiling it, or a macro takes more than about three arguments, or arguments have started to control branching. At that point the macro has become a program and the DAG has stopped describing your pipeline.
The answer most people give
"Yes, it is DRY." DRY on SQL that expresses business rules trades reviewability for repetition you probably did not have. dbt already has a mechanism for sharing logic without hiding it: a model that other models ref.
They’ll ask next
What would you build instead, if four models really do share that logic?
the SQL it randbt compile --select not_negative_stg_orders_amount
Why they ask this
It is the most common custom-test request in real work, and it checks the one rule that makes dbt's testing model click: a test is a query, and rows returned mean failure.
Say this
Write a `{% test not_negative(model, column_name) %}` block that selects the rows where the column is negative. dbt runs it and counts the rows — zero rows is a pass, any rows is a failure.
The reasoning
A generic test is a macro with a special wrapper, living in `tests/generic/` or `macros/`. It receives `model` — the relation under test — and, for a column test, `column_name`. Its body is a select that returns the offending rows. dbt wraps it in a count and compares against the threshold: `select count(*) from (<your query>)`, failing when the count exceeds zero.
That inversion is the thing to say out loud, because it feels backwards the first time. You do not write an assertion that should be true; you write a query for the rows that prove it false. Every built-in test works this way — `not_null` selects rows where the column is null, `unique` selects the values with a count above one.
Once defined, it is used in YAML like any built-in: under a column's `data_tests:` key by name, and with arguments if it takes any. dbt names the resulting test node from the test, model and column, which is what shows up in the run output and in `target/compiled/` — and reading that compiled test SQL is how you check the test does what you think.
Two refinements worth mentioning. Accept a `**kwargs`-style argument for configurability — a `min_value` rather than a hardcoded zero — so one test covers a family of checks. And set `config(severity='warn')` or a `where` filter in the YAML rather than in the test body, so the same test can be strict on one model and advisory on another.
What dbt did — 3 commands, in order run on dbt-core 1.12.2 / duckdb
load the sourcedbt seed
build and testdbt build --select stg_ordersfailed
One test, one failing row — the -30.
dbt says
Got 1 result, configured to fail if != 0
Test results
failnot_negative_stg_orders_amount1 failing rows
successstg_orders
the SQL it randbt compile --select not_negative_stg_orders_amount
Your macro body, with model and column_name substituted.
select amount
from "analytics"."main"."stg_orders"
where amount < 0
The answer most people give
Writing the test as `select count(*) = 0 from ...` or as an assertion returning true. dbt counts the rows your query returns; a query that returns one row containing `true` is a failing test.
They’ll ask next
How would you make the same test warn on one model and error on another?
You need a surrogate key from `(customer_id, order_date)`. Why not just `concat(customer_id, order_date)`, and what does `dbt_utils.generate_surrogate_key` do differently?
Why they ask this
Surrogate keys are everywhere in dimensional models built with dbt, and the naive concatenation has two failure modes that an interviewer can watch you either spot or miss.
Say this
Plain concatenation collides and breaks on nulls. The macro casts every component to a string, replaces nulls with a sentinel, joins them with a separator, and hashes the result — so it is fixed-width, collision-resistant and null-safe.
The reasoning
The collision problem first. `concat(customer_id, order_date)` on `(1, '23')` and `(12, '3')` both give `123`. Any concatenation without a separator that cannot appear in the data will eventually produce two different rows with the same key, and it will do it silently in a dimension nobody is watching.
The null problem is worse because it is invisible. On most warehouses `concat` with a null argument yields null for the whole expression, so every row with a missing component gets a null key. Your `unique` test passes — nulls do not collide — and your `not_null` test on the key fails, or worse, you never added one and downstream joins silently drop those rows.
The macro handles both: each field is cast to a string, coalesced to a fixed placeholder like `'_dbt_utils_surrogate_key_null_'`, concatenated with a separator, and hashed with MD5. The output is fixed-width regardless of input size, which matters for storage and join performance, and the components are unrecoverable, which is occasionally a privacy benefit.
Two things to get right in practice. Component order must be stable — reorder the list and every key in the table changes, which is a full-refresh event for you and a broken join for everyone downstream. And the components must be the *business* key at the grain you claim, not whatever happens to be unique today; a key built from three columns where two would do is a key that changes whenever the third does.
The answer most people give
"Concatenation is fine as long as the columns are not null." That is a constraint you have not enforced and cannot see. The whole reason to use a macro is that it makes the null and separator handling identical in every model without anyone remembering.
They’ll ask next
You add a fourth column to the key list. What happens to every downstream model, and what do you have to do?
You maintain a package that has to work on Snowflake, BigQuery and Postgres. How do you write one macro that emits different SQL per adapter?
Why they ask this
It comes up for anyone who has written a package or a shared macro library, and it is how dbt_utils actually works — so it doubles as a check on whether you know what you are calling.
Say this
`adapter.dispatch` — you write a dispatching macro that looks up an adapter-prefixed implementation at runtime, plus a `default__` version and a `snowflake__`, `bigquery__` and so on for each warehouse that needs different SQL.
The reasoning
The pattern is two layers. A public macro `{% macro my_thing(x) %}{{ return(adapter.dispatch('my_thing')(x)) }}{% endmacro %}`, and then implementations named `default__my_thing`, `bigquery__my_thing`, `snowflake__my_thing`. At runtime dbt looks for an implementation matching the current adapter and falls back to `default__` when there is none.
The reason this exists rather than `{% if target.type == 'bigquery' %}` is extensibility. Dispatch has a search-path mechanism — the `dispatch` config in `dbt_project.yml` lets a project override a package's implementation for its adapter without forking the package. That is how you fix a package's Snowflake behaviour without waiting for a release.
In practice you reach for it for the things that genuinely differ: date arithmetic, string aggregation, type casting, hashing, and anything involving information schema queries. Everything else should stay in ANSI-ish SQL, because each dispatched implementation is another thing to test on another warehouse.
The trap is silent fallback. If you write only `default__` and `snowflake__`, running on Redshift quietly gets the default, which may be valid SQL that means something slightly different. When the semantics genuinely differ per warehouse rather than just the syntax, the default should raise via `exceptions.raise_compiler_error` rather than guess.
The answer most people give
"Use `{% if target.type == ... %}` inside the macro." It works for your own project and is what most people do, but it is not overridable by consumers, so a package written that way cannot be fixed downstream when it gets an adapter wrong.
They’ll ask next
Your package's default implementation is subtly wrong on Redshift. What can a consumer do about it without forking?
The same column description is copy-pasted into eleven YAML files and is now wrong in four of them. What does dbt give you for this?
Why they ask this
Documentation questions are usually a proxy for whether you treat the project as something a team maintains. Doc blocks are the specific mechanism, and plenty of people who have used dbt for years have never used them.
Say this
Doc blocks: define the text once in a `.md` file inside `{% docs name %} ... {% enddocs %}` and reference it from any YAML with `description: "{{ doc('name') }}"`. One definition, eleven usages, one place to fix.
The reasoning
Doc blocks live in markdown files anywhere under your model paths. Inside, `{% docs order_status %}` ... `{% enddocs %}` defines a named block whose body is markdown, so you can use lists, tables and links rather than cramming a paragraph into a YAML string.
You reference it with `{{ doc('order_status') }}` in any `description:` — on a model, a column, a source, an exposure. dbt renders it into `catalog.json` and the docs site, and because the reference is by name, changing the block updates every place that uses it.
The practical use beyond deduplication is the long description. A YAML `description` is fine for a sentence; a doc block is where you put the paragraph explaining that `order_status` has five values, that `pending` means payment authorised but not captured, and that the mapping changed in March 2025. That is the content people actually need and that never survives in a one-line string.
Two neighbouring things worth naming. `persist_docs` pushes descriptions into the warehouse as table and column comments, so they show up in the BI tool and the information schema rather than only in the dbt docs site — which is where most consumers will actually look. And descriptions are testable indirectly: `dbt_project_evaluator` and similar packages will flag undocumented models, which is how you keep the coverage from rotting.
The answer most people give
"Put it in a comment in the model file." SQL comments do not reach the docs site, the catalog or the warehouse. They are invisible to every consumer who is not reading the repository.
They’ll ask next
How would you get those descriptions to show up in Looker rather than only in dbt docs?
Your macro calls `run_query(...)` and then reads `results.columns[0]`. dbt fails with "'None' has no attribute 'table'" before touching the warehouse. Why?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
4
11
20
cancelled
2026-03-01 12:00:00
The project
macros/status_list.sql
No `execute` guard. This is the version that fails.
{% macro status_list() %}
{% set results = run_query("select distinct status from " ~ ref('raw_orders')) %}
{% set values = results.columns[0].values() %}
{{ return(values) }}
{% endmacro %}
models/status_pivot.sql
select
customer_id,
{% for status in status_list() -%}
sum(case when status = '{{ status }}' then amount else 0 end) as {{ status }}_amount
{%- if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('raw_orders') }}
group by 1
What gets run
try to run itdbt run --select status_pivot
Why they ask this
It is the single most confusing dbt error for people who have not been told about the two passes, and the explanation is the whole parse-time-versus-run-time model in one example.
Say this
dbt renders your project twice. On the parse pass it does not execute queries, so `run_query` returns None and anything you do with the result explodes. Guard the result-handling with `{% if execute %}`.
The reasoning
dbt compiles every model twice. The first pass exists to build the graph: dbt needs to know what every model refs before it can decide what to run, so it renders the Jinja with query execution disabled. On that pass `run_query` returns None, `adapter.get_relation` returns None, and any attribute access on the result fails.
The second pass happens when the node is actually being built, with a live connection. `execute` is a Jinja variable that is false on the parse pass and true on the execution pass, so the fix is to do the query unconditionally but guard the *use* of its result: `{% if execute %}{% set values = results.columns[0].values() %}{% else %}{% set values = [] %}{% endif %}`.
The empty list in the else branch is not a formality — it is what makes the parse pass produce syntactically valid (if useless) SQL, which is all the parser needs. Skip the else and you get an undefined name on the parse pass, which fails in a different and equally confusing way.
The wider lesson is the one to lead with in an interview: dbt cannot know your DAG without rendering your templates, and it cannot render your templates against live data without knowing your DAG. `execute` is how that circularity is broken, and every parse-time oddity — why you cannot ref a dynamically-built name, why `{{ log() }}` prints twice — comes from the same place.
What dbt did — 1 command, in order run on dbt-core 1.12.2 / duckdb
try to run itdbt run --select status_pivotfailed
It never reaches the warehouse. dbt fails while parsing the project.
dbt says
Compilation Error in model status_pivot (models/status_pivot.sql)
'None' has no attribute 'table'
The answer most people give
"The upstream table does not exist yet." It fails before any SQL is sent — dbt is still parsing the project. You can prove it by pointing the macro at a table that definitely exists and watching the same error.
They’ll ask next
Why does `{{ log('hello', info=True) }}` in a model print twice?
Macros & JinjaTests (generic, singular, dbt-utils, unit tests)Models & ref/source
Build a pivot whose columns come from the distinct values in a table rather than from a hardcoded list. Show the macro, and say what you have just signed up for.
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
4
11
20
cancelled
2026-03-01 12:00:00
The project
macros/status_list.sql
The same macro with the guard. Note the empty list on the parse pass.
{% macro status_list() %}
{% set results = run_query("select distinct status from " ~ ref('raw_orders')) %}
{% if execute %}
{% set values = results.columns[0].values() | sort %}
{% else %}
{% set values = [] %}
{% endif %}
{{ return(values) }}
{% endmacro %}
models/status_pivot.sql
select
customer_id,
{% for status in status_list() -%}
sum(case when status = '{{ status }}' then amount else 0 end) as {{ status }}_amount
{%- if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('raw_orders') }}
group by 1
What gets run
seeddbt seed
run itdbt run --select status_pivot
Why they ask this
The classic "can you do dynamic SQL in dbt" question. The mechanism is worth knowing; the interviewer is at least as interested in whether you volunteer the downsides.
Say this
Query for the values in a macro with `run_query`, guard the result with `{% if execute %}`, and loop over them in the model. The cost is a model whose schema is decided by data rather than by code.
The reasoning
The macro runs the query, unwraps the agate table it gets back — `results.columns[0].values()` — and returns a list. Sort it, because the warehouse gives no ordering guarantee and an unsorted list means your column order changes between runs for no reason. The model then loops over the returned list exactly as it would over a literal one.
What you have signed up for is a model whose output schema is not in version control. A new status appears in the source and your table grows a column with no commit, no review and no warning. Every downstream consumer that did `select *` now gets an extra column, and every one that selected explicitly silently ignores it. If a value disappears, the column disappears, and anything referencing it breaks.
There is also a build-order subtlety: the macro queries a relation during compilation of your model, so that relation must already exist and be current. It works because dbt compiles a node just before running it, after its parents, but it means the compiled SQL for this model depends on the state of the warehouse — two people compiling the same commit can get different SQL.
So my default is a hardcoded list with a test. `accepted_values` on the source column fails loudly when a new status appears, which turns a silent schema change into a pull request. Dynamic columns are right when the value set is genuinely open-ended and large — a per-tenant or per-metric pivot — and wrong when it is a domain of five things that changes twice a year.
What dbt did — 2 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
run itdbt run --select status_pivot
The column list came from the warehouse: three columns, because the data has three statuses.
Compiled SQLmodels/status_pivot.sql
select
customer_id,
sum(case when status = 'cancelled' then amount else 0 end) as cancelled_amount,
sum(case when status = 'pending' then amount else 0 end) as pending_amount,
sum(case when status = 'shipped' then amount else 0 end) as shipped_amount
from "analytics"."main"."raw_orders"
group by 1
The warehouse now holds
status_pivot
customer_id
cancelled_amount
pending_amount
shipped_amount
10
0
0
120
11
20
30
0
The answer most people give
"You cannot do dynamic SQL in dbt." You can, through run_query at compile time. The reason to avoid it is that it makes your schema data-dependent, not that the mechanism is missing.
They’ll ask next
How would you get the same protection with a hardcoded list — what test would you add?
select
{{ star_except(ref('raw_orders'), except=['customer_id', 'updated_at']) }}
from {{ ref('raw_orders') }}
What gets run
seeddbt seed
build itdbt run --select orders_public
Why they ask this
A concrete task that separates people who reach for the adapter API from people who paste ninety column names. The catch is what makes it an interview question rather than a lookup.
Say this
`adapter.get_columns_in_relation(ref('x'))` asks the warehouse for the column list at compile time; filter it and join it. `dbt_utils.star` is the packaged version. The catch is that it needs the upstream relation to already exist.
The reasoning
`adapter.get_columns_in_relation` returns Column objects for a relation, which you can map to names, reject the unwanted ones, and join into a select list. It runs during compilation, so what lands in `target/compiled/` is an explicit column list — the warehouse never sees anything dynamic.
The catch is the ordering dependency. The warehouse has to be able to answer the question, which means the upstream relation must exist when your model compiles. On a fresh environment, or in CI against an empty schema, or when someone runs your model with `--select my_model` and no upstream build, the call returns nothing and you compile `select from ...` — a syntax error at best, and a silently narrow table at worst.
The second catch is the same one dynamic pivots have: your model's schema is now decided by whatever the upstream table happens to have. Adding a column upstream silently adds it downstream, which is sometimes exactly what you want in a staging passthrough and rarely what you want in a mart with a contract.
So use it where the intent is genuinely "whatever is upstream, minus these" — dropping PII columns in a staging layer, or building a passthrough over a wide vendor table. Where the mart's columns are part of an agreement with a consumer, list them, and let a schema test or a model contract tell you when the upstream changed.
What dbt did — 2 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
build itdbt run --select orders_public
The column list was read out of the warehouse at compile time, not typed.
Compiled SQLmodels/orders_public.sql
select
order_id,
amount,
status
from "analytics"."main"."raw_orders"
The answer most people give
"Use `select * except (a, b)`." BigQuery and Databricks have it, Snowflake added `exclude`, and Postgres and Redshift have nothing. If the answer has to be portable, it goes through the adapter API.
They’ll ask next
What happens to this model in a CI run against a brand-new empty schema?
Environments, profiles & targetsMacros & Jinjadbt Core vs Cloud
Where do warehouse credentials live in a dbt project, and what does `env_var` do that a var cannot?
Why they ask this
A security-adjacent question that also tests the profiles model. Anyone who has deployed dbt has an answer; anyone who has only run it locally against a checked-in profile usually does not.
Say this
Credentials belong in environment variables, read with `{{ env_var('DBT_PASSWORD') }}` in profiles.yml. Unlike `var`, it reads the process environment rather than the project, so the value never exists in a file you commit.
The reasoning
`var` resolves from `dbt_project.yml` or `--vars` — both of which are project material, and one of which is in git. `env_var` reads the process environment at render time, which is the only one of the two that is appropriate for a secret. It takes an optional default, and without one a missing variable is a hard error, which is usually what you want for a credential.
In practice: `profiles.yml` holds `password: "{{ env_var('DBT_PASSWORD') }}"` and the actual value comes from your shell, your CI secret store, or your orchestrator's connection management. dbt Cloud manages this through environment settings, which is one of the concrete operational things you buy with it.
It has a second, less obvious use: environment-driven configuration that is not secret. `DBT_ENV_CUSTOM_ENV_`-prefixed variables get captured into run artifacts, so you can stamp a run with the git SHA or the CI job id and later work out which deploy produced a table. Some teams also drive warehouse size or thread count from the environment rather than from the target definition.
The failure to avoid is a `profiles.yml` with a real password committed to the repository, which is depressingly common in first projects. The related one is a default that silently works: `env_var('DBT_TARGET', 'prod')` means a missing variable runs against production, and the safe default is always the harmless one — or no default at all, so it fails loudly.
The answer most people give
"Put them in profiles.yml, it is gitignored." It is by convention, not by dbt, and the file lives in `~/.dbt/` on a laptop where nothing rotates it. Environment variables are what let CI and production supply credentials they can rotate.
They’ll ask next
How would you stamp every run with the git SHA that produced it?
What belongs in a staging model and what does not? Give the rule you would put in a code review comment.
Why they ask this
It is the most reviewable convention in a dbt project, and the answer tells an interviewer whether you have worked in a codebase with a hundred models or twelve.
Say this
One staging model per source table, doing only renaming, casting, light cleaning and column selection — no joins, no aggregation, no business logic. Its job is to give the rest of the project one clean, consistently-named version of that table.
The reasoning
The rule of one staging model per source table is what makes the layer worth having. It means there is exactly one place where `cust_id` becomes `customer_id`, one place where the vendor's `0`/`1` becomes a boolean, and one place to change when the source schema moves. Two staging models over the same table means two conventions and a future disagreement.
What belongs: selecting the columns you actually use, renaming to your naming convention, casting types, trimming and lowercasing where the source is inconsistent, converting timestamps to a standard timezone, and simple case expressions that map source codes to readable values. Nothing that requires knowing about another table.
What does not belong: joins, aggregations, filters that encode a business rule, deduplication that involves a judgment call, and anything a stakeholder would have an opinion about. Those go in intermediate models or marts, where the DAG shows them and tests can attach to them. The one filter I do allow is removing rows that are structurally invalid — hard-deleted records, test accounts — and only if it is documented.
Two conventions that go with it. Materialize staging as views by default, because it is thin work and always-current beats stored. And name them `stg_<source>__<table>` with a double underscore, so `stg_shopify__orders` and `stg_stripe__orders` do not collide and the source is visible in every ref that uses it.
The answer most people give
"Staging is where you clean the data, so joins are fine if they are cleaning joins." The moment a staging model joins, it has a grain that is not the source's, and the one-to-one mapping that made the layer predictable is gone.
They’ll ask next
Two source tables need the same currency conversion. Where does that logic go?
You add a `post_hook` to a model that logs its row count. Where does that statement appear in what dbt runs, and what is a hook actually for?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
4
11
20
cancelled
2026-03-01 12:00:00
The project
models/orders_audited.sql
A post-hook that logs the row count. Where does it appear in what dbt runs?
{{ config(
materialized='table',
post_hook="insert into {{ target.schema }}.build_log values ('{{ this.identifier }}', (select count(*) from {{ this }}))"
) }}
select order_id, amount
from {{ ref('raw_orders') }}
models/build_log.sql
{{ config(materialized='table') }}
select 'seed'::varchar as model_name, 0 as row_count where false
What gets run
seeddbt seed
create the log tabledbt run --select build_log
build with the hookdbt run --select orders_audited
Why they ask this
Hooks are how grants, logging and vacuum-style maintenance get done, and where they run — same transaction, separate statement — is what decides whether they are safe.
Say this
The hook is not part of the model's SQL file at all; dbt issues it as a separate statement around the materialization, inside the same transaction where the adapter supports one. They are for side effects: grants, logging, index creation, cache invalidation.
The reasoning
`pre_hook` runs before the model's statement and `post_hook` after, both as separate statements in the same run. On adapters with transactional DDL they are inside the model's transaction, so a failed hook rolls the model back — which is the property that makes a post-hook a reasonable place for something that must be true whenever the table exists.
The legitimate uses are side effects that are not part of the query: granting select to a BI role, creating an index or applying a clustering directive, inserting an audit row, invalidating a cache, calling a stored procedure that refreshes something external. Note that grants specifically have a first-class `grants` config now, which is better than a hook because dbt diffs the current grants rather than reissuing them blindly.
Hooks are Jinja, so `{{ this }}`, `{{ target }}` and macros all work inside them — which is what makes them reusable. The common pattern is a macro that takes no arguments and reads `this`, called as `post_hook="{{ log_row_count() }}"`, so the model file stays one line.
Where hooks go wrong: putting transformation logic in them, so the DAG no longer describes what happens; putting a slow statement in a post-hook that runs on every model, which multiplies across the project; and relying on `on-run-end` for something critical, because it does not run if the run failed early. If it must happen, it belongs in the orchestrator, not in a hook.
What dbt did — 3 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
create the log tabledbt run --select build_log
build with the hookdbt run --select orders_audited
The hook is not in the run SQL file at all — dbt issues it as a separate statement in the same transaction.
What dbt actually ranmodels/orders_audited.sql
create table
"analytics"."main"."orders_audited__dbt_tmp"
as (
select order_id, amount
from "analytics"."main"."raw_orders"
);
The warehouse now holds
build_log
model_name
row_count
orders_audited
4
The answer most people give
"It gets appended to the model's SQL." It is a separate statement — you can see that the run SQL file contains only the CREATE TABLE. That separation is why a hook can be a DDL or DML statement that has nothing to do with your select.
They’ll ask next
You need every mart granted to the BI role. Would you use a post-hook, on-run-end, or something else?
Environments, profiles & targetsModels & ref/sourceCI/CD & slim CI (state:modified)
Every mart needs `grant select` to the BI role after every run. Compare doing it with a post-hook on each model, with `on-run-end`, and with the `grants` config.
Why they ask this
A real operational task with three defensible answers, so it is a good vehicle for watching someone reason about failure modes rather than recite a mechanism.
Say this
Use the `grants` config. It is declarative, dbt diffs against the warehouse and only issues what changed, and it applies at whatever level you set it. Hooks and on-run-end both work and both reissue blindly, and on-run-end does not run when the run fails.
The reasoning
The `grants` config — set once on `models: marts: +grants: {select: ['bi_role']}` — is the modern answer. dbt reads the existing grants, computes the difference and applies only that, so a run is idempotent and cheap. It also merges down the tree, so a project-level default plus a model-level addition gives you both.
A `post_hook` on every model works and is what everyone did before. Its problems are that it reissues the grant on every run whether or not anything changed, that it is easy to forget on a new model, and that it is spread across fifty files instead of declared once.
`on-run-end` in `dbt_project.yml` centralises it and can loop over `results` to grant on exactly what was built. Its problem is the one that matters at 3am: it runs at the end of the *run*, so if the run fails partway, it does not run at all — and you have a freshly built table nobody can read. Anything that must be true whenever a table exists belongs closer to the table than that.
The general principle worth stating: prefer declarative config over imperative hooks wherever dbt offers it, because config is diffable, inheritable and visible in the manifest, and a hook is a string dbt executes without understanding. The same argument applies to `persist_docs`, `contract` and `on_schema_change`.
The answer most people give
"on-run-end, so it happens once at the end." It also does not happen at all when the run errors out early, which is precisely the moment you have half a project rebuilt and permissions that no longer match.
They’ll ask next
Your run fails on model 40 of 60. Which of the three approaches has left the warehouse in the worst state?
You need a model that fits a regression and writes predictions. SQL cannot do it. What are your options inside dbt?
Why they ask this
It tests whether you know dbt's boundary has moved, and whether you can argue for staying in SQL — which is the answer most of the time and the one interviewers want to hear you consider.
Say this
dbt Python models: a model file that defines `def model(dbt, session)` and returns a DataFrame, executed by the warehouse's Python runtime — Snowpark, Databricks or BigQuery's Dataproc. They sit in the same DAG and are refed like any other model.
The reasoning
A Python model is a `.py` file in your models directory with a `model(dbt, session)` function that returns a DataFrame. Inside it, `dbt.ref('stg_orders')` gives you the upstream model as a DataFrame and `dbt.config(materialized='table')` sets the config. dbt ships the code to the warehouse's Python runtime and materializes the returned frame — so it is still the warehouse doing the work, and it still lands as a relation in your DAG.
Support is adapter-specific and is the first thing to check: Snowflake via Snowpark, Databricks via PySpark, BigQuery via Dataproc. Postgres, Redshift and duckdb have no Python model support at all, so this is not a portable answer.
The honest recommendation is to use them narrowly. They are right for genuinely non-SQL work — statistical fitting, an ML inference call, a library that has no SQL equivalent, complex string parsing. They are wrong for anything expressible in SQL, because you lose readability for reviewers, you lose the compiled-SQL debugging path entirely, they are slower to start, and testing them is harder.
The alternative worth naming: keep dbt to SQL and put the model training in a separate tool, writing predictions back to a table that dbt declares as a source. That keeps a clean boundary and does not tie your transformation layer to one warehouse's Python runtime. Which is better depends mostly on whether the team maintaining it is analytics engineers or ML engineers.
The answer most people give
"dbt is SQL-only, so you would do it outside dbt." That was true until dbt 1.3. It is still often the right *choice*, but not knowing Python models exist reads as not having kept up.
They’ll ask next
Your warehouse is Postgres. What changes about that answer?