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 | shOn Windows, in PowerShell:
irm https://dfmt.datoria.ai/install.ps1 | iexFree, 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_nameRight-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 523 options. No more.
Where teams disagree, there is an option. Where there is one right answer, there is not. Every option has a page; every example on it is a test.
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_idEach 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 100Runners.
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 finalFlyway 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
&®ion 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 = '&®ion') 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(
'&®ion: ' || v_rows || ' rows, ' || v_total
);
EXCEPTION
WHEN NO_DATA_FOUND THEN
DBMS_OUTPUT.PUT_LINE('no rows for &®ion');
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)
GOSafe.
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.
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 unformattedBenchmark
Seven formatters, five corpora, same files, same width. Time, and files actually formatted.
| Formatter | dbt projectSnowflake + Jinja3,488 files · 223k lines | PL/SQL packagesOracle282 files · 189k lines | Postgres schemaPostgreSQL922 files · 217k lines | T-SQL pipelineT-SQL571 files · 174k lines | Hive queriesHive333 files · 21k lines |
|---|---|---|---|---|---|
| dfmtnative binary | 3,446/3,4881.9ms | 282/28211ms | 922/9223.1ms | 553/5716.0ms | 331/3331.6ms |
| sqlfmt 0.32.0Python | 3,442/3,4880.9ms | 253/2828.9ms | 0/9222.0ms | 0/5713.9ms | 188/3331.8ms |
| sqlfluff 4.3.0Python | 1,743/3,48817ms | 180/28289ms | 637/92246ms | 414/57197ms | 177/33319ms |
| sqruff 0.26.5Rust | ✕ crash | 280/28217ms* | 679/9229.6ms | 568/5714.4ms* | 278/3330.3ms* |
| pg_format 5.11Perl | 400/3,48810ms | 198/282647ms | >2 min | 224/57113ms | 333/3333.8ms |
| sqlformat 0.6.0Pythonno width | 640/3,4880.8ms | 282/2828.3ms | 546/9225.7ms | 109/5711.6ms | 333/3334.3ms |
| sql-formatter 15.8.2JavaScriptno width | 0/3,4881.2ms | 22/2827.4ms | 29/9229.3ms | 33/5711.8ms | 132/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 placeBuilt 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.