sqlalign GitHub

Read this page to see exactly what sqlalign does to your SQL, construct by construct, and to find the handful of decisions you can change.

Every SQL block below is real output from sqlalign --stdout, paired with the messy input that produced it. A few later blocks reuse an input shown earlier. Nothing here is hand-written "expected" SQL.

If you only want the summary: sqlalign stacks lists one item per line with the separator leading, and pads repeated elements into vertical columns. It does not change what your SQL means, and it does not lint.

Two rules generate most of the layout#

One element per line, separator first. In any stacked list — select items, GROUP BY columns, SET assignments, CREATE TABLE columns — each item gets its own line, opened by the comma rather than closed by it. In a clause, the first item rides the clause keyword's line.

Repeated elements pad into a column. Table aliases, AS aliases, ON keywords, comparison operators, THEN values, column types: each kind gets one column per scope, wide enough for the longest entry.

Two details make the columns look the way they do:

A scope is the region a column spans: one select list, one whole FROM block, one WHERE clause, one CASE. Brackets end a scope, so a subquery or CTE aligns internally and never with its parent.

The select list#

select id, first_name, last_name, email, created_at from users where status = 'active' and created_at >= '2026-01-01' order by created_at desc limit 100;
SELECT id
     , first_name
     , last_name
     , email
     , created_at
FROM users
WHERE status      = 'active'
  AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 100;

The first item stays on the SELECT line. Every later item starts in that same column, with its comma two columns to the left. * is never expanded into columns.

When items carry aliases, the AS keywords form a column across the select list. Select-list aliases always render with AS, so SELECT a x comes back as SELECT a AS x — but sqlalign never invents an alias where you had none, and never changes the name you chose:

select customer_id, date_trunc('month', order_date) as order_month, count(*) as order_count, sum(total) as revenue, avg(total) as avg_order_value from orders where status = 'complete' group by customer_id, date_trunc('month', order_date) having count(*) > 3 and sum(total) > 1000 order by revenue desc;
SELECT customer_id
     , DATE_TRUNC('month', order_date) AS order_month
     , COUNT(*)                        AS order_count
     , SUM(total)                      AS revenue
     , AVG(total)                      AS avg_order_value
FROM orders
WHERE status = 'complete'
GROUP BY customer_id
       , DATE_TRUNC('month', order_date)
HAVING COUNT(*)   > 3
   AND SUM(total) > 1000
ORDER BY revenue DESC;

GROUP BY, ORDER BY, LIMIT#

GROUP BY and ORDER BY use the same stacking rule as the select list, with two exceptions. A single item stays inlineORDER BY revenue DESC above. So do positional references, however many: GROUP BY 1, 2 stays on one line (see the CTE example further down). Two or more named columns stack. LIMIT gets its own line.

Set operations#

A set operator sits alone on its line with a blank line on each side, and each branch is formatted independently:

select id, email, 'customer' as source from customers where created_at >= '2026-01-01' union all select id, email, 'lead' as source from leads where converted = false union all select id, email, 'partner' as source from partner_contacts;
SELECT id
     , email
     , 'customer' AS source
FROM customers
WHERE created_at >= '2026-01-01'

UNION ALL

SELECT id
     , email
     , 'lead' AS source
FROM leads
WHERE converted = FALSE

UNION ALL

SELECT id
     , email
     , 'partner' AS source
FROM partner_contacts;

The FROM block#

Table aliases align into one column that spans the whole FROM block — the base table and every join together, not each join on its own. Table aliases render implicitly, so FROM orders AS o comes back as FROM orders o.

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

shipping_addresses is the longest table name, so the alias column opens one space past it and cust, ord, line_items and addr all start there.

The ON/AND condition column#

This is the part of the style you are least likely to have seen before. Look at the same block again:

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'

Three columns run the full height of the block: the aliases, the ON keyword, and the = operators. The join conditions therefore read as a two-column table of left operand and right operand — across every join at once, not per join. (Table names are not a column; they start wherever their join keyword ends.)

An extra condition drops to its own line and its AND right-aligns to end where ON ends, so ON and AND read as one stack of connectives.

Aligning aliases is not unusual — sqlfluff aligns alias expressions too. Carrying one ON/AND column across an entire FROM block is the unusual part: the other formatters surveyed in the README break the line instead of padding it. If you want it gone, --align-targets drops join_conditions and keeps the rest.

