Database guide

How to Format SQL Queries So They Can Be Reviewed

Reading a query by clause structure rather than line order, dialect-sensitive identifiers and case folding, a layout that survives code review, and the class of bugs formatting exposes — plus the performance questions it cannot answer.

Formatting a query is not decoration. A one-line statement out of an ORM log and the same statement with its clauses stacked contain identical information, but only one of them can be reviewed by a person. This guide is about what the stacked version lets you see: the evaluation order that explains half of SQL's error messages, the quoting rules that differ per dialect, and the small set of defects that become obvious the moment the clauses line up.

A query is not evaluated in the order it is written

SQL is written SELECT first, but it is evaluated FROM first. The logical order is FROM and its joins, then WHERE, then GROUP BY, then HAVING, then window functions, then SELECT, then DISTINCT, then ORDER BY, then LIMIT and OFFSET. The database is free to execute things in any order that produces the same answer, but the logical order is what defines the meaning, and it explains a surprising share of the errors people hit.

It explains why a column alias defined in SELECT cannot be used in WHERE: at the time WHERE is evaluated, SELECT has not run and the alias does not exist. It explains why the same alias usually can be used in ORDER BY, which runs after SELECT. It explains why HAVING can filter on an aggregate and WHERE cannot, and why moving a predicate from HAVING to WHERE can change the result rather than merely optimising it — WHERE removes rows before grouping, HAVING removes groups after.

Formatting matters here because indentation makes the clause boundaries visible, and the clause boundaries are the evaluation steps. Once each keyword starts a line and its arguments are indented beneath it, reading a query top to bottom means walking the logical pipeline. That is the actual reason to format: not neatness, but the ability to see which stage each expression belongs to.

This also gives you a reading strategy for an unfamiliar query. Start at FROM and build up the row set: what is the driving table, what does each join add or filter, what does WHERE remove, what does GROUP BY collapse. Only then read SELECT, which is the last transformation rather than the first.

A layout that survives code review

The conventions that have survived are the ones that make diffs small and mistakes visible. Put each major clause keyword at the start of a line. Indent its arguments one level. Give each join its own line with its ON condition attached, and each AND in a WHERE clause its own line. Nothing here is aesthetic preference — every rule makes a specific class of change show up as a one-line diff rather than a reflowed block.

Leading commas are worth the initial discomfort. With a comma at the end of each line, adding a column at the bottom of a SELECT list touches two lines: the new one and the previous one that now needs a comma. With the comma at the start of each line, it touches one. The same applies to commenting out a column during debugging, which is the operation you actually perform at three in the morning. Teams split on this, and the only wrong answer is to switch conventions halfway through a file.

Indentation depth should track nesting depth and nothing else. A subquery or CTE body indented one level inside its parent reads correctly; a subquery aligned to an arbitrary column position becomes unreadable the moment someone renames a table. Prefer common table expressions over nested subqueries when a query grows past a screen, because a named CTE gives each stage a label and turns a pyramid into a list.

Finally, run the formatter as part of the workflow rather than as a one-off cleanup. A migration file that is formatted once and then edited by hand drifts, and the next formatting run produces a diff that mixes real changes with reflowed whitespace. Format on save or in a pre-commit hook and the problem does not arise.

The same query, laid out for review
-- Hard to review: clause boundaries are invisible.
select c.id, c.name, count(o.id) orders, sum(o.total) revenue from customers c
left join orders o on o.customer_id = c.id and o.status = 'paid' where c.region
in ('eu','uk') and c.created_at >= '2026-01-01' group by c.id, c.name having
count(o.id) > 0 order by revenue desc limit 25;

-- Reviewable: one clause per line, leading commas, joins carrying their own ON.
SELECT
    c.id
  , c.name
  , COUNT(o.id)  AS orders
  , SUM(o.total) AS revenue
FROM customers c
LEFT JOIN orders o
       ON o.customer_id = c.id
      AND o.status = 'paid'
WHERE c.region IN ('eu', 'uk')
  AND c.created_at >= '2026-01-01'
GROUP BY
    c.id
  , c.name
HAVING COUNT(o.id) > 0
ORDER BY revenue DESC
LIMIT 25;

Identifiers, quoting and case folding

Identifier handling is where a formatter can genuinely break a query, and it differs by dialect in two independent ways: which character quotes an identifier, and what happens to an unquoted one. The second is the dangerous half and it is the half people forget.

The SQL standard says an unquoted identifier folds to uppercase, and Oracle and DB2 follow it. PostgreSQL folds to lowercase instead, which is a documented deviation and by far the most common in practice. The practical effect is that in PostgreSQL the identifier userId, written without quotes, becomes userid — and if the column was created as "userId" with quotes, the two no longer refer to the same thing. This is the origin of the ORM-generated query that works in one environment and fails in another.

MySQL is different again: column and alias names are case-insensitive, while table name sensitivity depends on lower_case_table_names and, underneath that, on whether the filesystem is case-sensitive. The same schema can behave one way on a developer's macOS laptop and another way on a Linux server. SQL Server defers to the collation of the database, which is usually case-insensitive but need not be.

The rule that follows is simple: a formatter may change the case of keywords, and must never change the case of anything inside identifier quotes. When you check a formatted query, the quoted identifiers and the string literals are the two things to compare character by character against the original. Everything else is whitespace.

The middle column is the one that causes silent failures. It describes what happens to an identifier written without quotes.

