Skip to main content

dfmt

Beauty from understanding.

An SQL formatter for 43 dialects, dbt models and SQL scripts. It parses first. Every token comes back.

SELECT
    team_id,
    team_name,
    team_manager_name,
    DATE_TRUNC('day', valid_from) AS valid_from,
    COALESCE(
        DATE_TRUNC('day', valid_to),
        {{ var('tomorrow') }}
    ) AS valid_to
FROM {{ ref('workday_supervisory_organizations_source') }}
{% if is_incremental() %}
    WHERE valid_from >= (
        SELECT MAX(valid_from)
        FROM {{ this }}
    )
{% endif %}
QUALIFY
    ROW_NUMBER() OVER (
        PARTITION BY team_id
        ORDER BY valid_from DESC
    ) = 1

Install

One binary for macOS (Apple silicon), Linux x86_64 and Windows x86_64. No JVM, no Python, no dbt. Also for VS Code and Cursor.

curl -fsSL https://dfmt.datoria.ai/install.sh | sh

On Windows, in PowerShell:

irm https://dfmt.datoria.ai/install.ps1 | iex

Free, no account. Where it installs, upgrading and uninstalling.

Configurable.

Pick your team’s style. Combine options freely.

Alignment

Line up types, =, aliases, then values, comparisons and named arguments. All of them, or the ones you pick.

create table orders(
    id    bigint  primary key,
    total numeric not null
);

update orders
set
    tier = case
        when total > 100 then 'big'
        else                  'small'
    end,
    due  = make_date(
        year  => 2026,
        month => 1
    )
where id = 7;

select
    id           as order_id,
    total * 1.25 as gross_total
from orders
where
    tier      = 'big'
    and total > 100
    and due   >= current_date;

Comma first

Commas at the start of the line. Adding a column changes one line.

SELECT
    customer_id,
    customer_name,
    email,
    phone,
    address,
    city,
    state,
    zip_code,
    country,
    created_at,
    updated_at
FROM customers
WHERE
    active = true
    AND country IN (
        'US',
        'CA',
        'GB',
        'DE',
        'FR'
    )
ORDER BY created_at DESC, customer_name

Right-aligned keywords

Keywords end in one column. Read the left edge for what the query does, the right for what it does it to.

  select drivers.code,
         drivers.surname,
         count(*) as wins
    from results
    join drivers using (driverid)
    join races using (raceid)
   where results.position = 1
     and races.date >= date '2017-01-01'
group by drivers.code, drivers.surname
  having count(*) > 2
order by wins desc
   limit 5

Fluent.

43 dialects, in full.

It follows your lead

Type however you like. Save, and dfmt formats it. With preserveBreaks, the line breaks you chose stay.

select
    customer_id,
    sum(total) as revenue, count(*) as orders, max(placed_at) as last_order
from orders
where status = 'paid' and region = 'EU'
group by customer_id

Each dialect, in full

43 dialects, each with its own syntax. BigQuery's |> pipes format as clauses.

FROM orders
|> WHERE status = 'complete' AND total > 100
|> AGGREGATE count(*) as order_count, sum(total) as revenue
    GROUP BY customer_id
|> WHERE order_count >= 5
|> ORDER BY revenue DESC
|> LIMIT 100

Runners.

Read the way dbt, Flyway, psql or SQL*Plus will read it.

What runs the file

The same SQL means different things to different runners. psql treats \set as a command, Flyway fills in ${schema}, dbt renders Jinja. dfmt finds the runner from its project file, or from the folder's "runner" in dlab.json, and reads the file the way it will.

dbt models

dfmt renders the template, formats the SQL and puts every tag back. No dbt, no Python, no warehouse.

{%
    set updater_columns = var(
        'updater_columns',
        ['is_active', 'user_id', 'organization_id', 'last_login_at', 'name']
    )
%}

with
    history as (select * from {{ ref('prep_support_ticket_field_history') }}),

    updaters as (select * from {{ ref('zendesk_users_masked_source') }}),

    final as (
        select
            history.*
            {% if updater_columns %}
                {% for col in updater_columns %}
                    {% if col == 'is_active' %}
                        , coalesce(u.{{ col }}, true) as {{ col }}
                    {% elif col in ['user_id', 'organization_id'] %}
                        , coalesce(u.{{ col }}, -1) as {{ col }}
                    {% elif col == 'last_login_at' %}
                        , coalesce(u.{{ col }}, current_timestamp) as {{ col }}
                    {% else %}
                        , coalesce(u.{{ col }}, 'trigger_change') as {{ col }}
                    {% endif %}
                {% endfor %}
            {% endif %}
        from history
        left join updaters u
            on history.updater_id = u.updater_user_id
            and history.source_relation = u.source_relation
    )
select *
from final

Flyway migrations

Read from flyway.toml or flyway.conf, as Flyway reads them: the database from the JDBC url, the migrations from locations, and V2_3__orders.sql ordered before V2_10__users.sql. A placeholder like ${schema} stands where one value stands, so dfmt formats around it and keeps it as written.

create table ${schema}.orders(
    id bigint generated always as identity primary key,
    customer_id bigint not null
        references ${schema}.customers(id),
    total numeric(12, 2) not null,
    created_at timestamptz not null default now()
);

create index orders_customer_idx
    on ${schema}.orders(customer_id, created_at);

insert into ${schema}.releases(version, applied_by)
values ('${flyway:timestamp}', '${flyway:user}');

psql scripts

Read the way psql reads it. :schema is a variable, and \set and \copy keep their lines.

\set schema analytics
\timing on
select o.id, o.total, c.email
from :schema.orders o
join :schema.customers c on o.cid = c.id
where o.status = :'status' and o.total > :min_total;
\copy (select * from :schema.orders) to 'out.csv' csv

