sqlalign GitHub

Commit your team's style once instead of passing flags on every run. Put a .sqlalign.toml at the root of your repo, or a [tool.sqlalign] table in your pyproject.toml, and every sqlalign invocation under that directory picks it up.

# .sqlalign.toml
preset         = "trailing"
width          = 100
keyword_case   = "upper"
exclude        = ["vendor/*", "*.gen.sql"]
sqlalign --show-config models/orders.sql   # what will actually be applied
sqlalign --check .                         # CI gate, using the committed config

This page covers where sqlalign looks for that file, how it merges with presets and flags, every key you can put in it, and what happens when you get it wrong. For the flags themselves see the CLI reference; for what each setting does to your SQL see the style guide.

Where sqlalign looks#

sqlalign resolves a config per file, by walking up from the directory of the file it is about to format. At each directory it takes the first of:

Order File Shape
1 .sqlalign.toml keys at the top level
2 pyproject.toml keys under a [tool.sqlalign] table

The first directory that has either one wins, and it stops there — settings are not merged across levels. A pyproject.toml without a [tool.sqlalign] table is skipped entirely, so a repo that already has a pyproject.toml for other tools is unaffected until you add the table.

Two consequences worth knowing:

Both file forms accept the same keys, and the two forms mean the same thing:

# .sqlalign.toml
comma_position = "trailing"
# pyproject.toml
[tool.sqlalign]
comma_position = "trailing"

Precedence#

Settings compose in this order, each layer winning over the one before it:

built-in defaults  <  preset  <  config file  <  command-line flags

A preset only supplies a base. Any key you set explicitly — in the config file or on the command line — layers on top of it, so preset plus an override means both, not one or the other:

preset         = "compact"    # base: no alignment padding
comma_position = "trailing"   # ...and trailing commas on top

Two details follow from "a preset is only a base":

A flag you do not pass never overrides the config file. sqlalign distinguishes "not passed" from "passed the value that happens to be the default", so --comma-position leading forces leading commas even when the config says trailing, while omitting the flag leaves the config's choice alone.

Key reference#

These are the complete set of keys a config file accepts. Anything else is an error (see Unknown keys).

