DEV Community

Marcus
Marcus

Posted on Originally published at sql.marcus-belz.de

Formatting SQL Statements (Part 2) — Statement Structure: SELECT, WHERE, FROM, JOIN

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.
  • WHERE clause — align operands and operators column-style, indent equivalent conditions equally. The parenthesis structure becomes visually readable this way.
  • FROM clause — table directly after the JOIN operator, ON keyword on its own line, JOIN conditions formatted like a mini WHERE clause.
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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:    )
Enter fullscreen mode Exit fullscreen mode

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:

WHERE clause in Notepad++ with vertical indent guide lines at the tab stops, making the nesting of the parenthesis constructs visible.

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)
  • JOIN operator
  • ON keyword
  • JOIN conditions

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: [...]
Enter fullscreen mode Exit fullscreen mode

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:   [...]
Enter fullscreen mode Exit fullscreen mode

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 BY contains a list of grouping expressions — for simple queries formatted like a scaled-down SELECT: one expression per line with a leading comma, indented by convention.
  • HAVING is a condition list like WHERE — operands aligned column-style, equivalent conditions indented equally. The difference is semantic (post-GROUP BY filter), not typographic.
  • ORDER BY contains a list of sort expressions — formatted analogous to the GROUP BY clause, with optional ASC / DESC per 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
Enter fullscreen mode Exit fullscreen mode

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 when QUOTED_IDENTIFIER is 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 an ORDER BY that fixes the order within each group unambiguously — its leftmost expressions must be the DISTINCT ON expressions. In T-SQL, emulate this with ROW_NUMBER() OVER (PARTITION BY … ORDER BY …) and a filter on rn = 1. The formatting follows the SELECT field list.
  • LATERAL: lets a subquery in the FROM clause reference columns of preceding FROM items. T-SQL uses CROSS APPLY / OUTER APPLY for this. A LATERAL join is formatted like any other JOIN.
  • RETURNING: Postgres returns the affected rows on INSERT/UPDATE/DELETE and, since version 17, on MERGE as well. SQL Server offers OUTPUT, 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 options MATERIALIZED / NOT MATERIALIZED (see FAQ).
  • JOIN indentation, WHERE parenthesis pattern, ORDER BY lists: 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:

Upstream:

Downstream:

Top comments (0)