SQL formatter · Postgres · Redshift · SQL Server
sqlalign aligns operators, aliases and join conditions into vertical columns — and re-parses every statement it touches to prove it changed nothing but the whitespace.
Before
select cust.customer_id, cust.email, ord.total, addr.city from customers cust inner join orders ord on ord.customer_id = cust.customer_id left join shipping_addresses addr on addr.order_id = ord.order_id and addr.address_type = 'shipping' where ord.order_date >= '2026-07-01' and cust.segment = 'enterprise';
After
SELECT cust.customer_id
, cust.email
, ord.total
, addr.city
FROM customers cust
INNER JOIN orders ord ON ord.customer_id = cust.customer_id
LEFT JOIN shipping_addresses addr ON addr.order_id = ord.order_id
AND addr.address_type = 'shipping'
WHERE ord.order_date >= '2026-07-01'
AND cust.segment = 'enterprise';
│ alias · join condition · operator
The guarantee
Every statement is re-parsed after formatting and compared against the input as a syntax tree. If the output would differ semantically — or if the engine doesn't fully model the construct — that statement is passed through byte-identical with a warning instead.
Declining is the product, not a limitation. A formatter you can't trust on a repo you haven't read is a formatter you won't run.
:: or CAST — whichever you usedGROUP BY 1 stays positionalWhat makes it different
Every join's ON and AND condition lands in a single column
spanning the block, so the join logic reads as a table. Of the formatters surveyed,
none do this — their equivalents break the line instead of padding it.
FROM customers cust
INNER JOIN orders ord ON ord.customer_id = cust.id
LEFT JOIN order_lines lines ON lines.order_id = ord.id
LEFT JOIN addresses addr ON addr.order_id = ord.id
AND addr.kind = 'shipping';
$$ bodies too
A plpgsql body is parsed and formatted like any other SQL, not held as an opaque
string. T-SQL BEGIN … END procedures get the same treatment.
CREATE OR REPLACE FUNCTION get_customer_ltv(p_customer_id INT) RETURNS NUMERIC LANGUAGE plpgsql AS $$ DECLARE v_ltv NUMERIC; BEGIN SELECT SUM(total) INTO v_ltv FROM orders WHERE customer_id = p_customer_id AND status = 'complete'; RETURN v_ltv; END; $$;
Jinja expressions are masked with same-width placeholders, so the alias column is
measured against {{ ref('customers') }} as it reads, not against the
mask — and the template comes back untouched.
SELECT o.id
, o.total
, c.email
, {{ dbt_utils.star(ref('orders')) }}
FROM {{ ref('orders') }} o
JOIN {{ ref('customers') }} c ON c.id = o.customer_id
WHERE o.status = 'complete'
AND o.total > 0;
Configure
Nine of ten published SQL style guides produce unpadded output, so the alignment is a
switch, not a sermon. Settings live in .sqlalign.toml or a
[tool.sqlalign] table, discovered by walking up from each file.
The columnar default: aligned, leading separators, ON inline.
Same line structure, no alignment padding. The widest-reach starting point.
Lowercase keywords, the list stacked under a bare select at 4, trailing commas, unpadded. One deviation: a CTE body indents 2, not 4.
Their published guide, reproduced from the linter-processed example in their handbook. AS on table aliases; column aliases the only thing aligned.
Holywell's style: root keywords right-aligned to a six-column gutter, joins on the far side of it, otherwise unpadded.
The alignment kept, but commas and AND/OR moved to the end of the line.
| Setting | Default | Controls |
|---|---|---|
align | true | Column padding on or off |
align_targets | all six | Which columns are padded: aliases, operators, join_conditions, case_results, column_types, column_constraints |
comma_position | leading | Separator commas lead or trail the line |
boolean_operator_position | leading | Where AND/OR sit when a predicate spans lines |
on_placement | inline | Whether a join's ON drops below the table reference |
keyword_case | upper | Case for keywords, functions and type names |
select_placement | inline | Whether the first select item rides the SELECT line |
select_indent | 2 | How far the list indents when it starts below SELECT |
clause_keyword_align | left | Root clause keywords flush left, or right-aligned into a river |
river_gutter | 6 | The column a river aligns them to |
table_alias_style | bare | FROM orders o or FROM orders AS o |
width | 100 | Target line width for wrapping |
blank_lines_between_statements | unset | Unset means one blank line only between two multi-line statements |
protect_templating | true | Mask Jinja/dbt expressions before formatting |
format_dollar_bodies | true | Format inside $$ procedure bodies |
neq_style · decimal_style | != · NUMERIC | The only two spellings sqlalign picks for you, because the parser collapses each pair |
Install
# install the CLI uv tool install . # format in place sqlalign query.sql # or a whole tree sqlalign . # see what would change, write nothing sqlalign --diff . # CI gate sqlalign --check .
Python 3.12+. The only runtime dependency is sqlglot, pinned to a tested line.
.sqlalign.toml
preset = "house" width = 100 comma_position = "leading" keyword_case = "upper" exclude = ["vendor/*", "*.gen.sql"]
Precedence: defaults → preset → config file → flags. An unknown key is an error, so a typo can't leave you believing you have a setting you don't.
How it compares
Alignment isn't unique to sqlalign — sqlfluff aligns column aliases, and pgFormatter and
DataGrip both format $$ bodies. This page won't pretend otherwise.
It won't unify your cast styles, force aliases, or rewrite GROUP BY
references. That's sqlfluff's job, and the two are designed to run together.
It formats the dialect you give it. It won't translate Postgres into T-SQL — the safety model doesn't support it.
The AST check is a within-dialect guarantee, and comments sit outside it — which is why comment handling is pinned by byte-exact fixtures instead.