SQL Formatting Standards
SQL is read far more often than written, usually by someone else, usually under time pressure. Formatting is cheap insurance.
SQL has an unusual property: it is read vastly more often than it is written, and the reader is frequently not the author and frequently in a hurry. A query written once during a feature launch gets read during an incident eight months later by someone with no context. Unformatted SQL makes that reading actively error-prone.
There is also a mechanical benefit that pays off immediately in code review.
Why SQL formatting matters more than most code
Version control diffs are line-based. In a single-line query, editing one predicate rewrites the entire line, so the diff shows everything as changed and a reviewer cannot see what you actually did. In formatted SQL, one clause per line means one changed line.
- select u.id,u.email,count(o.id) as c from users u left join orders o on o.user_id=u.id where u.active=true and o.status='paid' group by u.id,u.email having count(o.id)>3 order by c desc limit 50;
+ select u.id,u.email,count(o.id) as c from users u left join orders o on o.user_id=u.id where u.active=true and o.status='shipped' group by u.id,u.email having count(o.id)>3 order by c desc limit 50;
Can you see the change? Neither can your reviewer. Now the same change, formatted:
WHERE u.active = true
- AND o.status = 'paid'
+ AND o.status = 'shipped'
Obvious. This is the single strongest argument for formatting SQL, and it applies to every query you will ever commit.
Before and after
-- Before
select u.id,u.email,count(o.id) as order_count,sum(o.total_cents)/100 as revenue from users u left join orders o on o.user_id=u.id and o.status<>'cancelled' where u.created_at>='2026-01-01' and u.deleted_at is null group by u.id,u.email having count(o.id)>3 order by revenue desc limit 50;
-- After
SELECT
u.id,
u.email,
COUNT(o.id) AS order_count,
SUM(o.total_cents) / 100 AS revenue
FROM users u
LEFT JOIN orders o
ON o.user_id = u.id
AND o.status <> 'cancelled'
WHERE u.created_at >= '2026-01-01'
AND u.deleted_at IS NULL
GROUP BY u.id, u.email
HAVING COUNT(o.id) > 3
ORDER BY revenue DESC
LIMIT 50;
The second version takes more vertical space and is unambiguously better. You can see
that o.status <> 'cancelled' belongs to the join rather than the
filter — which, as described below, is a real semantic distinction and not merely a
stylistic one.
The conventions
Uppercase keywords, lowercase identifiers
SELECT u.id FROM users u WHERE u.active = true;
This makes structure legible without syntax highlighting — in a review comment, a chat message, or a terminal.
One major clause per line, contents indented one level
SELECT, FROM, WHERE, GROUP BY,
HAVING, ORDER BY, LIMIT each start a line. Their
contents are indented. This is the highest-value rule on this page.
Alias every table, qualify every column
-- Unclear six months from now
SELECT id, email, created_at FROM users JOIN orders ON ...
-- Explicit
SELECT u.id, u.email, o.created_at
FROM users u
JOIN orders o ON o.user_id = u.id
Unqualified columns break with an ambiguity error the moment a matching column is added to the second table. Qualifying costs nothing.
Split join conditions onto their own lines
LEFT JOIN orders o
ON o.user_id = u.id
AND o.status = 'paid'
This is what makes the join-versus-filter distinction visible — and that distinction is a correctness issue, not aesthetics.
Trailing commas
SELECT
u.id,
u.email,
COUNT(o.id) AS order_count
FROM users u
Standard SQL does not permit a trailing comma before FROM, so commas go
at line end. This also keeps diffs additive: adding a column adds one line.
Align comparison operators within a clause
WHERE u.created_at >= '2026-01-01'
AND u.deleted_at IS NULL
AND u.plan <> 'free'
Optional, and it costs time to maintain — but it makes predicate lists genuinely
scannable in long WHERE clauses. Many teams skip this one; it is the least
important rule here.
The join mistake formatting prevents
This is the most common SQL bug in practice, and formatting makes it visible.
-- Intent: all users, plus their paid orders if any
SELECT u.id, o.id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'; -- BUG
-- What actually happens: the WHERE clause filters out every row where
-- o.status is NULL, which is exactly the users with no matching order.
-- The LEFT JOIN has silently become an INNER JOIN.
-- Correct: the condition belongs to the join
SELECT u.id, o.id
FROM users u
LEFT JOIN orders o
ON o.user_id = u.id
AND o.status = 'paid';
When join conditions sit on their own indented lines under ON, a stray
condition in WHERE stands out immediately. When everything is on one line,
it does not. This is the concrete argument for the indentation rule — it is not
decoration.
Formatting CTEs
Common table expressions dramatically improve long queries, and they format consistently:
WITH active_users AS (
SELECT id, email, created_at
FROM users
WHERE deleted_at IS NULL
AND last_seen_at >= CURRENT_DATE - INTERVAL '30 days'
),
user_totals AS (
SELECT
user_id,
COUNT(*) AS order_count,
SUM(total_cents) AS revenue_cents
FROM orders
WHERE status = 'paid'
GROUP BY user_id
)
SELECT
au.id,
au.email,
COALESCE(ut.order_count, 0) AS order_count,
COALESCE(ut.revenue_cents, 0) AS revenue_cents
FROM active_users au
LEFT JOIN user_totals ut ON ut.user_id = au.id
ORDER BY revenue_cents DESC;
Name CTEs for what they are (active_users), not what they do
(filtered_data). Indent the body one level, close the parenthesis on its own
line, and separate CTEs with a blank line.
Formatting is not performance
A beautifully formatted query can still be catastrophically slow. Formatting improves
readability and says nothing about execution — that depends on the query plan, which you
inspect with EXPLAIN and EXPLAIN ANALYZE.
The structural checks with the highest impact:
- Indexes supporting every
WHEREpredicate andJOINkey. Missing indexes are the most common cause of slow queries by far. - Select only the columns you need.
SELECT *prevents index-only scans, transfers unnecessary data, and breaks callers when the schema changes. - Never wrap an indexed column in a function.
WHERE DATE(created_at) = '2026-09-18'makes the index unusable; write a range comparison instead:WHERE created_at >= '2026-09-18' AND created_at < '2026-09-19'. - Leading wildcards cannot use a B-tree index.
LIKE '%term'forces a full scan; use a full-text index or trigram index if you need substring search at scale. - Check the join order the planner chose. An unexpected join order
usually means stale statistics — run
ANALYZE.
Making it stick on a team
A style guide nobody follows is worse than no style guide, because it creates an unspoken expectation that is violated constantly. Three things make it real:
- Automate it. A formatter in CI —
sqlfluff,pgFormatter, or a language-specific equivalent — removes the debate entirely. Formatting is exactly the kind of decision machines should make. - Never mix formatting and logic in one commit. Run the formatter as its own commit. Otherwise a reviewer cannot tell which changes are real, and the whole benefit of readable diffs is lost.
- Put the conventions in the repository. A short
SQL_STYLE.mdnext to the code, referenced from the review checklist, is enough. It does not need to be long — the six conventions above cover most of it.
Start with just two rules if adopting nothing else: one major clause per
line, and join conditions on their own lines under
ON. Those two deliver most of the value and catch the most common
bug.
Our SQL Formatter applies these conventions automatically if you want a quick starting point — but read the output before committing it, since SQL dialects vary and no formatter understands your intent.
Frequently asked questions
No. Whitespace is insignificant to the parser, so formatting affects readability only. Execution depends on the query plan, which you inspect with EXPLAIN.
Uppercase is the dominant convention and makes structure legible without syntax highlighting. The more important thing is consistency within a codebase — pick one and enforce it automatically.
A condition on the right-hand table placed in WHERE filters out the NULL-extended rows, turning the outer join into an inner join. Move the condition into the ON clause.
Decompose with CTEs. A 200-line query is usually several named steps that should be separate CTEs — this improves both readability and often the query plan, since the planner can optimise each part.
In production code, yes: it prevents index-only scans, transfers columns you do not need, and silently breaks callers when a column is added or reordered. It is fine for ad-hoc exploration.
Related guides
How AI Token Counting Works
A token is not a word, and the difference between the two is where most LLM cost estimates go wrong. Here is how tokenizers actually split your text.
LLM API Cost Compared
Published per-million-token prices are only half the equation. Tokenizer differences, caching, batching and reasoning tokens change the real number.
JWT Explained
A JWT is three Base64 segments and a signature. Understanding what the signature does — and does not — prevent is the whole game.