Readable SQL is a house style, not a standard. The rules that survive contact with a review are simple: one major clause per line, a single keyword case, joins stacked instead of nested in a paragraph, and commas that do not jump sides mid-list. Paste a query into the SQL Formatter (or the PostgreSQL preset) to apply indent and keyword case in your browser. Schema names and literals never leave the tab.
Start from a blob you cannot read
A typical log line looks like this:
select u.id, u.email, o.total from users u join orders o on o.user_id = u.id where o.created_at > '2026-01-01' and u.status = 'active' order by o.total desc;
After format with 2-space indent and upper keywords, the same statement is a checklist you can review:
SELECT u.id, u.email, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE o.created_at > '2026-01-01' AND u.status = 'active' ORDER BY o.total DESC;
Nothing about the plan changed. Leading-comma versus trailing-comma is taste; mixing them in one list is the bug. The formatter's job is to make the clauses visible so you can see a missing join or a WHERE that filters the wrong alias.
Style rules that pay rent
- One clause per line.
SELECT,FROM,JOIN,WHERE,GROUP BY,HAVING,ORDER BY,LIMIT. Subqueries get their own indent level, not a new paragraph style. - Pick a keyword case and keep it. Upper is the default in most style guides because it separates keywords from identifiers. Lower is fine if the codebase already uses it. Do not uppercase table names: in PostgreSQL, unquoted identifiers fold to lowercase.
- Stack joins. Each
JOIN … ONon its own line, aligned underFROM. A join hidden in a 200-character line is how a Cartesian product ships. - Put each selected expression on its own line once the list is longer than three items. Alias every computed column (
AS total_cents). - Keep predicates in WHERE, not in ON, unless they are join keys. Filter predicates in
ONchange inner vs outer join meaning. Formatting will not catch that; seeing the lines will. - CTEs read top-down. Name them for the result, not for the table they started from. A chain of
WITH x AS (…), y AS (…)should be formatted as stacked blocks, eachAS (opening a new indent.
Dialect is not decoration
Standard SQL, PostgreSQL, MySQL, SQL Server, SQLite and BigQuery disagree about quoting and a handful of clauses. Dollar-quotes ($$ … $$) are PostgreSQL. [Identifier] is T-SQL. Backticks are MySQL and BigQuery. RETURNING and DISTINCT ON are PostgreSQL. If you format a Postgres function body as MySQL, the dollar-quote can be split and the statement becomes illegal. Choose the dialect that matches the log; the PostgreSQL formatter locks that choice for the common case.
Minify is the inverse: it collapses insignificant whitespace and leaves strings and comments byte-for-byte. Use it when a ticket template wants one line, not when you are reading. Compare two formatted versions in the Diff Checker rather than eyeballing log wraps.
What formatting will not fix
- A wrong join type or a filter in the wrong clause.
- Identifier case bugs in PostgreSQL:
"Users"andusersare different names. - Bind-parameter style.
$1,?and@p1stay as written. - An unclosed quote or comment. The formatter then shows a friendly parse error; fix that first, the same way you fix not well-formed XML.
House style documents (SQLFluff, pgFormatter, your team's wiki) can encode more rules: leading commas, 120-character wrap, reserved-word quoting. Use the browser formatter to get to a readable baseline, then apply the linter in CI. Do not paste production dumps that contain personal data into a server-side beautifier; use a local tool.
Next: format a query on the SQL Formatter, switch keyword case to Upper, then copy the result into a review. If you need identifiers renamed, the Case Converter sits next to it.
A longer example: CTE plus window
Window functions and common table expressions are where unformatted SQL becomes unreviewable. This is a compact original:
with ranked as (select u.id, u.email, o.total, row_number() over (partition by u.id order by o.total desc) as rn from users u join orders o on o.user_id = u.id where o.created_at > date '2026-01-01') select id, email, total from ranked where rn = 1 order by total desc;
The same query, formatted, shows the partition, the filter and the outer consume-step:
WITH ranked AS (
SELECT
u.id,
u.email,
o.total,
ROW_NUMBER() OVER (
PARTITION BY u.id
ORDER BY o.total DESC
) AS rn
FROM
users u
JOIN orders o ON o.user_id = u.id
WHERE
o.created_at > DATE '2026-01-01'
)
SELECT
id,
email,
total
FROM
ranked
WHERE
rn = 1
ORDER BY
total DESC;You can now see that rn = 1 is “latest order per user” only because ORDER BY o.total DESC is inside the window, not in the outer query. That distinction disappears in the one-line form. If the dialect is PostgreSQL, DATE '2026-01-01' and ROW_NUMBER() stay intact; a MySQL pass may still format it, but you should not rely on another dialect's keyword list when the production server is Postgres.
Comments deserve the same care as strings. A -- trailing comment must stay on its line after minify or it will eat the next clause. Block comments (/* … */) must close. The formatter treats both as opaque, which is what you want when the comment contains a copied plan or a ticket id.
If two environments disagree after a format, they disagreed before: key order does not apply to SQL, but identifier quoting and Unicode spaces do. Diff the formatted texts, not the log wraps, and keep the customer's literals off any server that is not the database.