The SQL you most need to read rarely arrives in a readable shape. It comes from a slow-query log, an ORM's debug output or a tracing span, flattened onto one line in whatever keyword case the author felt like. Before you can review it or work out why it's slow, you need to see its structure: which SELECT owns which WHERE, where each CTE ends, what each table is joined on.
This guide formats one realistic query a step at a time and explains the rule behind each change. Paste the same query in and format the SQL in your browser as you read. Every formatted block below is taken from the formatter's actual output with its default settings (Standard SQL, 2-space indent, uppercase keywords, trailing commas, Compact WHERE off) unless the text says otherwise.
The query
This is the kind of thing you copy out of a log. It has two CTEs, an inner and a left join, a CASE expression, a window function, an IN subquery and aggregates:
With recent_orders AS (select o.customer_id, o.total, o.created_at from orders o where o.created_at >= '2026-01-01' and o.status <> 'cancelled'), ranked as (SELECT r.customer_id, r.total, r.created_at, row_number() over (partition by r.customer_id order by r.total desc) as rn FROM recent_orders r) select c.id, c.name, sum(r.total) as total_spent, case when sum(r.total) > 10000 then 'gold' when sum(r.total) > 1000 then 'silver' else 'bronze' end as tier, max(case when r.rn = 1 then r.created_at end) as biggest_order_at from customers c join ranked r on r.customer_id = c.id left join regions g on g.id = c.region_id where c.id in (select customer_id from support_tickets where severity = 'high') and g.code is not null group by c.id, c.name order by total_spent desc;
On one line you can't answer basic questions. Does where severity = 'high' filter the outer query or the subquery? Where does ranked end? Five rules answer them.
Step 1: one keyword case
Uppercase keywords and lowercase identifiers split the text into two layers, the SQL language and your schema, so your eye can jump from SELECT to FROM to WHERE without reading the column names in between. The formatter uppercases every word it recognises as a keyword or built-in function (SUM, ROW_NUMBER) and leaves identifiers and string literals exactly as written.
Unticking Uppercase keywords keeps keywords in the case they were typed rather than lowercasing them, so it won't tidy a query that mixes With, select and SELECT. If your team prefers lowercase, write it that way and leave the option off.
The formatter decides what is a keyword from a word list, so a column named date or key gets uppercased too. Unquoted identifiers are case-insensitive in most databases, so the query means the same thing; SQL formatter vs SQL parser explains why a formatter can't tell the difference.
Step 2: one clause per line
Each clause keyword starts a line at the current nesting level, and what the clause contains is indented one step under it, one item per line. Joins sit at the clause level with their ON condition indented beneath, so each join reads as a unit: what is joined, then how.
FROM
customers c
JOIN ranked r
ON r.customer_id = c.id
LEFT JOIN regions g
ON g.id = c.region_id
With Compact WHERE off, every AND and OR in a WHERE clause (outside parentheses) starts its own line. Keep it that way for SQL that will be reviewed: one condition per line means a diff points at the condition that changed. Compact WHERE keeps them on one line, fine for a quick read.
Step 3: indent CTEs and subqueries as queries of their own
A subquery is a complete query, so it should look like one. When the formatter meets an opening parenthesis followed by SELECT, it lays out the contents as a statement one level deeper and puts the closing parenthesis back at the indent of the line that opened it. Nesting depth becomes indentation depth:
WITH recent_orders AS (
SELECT
o.customer_id,
o.total,
o.created_at
FROM
orders o
WHERE
o.created_at >= '2026-01-01'
AND o.status <> 'cancelled'
), ranked AS (
The IN subquery gets the same treatment, and now severity = 'high' visibly belongs to support_tickets, not to the outer query:
WHERE
c.id IN (
SELECT
customer_id
FROM
support_tickets
WHERE
severity = 'high'
)
AND g.code IS NOT NULL
Step 4: CASE expressions and window functions
A CASE expression in the select list gets one line per branch, indented under CASE, with END and its alias back at the level where the CASE started:
CASE
WHEN SUM(r.total) > 10000 THEN 'gold'
WHEN SUM(r.total) > 1000 THEN 'silver'
ELSE 'bronze'
END AS tier,
MAX(CASE WHEN r.rn = 1 THEN r.created_at END) AS biggest_order_at
The last line shows the other half of the rule: anything inside parentheses that isn't a subquery (function calls, IN lists, window definitions) stays on one line. That keeps MAX(CASE … END) compact, and it's why the window function comes out as ROW_NUMBER() OVER (PARTITION BY r.customer_id ORDER BY r.total DESC) AS rn. For a short window that's the right call. Once a window has several partition or sort columns, break it by hand (this is recommended layout, not tool output):
ROW_NUMBER() OVER (
PARTITION BY r.customer_id, r.region_id
ORDER BY r.total DESC, r.created_at
) AS rn
Formatting again folds this back onto one line, so don't reformat a hand-tuned query wholesale.
Step 5: choose a comma style and keep it
Trailing commas, the default, read like prose. Untick Trailing commas and each item starts with its comma instead:
SELECT
c.id
, c.name
, SUM(r.total) AS total_spent
The difference shows up in diffs. Say the query gains c.email, which must be added to the GROUP BY as well. Appending it with trailing commas touches two lines, because the old last item needs a comma:
GROUP BY
c.id,
- c.name
+ c.name,
+ c.email
With leading commas the same change is a single added line, + , c.email. The trade flips at the top of a list, where inserting a new first item touches two lines. Leading commas also let you comment out the last item without leaving a dangling comma. Neither style is wrong; mixing them in one codebase is.
Format before code review and before diffing
Here is the whole query, formatted:
WITH recent_orders AS (
SELECT
o.customer_id,
o.total,
o.created_at
FROM
orders o
WHERE
o.created_at >= '2026-01-01'
AND o.status <> 'cancelled'
), ranked AS (
SELECT
r.customer_id,
r.total,
r.created_at,
ROW_NUMBER() OVER (PARTITION BY r.customer_id ORDER BY r.total DESC) AS rn
FROM
recent_orders r
)
SELECT
c.id,
c.name,
SUM(r.total) AS total_spent,
CASE
WHEN SUM(r.total) > 10000 THEN 'gold'
WHEN SUM(r.total) > 1000 THEN 'silver'
ELSE 'bronze'
END AS tier,
MAX(CASE WHEN r.rn = 1 THEN r.created_at END) AS biggest_order_at
FROM
customers c
JOIN ranked r
ON r.customer_id = c.id
LEFT JOIN regions g
ON g.id = c.region_id
WHERE
c.id IN (
SELECT
customer_id
FROM
support_tickets
WHERE
severity = 'high'
)
AND g.code IS NOT NULL
GROUP BY
c.id,
c.name
ORDER BY
total_spent DESC;
Laid out like this, a reviewer can spot what the one-liner hid: regions is LEFT JOINed, but WHERE requires g.code IS NOT NULL. A customer with no region gets a NULL g.code and is filtered out, so the query returns exactly the rows a plain JOIN would. Either the join type or the filter is wrong, and readability rules for joins and subqueries covers where filters on an outer-joined table belong.
Layout also makes diffs useful. If two versions of this query are each on one line, any change shows up as one changed line holding the entire query. Format both with identical settings, paste them into the diff checker, and a change from severity = 'high' to severity IN ('high', 'critical') becomes a small, located hunk:
WHERE
- severity = 'high'
+ severity IN ('high', 'critical')
)
Use the same options on both sides; diffing an uppercase, trailing-comma version against a leading-comma one buries the real change under style noise. Better still, commit SQL already formatted so future diffs stay this small.
What the formatter leaves to you
- Long lines. There is no line-width setting. A join with several conditions keeps them all on its
ONline, so break long ones by hand. - BETWEEN. In a WHERE clause, the
ANDinBETWEEN 100 AND 500gets its own line like a boolean AND. The query is unchanged, but it reads like an extra condition; Compact WHERE avoids it. - End-of-line comments. Comments are kept, but with trailing commas a
-- notewritten afterid,lands on the next line, above the following column. Put comments on their own line above what they describe.
Next steps
Paste your own query into the SQL formatter, agree on keyword case, comma style and the Compact WHERE setting with your team, and use those settings everywhere. Then look past layout: readability rules for joins and subqueries covers the choices no formatter makes for you.