SQL Formatting Style Guide: Readable Queries Your Team Can Agree On

By , founder of Softaware Commerce · Published · Updated

Drafted with AI assistance. Every command and code example was run and its output checked before publication. How guides are made

Readable SQL follows a few conventions that most teams agree on: one clause per line (SELECT, FROM, WHERE, GROUP BY…), one column per line in longer select lists, consistent keyword case, explicit JOIN … ON syntax, meaningful aliases written with AS, and common table expressions (CTEs) instead of deeply nested subqueries. The database ignores all of it; the point is that anyone on the team can read, review and diff a query quickly. The simplest way to make it stick is to let a formatter do the layout and a linter such as SQLFluff check the rest.

Should SQL keywords be uppercase or lowercase?

Keywords are case-insensitive in every mainstream database, so this is purely a convention. Uppercase keywords (SELECT, FROM) are the long-standing habit and make the structure stand out from table and column names; the PostgreSQL manual calls this "a convention often used". Many modern teams and style guides prefer lowercase because it is easier to type and editors already colour keywords. Either is fine if it is consistent.

Identifiers are a different matter. Unquoted names are case-insensitive, but databases fold them differently: PostgreSQL folds unquoted names to lower case, while the SQL standard says upper case, and quoting an identifier makes it case-sensitive. A table created as "Orders" in PostgreSQL must be quoted that way every time. The safest habit is snake_case names in lower case, never quoted.

The SQL Formatter has a Keyword case option with UPPER, lower and Preserve. It changes keywords only; function names such as count or sum and identifiers keep the case you typed.

How should I break a query across lines?

Put each major clause on a new line, and in longer select lists put each column on its own line, so a diff shows exactly which column changed. Beyond that, two layouts are common.

River style

In the river style, popularised by Simon Holywell's SQL Style Guide, keywords are right-aligned so that a vertical channel of space (the "river") runs between the keywords and the rest of the code:

SELECT c.id,
       c.name,
       SUM(o.total) AS total_spent
  FROM customers AS c
       JOIN orders AS o
         ON c.id = o.customer_id
 WHERE c.country = 'GB'
 GROUP BY c.id, c.name;

It reads well, but it is hard to maintain by hand and few formatters produce it, so it tends to drift out of alignment after a few edits.

Indented style

The indented style puts each clause keyword at the left margin and indents its contents by a fixed amount. It is what most formatters and linters produce:

SELECT
    c.id,
    c.name,
    SUM(o.total) AS total_spent
FROM customers AS c
INNER JOIN orders AS o
    ON c.id = o.customer_id
WHERE c.country = 'GB'
GROUP BY c.id, c.name;

SQLFluff 4.4.0 reports no problems with this query using its default rules and the ansi dialect.

Leading or trailing commas?

Trailing commas (c.name,) look like ordinary prose and are the more common choice. Leading commas put the separator at the start of the line:

SELECT
    c.id
    , c.name
    , c.country
FROM customers AS c;
SituationTrailing commasLeading commas
Comment out the last columnLeaves a dangling comma: syntax errorWorks
Comment out the first columnWorksNext line starts with a comma: syntax error
Add a column at the end (diff)Two lines changeOne line changes
Spot a missing commaCommas are at ragged line endsCommas line up in one column
FamiliarityMatches most code and proseTakes getting used to

A missing comma is a nasty bug in SQL because it is often still valid: SELECT id name FROM customers returns a single column, id, under the alias name. Either style is fine; pick one and enforce it. sql-formatter 15 always writes trailing commas (it reformatted the query above to c.id, / c.name,), while SQLFluff can enforce either (see below).

How should I write aliases?

  • Always write AS. sum(o.total) total_spent is valid, but AS makes it obvious that a name follows, and it protects against the missing-comma bug above. SQLFluff flags implicit aliases (rules AL01 for tables and AL02 for columns).
  • Use meaningful table aliases. c for customers and o for orders are fine in a short query; t1, t2, a, b are not. In a long query, a few extra letters (cust, ord) pay off.
  • Qualify every column when more than one table is involved, so the reader does not need to know the schema to tell where created_at comes from.
  • Name computed columns (COUNT(*) AS order_count), so result sets and views have usable column names.

How should JOINs be formatted?