WHERE and HAVING#

The first condition rides the keyword's line. Later conditions get their own lines, and their AND/OR right-aligns to end where the keyword ends — column 5 for WHERE, column 6 for HAVING. That is why AND is indented two under WHERE and three under HAVING.

Within the clause, comparison operators right-align:

SELECT id, name, category, price FROM products WHERE (category IN ('electronics', 'appliances', 'accessories') OR name LIKE '%refurb%') AND price BETWEEN 25 AND 500 AND discontinued = false AND supplier_id IS NOT NULL;
SELECT id
     , name
     , category
     , price
FROM products
WHERE (category  IN ('electronics', 'appliances', 'accessories')
       OR name LIKE '%refurb%')
  AND price  BETWEEN 25 AND 500
  AND discontinued = FALSE
  AND supplier_id IS NOT NULL;

Two things are worth reading closely there.

Operators end together, they do not start together. BETWEEN, = and IS are 7, 1 and 2 characters wide, and all three finish in the same column, so the values to their right line up.

A parenthesized group is its own sub-scope. IN and LIKE align with each other inside the parens; BETWEEN, = and IS align with each other outside it. The two groups do not share a column. Inside the group, OR aligns with the group's first condition rather than with the outer AND.

CASE#

sqlalign has two CASE forms and picks between them automatically.

Short form puts each WHEN cond THEN val on one line, with the WHENs aligned one space past CASE and the THENs padded into a column. END sits under CASE.

Long form puts THEN on its own line, indented two past its WHEN. You get it when a WHEN condition is a compound boolean (AND/OR), or when a WHEN … THEN … row would not fit the width limit.

Both forms in one query — the second CASE goes long because its second WHEN is compound, even though every row would have fit:

select order_id, total, case when total >= 1000 then 'large' when total >= 100 then 'medium' when total > 0 then 'small' else 'invalid' end as order_size, case when status = 'complete' then 1 when status = 'pending' and not is_archived then 2 else 99 end as status_rank from orders;
SELECT order_id
     , total
     , CASE WHEN total >= 1000 THEN 'large'
            WHEN total >= 100  THEN 'medium'
            WHEN total > 0     THEN 'small'
            ELSE 'invalid'
       END AS order_size
     , CASE WHEN status = 'complete'
              THEN 1
            WHEN status = 'pending'
             AND NOT is_archived
              THEN 2
            ELSE 99
       END AS status_rank
FROM orders;

CASE is never left alone on a line: the first WHEN always stays with it.

Here is the width trigger on its own — no compound condition, just a result string too long for the line:

select order_id, case when total >= 1000 then 'a really quite long result string that pushes this row over the configured width limit' when total > 0 then 'small' else 'none' end as bucket from orders;
SELECT order_id
     , CASE WHEN total >= 1000
              THEN 'a really quite long result string that pushes this row over the configured width limit'
            WHEN total  > 0
              THEN 'small'
            ELSE 'none'
       END AS bucket
FROM orders;

Window functions#

OVER takes a space before its paren — it is a keyword, not a function name. A window that fits stays on one line. One that does not breaks at window sub-clause boundaries, with continuation lines aligned under PARTITION. The AS alias still joins the select list's alias column, measured against the last line:

select customer_id, order_id, order_date, total, row_number() over (partition by customer_id order by order_date desc) as rn, sum(total) over (partition by customer_id order by order_date rows between unbounded preceding and current row) as running_total, lag(order_date) over (partition by customer_id order by order_date) as prev_order_date from orders;
SELECT customer_id
     , order_id
     , order_date
     , total
     , ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
     , SUM(total) OVER (PARTITION BY customer_id ORDER BY order_date
                        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)    AS running_total
     , LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)   AS prev_order_date
FROM orders;

The frame clause is the first thing to move down when a window is too wide.

CTEs#

Each CTE opens with name AS (, indents its body two, and closes with ) at the statement's anchor column. A blank line separates CTEs from each other and from the final SELECT, and the separator comma opens the next CTE's line:

