In dbt, how do you generate model columns from values a query returns at run time?
answer
- the model asks the warehouse a question first
- dbt looks at every file before it runs one
- there is a flag that is false while parsing
- {% if execute %} around run_query results
basics
~20 sQuery the values with run_query(), guard the result with {% if execute %} because run_query returns nothing during parsing, pull the column with results.columns[0].values(), then loop over that list to emit one SQL expression per value.
solid answer
~40 sdbt parses the whole project before it runs anything, and during that parse pass the `execute` flag is `False` and `run_query()` returns `None`. So the pattern is: build the query with `{% set q %}...{% endset %}`, call `{% set results = run_query(q) %}`, then take the values only inside `{% if execute %}` and set an empty list in the `{% else %}` branch so parsing does not blow up iterating `None`. `results` is an agate table, so `results.columns[0].values()` gives you the first column as a list. A `{% for %}` loop then emits one `sum(case when ...)` per value, using `loop.last` to place commas. It works, but the model's output schema now depends on data, so a stable value set is better kept in a seed.
code
sql · 20 lines-- models/payments_pivoted.sql
{% set payment_types_query %}
select distinct payment_type from {{ ref('stg_payments') }}
{% endset %}
{% set results = run_query(payment_types_query) %}
{% if execute %}
{% set payment_types = results.columns[0].values() %}
{% else %}
{% set payment_types = [] %}
{% endif %}
select
order_id
{% for pt in payment_types %}
, sum(case when payment_type = '{{ pt }}' then amount end) as {{ pt }}_amount
{% endfor %}
from {{ ref('stg_payments') }}
group by 1go deeper
Recognise the shape when you see it: a query set into a variable, an execute guard, then a for loop building columns. You are not usually asked to write it from scratch.
Explain why parsing must not run the query, what run_query returns and how you get a list out of the agate table, and place commas correctly with loop.last.
Argue the tradeoff out loud: a data-dependent output schema, an introspective query on every compile, and the fresh-environment failure mode — then say when a seed is the better answer.
Set the policy on where run-time introspection is allowed at all, and how CI stays able to compile the project when the upstream relations it introspects do not exist yet.
## The problem You want a pivoted model: one column per payment type, where the set of payment types lives in the data rather than in your head. SQL alone cannot do this — the column list is fixed at query-parse time. Jinja can, because it runs before the query exists. The mechanism is an *introspective query*: dbt asks the warehouse a question during compilation and templates the answer into the SQL it is about to run. ## run_query and the agate table `run_query(sql)` executes a statement against the target and returns an [agate](https://agate.readthedocs.io) table — a lightweight in-memory table object. The two accessors you need in practice: - `results.columns[0].values()` — the first column as a sequence. - `results.rows` — row-wise iteration when the query returns several columns. `run_query` is a thin wrapper around the `statement` block. The long form is equivalent and still seen in older code: ```sql {% call statement('get_types', fetch_result=True) %} select distinct payment_type from {{ ref('stg_payments') }} {% endcall %} {% set results = load_result('get_types')['table'] %} ``` ## Why the execute guard exists dbt runs in two passes. In the **parse** pass it renders every model far enough to find `ref()`, `source()` and `config()` and build the graph. Executing arbitrary queries then would be wrong and slow — the upstream models may not even exist yet — so dbt sets the context variable `execute` to `False` and makes `run_query()` return `None`. In the **execute** pass, during an actual `dbt run` or `dbt compile`, `execute` is `True` and the query really runs. Without the guard, parsing reaches `results.columns[0]` on a `None` and the whole project fails to parse — often with an error that names the model but not the cause. The canonical shape: ```sql {% set results = run_query(payment_types_query) %} {% if execute %} {% set payment_types = results.columns[0].values() %} {% else %} {% set payment_types = [] %} {% endif %} ``` The `else` branch matters as much as the `if`: the loop below still needs a variable to iterate, and an empty list renders a syntactically odd but harmless stub during parsing. ## The loop ```sql select order_id {% for pt in payment_types %} , sum(case when payment_type = '{{ pt }}' then amount end) as {{ pt }}_amount {% endfor %} from {{ ref('stg_payments') }} group by 1 ``` Leading commas sidestep the classic trailing-comma bug. If you prefer trailing commas, Jinja's loop object gives you `{{ "," if not loop.last }}`; `loop.index`, `loop.first` and `loop.last` are all available inside a `{% for %}`. Watch the quoting: `'{{ pt }}'` puts the value inside a SQL string literal, while `{{ pt }}_amount` uses it as a bare identifier. Values that contain spaces, quotes or reserved words will produce broken SQL — sanitise or alias them explicitly if the source is untrusted. ## Real costs This pattern is genuinely useful and genuinely dangerous: - **The output schema now depends on data.** A new payment type appears upstream and your model silently grows a column; one disappears and a downstream model referencing it breaks. Table materializations absorb this; incremental models with `on_schema_change` set to `ignore` will not. - **It runs at compile time, on every compile.** `dbt compile`, `dbt run`, `dbt docs generate` and CI all pay for the introspective query, and the model cannot be compiled at all if the upstream relation does not yet exist — which bites in a fresh environment or a slim CI run. - **Diffs stop being meaningful.** The git diff shows a template; the SQL that ran is only in `target/compiled/`. Because of that, the mature answer to "how do I pivot on a dynamic value set" often is: don't. Keep the value set in a seed file or a small dimension model, `dbt_utils.get_column_values()` it (which is the packaged version of exactly this pattern), and accept an explicit pull request when the list changes. Reserve run-time introspection for cases where the list truly is unbounded and the model is a table that downstream consumers query loosely. ## Debugging it Add `{{ log("types: " ~ payment_types, info=True) }}` after the guard to see what the query actually returned, and read `target/compiled/` for the generated SQL. Most bugs here are an empty list (query returned nothing, or you read the wrong column index) or a comma in the wrong place.
- What breaks if you omit the else branch that sets an empty list?Parsing fails. During the parse pass `run_query()` returns `None` and the variable is never set, so the `{% for %}` loop either iterates an undefined name or dbt raises while rendering. The `else` branch gives the parse pass a harmless empty list so the template renders to a stub.
- How does dbt_utils.get_column_values() relate to this pattern?It packages it. The macro runs the distinct query, handles the `execute` guard and returns a list, so your model only writes the loop. It also accepts a default so a fresh environment where the relation does not exist yet still compiles. Prefer it over hand-rolling the guard.
- What happens to downstream models when a new value appears upstream?The pivoted model gains a column on its next build. Downstream models that select `*` absorb it; ones naming columns explicitly are unaffected until someone needs the new value. The dangerous direction is a value disappearing — the column vanishes and anything referencing it fails at run time, with no test to catch it unless you wrote one.
saying these in an interview costs you the question
- Calls run_query without guarding on execute
- Thinks execute is a user-set variable rather than dbt's phase flag
- Expects run_query to return rows during parsing
- Loops over a string because the macro returned text not a list
- Ignores that the model's schema now changes with the data