Use explicit JOIN … ON rather than listing tables with commas and filtering in WHERE; it keeps join conditions next to the table they belong to and makes an accidental cross join much harder. Put each join on its own line, and the ON condition either on the same line or indented beneath it. Write the join type in full: SQLFluff's rule AM05 asks for INNER JOIN instead of a bare JOIN, and its rule ST09 by default wants the earlier table first in the condition (c.id = o.customer_id). Keep conditions that restrict the joined table in the ON clause of a LEFT JOIN; moving them to WHERE silently turns it into an inner join.

Why use CTEs instead of nested subqueries?

A nested subquery has to be read from the inside out:

SELECT name, spent
FROM (
    SELECT c.name, SUM(o.total) AS spent
    FROM customers AS c
    INNER JOIN orders AS o ON o.customer_id = c.id
    WHERE o.status = 'paid'
    GROUP BY c.name
) AS t
WHERE spent > 100;

A WITH clause names each step and lets you read top to bottom:

WITH paid_orders AS (
    SELECT customer_id, total
    FROM orders
    WHERE status = 'paid'
),
customer_spend AS (
    SELECT c.name, SUM(p.total) AS spent
    FROM customers AS c
    INNER JOIN paid_orders AS p ON p.customer_id = c.id
    GROUP BY c.name
)
SELECT name, spent
FROM customer_spend
WHERE spent > 100;

Both return the same rows. CTEs are supported by PostgreSQL, SQLite and MySQL from version 8.0, among others; both examples above were run in SQLite. Performance can differ by database: the PostgreSQL documentation explains that a non-recursive, side-effect-free CTE referenced once is folded into the main query, and that MATERIALIZED and NOT MATERIALIZED override the default. Check the query plan rather than assuming.

How should CASE expressions be laid out?

Put CASE on its own line, each WHEN … THEN on an indented line, ELSE at the same level, and END aligned with CASE, followed by the alias. Always include ELSE, even if it is ELSE NULL, so readers know the fallback was intended.

SELECT
    o.id,
    CASE
        WHEN o.total >= 1000 THEN 'large'
        WHEN o.total >= 100 THEN 'medium'
        ELSE 'small'
    END AS order_size
FROM orders AS o;

How should I comment SQL?

Use -- for short notes and /* … */ for longer explanations. Comment on why (business rules, odd data, a performance trick), not on what the SQL obviously does:

-- Customers who have paid for at least one order
SELECT
    c.id,
    c.name  -- display name, not unique
FROM customers AS c
WHERE EXISTS (
    SELECT 1
    FROM orders AS o
    WHERE o.customer_id = c.id
      AND o.status = 'paid'  /* refunds are 'refunded' */
);

A -- comment runs to the end of the line, so it breaks a query that is later collapsed onto one line. The SQL Formatter's Minify button handles this by rewriting -- note as /* note */; its Beautify button keeps both kinds of comment in place.

What does a formatter do to a messy query?

The SQL Formatter runs sql-formatter 15 in your browser with four options: dialect (Standard SQL, MySQL, MariaDB, PostgreSQL, SQLite, T-SQL or BigQuery), keyword case, and an indent of 2 spaces, 4 spaces or a tab. This query was pasted as one line:

select c.id, c.name, count(o.id) as order_count, sum(o.total) total_spent, case when sum(o.total) > 1000 then 'gold' when sum(o.total) > 100 then 'silver' else 'bronze' end as tier from customers c left join orders o on o.customer_id = c.id and o.status <> 'cancelled' where c.created_at >= '2026-01-01' and (c.country = 'GB' or c.country = 'IE') group by c.id, c.name having count(o.id) > 0 order by total_spent desc limit 50;

