sqlalign parses and prints three dialects. Pick one with --dialect:
sqlalign query.sql # postgres (the default)
sqlalign --dialect redshift ddl.sql
sqlalign --dialect tsql report.sql
| Dialect | Flag | Covers |
|---|---|---|
| Postgres | --dialect postgres (default) |
Postgres SQL, including $$-quoted plpgsql function and procedure bodies |
| Redshift | --dialect redshift |
Redshift SQL: ENCODE, DISTSTYLE/DISTKEY/SORTKEY, LISTAGG … WITHIN GROUP, plpgsql stored procedures |
| SQL Server | --dialect tsql |
T-SQL: TOP, [bracketed] identifiers, @variables, AS BEGIN … END procedures, GO batches |
Three things to know before you read the per-dialect sections:
--dialect is a command-line flag only. It is not a config-file key. Putting
dialect = "redshift" in .sqlalign.toml is an error, not a silent no-op:
$ sqlalign --stdout q.sql
sqlalign: /home/you/warehouse/.sqlalign.toml: unknown setting(s) ['dialect']; valid: ['align', 'align_targets', 'blank_lines_between_statements', 'boolean_operator_position', 'comma_position', 'decimal_style', 'exclude', 'format_dollar_bodies', 'keyword_case', 'neq_style', 'on_placement', 'preset', 'protect_templating', 'width']
If a repository holds SQL for more than one engine, run sqlalign once per
directory with the right flag, or use --exclude to split the run.
There is no dialect auto-detection. sqlalign will not sniff your file, and naming the wrong dialect is not covered by the safety guarantee — that guarantee is scoped to the dialect you asked for, and the layout handlers emit that dialect's keywords. Pass the flag that matches the engine the file will run on.
A dialect outside the three is refused, not attempted. The CLI rejects the argument outright:
$ sqlalign --dialect mysql q.sql
sqlalign: error: argument --dialect: invalid choice: 'mysql' (choose from postgres, redshift, tsql)
and the Python API raises rather than producing output:
ValueError: unsupported dialect 'snowflake'; sqlalign supports postgres, redshift, tsql
The reason that refusal is loud, rather than a best-effort attempt, is the subject of the last section on this page.
Postgres#
The default, and the dialect the style was designed against. Twenty-four of the
twenty-nine golden fixtures are Postgres: selects, joins, WHERE with
AND/OR/IN/BETWEEN/LIKE, aggregates, CTEs, subqueries, CASE, window
functions, set operations, INSERT/UPDATE/DELETE/MERGE, nested expressions
and comments, CREATE TABLE, CTAS, views and materialized views, CREATE FUNCTION and CREATE PROCEDURE, TRUNCATE, CREATE INDEX and GRANT.
$ sqlalign --stdout query.sql
SELECT cust.customer_id
, cust.email
, ord.order_id
, ord.total
, line_items.product_id
, line_items.quantity
, addr.city
FROM customers cust
INNER JOIN orders ord ON ord.customer_id = cust.customer_id
LEFT JOIN order_line_items line_items ON line_items.order_id = ord.order_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';
Both cast forms survive as written, at every nesting depth — sqlalign will not unify them for you:
$ sqlalign --stdout casts.sql
SELECT x::NUMERIC AS v
, CAST(y AS DATE) AS d
FROM t;
Redshift behaves identically here. T-SQL does not, because it has no ::
operator at all — see below.
Dollar-quoted bodies#
A $$ … $$ plpgsql body is parsed and formatted with the same engine as
top-level SQL, not treated as an opaque string:
$ sqlalign --stdout function.sql
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';
IF v_ltv IS NULL
THEN v_ltv := 0;
END IF;
RETURN v_ltv;
END;
$$;
--no-format-bodies leaves every $$ body byte-identical if you would rather it
did not.
Unmodelled plpgsql constructs — cursors, FOR loops, EXCEPTION blocks — pass
through per statement inside the body, so the rest of the procedure still
formats.
LANGUAGE sql bodies format too. They are a bare statement list rather than a
DECLARE/BEGIN/END block, so they get their own branch — but each statement goes
through the same renderer a plpgsql body's statements do, so the two cannot
drift apart. A body in a language that is not SQL at all (plpythonu,
plperl) is declined at the header.
What Postgres declines#
Anything without a layout handler passes through byte-identical with a warning.
PIVOT is a current example:
$ sqlalign --stdout declines.sql
sqlalign: declines.sql: unsupported construct, passed through: select * from t pivot (sum(x) for y in (
select * from t pivot (sum(x) for y in (1, 2)) p;
INSERT INTO staging
VALUES (1, 2);
INSERT INTO staging
( a)
SELECT a
FROM source;
PIVOT/UNPIVOT declines under Postgres only, and not because it is hard:
Postgres has no such syntax. sqlglot's Postgres generator drops the clause
silently — SELECT * FROM t PIVOT(...) comes back as SELECT * FROM t, the
whole thing gone — so declining is the only safe answer, and the re-parse guard
is what noticed. Under --dialect redshift and --dialect tsql, which do have
it, PIVOT formats:
SELECT *
FROM (SELECT a
, b
, c
FROM src
) s PIVOT(AVG(c) FOR b IN (1, 2)) AS p;
The check renders the node and looks at what comes out, rather than consulting a list of dialects — so if sqlglot ever grows Postgres support, this stops declining on its own.
Run sqlalign --report --check over your own SQL rather than trusting this
list — it counts what actually declined, ranked, so you can see whether any of
it matters to you. --check is what keeps it a survey: --report adds output
without changing the mode, so on its own it would rewrite the files it counted.
The legacy comma join — FROM a, b — formats, and is worth a note because it
was once declined. It parses as a join with no condition, exactly like
CROSS JOIN does, and rebuilding it through the join keyword would emit
FROM a JOIN b, which Postgres rejects: a bare JOIN requires a condition.
The re-parse check cannot catch that either, because sqlglot reads its own
lenient output back without complaint. The option that was missed is the
obvious one — emit the comma, which is both valid and what the author wrote:
SELECT 1
FROM a x
, b y
WHERE x.id = y.id;
Redshift#
Use --dialect redshift for Redshift DDL in particular — the column encodings
and table attributes get their own aligned columns and clause lines:
$ sqlalign --stdout --dialect redshift fact_orders.sql
CREATE TABLE fact_orders (
order_id BIGINT NOT NULL
, customer_id BIGINT NOT NULL ENCODE az64
, order_date DATE NOT NULL ENCODE az64
, total NUMERIC(12, 2) ENCODE az64
, channel VARCHAR(32) ENCODE lzo
)
DISTSTYLE KEY DISTKEY (customer_id)
COMPOUND SORTKEY (order_date, customer_id);
Two details worth noting in that output. The ENCODE keyword is uppercased but
its value is passed through as written — az64 stays az64, and AZ64 would
stay AZ64. And the constraint alignment is per kind: the NOT NULL column pads
over only the lines that carry NOT NULL, and the ENCODE column pads over only
the lines that carry ENCODE, so a row with no nullability constraint does not
drag the encoding column right.
One type spelling changes under Redshift that does not under Postgres: int
renders as INTEGER, because sqlglot's Redshift generator prints the long form.
The two are the same type. DECIMAL renders as NUMERIC here as it does under
Postgres, and --decimal-style DECIMAL brings it back.
LISTAGG(…) WITHIN GROUP (ORDER BY …) formats, with a space before the keyword's
paren, the same family as OVER (:
$ sqlalign --stdout --dialect redshift listagg.sql
SELECT customer_id
, LISTAGG(channel, ',') WITHIN GROUP (ORDER BY channel) AS channels
FROM orders
GROUP BY customer_id;
Redshift stored procedures use the same $$ body machinery as Postgres:
$ sqlalign --stdout --dialect redshift proc.sql
CREATE OR REPLACE PROCEDURE refresh_totals()
LANGUAGE plpgsql
AS $$
DECLARE v_rows INT;
BEGIN
DELETE FROM daily_totals
WHERE report_date = CURRENT_DATE;
INSERT INTO daily_totals
SELECT order_date
, SUM(total)
FROM orders
WHERE order_date = CURRENT_DATE
GROUP BY order_date;
END;
$$;
TEXT declines, and why#
Redshift TEXT is an alias for VARCHAR(256). sqlglot renders it as
VARCHAR(MAX), which is VARCHAR(65535) — a different column width. That is a
change to your schema, not to its presentation, so the safety net rejects it and
the statement is passed through untouched:
$ sqlalign --stdout --dialect redshift table.sql
sqlalign: table.sql: sqlglot cannot round-trip this statement, passed through unformatted: create table t (a text not null, b int);
create table t (a text not null, b int);
This one is caught rather than declined on sight because TEXT and
VARCHAR(MAX) parse to different AST nodes — the rewrite is visible to the
comparison, so the general guarantee handles it. (Contrast the T-SQL type
collapses below, which are not visible and need a separate guard.)
CAST(x AS TEXT) declines the same way. If you want those statements formatted,
write the width you mean: VARCHAR(256).
Other Redshift declines#
| Construct | What happens |
|---|---|
UNLOAD ('…') TO 's3://…' |
unsupported construct, passed through |
Python/Perl UDFs (LANGUAGE plpythonu) |
unsupported construct, passed through — the body is not SQL |
TEXT in a type position |
formatting would change semantics, passed through unformatted |
SQL Server (T-SQL)#
--dialect tsql covers ordinary queries and DDL, plus three T-SQL specifics:
TOP with bracketed identifiers, AS BEGIN … END procedures, and GO batches.
It is the newest and narrowest of the three — the declines below are longer than
the other dialects' for that reason.
TOP and bracketed identifiers#
TOP n rides the SELECT line; continuation commas keep their usual column, and
[bracketed] identifiers are preserved verbatim like every other identifier:
$ sqlalign --stdout --dialect tsql orders.sql
SELECT TOP 10 [Order Id]
, cust.[Full Name]
, ord.total
FROM [Sales Orders] ord
INNER JOIN customers cust ON cust.id = ord.customer_id
WHERE ord.status = 'complete'
AND ord.total > 100
ORDER BY ord.total DESC;
The statement splitter understands brackets, so a semicolon inside one
([my;col]) does not cut the statement in half, and ]] escapes a literal
bracket.
TOP is worth calling out because it is where the T-SQL support began. sqlglot
parses TOP n into the same Limit node Postgres uses, so a dialect-agnostic
layout handler emitted LIMIT n — valid SQL to sqlglot, rejected by SQL Server.
See Adding a dialect
below.
CREATE TABLE#
$ sqlalign --stdout --dialect tsql daily_revenue.sql
CREATE TABLE daily_revenue (
report_id INTEGER NOT NULL
, report_date DATE NOT NULL
, channel NVARCHAR(50) NOT NULL
, revenue NUMERIC(12, 2)
, PRIMARY KEY (report_id)
);
Two spelling changes in that output are worth knowing about, because both are sqlglot parse-time collapses that sqlalign cannot undo:
| You wrote | You get | Why it is safe |
|---|---|---|
int |
INTEGER |
INT and INTEGER are the same type |
decimal(12,2) |
NUMERIC(12, 2) |
SQL Server treats decimal and numeric as synonyms |
Under --dialect tsql, --decimal-style DECIMAL does not bring DECIMAL
back — the T-SQL generator has already printed NUMERIC before the knob is
consulted. --neq-style does work under T-SQL. If preserving the DECIMAL
spelling matters to you, put those statements behind -- sqlalign: skip.
BEGIN/END procedures#
A procedure body is ordinary T-SQL — there is no dollar quoting — so it goes through the main engine. Statements sit at column 1 inside the block, separated by blank lines:
$ sqlalign --stdout --dialect tsql refresh_daily.sql
CREATE PROCEDURE refresh_daily @target DATE
AS
BEGIN
DELETE FROM daily_revenue
WHERE report_date = @target;
INSERT INTO daily_revenue
( report_date
, channel
, revenue)
SELECT order_date
, channel
, SUM(total)
FROM orders
WHERE order_date = @target
GROUP BY order_date
, channel;
END;
The splitter keeps the whole routine as one statement rather than cutting at the
semicolons inside it. It does that with a small stack that records what opened
each block, so a CASE … END inside the body does not look like the end of the
procedure, and BEGIN TRANSACTION — which never gets a matching END — does not
open one.
sqlalign closes the block with END; whichever way the source spells it — with
or without a terminating semicolon:
$ sqlalign --stdout --dialect tsql proc.sql
CREATE PROCEDURE refresh_daily @target DATE
AS
BEGIN
DELETE FROM daily_revenue
WHERE report_date = @target;
END;
A source that wrote END; used to decline here, which this guide described as a
sharp edge. It was a bug, not an edge: the layout owns that semicolon, and the
statement emitter appended the source's own on top of it, producing END;; —
which re-parses differently, so the safety net stopped it. Fixed.
GO batches#
GO is a client directive, not SQL. sqlglot cannot parse it, and worse, it
swallows the following statement into the GO as a string literal. sqlalign
splits the file at GO lines before anything is parsed, formats each batch
independently, and passes the GO line through verbatim:
$ sqlalign --stdout --dialect tsql batches.sql
TRUNCATE TABLE staging;
GO
INSERT INTO staging
( a
, b)
SELECT a
, b
FROM source
WHERE a IS NOT NULL;
GO
The safety check is batch-aware too: it compares each batch's AST separately and
compares the separators themselves, so a dropped or added GO is still caught.
GO 5 (with a repeat count) is recognised.
REAL and NTEXT decline on sight#
These two are the reason T-SQL needs a guard the other dialects do not.
$ sqlalign --stdout --dialect tsql types.sql
sqlalign: types.sql: unsupported construct, passed through: create table t (a real not null, b int);
sqlalign: types.sql: unsupported construct, passed through: select cast(x as ntext) from t;
create table t (a real not null, b int);
select cast(x as ntext) from t;
sqlglot collapses REAL/FLOAT to one node, and NTEXT/TEXT to one node, at
parse time. In T-SQL those pairs are not synonyms:
| Pair | Difference |
|---|---|
REAL vs FLOAT |
REAL is FLOAT(24), FLOAT is FLOAT(53) — rewriting one widens precision |
NTEXT vs TEXT |
NTEXT is Unicode, TEXT is not |
The AST safety net is useless here, and not because it is weak: the distinction is
destroyed before the AST exists, so both sides of the comparison collapse the
same way and ast_equal returns True on a rewrite that changed your schema.
The only defence left is the raw source, so a statement whose text contains REAL
or NTEXT under --dialect tsql is declined by a regex over the input before any
layout runs.
INT/INTEGER, DECIMAL/NUMERIC and TIMESTAMP/ROWVERSION also collapse,
but those are synonyms, so canonicalising them is harmless and they still
format.
Other T-SQL declines#
| Construct | Example |
|---|---|
IF/WHILE inside a procedure body |
sqlglot does not parse it — part of the body comes back as raw text, and an IF … ELSE is indistinguishable from an IF without one, so laying out the tree would drop the ELSE |
TRY/CATCH blocks |
create procedure q as begin begin try … end try begin catch … end catch end |
REAL, NTEXT in a type position |
see above |
All of these pass through byte-identical with a warning and exit code 0.
Procedure bodies containing only DML and SELECT format; a body containing
control flow declines as a whole.
Adding a dialect is more than registering a parser#
SUPPORTED_DIALECTS is a whitelist of three, and it is not the list of dialects
sqlglot can parse — sqlglot parses many more. It is the list whose emitted
keywords have been audited against a real engine.
The distinction is not theoretical. It shipped once. The chain:
- sqlglot parses T-SQL
SELECT TOP 10 id FROM usersinto the sameLimitnode it uses for PostgresLIMIT 10. - The layout handler emitted its hard-coded
LIMITkeyword. Handlers are dialect-agnostic, which is true for Postgres and Redshift — they share the syntax — and false for SQL Server. - sqlglot then parsed that
LIMITback as T-SQL, because its grammar accepts it, soast_equalcompared the two trees equal and reported nothing.
Valid SQL in, SQL Server rejects the output, no warning. Two of the twenty-six
keywords the handlers emit diverge for T-SQL (LIMIT and OFFSET); finding that
out required going through them.
The lesson generalises, and it is the one real limit on sqlalign's guarantee: the AST safety net cannot detect dialect-invalid output. The round trip never leaves sqlglot's permissive grammar, so a keyword that is valid to sqlglot and invalid to your engine is invisible to the comparison. "sqlalign cannot change what your SQL means" holds within a verified dialect.
That is why format_sql raises for an unlisted dialect instead of doing its best.
A passthrough would be the wrong response — an unsupported dialect is a caller
error, not an unmodelled statement, so it fails where you can see it rather than
silently mid-file.
If you want a fourth dialect, the work is: audit every keyword the layout
handlers emit against that engine's grammar, hunt for parse-time collapses whose
two spellings are not synonyms (the REAL/NTEXT class), and add byte-exact
golden fixtures pinning the result. Registering the parser is the easy part.
See also#
- Configuration — the config keys, and why
dialectis not one - Command-line reference — every flag and the exit codes
- Architecture — the safety model and the decline contract in full
- FAQ — why a statement did not get formatted, and what to do about it