If you can't tell at a glance where the WHERE clause of a 200-line SELECT statement starts and ends, you have a structure problem — not a content problem. This article shows how to format SQL statements so that SELECT, FROM, WHERE and JOIN stay immediately recognizable even in long queries.
→ Part of a series. This is part 2 and covers statement structure (
SELECT,WHERE,FROM,JOIN). The basics for identifiers, delimiters, commas, and aliases are in Part 1 — Identifiers, Delimiters, Commas, Aliases.
TL;DR — what this article delivers:
-
Main elements (
SELECT,FROM,WHERE,GROUP BY,HAVING,ORDER BY) belong on separate lines with consistent indentation. -
WHEREclause — align operands and operators column-style, indent equivalent conditions equally. The parenthesis structure becomes visually readable this way. -
FROMclause — table directly after theJOINoperator,ONkeyword on its own line,JOINconditions formatted like a miniWHEREclause. -
Postgres bridge + auto-formatters at the end —
[brackets]vs."quotes",DISTINCT ON,LATERAL. sqlfluff and pgFormatter complement manual discipline, they don't replace it.
Prerequisite: SSMS or any SQL editor with configurable tab width is enough. A live AdventureWorks database is not required — the examples illustrate patterns, not runnable pipelines.
General Thoughts on Indentation
A useful analogy for SQL structure is the outline of a table of contents. The indented version below is much faster to scan than the flat variant further down:
1. Chapter Level 1
1.1. Chapter Level 2
1.1.1. Chapter Level 3
1.1.2. Chapter Level 3
1.2. Chapter Level 2
2. Chapter Level 1
2.1. Chapter Level 2
2.1.1. Chapter Level 3
For comparison, the same TOC flat:
1. Chapter Level 1
1.1. Chapter Level 2
1.1.1. Chapter Level 3
1.1.2. Chapter Level 3
1.2. Chapter Level 2
2. Chapter Level 1
2.1. Chapter Level 2
2.1.1. Chapter Level 3
Flat TOCs work too, but only with additional formatting options like upper/lower case, bold, or italic to distinguish levels. In a SQL editor, those options are typically not available (SSMS renders plain text with syntax highlighting, no bold for identifiers). So indentation remains the structural tool.
One thing up front: All layout rules in this article are formatting conventions. SQL enforces neither the line breaks nor the indentation or the position of commas and keywords. The conventions aim to make the structure of a statement visible before you read it.
Main Language Elements
This article looks at the six central clauses of a SELECT statement:
SELECT
FROM
WHERE
GROUP BY
HAVING
ORDER BY
A complete SELECT statement can contain further clauses (WITH, TOP or LIMIT, OFFSET/FETCH, window definitions) — the same indentation principles apply to them. Treating the six clauses as the first level, their contents sit one indentation level deeper. The resulting basic structure of a SQL statement looks like this:
1: SELECT
2: field list
3: FROM
4: data sources
5: WHERE
6: conditions on data source
7: GROUP BY
8: grouping fields
9: HAVING
10: conditions on aggregations
11: ORDER BY
12: sort fields
The ground rule of this style guide: The main elements of a SQL statement sit on separate lines.
As counterexamples, here are two commonly seen formatting styles that ignore this rule. In both, you have to read at least parts of the statement to recognize where a main element starts and ends.
Left-Aligned Main and Sub-Elements
You occasionally find both top-level elements and the next-level elements left-aligned in the same column. You see this especially often in the FROM clause: Data sources (tables, views, CTEs) are indented the same as the introducing keyword FROM:
1: SELECT
2: field list
3: FROM
4: table1
5: JOIN table2 ON [...]
6: JOIN table3 ON [...]
7: WHERE
8: conditions on data source
9: GROUP BY
10: grouping fields
11: HAVING
12: conditions on aggregations
13: ORDER BY
14: sort fields
Right-Aligned Keywords
In this example, the main clauses of the SELECT statement (without regard for the BY keyword) are right-aligned. This indentation style makes alignment extra work because you have to deal with varying indent widths:
1: SELECT field1, field2, field3
2: FROM table1
3: LEFT JOIN table2 ON [...]
4: LEFT JOIN table3 ON [...]
5: WHERE condition1
6: OR condition2
7: OR condition3
8: GROUP BY grouping fields
9: HAVING condition1
10: OR condition2
11: OR condition3
12: ORDER BY sort fields
For simple statements, however, right-aligned keywords can read quite well — CREATE INDEX is a typical example:
1: CREATE UNIQUE NONCLUSTERED
2: INDEX [IndexName]
3: ON [dbo].[FactInternetSalesReason]([SalesOrderNumber] ASC);
For full SELECT statements with multiple JOINs, group-by columns and complex conditions, this style breaks down.
SELECT Field List
The natural reading direction of a SQL statement is left to right and top to bottom. With keyboard and mouse, vertical navigation is easier than horizontal navigation. The scroll wheel and the Page Up/Page Down keys make fast vertical navigation possible even within long complex statements — provided that field lists are written vertically. The more important effect is editor-independent: One line per expression makes long field lists scannable and easier to compare, move, or extend.
Field names should be written as a vertical list with leading commas — the detailed reasoning (comma readability, box-selection pattern) is in Part 1, section “The Comma”. One field per line. Because the field list is logically subordinate to the SELECT keyword, field names are indented by the agreed indent width:
1: SELECT
2: field1
3: ,field2
4: ,field3
5: FROM [...]
6: WHERE [...]
7: GROUP BY
8: field1
9: ,field2
10: ,field3
11: HAVING [...]
12: ORDER BY
13: field1
14: ,field2
15: ,field3
WHERE Clause
The order here deliberately departs from SQL syntax: The WHERE clause introduces the central condition pattern first, which is then reused for the FROM and HAVING clauses. A WHERE clause contains one or more conditions (predicates) connected by logical operators. Two points deserve attention when formatting these conditions:
- alignment of operands
- indentation of equivalent conditions
Alignment of Operands
A simple condition consists of two operands and an operator (=, !=, <>, IN, NOT IN, etc.). In a compound expression built from multiple single conditions, operands and operators should be aligned column-style. In the following example, field names have different lengths and different operators are applied:
1: [...]
2: WHERE
3: T01.[field___1] = 'something'
4: AND T01.[field__2] <> 1
5: AND T01.[field_____3] NOT IN (1, 2, 3)
6: AND T01.[field4] = T02.[field5]
This produces a table-like notation that allows fast visual navigation within the condition components.
Indentation of Equivalent Conditions
If the WHERE clause contains more than one condition, they are connected by logical operators like AND or OR. Parentheses are required when the intended logical grouping differs from operator precedence (AND binds more tightly than OR). Beyond that, they can improve readability in complex expressions. Depending on complexity, you quickly end up with deeply nested structures.
To keep complex nested expressions readable, give particular attention to structure and indentation of the WHERE clause: equivalent conditions are written underneath each other with the same indent, and a logical connection of equivalent conditions gets an indent that matches the parenthesis hierarchy:
1: [...]
2: WHERE (
3: (
4: [operand01] = [operand02]
5: OR [operand03] = [operand05]
6: OR [operand05] = [operand06]
7: )
8: AND (
9: [operand07] = [operand08]
10: OR [operand09] = [operand10]
11: )
12: AND [operand11] = [operand12]
13: )
14: OR (
15: [operand13] = [operand14]
16: )
The logical connections become visually readable through this indentation. A screenshot of the same WHERE clause in Notepad++ makes the effect even more obvious, because the editor's vertical guide lines at the tab stops emphasize the parenthesis hierarchy:
FROM Clause
As with the other main elements, the subordinate elements of the FROM clause are written indented. In most cases these are data sources — tables, views, and Common Table Expressions (CTEs).
Sub-SELECTs in the FROM clause can be replaced by CTEs when the subquery forms a self-contained logical step, is needed more than once, or makes the statement hard to follow: A CTE structures it top-down. For small subqueries used only once, a derived table is often the more compact choice. Converting is a structuring decision — how the CTE is executed is up to the engine's optimizer. What Postgres does differently (MATERIALIZED / NOT MATERIALIZED) is covered in the FAQ at the end of the article.
For formatting a JOIN clause there are four building blocks:
- table (or view / CTE)
-
JOINoperator -
ONkeyword -
JOINconditions
In the following code example, there is no visual anchor to identify the elements of the FROM clause:
1: FROM
2: table1 T01
3: JOIN table2 T02
4: ON T01.[FK] = T02.[ID]
5: JOIN table3 T03 ON
6: T02.[FK] = T03.[ID]
7: JOIN table4 T04
8: ON T03.[FK] = T04.[ID]
9: WHERE
10: [...]
The elements of the FROM clause belong on separate lines for readability. Exception: the joined table sits directly after the JOIN operator. In this style guide, the ON keyword sits left-aligned with the JOIN operator on the line below — that keeps JOIN and join condition visible as two separate structural levels. The same rules as for the WHERE clause apply to JOIN conditions:
1: SELECT
2: [...]
3: FROM
4: table1 T01
5: INNER JOIN table2 T02
6: ON
7: T01.[FK] = T02.[ID]
8: INNER JOIN table3 T03
9: ON
10: T02.[FK1] = T03.[FK1]
11: AND T02.[field2] = T03.[field2]
12: INNER JOIN table4 T04
13: ON
14: T03.[FK] = T04.[ID]
15: WHERE
16: [...]
GROUP BY, HAVING, ORDER BY
The remaining main elements follow the same principles as the SELECT field list and the WHERE clause — in short:
-
GROUP BYcontains a list of grouping expressions — for simple queries formatted like a scaled-downSELECT: one expression per line with a leading comma, indented by convention. -
HAVINGis a condition list likeWHERE— operands aligned column-style, equivalent conditions indented equally. The difference is semantic (post-GROUP BYfilter), not typographic. -
ORDER BYcontains a list of sort expressions — formatted analogous to theGROUP BYclause, with optionalASC/DESCper expression (held in a separate column when both sort directions appear in the same statement).
1: SELECT
2: T01.[Region]
3: ,T01.[Year]
4: ,SUM(T01.[Sales]) AS [Total]
5: FROM
6: [dbo].[FactSales] T01
7: GROUP BY
8: T01.[Region]
9: ,T01.[Year]
10: HAVING
11: SUM(T01.[Sales]) >= 1000
12: AND COUNT(*) >= 10
13: ORDER BY
14: T01.[Region] ASC
15: ,T01.[Year] DESC
Auto-Formatters and “Formatting Is Learning”
Tools like sqlfluff (multi-dialect — T-SQL, Postgres, MySQL, BigQuery, …) and pgFormatter (Postgres-focused) generate the layouts shown here automatically and are useful as a pre-commit hook or CI step. They are not a replacement for manual formatting.
The act of indenting, aligning aliases and placing parentheses forces the writer to read the statement fully and mentally model the table relationships. Auto-formatters produce the layout — they don't produce the mental model that emerges while writing. In the age of Copilot and Cursor, this matters twice: Generated SQL without understanding is a risk, because it produces technically correct queries that still don't answer the business question.
Pragmatic recommendation: format manually first, then let the formatter run as a final consistency pass (e.g. sqlfluff fix).
Postgres Bridge
The examples in this article use T-SQL notation ([brackets], positional T01 aliases). The layout rules themselves are engine-neutral — they apply 1:1 to Postgres as well. There are only a handful of places where Postgres behaves differently, and none of them change the format pattern:
-
Identifier quoting: The SQL standard uses double quotes for delimited identifiers (
"name") — Postgres follows it. T-SQL typically uses[brackets]but also understands double quotes whenQUOTED_IDENTIFIERis active. With case-sensitive identifiers, quoting becomes semantically relevant in Postgres (a separate article on case sensitivity in SQL Server vs. Postgres is planned). -
DISTINCT ON: the Postgres idiom for “first row per group”. Which row comes first is determined only by anORDER BYthat fixes the order within each group unambiguously — its leftmost expressions must be theDISTINCT ONexpressions. In T-SQL, emulate this withROW_NUMBER() OVER (PARTITION BY … ORDER BY …)and a filter onrn = 1. The formatting follows theSELECTfield list. -
LATERAL: lets a subquery in theFROMclause reference columns of precedingFROMitems. T-SQL usesCROSS APPLY/OUTER APPLYfor this. ALATERALjoin is formatted like any otherJOIN. -
RETURNING: Postgres returns the affected rows onINSERT/UPDATE/DELETEand, since version 17, onMERGEas well. SQL Server offersOUTPUT, a functionally related clause with its own syntax and forms (OUTPUT INTO). Both are formatted as their own clause line with a field list. -
CTEs:
WITH …is standardized and structured essentially the same in both engines, the extensions differ in detail. Postgres has the optionsMATERIALIZED/NOT MATERIALIZED(see FAQ). -
JOINindentation,WHEREparenthesis pattern,ORDER BYlists: There is no engine difference for these layout patterns.
So the format discipline carries on both engines. A separate follow-up article on the Postgres identifier specifics is planned.
Conclusion
The main elements of a SELECT statement belong on separate lines, their subordinate elements one indentation level deeper. Every pattern in this article derives from that ground rule: the field list as a vertical list with leading commas, the WHERE clause with column-aligned operands and equally indented equivalent conditions, the FROM clause with the table directly after the JOIN operator and the ON keyword on its own line.
Applied consistently, these patterns make the structure of a statement visible before its content is read: The parenthesis hierarchy of a nested WHERE clause and the relationships between the tables are already in the layout. The pattern is engine-neutral and carries in SQL Server and Postgres alike. The vocabulary level (identifiers, delimiters, commas, aliases) is covered in Part 1. More important than any single rule is that the same hierarchy stays consistently visible across the whole project — consistency beats purity.
FAQ
How do you format complex SQL queries with multiple JOINs and nested WHERE conditions?
Following the rules in this article: each clause on its own line, subordinate elements one level deeper. Each JOIN gets its table right next to the operator and its ON on the following line. In the WHERE clause, equivalent conditions sit underneath each other and the parenthesis hierarchy sets the indent depth. That keeps even a statement with five joins navigable.
When should a sub-SELECT be replaced by a CTE?
As soon as the subquery makes the statement hard to follow, is needed more than once, or forms a self-contained logical step. CTEs read top-down instead of nested and can be referenced multiple times in the same query. SQL Server, however, may execute the defining query again for each reference, because a CTE is not a materialized intermediate result there. For testing, temporarily replace the outer query with a SELECT * FROM cte_name — a CTE definition cannot be run standalone. Small subqueries used only once may stay.
Should the JOIN operator always be explicit (INNER, LEFT, RIGHT, FULL)?
Syntactically, the INNER is optional — a bare JOIN means INNER JOIN. This style guide still always writes out the JOIN type (INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN): the default is not obvious to every reader, and when skimming, the difference between JOIN and LEFT JOIN is easy to miss.
Does the format pattern apply to Postgres as well?
Yes — the layout rules transfer unchanged, see the Postgres Bridge section above. The only practical adjustment is identifier quoting ("…" instead of […]). The layout rules for SELECT field lists, WHERE clauses, FROM clauses and JOIN indentation are engine-neutral.
Is an auto-formatter like sqlfluff or pgFormatter enough — or do you still need to format manually?
Auto-formatters deliver layout, but not the learning effect. Anyone who only pipes a 200-line statement through a formatter hasn't read the statement. Anyone who structures it manually builds the mental models of table relationships — and often spots logical errors along the way. Pragmatic workflow: manual first, then the formatter as a finishing pass for consistency.
What about MATERIALIZED / NOT MATERIALIZED for CTEs in Postgres?
Since version 12, Postgres folds a non-recursive, side-effect-free CTE into the parent query by default when it is referenced exactly once — predicate pushdown is then possible. With multiple references, Postgres does not fold such a CTE by default and treats it as a separate computation instead. MATERIALIZED forces this separate computation, NOT MATERIALIZED allows folding even with multiple references. For performance-critical queries with expensive CTEs, the Postgres docs on WITH queries are worth a read. SQL Server has no corresponding syntax: a CTE there is not an independently materialized object, it is optimized as part of the overall statement.
Where do I find Part 1 (identifiers, delimiters, commas, aliases)?
Part 1 — Formatting SQL Statements. It covers the smaller building blocks: regular vs. delimited identifiers, leading vs. trailing comma, systematic T01/T02 aliases, qualified column names. Part 1 + Part 2 together form a complete style guide for SELECT statements.
Related Articles
Part of the series:
- Formatting SQL Statements (Part 1) — Identifiers, Delimiters, Commas, Aliases — the vocabulary layer: regular vs. delimited identifiers, comma position, systematic aliases.
- Structuring and Formatting SQL Statements — The Cluster Path — the hub article with the reading path through all formatting topics on this blog.
Upstream:
- The Functional Aesthetics of SQL — the “why” of SQL formatting: Structured code is faster to read, review, and change.
Downstream:
- Inline Comments in Complex SQL Statements — the meta layer: inline and block comments that document the structure shown here without destroying it.
- Editor Options in SSMS — the editor basis for this layout: tab width, spaces instead of tabs, block selection.
- SQL Conventions // PL/pgSQL Procedures You Can Still Read in Two Years — the continuation of the statement layout for Postgres procedures.