with monthly_revenue as (select customer_id, date_trunc('month', order_date) as month, sum(total) as revenue from orders group by 1, 2), top_customers as (select customer_id from monthly_revenue group by customer_id having sum(revenue) > 10000) select m.customer_id, m.month, m.revenue from monthly_revenue m join top_customers t on t.customer_id = m.customer_id order by m.customer_id, m.month;
WITH monthly_revenue AS (
  SELECT customer_id
       , DATE_TRUNC('month', order_date) AS month
       , SUM(total)                      AS revenue
  FROM orders
  GROUP BY 1, 2
)

, top_customers AS (
  SELECT customer_id
  FROM monthly_revenue
  GROUP BY customer_id
  HAVING SUM(revenue) > 10000
)

SELECT m.customer_id
     , m.month
     , m.revenue
FROM monthly_revenue m
JOIN top_customers   t ON t.customer_id = m.customer_id
ORDER BY m.customer_id
       , m.month;

The AS column inside monthly_revenue is computed from that CTE's items alone. top_customers and the final SELECT each resolve their own columns; the brackets keep them apart.

WITH RECURSIVE gets the same layout, with the set operator inside the CTE body following the usual blank-line rule:

with recursive tree as (select id, parent_id, 1 as depth from nodes where parent_id is null union all select n.id, n.parent_id, t.depth + 1 from nodes n join tree t on t.id = n.parent_id) select id, depth from tree order by depth;
WITH RECURSIVE tree AS (
  SELECT id
       , parent_id
       , 1 AS depth
  FROM nodes
  WHERE parent_id IS NULL

  UNION ALL

  SELECT n.id
       , n.parent_id
       , t.depth + 1
  FROM nodes n
  JOIN tree  t ON t.id = n.parent_id
)

SELECT id
     , depth
FROM tree
ORDER BY depth;

Subqueries#

A derived table opens inline after (, aligns its body to its own SELECT, and drops the closing ) to a line of its own, with the alias and ON following it there. Scalar, IN (…) and EXISTS (…) subqueries close inline instead:

select u.id, u.email, recent.last_order_date, (select count(*) from support_tickets st where st.user_id = u.id) as ticket_count from users u join (select customer_id, max(order_date) as last_order_date from orders group by customer_id) recent on recent.customer_id = u.id where u.id in (select user_id from subscriptions where plan = 'premium');
SELECT u.id
     , u.email
     , recent.last_order_date
     , (SELECT COUNT(*)
        FROM support_tickets st
        WHERE st.user_id = u.id) AS ticket_count
FROM users u
JOIN (SELECT customer_id
           , MAX(order_date) AS last_order_date
      FROM orders
      GROUP BY customer_id
     ) recent ON recent.customer_id = u.id
WHERE u.id IN (SELECT user_id
               FROM subscriptions
               WHERE plan = 'premium');

A derived table is deliberately excluded from the FROM block's alias and ON columns — its alias sits on the closing-paren line, anchored to the subquery.

INSERT, UPDATE, DELETE#

INSERT INTO table takes its own line. The column list uses the same leading separator as a select list, opened by ( and two spaces, and closed by ) touching the last column. The SELECT then starts at column 1. (A top-level INSERT … VALUES is not modelled yet and passes through byte-identical; VALUES inside a MERGE is formatted, as below.)

UPDATE … SET stacks assignments with the = aligned. DELETE FROM is followed by a normal WHERE clause.

insert into daily_revenue (report_date, channel, revenue, order_count) select order_date, channel, sum(total), count(*) from orders where order_date = current_date - 1 group by order_date, channel;
update products set price = price * 1.05, updated_at = current_timestamp where category = 'electronics' and discontinued = false;
delete from daily_revenue where report_date < '2026-01-01' and channel = 'legacy';
INSERT INTO daily_revenue
(  report_date
 , channel
 , revenue
 , order_count)
SELECT order_date
     , channel
     , SUM(total)
     , COUNT(*)
FROM orders
WHERE order_date = CURRENT_DATE - 1
GROUP BY order_date
       , channel;

UPDATE products
SET price      = price * 1.05
  , updated_at = CURRENT_TIMESTAMP
WHERE category     = 'electronics'
  AND discontinued = FALSE;

DELETE FROM daily_revenue
WHERE report_date < '2026-01-01'
  AND channel     = 'legacy';

The blank lines between those three statements are not from the input, which had none. sqlalign puts exactly one blank line between two statements when both are multi-line, and leaves adjacency alone otherwise. --blank-lines-between-statements N forces a fixed count instead.

MERGE#

