DEV Community

William Andrews
William Andrews

Posted on • Originally published at devcrate.net

SQL formatting — a practical guide for developers

SQL is one of the oldest languages developers still write by hand every day. It's also one of the most inconsistently formatted. Open any codebase with more than one contributor and you'll find queries written in four different styles — keywords uppercase in one file, lowercase in another, indentation that made sense to whoever wrote it six months ago but nobody else.

This guide covers the conventions that make SQL readable, why they exist, and how to apply them consistently regardless of which database you're using.


Why formatting matters more in SQL than most languages

Most languages have autoformatters that make style debates irrelevant — Prettier for JavaScript, Black for Python, gofmt for Go. SQL has no universal equivalent. A query can be written in one line or twenty, with keywords uppercase or lowercase, with or without aliases, and the database will execute it identically. The only thing formatting affects is how easy the query is to read, debug, and modify.

That gap matters more for SQL than most languages because SQL queries tend to be long, because they often live in places autoformatters don't reach (migration files, ORM string literals, analytics dashboards, stored procedures), and because a poorly formatted query in a production codebase is genuinely difficult to audit for correctness.

A well-formatted query tells the reader what it's doing before they have to parse it. A poorly formatted one makes them do the parser's job manually.


The core conventions

Uppercase keywords

Capitalize SQL reserved words: SELECT, FROM, WHERE, JOIN, ON, GROUP BY, ORDER BY, HAVING, LIMIT, AS, AND, OR, NOT, IN, IS, NULL, LIKE, BETWEEN, CASE, WHEN, THEN, ELSE, END.

Lowercase everything else: table names, column names, aliases, string literals, function names. This distinction makes keywords visually separate from data — it immediately tells the reader which parts of the query are instructions and which are data references.

-- Hard to scan
select id, first_name, last_name from users where active = true order by last_name;

-- Much clearer
SELECT id, first_name, last_name
FROM users
WHERE active = TRUE
ORDER BY last_name;
Enter fullscreen mode Exit fullscreen mode

One clause per line

Each major clause starts on its own line: SELECT, FROM, WHERE, JOIN, GROUP BY, ORDER BY, HAVING, LIMIT. This makes the structure of the query immediately visible — you can see at a glance what tables are involved, what conditions apply, and how results are sorted.

SELECT
    u.id,
    u.email,
    COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at >= '2026-01-01'
GROUP BY u.id, u.email
ORDER BY order_count DESC
LIMIT 100;
Enter fullscreen mode Exit fullscreen mode

Indentation

Indent the contents of a clause by four spaces. This applies to the column list after SELECT, to conditions after WHERE when there are multiple, and to subqueries.

SELECT
    id,
    email,
    created_at
FROM users
WHERE
    active = TRUE
    AND role = 'admin'
    AND created_at >= '2026-01-01';
Enter fullscreen mode Exit fullscreen mode

Leading AND / OR

When a WHERE clause has multiple conditions, start each condition with the logical operator at the beginning of the line rather than the end. This makes it trivial to comment out individual conditions during debugging.

-- Trailing AND — hard to comment out individual conditions
WHERE active = TRUE AND
      role = 'admin' AND
      created_at >= '2026-01-01'

-- Leading AND — easy to comment out individual conditions
WHERE active = TRUE
  AND role = 'admin'
  AND created_at >= '2026-01-01'
Enter fullscreen mode Exit fullscreen mode

Explicit column lists

SELECT * is fine for exploratory queries at a REPL or in a migration test. It should never appear in production application code. Explicit column lists make queries self-documenting, prevent breakage when columns are added to a table, and avoid fetching data you don't need.

-- Never in production code
SELECT * FROM users;

-- What the query actually needs
SELECT id, email, role, created_at FROM users;
Enter fullscreen mode Exit fullscreen mode

Table aliases

Use short, meaningful aliases when joining multiple tables. Always define the alias in the FROM or JOIN clause and use it consistently throughout.

SELECT
    u.id,
    u.email,
    p.plan_name,
    p.expires_at
FROM users u
INNER JOIN subscriptions s ON s.user_id = u.id
INNER JOIN plans p ON p.id = s.plan_id
WHERE s.status = 'active';
Enter fullscreen mode Exit fullscreen mode

Formatting JOINs

Each JOIN and its ON condition go on separate lines. The join type should always be written explicitly — never just JOIN, which defaults to INNER JOIN and hides intent. When the ON condition spans multiple columns, each condition gets its own line:

FROM orders o
INNER JOIN users u ON u.id = o.user_id
LEFT JOIN coupons c ON c.id = o.coupon_id
LEFT JOIN order_items oi
    ON oi.order_id = o.id
    AND oi.deleted_at IS NULL;