Key Type Default What it does
preset "house" | "compact" | "trailing" | "dbt" none Named starting point. Every other key layers on top of it.
width integer 100 Target line width for wrapping decisions. A target, not a hard cap — see below.
align boolean true Master alignment switch. false emits one space between tokens and keeps the same line structure.
align_targets list of strings all six Which alignment columns are padded: aliases, operators, join_conditions, case_results, column_types, column_constraints. Anything omitted collapses to a single space.
comma_position "leading" | "trailing" "leading" Where the separator comma sits in a stacked list.
boolean_operator_position "leading" | "trailing" "leading" Where AND/OR sit when a predicate spans lines.
on_placement "inline" | "own_line" "inline" Whether a JOIN's ON rides the table line or drops below it.
select_placement "inline" | "own_line" "inline" Whether the first select item rides the SELECT line or the list starts below it.
select_indent integer 2 Columns the select list indents when it starts below SELECT. Ignored when inline.
clause_keyword_align "left" | "river" "left" Root clause keywords flush left, or right-aligned so their last character lands on the gutter.
river_gutter integer 6 The column a river aligns them to. 6 is the width of SELECT.
table_alias_style "bare" | "as" "bare" FROM orders o or FROM orders AS o. sqlglot destroys the distinction at parse time, so sqlalign must pick one.
keyword_case "upper" | "lower" "upper" Case for keywords, function names and type names. Your identifiers and string literals are never touched.
neq_style "!=" | "<>" "!=" Spelling for the not-equal operator.
decimal_style "NUMERIC" | "DECIMAL" "NUMERIC" Spelling for the NUMERIC/DECIMAL type.
format_dollar_bodies boolean true Format inside dollar-quoted ($$) procedure and function bodies. false passes the whole CREATE FUNCTION through byte-identical.
protect_templating boolean true Mask Jinja/dbt template expressions ({{ }}, {% %}, {# #}) before formatting so a templated model can be formatted at all.
blank_lines_between_statements integer, or unset unset Force N blank lines between every pair of statements. Unset means the house rule: one blank line between two multi-line statements, none otherwise.
exclude list of glob strings (a bare string is also accepted) none Skip matching files when a directory is expanded. Selects files, not style — see Excluding files.

neq_style and decimal_style exist because the parser collapses each of those pairs to a single node, so a spelling has to be chosen when printing. They are the only two places sqlalign picks for you.

Not config keys#

--dialect and --line-ending are command-line only. Putting dialect in a config file is a hard error:

sqlalign: /home/you/warehouse/sqlalign.toml: unknown setting(s) ['dialect']; valid: ['align', 'align_targets', ...]

(the real message lists every valid key; elided here for width).

width is a target, not a limit#

width drives wrapping decisions, but a construct anchored deep in an indent is allowed some room past it rather than being shredded. On the multi-join sample, --width 60 produces output byte-identical to the default --width 100, whose longest line is 81 characters. Lower width when you want more aggressive wrapping; do not treat it as a hard maximum your lines cannot cross.

on_placement = "own_line" retires the ON column#

With own_line, the ON drops below the table reference (FROM block shown):

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'

There is no longer an ON sitting after each alias, so the join_conditions target has nothing to act on. Set on_placement = "own_line" and join_conditions together and only the first has an effect.

Presets#

A preset is one word instead of a handful of keys. Pick the one closest to your team's existing style and override the rest.

Preset Sets For
house nothing — the built-in defaults Teams adopting sqlalign's columnar style as-is: aligned, leading separators, ON inline, uppercase keywords.
compact align = false Teams that want sqlalign's line structure without the padding. 9 of 10 published SQL style guides produce unpadded output, so this is the widest-reach starting point.
trailing comma_position = "trailing", boolean_operator_position = "trailing" Teams that want the alignment but write trailing commas (8 of 10 guides) and trailing booleans (7 of 10).
dbt keyword_case = "lower", comma_position = "trailing", align = false dbt and analytics-engineering repos.

Everything a preset does not set stays at the built-in default — dbt leaves boolean_operator_position at leading, for instance. Run sqlalign --isolated --preset NAME --show-config file.sql to see the full picture for any of them.

The house, compact and trailing blocks below are all the same input query, formatted under each preset; dbt uses a CTE query so its one deviation is visible.

house#

-- #13: multi-character aliases (mixed lengths)
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';

compact#

Same line structure, every run of padding collapsed to one space.

-- #13: multi-character aliases (mixed lengths)
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';

trailing#

Alignment intact; both separators move to the end of the line.

-- #13: multi-character aliases (mixed lengths)
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';

dbt, and its one deviation#

-- #6: CTEs
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 deviation: dbt's own style guide indents a nested block 4 spaces. sqlalign indents a CTE body 2, as you can see above. sqlalign's indent literals are three different concepts internally rather than one knob, so there is no setting that changes this. If your dbt project enforces 4-space CTE bodies in review, this preset will fight you on exactly that one point and nothing else.

Unknown keys are an error#

A key sqlalign does not recognise stops the run. The file is left untouched and the exit code is 2:

$ sqlalign q.sql
sqlalign: /home/you/warehouse/.sqlalign.toml: unknown setting(s) ['comma_postion']; 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']

This is deliberate, and it is the opposite of what most tools do. A typo in a committed config that is silently ignored means a whole team believes it has a setting it does not have, and finds out from a surprising diff months later. A failure at the moment you commit the typo costs you thirty seconds; a silent no-op costs you the trust you had in the file.

The same strictness applies to values, not just key names. Every one of these exits 2 without touching a file:

Config Error
comma_position = "sideways" comma_position must be 'leading' or 'trailing', got 'sideways'
width = "wide" width must be an integer, got 'wide'
align_targets = ["aliases", "typo"] unknown align_targets ['typo']; valid: ['aliases', 'case_results', 'column_constraints', 'column_types', 'join_conditions', 'operators']
preset = "nice" unknown preset 'nice'; valid: ['compact', 'dbt', 'house', 'trailing']
malformed TOML invalid TOML: ...

--no-strict-config#

Pass --no-strict-config to downgrade the unknown-key failure to a warning. The unrecognised key is dropped and formatting proceeds:

$ sqlalign --no-strict-config --stdout q.sql
sqlalign: /home/you/warehouse/.sqlalign.toml: unknown setting(s) ['comma_postion']; valid: [...]
SELECT a
     , b
FROM t
WHERE a = 1
  AND b = 2;

Use it when one config file is shared with a newer sqlalign that knows keys your version does not — a mixed-version team, or a rollout in progress. Do not use it as a default in CI: that gives you the silent no-op the strict behaviour exists to prevent. Note that it only relaxes unknown keys; a bad value is still a hard error either way.

Inspecting what will be applied#

--show-config prints the effective settings and the file they came from, then exits without formatting anything:

$ sqlalign --show-config models/orders.sql
# /home/you/warehouse/.sqlalign.toml
width = 100
align = true
align_targets = ["aliases", "join_conditions", "operators"]
comma_position = "trailing"
boolean_operator_position = "trailing"
on_placement = "inline"
select_placement = "inline"
select_indent = 2
clause_keyword_align = "left"
river_gutter = 6
format_dollar_bodies = true
neq_style = "!="
decimal_style = "NUMERIC"
table_alias_style = "bare"
keyword_case = "upper"
protect_templating = true
# blank_lines_between_statements is unset: one blank line between two
# multi-line statements, none otherwise. Set an integer to force a count.

The body is valid TOML you can paste straight into a .sqlalign.toml — that round trip is a tested guarantee, which is why an unset blank_lines_between_statements is emitted commented out rather than as something with no TOML spelling.

Three things to know about it:

Overriding discovery#

Flag Effect
--config PATH Use this file instead of discovering one. Any filename works — it does not have to be called .sqlalign.toml. A pyproject.toml passed this way is still read from its [tool.sqlalign] table.
--isolated Ignore every config file and start from the built-in defaults. Presets and flags still apply, so --isolated --preset dbt is meaningful.

--config pointing at a file that does not exist is an error, not a fallback to discovery:

$ sqlalign --config /tmp/nope.toml --check q.sql
sqlalign: /tmp/nope.toml: [Errno 2] No such file or directory: '/tmp/nope.toml'

Reach for --isolated when you want to prove what sqlalign does with no local influence — reproducing a bug report, or checking whether a config is the reason for a diff you did not expect.

Excluding files#

exclude skips files when you hand sqlalign a directory. It selects files rather than style, so it never reaches the formatter.

exclude = ["vendor/*", "*.gen.sql"]

Given that config and a tree holding models/orders.sql, models/build.gen.sql, vendor/v.sql and vendor/deep/d.sql, only the first is considered:

$ sqlalign --check .
would reformat models/orders.sql

The rules:

A bare string is accepted where you would write a one-element list, so exclude = "*.gen.sql" works.

A realistic committed config#

This is the shape most repos end up with: a preset, two or three deliberate overrides, and the excludes.

# .sqlalign.toml — committed at the repo root
#
# Style is `trailing`: sqlalign's columnar alignment, but with the commas and
# AND/OR at end of line, which is what our existing SQL already does.
preset        = "trailing"

# Alignment we actually want. `case_results`, `column_types` and
# `column_constraints` are left out, so those collapse to single spaces.
align_targets = ["aliases", "operators", "join_conditions"]

width         = 100

# Generated and third-party SQL is not ours to restyle.
exclude       = ["vendor/*", "*.gen.sql"]

Verify it before you commit:

$ sqlalign --show-config models/orders.sql
# /home/you/warehouse/.sqlalign.toml
width = 100
align = true
align_targets = ["aliases", "join_conditions", "operators"]
comma_position = "trailing"
boolean_operator_position = "trailing"
on_placement = "inline"
select_placement = "inline"
select_indent = 2
clause_keyword_align = "left"
river_gutter = 6
format_dollar_bodies = true
neq_style = "!="
decimal_style = "NUMERIC"
table_alias_style = "bare"
keyword_case = "upper"
protect_templating = true
# blank_lines_between_statements is unset: one blank line between two
# multi-line statements, none otherwise. Set an integer to force a count.

And what it produces:

-- #13: multi-character aliases (mixed lengths)
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';

Then gate it in CI with sqlalign --check ., which writes nothing and exits 1 if any file would change.

Staging adoption across a legacy repo#

Two keys exist for repos you cannot reformat all at once:

Error reference#

Every config failure exits 2 and prints to stderr. A broken config never gets guessed past: the file it applies to is left untouched, but because resolution is per file, other files in the same run still process. Formatting two files where only one has a broken config gives you the good file's output, the error on stderr, and exit 2.

Message Cause Fix
unknown setting(s) [...]; valid: [...] A key sqlalign does not recognise Fix the spelling, or --no-strict-config for a mixed-version rollout
<key> must be ... A valid key with an invalid value Use one of the listed values
width must be an integer, got ... width given a string or float Write it unquoted: width = 100
align_targets must be a list, got ... align_targets given a non-list Write it as a TOML array
unknown align_targets [...] A misspelled target name Use one of the six listed
unknown preset '...' A misspelled preset house, compact, trailing, dbt
blank_lines_between_statements must be a non-negative integer or unset, got ... A negative or non-integer count Use 0 or more, or delete the key
invalid TOML: ... The file does not parse Fix the syntax
<path>: [Errno 2] No such file or directory --config points at nothing Correct the path
exclude must be a list of glob patterns exclude given a non-string, non-list Write a string or an array of strings

exclude is the one exception to per-file recovery. It is read while expanding the directories you named, before any file is formatted, so a bad exclude aborts the whole run with an argparse-style sqlalign: error: ... and a usage banner rather than a per-file message.