DialectUnquoted identifier folds toIdentifier quoteNotes
PostgreSQLlowercase"order"A quoted identifier is case-sensitive and must match exactly
Oracle / DB2uppercase"ORDER"Follows the SQL standard; quoted names are usually all caps
MySQL / MariaDBno folding; columns compare case-insensitively`order`Double quotes are string literals unless ANSI_QUOTES is set
SQL Serverdepends on the database collation[order]Also accepts double quotes when QUOTED_IDENTIFIER is on
SQLiteno folding; compares case-insensitively for ASCII"order"Also tolerates backticks and brackets for compatibility

Keyword casing is a convention, and only a convention

SQL keywords are case-insensitive in every major dialect, so SELECT, select and SeLeCt are identical to the parser. The near-universal convention of uppercase keywords against lowercase identifiers exists because it gives the eye a second signal: clause boundaries can be found by scanning for capitals, without parsing the line.

A minority style keeps everything lowercase on the grounds that shouting is unnecessary in a world with syntax highlighting, and it is perfectly defensible. What is not defensible is mixing both in one codebase, because then capitalisation carries no information at all and every diff includes noise. Pick one, put it in the formatter configuration, and let the tool enforce it.

One caution: keyword casing settings in some formatters also touch built-in function names and data types, and a few of them will happily uppercase an identifier that happens to collide with a reserved word. If your schema contains a column named order, value or user, check those specifically after the first formatting run.

What stacked clauses reveal

The payoff for formatting is a small set of defects that are nearly invisible in a single line and nearly obvious once the clauses are stacked. The most common is an outer join quietly turned into an inner one. A LEFT JOIN preserves rows from the left table with NULLs on the right; a predicate on a right-hand column in the WHERE clause then discards exactly those rows, because NULL does not satisfy an equality. The query still says LEFT JOIN, and it no longer behaves like one. Moving the predicate into the ON clause restores the intent.

Once clauses line up, several other patterns become scannable. A comma-separated FROM list with a missing join predicate is a cross join, and stacking the tables makes the missing condition an empty line rather than a hidden omission. A correlated subquery inside SELECT sits at its own indentation level, which is where you notice it runs once per output row. A NOT IN against a subquery that can return NULL returns no rows at all, which is a NULL-semantics trap that reads fine in prose and is visible when the subquery is broken out.

Formatting also makes aggregation errors visible. Every non-aggregated column in SELECT must appear in GROUP BY, and when both lists are stacked one under the other they can be compared at a glance. PostgreSQL rejects a mismatch; MySQL historically accepted it and returned an arbitrary row from each group, which is a correctness bug that no error message will report.

None of this is the formatter finding bugs. The formatter only makes the structure visible; the reader finds the bugs. That is exactly why formatting belongs before review rather than after it.

The LEFT JOIN that is not a LEFT JOIN
-- Intent: every customer, with their paid orders if any.
-- Actual: customers with no paid order are dropped, because
-- o.status is NULL for them and NULL = 'paid' is never true.
SELECT c.id, c.name, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';

-- Correct: the condition belongs to the join, not to the row filter.
SELECT c.id, c.name, o.total
FROM customers c
LEFT JOIN orders o
       ON o.customer_id = c.id
      AND o.status = 'paid';

-- The deliberate anti-join is the one case where the WHERE form is right:
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

What formatting cannot tell you about performance

A formatted query is easier to reason about, and that is all. Formatting contains no information about table sizes, index definitions, statistics, data distribution or the plan the optimiser will choose, so it cannot make any query faster and cannot predict which query is slow. The only tool that answers those questions is EXPLAIN, and preferably EXPLAIN ANALYZE against realistic data volumes, because a plan chosen for a thousand rows is frequently the wrong plan for ten million.

There is a specific trap here: formatted queries look competent, and competence is easy to mistake for efficiency. A neatly stacked five-way join with a correlated subquery in the select list is still a correlated subquery in the select list. The query that is actually slow is usually the one whose predicate cannot use an index — a function wrapped around a column, a leading wildcard in a LIKE, an implicit type cast between a bigint column and a string parameter, or an OR across two tables that prevents index intersection. None of those look wrong when formatted.

The honest division of labour is that formatting serves correctness review and readability, while EXPLAIN serves performance. Doing the first does not reduce the need for the second. If a query matters enough to format carefully, it usually matters enough to look at its plan.

One last note on minification, which is the same tool in reverse. Collapsing a query to one line does not make the database parse it faster in any measurable way; parse time is negligible beside planning and execution, and most drivers cache prepared statements anyway. Minify when a query has to live inside a string literal or a single log field, and keep the formatted version in the repository.

  • Format before review, not after — the point is to let a human see the clause structure.
  • Compare quoted identifiers and string literals against the original after any reformat; everything else is whitespace.
  • Treat a WHERE predicate on the right-hand table of a LEFT JOIN as a bug until proven otherwise.
  • Set the dialect to the database that will run the statement, not to the one you use most often.
  • Diff two formatted versions with the Text Compare tool rather than reading them side by side.
  • When a query has to be embedded in application code, escape it once with the Escape tool rather than hand-editing the quotes.

What to remember

  • Read a query in its logical order — FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY — because that order explains most of SQL's error messages.
  • A formatter may change keyword case but must never change the case of a quoted identifier, and PostgreSQL folding unquoted names to lowercase is the usual cause of an identifier that suddenly resolves differently.
  • Put each clause keyword at the start of a line and each join and AND on its own line; that single habit is what makes a LEFT JOIN wrongly filtered in WHERE visible.
  • Keyword casing is purely a display convention, so choose one style, configure it once, and never mix two in a codebase.
  • Formatting proves nothing about performance; run EXPLAIN against realistic data, and be suspicious of predicates that wrap a column in a function or start a LIKE with a wildcard.

Continue with related checks and tools