How should SQL be formatted?
SQL Formatting Conventions: A Defensible Style Guide
Format SQL so that its structure is visible at a glance: one major clause per line, starting at the left margin; the contents of each clause indented beneath it; one column, join or condition per line; keywords in a consistent case (uppercase is the most common choice); lowercase snake_case identifiers that never need quoting; and common table expressions instead of nested subqueries. Those rules cover most of what matters. The remaining choices, such as leading or trailing commas and two or four spaces, matter much less than applying one choice everywhere, ideally with a formatter so nobody argues about it in review.
Why SQL style deserves rules
SQL is declarative and whitespace-insensitive, so a 40-line query can be written on one line, and often is when it comes out of an ORM log or a BI tool. Queries also live a long time: they get copied into dashboards, migrations and incident notes, and are read far more often than they are written. A consistent layout lets a reviewer see which tables are joined, which filters apply to which join, and where aggregation happens, without reading every token. It also makes diffs smaller, because a change to one condition touches one line.
The rules, with reasons
One clause per line, contents indented
Put SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY and LIMIT on their own lines at the same indentation, and indent what belongs to them. Your eye can then scan the left margin to see the shape of the query. Paste a one-line query into the SQL Formatter to see the layout:
with paid as (select customer_id, sum(total) as revenue from orders where status = 'paid' and created_at >= '2026-01-01' group by customer_id) select c.id, c.name, p.revenue from customers c left join paid p on p.customer_id = c.id where c.country = 'IN' and p.revenue > 0 order by p.revenue desc;With default settings the result is:
WITH
paid AS (
SELECT
customer_id,
sum(total) AS revenue
FROM
orders
WHERE
status = 'paid'
AND created_at >= '2026-01-01'
GROUP BY
customer_id
)
SELECT
c.id,
c.name,
p.revenue
FROM
customers c
LEFT JOIN paid p ON p.customer_id = c.id
WHERE
c.country = 'IN'
AND p.revenue > 0
ORDER BY
p.revenue DESC;Formatting also exposes logic. Once p.revenue > 0 sits on its own line under WHERE, it is easy to see that a filter on the right-hand table of a LEFT JOIN discards the rows where p is null, turning the outer join into an inner join. If customers without revenue should stay, that condition belongs in the ON clause.
An alternative layout, sometimes called "river" style, right-aligns the keywords so that a vertical gap runs down the query. It reads well but is hard to maintain by hand and few formatters produce it. The left-aligned layout above is what formatters output, which is the stronger argument.
Keyword case
Uppercase keywords (SELECT, LEFT JOIN, IS NOT NULL) are the long-standing convention, from a time when SQL was read without syntax highlighting and case was the only way to separate language from names. Lowercase keywords are common in analytics codebases and are perfectly readable in an editor. Both are defensible; mixing them is not. The formatter's Keyword case option rewrites reserved words and leaves function names such as sum and row_number as written, so pick a convention for functions too.
Identifiers: lowercase snake_case, never quoted
Unquoted identifiers are case-insensitive, but databases disagree about what they fold to. The SQL standard and Oracle fold to uppercase; PostgreSQL folds to lowercase; SQL Server follows the collation; MySQL table names follow the file system unless lower_case_table_names says otherwise. A table created as "OrderItems" in PostgreSQL must be quoted with that exact case in every query forever. Lowercase snake_case names (order_items, created_at) behave identically everywhere. See naming conventions for how this maps to application code.
Quotes: single for strings, double for identifiers
In standard SQL, 'text' is a string literal and "name" is an identifier. MySQL treats double quotes as string delimiters unless ANSI_QUOTES is enabled, and uses backticks for identifiers; SQL Server uses square brackets. If you never need to quote identifiers, the difference never bites.
Commas: trailing or leading
Trailing (c.id,) |
Leading (, c.id) |
|
|---|---|---|
| Reads like prose | Yes | No |
| Comment out the last column | Leaves a dangling comma: syntax error | Works |
| Comment out the first column | Works | Leaves a leading comma: syntax error |
| Append a column: lines changed in the diff | Two | One |
| Common in | Application code, most formatters' defaults | Analytics and dbt-style codebases |
Leading commas exist because standard SQL does not allow a trailing comma in a select list, so the last line is the one you most often break while editing. Some engines, including BigQuery and DuckDB, now accept a trailing comma after the last select item, which removes most of the argument. The formatter supports both with the Comma position option. Choose once per repository.
Boolean operators at the start of the line
Put AND and OR at the start of each condition. The operator is the most important token on the line, and leading placement lets you scan the conditions and comment one out cleanly. When a condition mixes AND and OR, add parentheses even where precedence makes them redundant; AND binds tighter than OR, and readers forget.
Explicit joins
Write JOIN ... ON rather than listing tables in FROM and joining in WHERE. Explicit joins keep each join condition next to its table, make outer joins possible to express, and prevent accidental cross joins when a condition is forgotten. Spell out INNER JOIN or LEFT JOIN; bare JOIN is inner, but not every reader remembers that under pressure. Put the new table's column first in the ON condition (p.customer_id = c.id) so the condition reads from the table being joined.
Aliases
Use AS for column aliases, always; SELECT total revenue (a missing comma) silently aliases total as revenue, and requiring AS makes that mistake visible. For table aliases, use short meaningful abbreviations (o for orders, c for customers) rather than a, b, c in join order. Note that Oracle rejects AS before a table alias, so FROM orders o is the portable form.
CTEs instead of nested subqueries
A WITH clause turns a query into named steps that read top to bottom: paid, then ranked, then the final select. Nested subqueries have to be read inside out. Name each CTE after what its rows are, not what it does (active_customers, not step2). Modern optimisers inline most CTEs, so the cost of this readability is usually nil; PostgreSQL before version 12 materialised every CTE, which is worth knowing if you maintain an older server.
Smaller rules
- End every statement with a semicolon, even the last one; tools that run scripts split on them.
- Avoid
SELECT *outside ad-hoc exploration. It hides the columns a query depends on and breaks when the table changes. - Prefer column names over ordinals in
GROUP BYandORDER BY.GROUP BY 1, 2is convenient but silently changes meaning when the select list is reordered. - Use
<>for inequality; it is the standard operator, and!=is an extension, though widely supported. - For comments,
--is portable, but MySQL requires a space or control character after the two dashes. - Indent with spaces, two or four. Tabs render differently in every tool a query passes through.
A summary style sheet
| Decision | Recommendation | Formatter setting |
|---|---|---|
| Clause layout | One clause per line, contents indented | Always on |
| Keyword case | UPPER (or lower, consistently) | Keyword case |
| Indentation | 2 spaces | Indent |
| Commas | Trailing, unless your team prefers leading | Comma position |
| Statements | Separated by one blank line, each ending in ; |
Lines between queries |
| Identifiers | Lowercase snake_case, unquoted |
Manual |
| Joins | Explicit, with the join type spelled out | Manual |
| Subqueries | CTEs, named for their rows | Manual |
The first half of that table is mechanical and belongs to a formatter; set the SQL Formatter to your dialect and team settings, and it will apply them on every paste. The second half is judgement, and belongs in review. When a query needs to travel as a single line, for example inside a JSON payload or a log message, compress it with the SQL Minifier and format it again when a human needs to read it.