Data Engineering  /  dbt

๐Ÿ”„ dbt โ€” Data Build Tool 60 guides ยท updated 2026

Analytics engineering with SQL โ€” models, tests, sources, and Jinja macros that turn raw warehouse tables into trustworthy, documented data products.

Jinja Templating in dbt: Writing SQL That Writes Itself

SQL is a wonderful language for describing what data you want and a genuinely bad one for describing repetitive structure. Writing the same case when block for twelve country codes, or the same aggregation for every month of the year, means either a lot of copy-pasting or accepting that your SQL file is going to be six hundred lines long. Jinja is what dbt uses to escape that tradeoff โ€” itโ€™s a templating language that runs before your SQL does, generating the final query text dynamically.


Two Kinds of Curly Braces

dbtโ€™s Jinja syntax uses two distinct delimiter styles, and knowing which is which is the first thing to get comfortable with:

{{ this_is_an_expression }}
{% this_is_a_statement %}

Expressions ({{ }}) output a value directly into the compiled SQL โ€” this is what ref() and source() use. Statements ({% %}) control flow โ€” loops, conditionals, variable assignment โ€” and donโ€™t themselves produce output.

{% set status_list = ['completed', 'shipped', 'delivered'] %}
select *
from {{ ref('stg_orders') }}
where status in (
{% for status in status_list %}
'{{ status }}'{% if not loop.last %},{% endif %}
{% endfor %}
)

This compiles to plain SQL before it ever touches the warehouse:

select *
from analytics.stg_orders
where status in (
'completed',
'shipped',
'delivered'
)

The warehouse never sees a single curly brace โ€” by the time your query runs, itโ€™s ordinary SQL. Jinja is entirely a compile-time tool.


Conditionals: Building Environment-Aware SQL

{% if %} blocks let a single model behave differently depending on context โ€” most commonly, whether itโ€™s running incrementally or from scratch.

select
order_id,
order_date,
amount
from {{ source('raw', 'orders') }}
{% if is_incremental() %}
where order_date > (select max(order_date) from {{ this }})
{% endif %}

On a full refresh, the if block is skipped entirely and every row is processed. On an incremental run, the where clause is included, filtering to only new rows. This single model file handles both cases without any duplicated logic โ€” the pattern that makes dbtโ€™s incremental materializations practical.


Loops: Eliminating Repetitive SQL

A {% for %} loop is the most common way teams eliminate copy-pasted SQL blocks. A frequent real-world case: pivoting a columnโ€™s distinct values into separate output columns.

{% set payment_methods = ['credit_card', 'paypal', 'bank_transfer', 'crypto'] %}
select
order_id,
{% for method in payment_methods %}
sum(case when payment_method = '{{ method }}' then amount else 0 end) as {{ method }}_total
{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('stg_payments') }}
group by order_id

Adding a fifth payment method later means adding one string to the list, not hand-writing another case when line and remembering to update every place itโ€™s used.


Whitespace Control

Jinja loops and conditionals can leave behind extra blank lines and awkward indentation in the compiled SQL. The - modifier trims whitespace around a tag:

select
{%- for col in ['a', 'b', 'c'] %}
{{ col }}{% if not loop.last %},{% endif %}
{%- endfor %}
from my_table

This is purely cosmetic โ€” the compiled SQL runs identically either way โ€” but readable compiled SQL matters enormously when youโ€™re debugging a model by looking at target/compiled/ output, which is the first place to look when a Jinja-heavy model produces unexpected results.


Variables Inside Jinja Blocks

Jinjaโ€™s {% set %} creates a variable scoped to the current modelโ€™s compilation, useful for anything youโ€™d otherwise repeat multiple times in one file.

{% set start_date = "2024-01-01" %}
select *
from {{ ref('stg_orders') }}
where order_date >= '{{ start_date }}'
union all
select *
from {{ ref('stg_returns') }}
where return_date >= '{{ start_date }}'

For values that need to change across environments or runs โ€” not just within a single model โ€” dbtโ€™s project-level var() function is the right tool instead, covered in Variables (var()).


Jinja Comments: Documenting Intent Without Affecting Output

Jinja supports its own comment syntax, distinct from SQLโ€™s -- comments, specifically for annotating templating logic without it appearing anywhere in the compiled output.

{# This loop generates one summed column per payment method.
Add new methods to the payment_methods list above, not here. #}
{% for method in payment_methods %}
sum(case when payment_method = '{{ method }}' then amount else 0 end) as {{ method }}_total
{% endfor %}

This matters because a regular SQL -- comment placed inside a Jinja block can end up in an unexpected position once the loop renders multiple times, or can interact confusingly with whitespace control โ€” a Jinja comment is always stripped entirely during compilation, regardless of where it sits relative to loops and conditionals, making it the safer choice for documenting templating logic specifically.

Inspecting What Jinja Actually Produces

Every modelโ€™s fully-rendered SQL โ€” with all Jinja resolved โ€” is written to the target/compiled/ directory after a run. This is the single most useful debugging habit for anyone writing non-trivial Jinja:

Terminal window
dbt compile --select customer_orders
cat target/compiled/my_project/models/core/customer_orders.sql

When a loop produces a trailing comma or a conditional doesnโ€™t behave the way you expected, reading the compiled output tells you exactly what went wrong, without needing to run anything against the warehouse.


Where Jinja Logic Belongs vs. Where It Doesnโ€™t

A model with three nested loops and five conditionals is usually a sign that logic belongs in a macro instead โ€” reusable Jinja functions that can be called from multiple models, covered in dbt Macros. Jinja directly inline in a model is best kept to genuinely model-specific logic; anything reused across more than one or two models should graduate to a macro.

Summary

SyntaxPurpose
{{ }}Outputs a value into the compiled SQL
{% %}Statement โ€” loops, conditionals, variable assignment
{% set %}Defines a variable scoped to the current model
{%- -%}Trims surrounding whitespace
target/compiled/Where to inspect the fully-rendered SQL for debugging

Jinja is what separates dbt from โ€œSQL scripts with extra steps.โ€ Once youโ€™re comfortable generating SQL dynamically instead of writing every variation by hand, entire categories of copy-paste maintenance burden disappear from a project.