Every MERGE clause starts its own line at column 1. The ON block uses WHERE-clause geometry — ON and AND right-aligned to end at column 5, with the operators padded — and SET behaves exactly like an UPDATE:

merge into daily_revenue tgt using staging_daily_revenue src on tgt.report_date = src.report_date and tgt.channel = src.channel when matched then update set revenue = src.revenue, order_count = src.order_count, updated_at = current_timestamp when not matched then insert (report_date, channel, revenue, order_count) values (src.report_date, src.channel, src.revenue, src.order_count);
MERGE INTO daily_revenue tgt
USING staging_daily_revenue src
   ON tgt.report_date = src.report_date
  AND tgt.channel     = src.channel
WHEN MATCHED
THEN UPDATE
SET revenue     = src.revenue
  , order_count = src.order_count
  , updated_at  = CURRENT_TIMESTAMP
WHEN NOT MATCHED
THEN INSERT
(  report_date
 , channel
 , revenue
 , order_count)
VALUES
(  src.report_date
 , src.channel
 , src.revenue
 , src.order_count
);

CREATE TABLE#

One column per line. The first is indented 4, the leading commas sit at column 3, and three columns align: name, type, then constraints. A table-level constraint is the last list item.

create table daily_revenue (report_date date not null, channel varchar(50) not null, revenue numeric(12,2) default 0, order_count int default 0, created_at timestamp default current_timestamp, primary key (report_date, channel));
CREATE TABLE daily_revenue (
    report_date DATE           NOT NULL
  , channel     VARCHAR(50)    NOT NULL
  , revenue     NUMERIC(12, 2) DEFAULT 0
  , order_count INT            DEFAULT 0
  , created_at  TIMESTAMP      DEFAULT CURRENT_TIMESTAMP
  , PRIMARY KEY (report_date, channel)
);

Note NUMERIC(12,2) became NUMERIC(12, 2): inline lists always take a space after the comma.

On Redshift, constraints align per kind rather than sharing one column, and table attributes follow the closing paren, one clause per line. ENCODE values are identifiers, so they come through exactly as you wrote them — az64 stays lowercase, AZ64 stays uppercase:

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

The NOT NULL column is measured over only the three lines that carry it; the ENCODE column over only the four that carry it. That is why total and channel have no NOT NULL gap to pay for.

plpgsql $$ bodies#

sqlalign parses the body between $$ … $$ and formats it with the same engine it uses at the top level, rather than treating it as an opaque string. Header clauses each get their own line, body statements sit at column 1 with blank lines between them, and $$; closes at column 1.

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

The WHERE clause inside the body is aligned by the same rules as one at the top level. A larger body shows the full treatment — DELETE, an INSERT with its column list, GET DIAGNOSTICS, and an IF/THEN/ELSE block:

create procedure refresh_daily_revenue(target_date date) language plpgsql as $$ declare row_count int; begin delete from daily_revenue where report_date = target_date; insert into daily_revenue (report_date, channel, revenue, order_count) select order_date, channel, sum(total), count(*) from orders where order_date = target_date group by order_date, channel; get diagnostics row_count = row_count; if row_count = 0 then raise warning 'no orders found for %', target_date; else raise notice 'loaded % rows', row_count; end if; end; $$;
CREATE PROCEDURE refresh_daily_revenue(target_date DATE)
LANGUAGE plpgsql
AS $$

DECLARE row_count INT;

BEGIN

DELETE FROM daily_revenue
WHERE report_date = target_date;

INSERT INTO daily_revenue
(  report_date
 , channel
 , revenue
 , order_count)
SELECT order_date
     , channel
     , SUM(total)
     , COUNT(*)
FROM orders
WHERE order_date = target_date
GROUP BY order_date
       , channel;

GET DIAGNOSTICS row_count = ROW_COUNT;

IF row_count = 0
  THEN RAISE WARNING 'no orders found for %', target_date;
  ELSE RAISE NOTICE 'loaded % rows', row_count;
END IF;

END;
$$;

One header normalization to know about: LANGUAGE 'plpgsql' loses its quotes and comes back as LANGUAGE plpgsql. The quoted form is legacy syntax; the Postgres and Redshift docs both use the unquoted one.

--no-format-bodies turns body formatting off. Note what that does: the whole CREATE FUNCTION/CREATE PROCEDURE statement is left byte-identical, header included, while other statements in the same file still format.