SQL*Plus scripts

&&region is a substitution, / runs the block. The block formats as code.

SET DEFINE ON
DECLARE
    v_total NUMBER := 0;
    v_rows PLS_INTEGER := 0;
BEGIN
    FOR r IN (select total from orders where region = '&&region') LOOP
        v_total := v_total + r.total;
        v_rows := v_rows + 1;
        IF v_total > &max_total THEN
            EXIT;
        END IF;
    END LOOP;
    DBMS_OUTPUT.PUT_LINE(
        '&&region: ' || v_rows || ' rows, ' || v_total
    );
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        DBMS_OUTPUT.PUT_LINE('no rows for &&region');
END;
/

Every other runner

sqlcmd and its GO, mysql and its DELIMITER, snowsql, the db2 command line, duckdb, sqlite3, trino, athena and beeline. schemachange, with its vars. bqetl. A PostgreSQL extension's scripts, as the server runs them. Qlik, with its $(variables) kept as written. And none, for files the database reads whole. Runners says what each one changes.

create table dbo.Orders(
    OrderID int not null,
    CustomerID int,
    Total decimal(12, 2),
    CreatedAt datetime2 default sysutcdatetime()
)
GO
create nonclustered index IX_Orders_Customer
    on dbo.Orders(CustomerID, CreatedAt)
GO
alter table dbo.Orders
    add constraint PK_Orders primary key clustered (OrderID)
GO

Safe.

Only whitespace changes. Every run checks.

Only whitespace changes.

Every run re-reads its output and counts the tokens. Names keep their case by default. Set lower, and quoted names still don't change.

select
    o.OrderID,
    c.CustomerID,
    c.FirstName || ' ' || c.LastName as FullName,
    o.payload:"deliveryWindow" as DeliveryWindow
from Orders o
join Customers c on o.CustomerID = c.CustomerID
where o.OrderStatus = 'shipped'

Broken files format too

What parses is formatted. What doesn't comes back exactly as you wrote it.

select customer_id, sum(order_total) as lifetime
from orders
where status = 'shipped'
group by customer_id;

-- still typing this one: no column list yet
select   from orders  where status = 'shipped'  and created_at > now();

update customers
set tier = 'gold', updated_at = now()
where lifetime_value > 10000

Clarifying parentheses

a or b and c means a or (b and c). clarifyPrecedence writes the parentheses. Off by default, and only on files that parse cleanly.

SELECT id, name
FROM users
WHERE
    role = 'admin'
    OR (role = 'editor' AND active = true AND created_at > '2024-01-01')
UNION ALL
SELECT id, name
FROM archived_users
WHERE role = 'admin' OR (role = 'editor' AND deleted = false)

Opt out

-- fmt:off and -- fmt:on skip a region, -- fmt:ignore one statement.

SELECT id, name, email FROM users WHERE active = true;

-- fmt:off
SELECT   a,    b,     c
FROM     t1
WHERE    x = 1;
-- fmt:on

SELECT id, total FROM orders WHERE status = 'complete'

Zero tokens lost

Whole repositories. Every output re-read and counted.

0tokens lost
0outputs that would not re-parse
97 396files formatted
8.4Mlines of whole files
1152projects and corpora
100.0%of 613,988 statements round-tripped by the parser

At work.

In your editor and in CI. Fast.

Editor and CI

Format on save. Check in CI. --changed-since formats only what you touched.

dfmt diff                        # see what would change
dfmt write                       # format in place
dfmt check --changed-since main  # CI: exit 1 on anything unformatted

Benchmark

Seven formatters, five corpora, same files, same width. Time, and files actually formatted.

Formatterdbt projectSnowflake + Jinja3,488 files · 223k linesPL/SQL packagesOracle282 files · 189k linesPostgres schemaPostgreSQL922 files · 217k linesT-SQL pipelineT-SQL571 files · 174k linesHive queriesHive333 files · 21k lines
dfmtnative binary3,446/3,4881.9ms282/28211ms922/9223.1ms553/5716.0ms331/3331.6ms
sqlfmt 0.32.0Python3,442/3,4880.9ms253/2828.9ms0/9222.0ms0/5713.9ms188/3331.8ms
sqlfluff 4.3.0Python1,743/3,48817ms180/28289ms637/92246ms414/57197ms177/33319ms
sqruff 0.26.5Rust✕ crash280/28217ms*679/9229.6ms568/5714.4ms*278/3330.3ms*
pg_format 5.11Perl400/3,48810ms198/282647ms>2 min224/57113ms333/3333.8ms
sqlformat 0.6.0Pythonno width640/3,4880.8ms282/2828.3ms546/9225.7ms109/5711.6ms333/3334.3ms
sql-formatter 15.8.2JavaScriptno width0/3,4881.2ms22/2827.4ms29/9229.3ms33/5711.8ms132/3337.7ms

Files rewritten above, time per file below, shaded per corpus. Median of 3, in place from a fresh copy, Apple M5 Pro, 18 cores. How this was measured.

  • no widthAims at no column count. An easier task, so the time is not comparable — shown unshaded, off the scale.
  • *Run under a dialect the tool does not have.
  • ✕ crashThe tool died on this corpus.
  • >2 minStill running when the run was cut off.

Get started

Run it in your project. The first time, it asks which dialect your SQL is in and saves the answer for your team and CI. The walkthrough goes from there to a dbt project.

curl -fsSL https://dfmt.datoria.ai/install.sh | sh
cd your-project
dfmt          # sets the project up, then says what would change
dfmt write    # format every SQL file and dbt model in place

Built on dquery

dfmt runs on dquery: a parser for 43 dialects, with lineage, types and optimisation on top. dfmt is free. dquery is in early access.

Each dialect is described once. That description builds the parser and tells dfmt how to lay out every construct.