dbt Integration
A dbt model is not SQL. It is a Jinja template that becomes SQL, and that is the whole difficulty:
you cannot format the template as SQL, because it isn't SQL, and you cannot format the compiled SQL
and hand that back, because every {{ ref() }} the author wrote would be gone.
dfmt does neither. It evaluates the template itself, lays out the SQL that comes out, and then puts the template back over the formatted text.
dfmt does not run dbt compile and needs neither Python nor dbt to format. It has its own Jinja
evaluator and dbt compiler, and it bundles the standard adapter macros. The one thing dbt has to do
first is install a project's packages: see Packages.
How it works
When dfmt finds dbt_project.yml, it:
- Loads the project —
dbt_project.yml, models, macros, packages, and the adapter type. - Evaluates the template with a dbt-shaped context:
ref(),source(),config(),var(),is_incremental(), your macros, and the bundled adapter macros. - Formats the compiled SQL — one parse, one layout pass, in the dialect the adapter implies.
- Re-imposes the template on the formatted text by position, so every construct returns to the place it was written, character for character.
- Formats branches that were not taken. A
{% if %}whose condition was false contributed no SQL to step 3, so its body gets its own pass and is spliced back in. Otherwise the code you cannot see today would be the code that never gets formatted.
What survives
Every Jinja construct is reproduced verbatim — the text between {% and %} (or inside {{ … }})
is never rewritten:
| Construct | Example |
|---|---|
| Expressions | {{ ref('users') }} |
| Filters | {{ col | upper }} |
| Config blocks | {{ config(materialized='table') }} |
| Conditionals | {% if is_incremental() %}…{% endif %} |
| Loops | {% for c in cols %}…{% endfor %} |
| Set blocks | {% set q %}…{% endset %} |
| Macro calls | {{ dbt_utils.star(ref('m')) }} |
| Comments | {# … #} |
| Split identifiers | {{ schema }}.{{ table }} |
The Jinja Examples appendix is a gallery of real before→after templates for each of these, grouped by construct — every pair is drawn from dfmt's own test corpus.
dbt features
ref()andsource()config()var(), with defaultsis_incremental()- project and package macros (
dbt_utils,dbt_expectations, …) - adapter dispatch, and
{% materialization %}blocks env_var()— resolved to a placeholder, since its value cannot change the layout
Packages
If the project has a packages.yml, its packages must be installed before dfmt runs: run
dbt deps once on your machine, and in every CI job (see CI Integration).
dfmt reads the macros those packages define, and does not download them itself.
When packages.yml lists packages that are not in dbt_packages/, dfmt stops with an error
rather than expand the missing macros to nothing and format whatever that produces. A project with
no packages.yml needs nothing installed.
The dialect and the profile
dfmt reads the dialect from the adapter type of the project's target in profiles.yml, which it
looks for where dbt does: the folder DBT_PROFILES_DIR names when it is set, otherwise the
project folder and then ~/.dbt/. The profile is loaded as dbt loads it, so the target must have the fields dbt
requires for that adapter, but dfmt never connects to anything and the values need not be real.
A CI runner usually has no profile: CI Integration shows how to write one.
What is not formatted
dfmt skips directories that are not your code: dbt_packages, target, node_modules, .venv,
venv, .git.
dbt_packages is the important one. It is what dbt deps writes and what the next dbt deps
overwrites — the dbt equivalent of node_modules. Formatting it produces a diff against files you do
not own and will not keep.
To format one of those directories anyway, name it in include:
{ "include": ["**.sql", "dbt_packages/**.sql"] }
Naming it is the opt-in; a catch-all is not.
Commands
dlab.json is optional in a dbt project, since the dialect comes from the adapter type. Add one
to set formatting options or to narrow which files are included, and commit it so the team and CI
format the same way:
{
"include": ["models/**/*.sql", "macros/**/*.sql"],
"dfmt": {
"keywordCase": "lower",
"maxLineWidth": 100
}
}
dfmt diff . # what would change
dfmt write . # format every model in place
dfmt format --model dim_customers # one model, formatted, to stdout
dfmt write --changed-since main # only what this branch touched
When dfmt will not format a model
Two safeguards, and both leave the file untouched rather than guessing.
A template with no dbt project is refused. dfmt searches upward for dbt_project.yml from the
file, not from your working directory. If there is none, the file is reported as a warning and
skipped — because {% if x %} handed to an SQL parser does not fail, it lexes as operators and
identifiers and yields a confident parse of a query nobody wrote.
A construct the reconstruction cannot place makes dfmt hand back the original. The formatter checks its own work — every construct present in the input must be present in the output, in the same order — and if that check fails, the model is emitted exactly as it came in, with a warning. Nothing is ever half-formatted.
Both outcomes count as not assessed, which fails dfmt check. A file nobody could assess is
not a file that passed.
What "correct" is checked against
Formatting only moves whitespace, and dfmt verifies exactly that: the tokens of the output must be the same tokens, in the same order, as the tokens of the input. A run that would drop or reorder your SQL fails instead of writing.
The one exception is deliberate. clarifyPrecedence adds parentheses — a OR b AND c becomes
a OR (b AND c) — which changes the token stream on purpose. Because adding a token is only safe if
the parse tree really describes the input, it is switched off for any file where the parser had to
recover, and the verification tolerates added parentheses and nothing else.