sql-formatter 15.9 with Standard SQL, UPPER keywords and a 2-space indent (the tool's defaults) produced:

SELECT
  c.id,
  c.name,
  count(o.id) AS order_count,
  sum(o.total) total_spent,
  CASE
    WHEN sum(o.total) > 1000 THEN 'gold'
    WHEN sum(o.total) > 100 THEN 'silver'
    ELSE 'bronze'
  END AS tier
FROM
  customers c
  LEFT JOIN orders o ON o.customer_id = c.id
  AND o.status <> 'cancelled'
WHERE
  c.created_at >= '2026-01-01'
  AND (
    c.country = 'GB'
    OR c.country = 'IE'
  )
GROUP BY
  c.id,
  c.name
HAVING
  count(o.id) > 0
ORDER BY
  total_spent DESC
LIMIT
  50;

Note what a formatter does not do. It did not add the missing AS before total_spent or c, did not change the case of count and sum, and placed the second join condition (AND o.status …) at the same indent as the LEFT JOIN line. Layout is automated; naming and structure are still up to you. With lower case and a 4-space indent, the same query comes out identically apart from keyword case and indent width.

When does the dialect matter?

For plain SELECT statements, rarely. For database-specific syntax, it does. In our tests with sql-formatter 15.9, select x::int from t failed to parse as Standard SQL (Parse error: Unexpected "::int from") but formatted correctly as PostgreSQL, and select top 5 * from t kept top in lower case under Standard SQL but became TOP under T-SQL, because only that dialect knows it as a keyword. Choose the dialect your query runs on.

How do I lint SQL with SQLFluff?

A formatter fixes layout; a linter also checks naming, aliasing and ambiguous constructs. SQLFluff is an open-source SQL linter and fixer written in Python. It needs a dialect, either on the command line or in a config file; without one, version 4.4.0 stops with "No dialect was specified". Given this file:

select c.id, C.name, sum(o.total) total_spent
from customers c join orders o on o.customer_id = c.id
group by 1, 2

sqlfluff lint bad.sql --dialect ansi reported, among others: LT09 (select targets should be on separate lines), CP02 (inconsistent identifier case, for C.name), AL02 and AL01 (implicit column and table aliases), AM05 (join should be fully qualified) and ST09 (join condition order). It also flagged RF01 for C.name, because the alias C did not match c in the FROM clause. sqlfluff fix rewrote it as:

select
    c.id,
    c.name,
    sum(o.total) as total_spent
from customers as c inner join orders as o on c.id = o.customer_id
group by 1, 2

Rules are configured in a .sqlfluff file in the project. This one sets the dialect, asks for uppercase keywords and switches to leading commas, using the layout configuration:

[sqlfluff]
dialect = postgres

[sqlfluff:layout:type:comma]
line_position = leading

[sqlfluff:rules:capitalisation.keywords]
capitalisation_policy = upper

With that file, sqlfluff fix turned a lower-case query with trailing commas into SELECT / id / , name / FROM customers. The full list of rules is in the rules reference. Running sqlfluff lint in CI keeps the style consistent without anyone arguing about it in code review.

Checklist

  • One clause per line; one column per line in long select lists.
  • One keyword case across the codebase, enforced by a formatter or linter.
  • Lower-case, unquoted snake_case identifiers.
  • Explicit AS for every alias; meaningful table aliases; qualified columns in multi-table queries.
  • Explicit INNER JOIN / LEFT JOIN with ON, never comma joins.
  • CTEs for multi-step logic; check the query plan if performance matters.
  • CASE with one WHEN per line and an explicit ELSE.
  • Comments that explain why, and /* */ anywhere a query might be put on one line.
  • Leading or trailing commas: pick one and let the tools enforce it.

Frequently asked questions

Does formatting change how a query runs?

No. Whitespace, line breaks and keyword case outside string literals and quoted identifiers do not affect the result or the query plan. Adding AS or changing join syntax is also equivalent, but moving a condition between ON and WHERE in an outer join is not.

Is GROUP BY 1, 2 bad style?

Positional references are short but break silently if someone reorders the select list. Many teams allow them in ad-hoc queries and require column names in code that is committed. SQLFluff's default rules did not flag GROUP BY 1, 2 in our test, so decide this one as a team.

Can sql-formatter produce leading commas or river alignment?

Not through the options the SQL Formatter on this site exposes. Version 15 always writes trailing commas; it has an indentStyle option for tabular layouts, but its documentation marks it as deprecated and the tool does not offer it. Use SQLFluff if you need leading commas enforced.

Should I use SELECT * in production queries?

It is better to list columns. SELECT * returns columns you did not ask for, changes when the table changes and hides which columns the code depends on. It is fine for exploring data and inside EXISTS (…).

Is my SQL sent anywhere when I format it on this site?

No. The SQL Formatter loads sql-formatter from jsDelivr and formats in your browser; the query is not sent to a server by the tool. To compare a query before and after formatting, use the Diff Checker.

Tools for this guide