sqlalign

SQL formatter · Postgres · Redshift · SQL Server

Your SQL, in columns.
Never rewritten.

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

It cannot change what your SQL means.

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.

What makes it different

Alignment is the engine, not a pass at the end.

One column across the whole FROM block

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';

Inside $$ 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;
$$;

dbt models, not just plain SQL

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

Pick a preset. Override what you disagree with.

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.

house

The columnar default: aligned, leading separators, ON inline.

compact

Same line structure, no alignment padding. The widest-reach starting point.

dbt

Lowercase keywords, the list stacked under a bare select at 4, trailing commas, unpadded. One deviation: a CTE body indents 2, not 4.

gitlab

Their published guide, reproduced from the linter-processed example in their handbook. AS on table aliases; column aliases the only thing aligned.

river

Holywell's style: root keywords right-aligned to a six-column gutter, joins on the far side of it, otherwise unpadded.

trailing

The alignment kept, but commas and AND/OR moved to the end of the line.

SettingDefaultControls
aligntrueColumn padding on or off
align_targetsall sixWhich columns are padded: aliases, operators, join_conditions, case_results, column_types, column_constraints
comma_positionleadingSeparator commas lead or trail the line
boolean_operator_positionleadingWhere AND/OR sit when a predicate spans lines
on_placementinlineWhether a join's ON drops below the table reference
keyword_caseupperCase for keywords, functions and type names
select_placementinlineWhether the first select item rides the SELECT line
select_indent2How far the list indents when it starts below SELECT
clause_keyword_alignleftRoot clause keywords flush left, or right-aligned into a river
river_gutter6The column a river aligns them to
table_alias_stylebareFROM orders o or FROM orders AS o
width100Target line width for wrapping
blank_lines_between_statementsunsetUnset means one blank line only between two multi-line statements
protect_templatingtrueMask Jinja/dbt expressions before formatting
format_dollar_bodiestrueFormat inside $$ procedure bodies
neq_style · decimal_style!= · NUMERICThe only two spellings sqlalign picks for you, because the parser collapses each pair

Install

One command, one dependency.

# 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

What it is, and what it leaves to others.

Alignment isn't unique to sqlalign — sqlfluff aligns column aliases, and pgFormatter and DataGrip both format $$ bodies. This page won't pretend otherwise.

Not a linter

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.

Not a transpiler

It formats the dialect you give it. It won't translate Postgres into T-SQL — the safety model doesn't support it.

Honest about limits

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.