What the style deliberately does not do#

It cannot change what your SQL means. Every formatted statement is re-parsed and AST-compared against the input. Anything that would differ semantically is discarded and the original bytes are kept. Identifiers, string literals, cast form (:: versus CAST), GROUP BY reference form and the alias names you chose all survive exactly as written. (The AS keyword itself is layout, not content, so it is normalized: present in select lists, absent for table aliases.)

It does not lint. sqlalign will not unify your cast styles, insert missing aliases, turn JOIN into INNER JOIN, or make GROUP BY references consistent. That is sqlfluff's job, and the two are designed to run together.

It declines what it cannot reproduce exactly. A construct the engine does not model passes through byte-identical, with a warning on stderr and exit code 0. JOIN … USING is one such construct today:

select a.id from a join b using (tenant_id);
select a.id from a join b using (tenant_id);
sqlalign: query.sql: unsupported construct, passed through: select a.id from a join b using (tenant_

Nothing was reformatted and nothing was mangled. The rest of the file still formats normally.

What you can configure#

Eleven settings change the style itself. Each is a CLI flag and a .sqlalign.toml key (or a [tool.sqlalign] key in pyproject.toml), and each can also arrive via a preset. sqlalign --show-config file.sql prints what is actually in effect.

Flag Config key Default Changes
--no-align align true all column padding, on or off
--align-targets a,b,… align_targets all but table_names which columns are padded
--comma-position comma_position leading where the separator comma sits
--boolean-operator-position boolean_operator_position leading where AND/OR sit
--on-placement on_placement inline whether a join's ON rides the table line
--keyword-case keyword_case upper case of keywords, functions and types
--select-placement select_placement inline whether the first select item rides the SELECT line
--select-indent N select_indent 2 how far the list indents when it starts below SELECT
--clause-keyword-align clause_keyword_align left root clause keywords flush left, or in a river
--river-gutter N river_gutter 6 the column a river aligns them to
--table-alias-style table_alias_style bare FROM orders o or FROM orders AS o

Most of what follows is generated from this one query, so you can compare the outputs directly:

select cust.email, ord.total, ord.order_date from customers cust 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';

The default output — the baseline for every comparison that follows:

SELECT cust.email
     , ord.total
     , ord.order_date
FROM customers               cust
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';

align — turn the padding off#

--no-align (or align = false) keeps sqlalign's line structure and emits one space between tokens instead of padding into columns. This is what most published SQL style guides ask for.

sqlalign --no-align query.sql
SELECT cust.email
     , ord.total
     , ord.order_date
FROM customers cust
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';

Same lines, same breaks, no padding. Note that the trailing AND condition, which had been right-aligned under ON, falls back to a two-space indent.

align_targets — keep some columns, drop others#

--align-targets takes a comma-separated list. Anything you leave out collapses to a single space; --no-align is the shorthand for leaving out all six. An unrecognised name is an error, not a silent no-op.

Target Aligns
aliases AS x in a select list, and table aliases in FROM/JOIN
operators =, !=, <, LIKE, IS … in WHERE/ON/HAVING/CASE
join_conditions the ON/AND column across a whole FROM block
case_results THEN in a short-form CASE
column_types column types in CREATE TABLE
column_constraints NOT NULL/DEFAULT, and Redshift ENCODE

Keeping only the alias column:

sqlalign --align-targets aliases query.sql
SELECT cust.email
     , ord.total
     , ord.order_date
FROM customers               cust
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';

The remaining examples need a query with more columns in play:

select o.order_id, sum(o.total) as revenue, count(*) as order_count, case when sum(o.total) >= 1000 then 'large' when sum(o.total) > 0 then 'small' else 'none' end as bucket from orders o join customers cust on cust.id = o.customer_id where o.status = 'complete' and o.channel = 'web' group by o.order_id;

Keeping only the operator column:

sqlalign --align-targets operators wide.sql
SELECT o.order_id
     , SUM(o.total) AS revenue
     , COUNT(*) AS order_count
     , CASE WHEN SUM(o.total) >= 1000 THEN 'large'
            WHEN SUM(o.total) > 0 THEN 'small'
            ELSE 'none'
       END AS bucket
FROM orders o
JOIN customers cust ON cust.id = o.customer_id
WHERE o.status  = 'complete'
  AND o.channel = 'web'
GROUP BY o.order_id;

The WHERE operators still line up; the AS, alias and THEN columns are gone. That same query with everything except case_results shows how narrow a single target's effect is — only the THEN column disappears:

sqlalign --align-targets aliases,operators,join_conditions,column_types,column_constraints wide.sql
SELECT o.order_id
     , SUM(o.total) AS revenue
     , COUNT(*)     AS order_count
     , CASE WHEN SUM(o.total) >= 1000 THEN 'large'
            WHEN SUM(o.total) > 0 THEN 'small'
            ELSE 'none'
       END          AS bucket
FROM orders    o
JOIN customers cust ON cust.id = o.customer_id
WHERE o.status  = 'complete'
  AND o.channel = 'web'
GROUP BY o.order_id;

The two DDL targets work the same way. Run the CREATE TABLE from earlier on this page with --align-targets column_types and the type column survives while the constraint column does not:

sqlalign --align-targets column_types table.sql
CREATE TABLE daily_revenue (
    report_date DATE NOT NULL
  , channel     VARCHAR(50) NOT NULL
  , revenue     NUMERIC(12, 2) DEFAULT 0
  , order_count INT DEFAULT 0
  , created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  , PRIMARY KEY (report_date, channel)
);

and with --align-targets column_constraints, the reverse:

sqlalign --align-targets column_constraints table.sql
CREATE TABLE daily_revenue (
    report_date DATE       NOT NULL
  , channel VARCHAR(50)    NOT NULL
  , revenue NUMERIC(12, 2) DEFAULT 0
  , order_count INT        DEFAULT 0
  , created_at TIMESTAMP   DEFAULT CURRENT_TIMESTAMP
  , PRIMARY KEY (report_date, channel)
);

comma_position — trailing commas#

--comma-position trailing moves every separator comma to the end of the preceding line. The item column does not move, and no other alignment changes: only the comma travels.

sqlalign --comma-position trailing query.sql
SELECT cust.email,
       ord.total,
       ord.order_date
FROM customers               cust
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';

boolean_operator_position — trailing AND/OR#

--boolean-operator-position trailing moves AND/OR to the end of the line they follow. Conditions then start under the first condition rather than being pushed right by the connective.

sqlalign --boolean-operator-position trailing query.sql
SELECT cust.email
     , ord.total
     , ord.order_date
FROM customers               cust
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';

This setting and comma_position are independent, but the trailing preset sets both at once.

on_placement — drop ON below the table#

--on-placement own_line moves each join's ON off the table line and onto its own, right-aligned to end at column 5 like a WHERE. The alias column stays, and the conditions now stack down the left margin instead of hanging off the right.

sqlalign --on-placement own_line query.sql
SELECT cust.email
     , ord.total
     , ord.order_date
FROM customers               cust
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';

The condition operators still align across the whole block.

keyword_case — lowercase keywords#

--keyword-case lower lowercases keywords, function names and type names. No setting recases identifiers, aliases or string literals.

sqlalign --keyword-case lower query.sql
select cust.email
     , ord.total
     , ord.order_date
from customers               cust
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';

Type names go with them — and so do function names, so SUM(o.total) renders as sum(o.total) and COUNT(*) as count(*):

create table daily_revenue (
    report_date date           not null
  , channel     varchar(50)    not null
  , revenue     numeric(12, 2) default 0
  , order_count int            default 0
  , created_at  timestamp      default current_timestamp
  , primary key (report_date, channel)
);

Presets#

If one of the four presets is close enough, start there and override the rest. --preset NAME on the command line, or preset = "name" in a config file.

Preset Sets
house nothing — the default style on this page
compact align = false
trailing comma_position = "trailing", boolean_operator_position = "trailing"
dbt keyword_case = "lower", comma_position = "trailing", align = false

--preset dbt on the same query:

sqlalign --preset dbt query.sql
select cust.email,
       ord.total,
       ord.order_date
from customers cust
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';

Precedence runs built-in defaults, then preset, then config file, then command-line flags — so a flag always wins.

Two settings sqlalign has to choose for you#

--neq-style {!=,<>} and --decimal-style {NUMERIC,DECIMAL} exist because sqlglot's parser collapses each of those pairs to a single node, erasing which spelling you wrote. A spelling has to be picked when printing. These are the only two places sqlalign decides for you; everything the parser preserves is passed through as written.