2 readers, 1 file
You have been writing Jinja since your first model. {{ ref('stg_merge_requests') }} is a Jinja expression that calls a function, and {{ source('raw', 'merge_requests') }} is another. dbt builds both functions in and makes them available inside every model, and each returns text: the fully qualified name of a relation, quoted so the warehouse resolves it without guessing. The call disappears and the name stands where it stood. A function you write yourself in Jinja is called a macro, and a later lesson in this track covers writing one.
Only the from line changed. The 4 column lines passed through byte for byte, because Jinja treats everything outside its delimiters as text it doesn't understand and does not touch. That indifference is what makes it useful: the renderer cannot tell a SQL keyword from a string literal, so it can write any SQL at all. It also means you can predict the output with a pen: copy the file, replace each expression with its value, and you hold the SQL.
An expression need not call a function at all. {{ 60 * 24 }} renders to 1440, so a filter written where minutes_open > {{ 60 * 24 }} reaches the warehouse as where minutes_open > 1440. Jinja did the multiplication, and the query log shows only the product. Whatever runs inside the braces runs in dbt before the warehouse is involved, whether dbt is on your laptop or on the machine that schedules the build.
3 delimiters
| Delimiter | Name | What stays in the SQL |
|---|---|---|
| {{ ... }} | expression | the value it evaluates to, as text |
| {% ... %} | statement | nothing: it controls what gets rendered |
| {# ... #} | Jinja comment | nothing: the warehouse never sees a trace of it |
An expression prints. A statement acts without printing. {% set %} is one, and section 2 covers it along with {% if %} and {% for %}. Each leaves an empty spot where it stood, and that spot keeps its line break, so compiled SQL often opens with blank lines. A Jinja comment vanishes before the SQL is sent. A SQL line comment, the kind that opens with 2 hyphens, is text to Jinja, so it survives rendering, and the warehouse reads it and ignores it. Pick the delimiter by who should be able to see the note.
config() prints nothing
config() sits inside expression braces, so by the table above it should print. It returns an empty string on purpose. Its work happens inside dbt: it records that this model is a table, and dbt later wraps the rendered SELECT in a create table statement because of that record. The warehouse receives the wrapper and the SELECT. It never learns that a configuration existed, and the blank lines at the top are the only trace the call leaves behind.
The braces stop at the second box. The warehouse receives the text written by the third.
2 folders under target/
Every command that renders a model writes the result to disk before anything reaches the warehouse. dbt compile renders and stops there. dbt run and dbt build render, write, then execute. In every case the text lands in the target/ folder at the project root, in 2 subfolders that hold 2 different things, and reading the wrong one is a common way to be puzzled by a correct answer.
| Path | Holds | Written by |
|---|---|---|
| target/compiled/analytics/models/marts/mr_by_state.sql | your SELECT with every Jinja expression replaced | compile, run, build |
| target/run/analytics/models/marts/mr_by_state.sql | the statement the warehouse executed: that SELECT inside its create table or create view wrapper | run, build |
The folder under each, analytics here, is the name: from dbt_project.yml, and the path under it mirrors your models/ tree. Compiled SQL is your model after Jinja and before materialization. Run SQL is what appears in the warehouse's query history. The difference between the 2 is the wrapper the materialization adds, and nothing else.
Compiled and run, side by side
A model configured as a view gets the same SELECT under a different first line. Here is the staging model from section 0 as the warehouse received it. The first words of the run file tell you how dbt built the model: it opens with either create table or create view.
The adapter for your warehouse may build into a temporary name first and swap it into place, so the run file can hold more than one statement. The SELECT inside is the compiled file, unchanged.
Reasons to open target/ first
Counting a cited line
| Run file line | Compiled file line | Text |
|---|---|---|
| 1 | none | create table "analytics"."dbt_dev"."mr_by_state" as |
| 2 | 1 | select |
| 7 | 6 | then 1 else 0 end) as opened_mrs |
| 8 | 7 | from "analytics"."dbt_dev"."stg_merge_requests" |
- ▸A database error cites a line number: count it in target/run, then read that text in target/compiled
- ▸A loop or an if produced output you did not expect, or a comma you can't see
- ▸A ref() resolved to a schema or database you did not intend to build in
- ▸Someone outside the dbt project needs to run or review the exact query
Compile alone
dbt compile --select mr_by_state renders that 1 model, writes it under target/compiled and prints it in the terminal. It creates no table and no view, so you can check what a loop produces as often as you like without rebuilding anything. Because ref() must resolve, compile still reads the whole project and still needs the connection profile the project uses; what it leaves out is executing a model. Change the template, compile, read, repeat: the loop costs seconds and touches no data.
target/ holds the output of the last command that touched each node. If you edit a model and open its compiled file without recompiling, you're reading the previous version. dbt clean deletes the folder when you want to start from nothing.
set names a value
{% set states = ['opened', 'merged', 'closed'] %} creates a variable that lives for the rest of the file. It prints nothing, because it's a statement. Writing {{ states[0] }} later prints opened without quotes, because Jinja inserts the raw text of the value. When the SQL needs a string literal, you type the quotes: '{{ states[0] }}' renders to 'opened'. A number set with {% set min_year = 2024 %} renders through {{ min_year }} as 2024, which the warehouse reads as a number because nothing quotes it.
for repeats a block
Everything between {% for s in states %} and {% endfor %} is rendered once per item, with s holding the current item. The body is text with expressions in it, so it can hold a single column or a whole UNION branch. The 3 CASE columns from the intro become 1 block with 1 copy to edit, and the 4th state is 1 more entry in the list.
The if on the comma line places a comma only when another state follows. The loop variable it tests and the - inside {%- are explained further down this section.
Each pass printed the same 2 lines with s substituted twice: once inside quotes as a literal the warehouse compares against, once bare as part of a column name. The for statement left a blank line where it stood, and if and endfor did the same, so blank lines separate the columns. The warehouse skips them.
if decides whether text appears
The comma after each column is the detail that breaks copied SQL most often, and {% if not loop.last %},{% endif %} places it only when another column follows. loop is a variable Jinja provides inside every for block, and it carries the position of the current pass.
| Attribute | Value during the states loop |
|---|---|
| loop.index | 1 on the first pass, 2 on the second, 3 on the third |
| loop.index0 | 0 on the first pass |
| loop.first | true on the first pass only |
| loop.last | true on the third pass only |
| loop.length | 3, the number of items |
{% set states = ['opened', 'merged', 'closed'] %}selectproject_id,{% for s in states %}sum(case when state = '{{ s }}'then 1 else 0 end) as {{ s }}_mrs{%- if not loop.last %},{% endif %}{% endfor %}from {{ ref('stg_merge_requests') }}group by project_id
Looping over UNION branches
A loop body can be a whole SELECT. Suppose each region's merge requests land in a raw table of its own, declared as 3 sources. The loop below writes 1 branch per region, and the same loop.last test that placed commas now places union all between branches. ~ is Jinja's string join, so 'merge_requests_' ~ r evaluates to merge_requests_us on the first pass.
3 places to set one config
materialized can be set in 3 files that all claim the same model. dbt_project.yml sets it for every model under a folder. A properties file sets it for 1 named model. The model file sets it for itself with config(). When they agree there is nothing to decide. When they disagree, dbt follows 1 rule: the setting closest to the model wins.
The + marks a config key as opposed to a folder name, and the nesting follows the folder tree: marts here means models/marts/. A deeper key overrides a shallower one, so every model under models/marts/ is a table and every other model in the project is a view, before any other file has spoken.
Resolving 3 models
| Model | Folder | dbt_project.yml | Properties file | In the file | Result |
|---|---|---|---|---|---|
| stg_merge_requests | models/staging/ | view | not named | no config() | view |
| mr_by_project | models/marts/ | table | view | no config() | view |
| mr_by_state | models/marts/ | table | view | table | table |
Deeper folders, moved files
Folder rules nest as deep as the folders do, and among them the deepest key whose folder contains the model wins. A model in models/marts/finance/ is a view, one directly in models/marts/ is a table, and one in models/staging/ falls back to the project-wide view. Because a folder rule follows the file's location, moving a file can change how it's built without changing a line inside it. Move a model with no config() and no properties entry from models/staging/ into models/marts/, and the next run builds a table where a view stood.
Seeing the answer dbt reached
target/run shows which setting won. A statement that opens with create table or create view is the materialization dbt resolved, whatever the 3 files seem to say, so the habit from section 1 covers configs as well as SQL. One config behaves differently: tags from every level are combined rather than replaced, so a model tagged nightly by its folder and finance by its properties carries both.
| Where it is set | materialized | tags |
|---|---|---|
| dbt_project.yml, for models/marts/ | table | nightly |
| marts.yml, under mr_by_state | view | finance |
| config() in mr_by_state.sql | table | not set |
| What dbt resolves | table: the closest level | nightly and finance: every level |
- Set the common case once, in dbt_project.yml, by folder
- Use a properties file or config() only for the models that differ
- Check target/run when you are unsure which level won
- Keep the 3 levels in agreement where you can, so nobody has to resolve them
- Repeat the folder default inside every model file
- Set the same config in 2 places with 2 values and leave it for the next reader
- Assume a folder rule applies to a model whose file says otherwise
- Guess at the result when dbt has already written it to disk
2 moments a model can fail
- Raised by dbt while rendering Jinja or resolving refs
- The warehouse received nothing
- Causes include a ref() to a model that doesn't exist and a missing endfor or endif. A name that was never set does it too
- Fix the template, and dbt compile confirms the fix without building
- Raised by the warehouse while running the SQL
- dbt forwards the warehouse's own message, line number included
- Causes: a misspelled column, a type mismatch, a comma the loop left behind
- Find the cited line in target/run, read it in target/compiled, then fix the template that produced it
Each error in the terminal
Type {{ ref('stg_merge_request') }} with the final s missing, and dbt reports a Compilation Error in model mr_by_state. The message names the file and says the model depends on a node that was not found. Because dbt resolves every ref() when it parses the project, this error arrives before any node runs, and the whole command stops. A {% for %} with no {% endfor %} fails the same way: Jinja cannot finish the template, so there's no SQL to send.
Type stat for state inside the CASE and the template renders without complaint, because Jinja has no idea what a column is. The warehouse rejects the query, and dbt reports a Database Error in model mr_by_state followed by the warehouse's message: a column that does not exist, and the line it appears on. That line counts the statement in target/run, wrapper included, so find it there and then read the same text in the compiled file. Other models keep building; this one fails and its children are skipped, as you saw with failing tests.
The case that confuses people sits between the 2. Remove the if not loop.last guard from section 2's loop and every pass emits a comma, so the compiled file reads as closed_mrs, on 1 line and from on the next. Jinja was content: a comma is text. The warehouse reports a syntax error near from. The heading says Database Error but the cause is in the template, so read the compiled file to see the stray comma, then fix the loop that printed it.
| The heading reads | What failed | Where to look |
|---|---|---|
| Compilation Error in model ... | Jinja rendering or dependency resolution, inside dbt | the template; the compiled file for that model is missing or stale |
| Database Error in model ... | the warehouse rejected the SQL it received | target/run at the cited line, then the same text in target/compiled |
| Database Error after editing a loop or an if | rendering succeeded and produced SQL the warehouse cannot run | the compiled file to see the shape, then the template to fix it |
A debugging order
> GitLab's data team moves mr_by_state from models/staging to models/marts, where dbt_project.yml defaults to table, and adds a 4th state, locked, to its loop in the same change. While editing the loop, the engineer replaces the if not loop.last guard with a plain comma after the alias. The nightly build fails 1 model and skips its 3 children.
dbt enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications.
github.com/dbt-labs/dbt-core
dbt and the dbt logo are trademarks of dbt Labs, LLC.
dbt turns your Jinja into SQL before the warehouse sees a line, and you can read exactly what it sent.
- Category
- dbt
- Difficulty
- beginner
- Duration
- 20 minutes
- Challenges
- 9 hands-on challenges
Topics covered: Jinja Renders First, Reading Compiled SQL, Set, If, and For, Where a Config Comes From, Compile vs Database Errors
Lesson Sections
- Jinja Renders First (concepts: Compilation)
2 readers, 1 file A model file is written for 2 readers. dbt reads it first. To dbt the file is a template, and every piece of Jinja inside it runs to produce text. The warehouse reads second, and the only thing it receives is that text: plain SQL with no braces left in it. The order never varies. Rendering finishes before a single byte crosses the connection, so the warehouse needs no Jinja interpreter and has none. 3 delimiters config() prints nothing
- Reading Compiled SQL (concepts: Compilation)
2 folders under target/ Compiled and run, side by side The adapter for your warehouse may build into a temporary name first and swap it into place, so the run file can hold more than one statement. The SELECT inside is the compiled file, unchanged. Reasons to open target/ first A warehouse that rejects a query reports a line number, and that number counts lines of the statement it received. That statement is the file in target/run, and the count includes the wrapper and any leading blank lines.
- Set, If, and For (concepts: Jinja in models)
set names a value for repeats a block The if on the comma line places a comma only when another state follows. The loop variable it tests and the - inside {%- are explained further down this section. if decides whether text appears An if also works outside any loop. A flag set at the top of the file can switch a column or a filter on and off, and the compiled SQL contains the branch that was taken and no sign of the other. The template below adds a 3rd column only when the flag is true. With the
- Where a Config Comes From (concepts: Config resolution)
3 places to set one config Resolving 3 models Read each row from right to left and stop at the first value. The staging model is set in 1 place, so it's a view. mr_by_project is a table by folder, but its properties name it a view, and the properties sit closer to the model than the folder rule does, so it is a view. mr_by_state has all 3, and the config() call inside the file beats both: a table. A folder default is a statement about what most models there should be; the 2 closer levels are for
- Compile vs Database Errors (concepts: Compilation)
2 moments a model can fail Section 0 fixed the order: dbt renders, then the warehouse executes. Each step can fail, and the 2 failures print different headings, point to different files and call for different fixes. A Compilation Error happens inside dbt while it turns Jinja into SQL, and the warehouse received nothing. A Database Error happens inside the warehouse after it received SQL it could not run; rendering succeeded, and the compiled file exists for you to read. Each error in the termina