Enter fullscreen mode Exit fullscreen mode

Subqueries

Indent subqueries by four spaces relative to the outer query. The opening parenthesis stays on the same line as the clause it belongs to; the closing parenthesis goes on its own line.

SELECT
    u.id,
    u.email,
    (
        SELECT COUNT(*)
        FROM orders o
        WHERE o.user_id = u.id
          AND o.status = 'completed'
    ) AS completed_order_count
FROM users u
WHERE u.active = TRUE;
Enter fullscreen mode Exit fullscreen mode

For subqueries in a WHERE clause:

WHERE u.id IN (
    SELECT user_id
    FROM subscriptions
    WHERE status = 'active'
      AND expires_at > NOW()
)
Enter fullscreen mode Exit fullscreen mode

CTEs — Common Table Expressions

CTEs (WITH clauses) are one of the most underused SQL features for readability. Instead of nesting subqueries three levels deep, you name each logical step and reference it by name. Format each CTE with the name and AS on the first line, the query indented inside the parentheses, and each CTE separated by a comma and a blank line.

WITH active_users AS (
    SELECT id, email, created_at
    FROM users
    WHERE active = TRUE
      AND role != 'guest'
),

recent_orders AS (
    SELECT user_id, COUNT(*) AS order_count
    FROM orders
    WHERE created_at >= NOW() - INTERVAL '30 days'
    GROUP BY user_id
)

SELECT
    u.id,
    u.email,
    COALESCE(o.order_count, 0) AS orders_last_30_days
FROM active_users u
LEFT JOIN recent_orders o ON o.user_id = u.id
ORDER BY orders_last_30_days DESC;
Enter fullscreen mode Exit fullscreen mode

CTEs don't just improve formatting — they change how you write queries. When you can name a logical step, you start thinking in terms of "first get active users, then find their recent orders, then join them" rather than writing the whole thing inside-out as nested subqueries.


CASE expressions

Format CASE expressions with each WHEN and the ELSE on its own indented line, and END at the same indentation level as CASE:

SELECT
    id,
    email,
    CASE
        WHEN subscription_tier = 'pro' THEN 'Pro user'
        WHEN subscription_tier = 'team' THEN 'Team member'
        WHEN trial_expires_at > NOW() THEN 'Trial'
        ELSE 'Free'
    END AS user_type
FROM users;
Enter fullscreen mode Exit fullscreen mode

Comments

Use -- for single-line comments and /* */ for multi-line blocks. Comments in SQL are especially valuable because queries often encode business logic that isn't obvious from the SQL itself.

-- Exclude soft-deleted records and internal test accounts
WHERE deleted_at IS NULL
  AND email NOT LIKE '%@devcrate.net'

/*
  This CTE handles the edge case where a user can have
  multiple active subscriptions during a plan change window.
  We take the most recently created one.
*/
Enter fullscreen mode Exit fullscreen mode

Dialect differences worth knowing

The conventions above apply across PostgreSQL, MySQL, SQLite, and SQL Server. A few syntax differences are worth knowing:

String quoting: Standard SQL uses single quotes for strings. MySQL also accepts double quotes by default, but this is non-standard and should be avoided. PostgreSQL uses single quotes strictly.

Identifier quoting: PostgreSQL uses double quotes ("My Column"). MySQL uses backticks (`My Column`). SQL Server uses square brackets ([My Column]). Avoid column or table names that require quoting entirely — it adds noise to every query that references them.

LIMIT vs TOP vs FETCH: PostgreSQL, MySQL, and SQLite use LIMIT n. SQL Server uses SELECT TOP n. Standard SQL uses FETCH FIRST n ROWS ONLY.

Date functions: Date arithmetic varies significantly. NOW() works in PostgreSQL and MySQL. SQLite uses datetime('now'). INTERVAL syntax also differs. This is one of the most common sources of queries that work in development but break in production when the databases differ.


A formatter won't replace judgment

Autoformatters are useful — they eliminate whitespace debates and catch obvious inconsistencies. But SQL formatting judgment goes beyond what a formatter can enforce. Knowing when to use a CTE vs a subquery, when to break a long condition across multiple lines, when an alias adds clarity vs noise — these require understanding the query, not just the syntax.

The goal of formatting is to make the query's intent legible to someone reading it cold. A query that passes a linter but buries its logic in three levels of nested subqueries with cryptic single-letter aliases hasn't achieved that goal.


If you want to format an existing query quickly, DevCrate's SQL Formatter runs entirely in your browser — paste any query and it applies consistent indentation and keyword casing. Nothing leaves your machine.

Top comments (0)