Formatting Is a Solved Problem

SQL formatting should not be a manual activity. Like gofmt, prettier, and black, SQL has mature automated formatters that eliminate style arguments and enforce consistency without human effort.

The value of this guide is not "how to manually indent SQL" — it's understanding the design decisions behind formatting rules so you can configure your automated tools correctly and handle the patterns they struggle with.

The Major Style Debates

Every SQL style guide takes a position on these contested choices. None is objectively correct — what matters is consistency within a codebase.

Keyword Case

Style Example Used by
UPPERCASE keywords SELECT id FROM users WHERE ... Most traditional guides, Simon Holywell's guide
lowercase keywords select id from users where ... Gitlab, some modern teams
Mixed (only major clauses) SELECT id from users WHERE ... Uncommon, not recommended

The case debate is largely aesthetic. Syntax highlighting in modern editors makes keywords visually distinct regardless of case. Choose one and enforce it with a formatter.

Leading vs Trailing Commas

sql
-- Trailing commas (traditional)
SELECT
    id,
    name,
    email,
    created_at
FROM users;

-- Leading commas (easier diffs, no trailing comma errors)
SELECT
    id
  , name
  , email
  , created_at
FROM users;

Leading commas produce cleaner git diff output (adding a column is a single-line addition, not a modification + addition). But most formatters default to trailing commas due to convention.

Right-Aligned Keywords (River Style)

sql
-- Left-aligned (most common)
SELECT
    id,
    name
FROM users
WHERE status = 'active'
ORDER BY created_at;

-- Right-aligned / "river" style
SELECT id,
       name
  FROM users
 WHERE status = 'active'
 ORDER BY created_at;

River style creates a vertical "river" of whitespace between keywords and content. It's compact but harder to maintain by hand and poorly supported by most formatters.

Indentation Width

2 spaces, 4 spaces, or tab — same debate as every other language. 4 spaces is most common in SQL because queries nest deeply (subqueries inside subqueries).

Automated Formatters: Tool Comparison

Tool Language Dialects Linting Fixable Configuration
sqlfluff Python ANSI, PostgreSQL, MySQL, BigQuery, Snowflake, T-SQL, Spark Yes Yes .sqlfluff file, extensive rules
sqlfmt Python PostgreSQL, general ANSI No Yes Opinionated (minimal config)
pg_format Perl PostgreSQL No Yes CLI flags and config file
sql-formatter JS/TS ANSI, PostgreSQL, MySQL, MariaDB, T-SQL, PL/SQL, BigQuery, Spark No Yes Programmatic API
prettier-plugin-sql JS Via sql-formatter No Yes .prettierrc integration
dbt sqlfluff Python dbt-specific Jinja+SQL Yes Yes dbt project integration

sqlfluff: The Most Comprehensive

sqlfluff is both a formatter and a linter. It understands SQL semantics, not just syntax:

bash
# Format a file
sqlfluff fix query.sql --dialect postgres

# Lint without fixing
sqlfluff lint query.sql --dialect postgres

# Configuration (.sqlfluff)
cat .sqlfluff
ini
[sqlfluff]
dialect = postgres
templater = raw
max_line_length = 120

[sqlfluff:indentation]
indent_unit = space
tab_space_size = 4

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

[sqlfluff:rules:layout.long_lines]
ignore_comment_lines = True

sqlfmt: The Opinionated Choice

sqlfmt takes the gofmt philosophy — one style, no configuration, no debates:

bash
pip install shandy-sqlfmt
sqlfmt query.sql

It always produces:

  • Lowercase keywords
  • Trailing commas
  • 4-space indentation
  • One clause per line

If your team values zero-config consistency over style preference, sqlfmt eliminates all discussion.

Complex Pattern Formatting

Automated tools handle simple SELECT...FROM...WHERE well. The challenge is complex patterns where formatting significantly affects readability.

CTEs (WITH Clause)

CTEs are the most important formatting challenge in modern SQL. A poorly formatted CTE chain is unreadable:

sql
-- Well-formatted CTE chain
WITH monthly_revenue AS (
    SELECT
        DATE_TRUNC('month', order_date) AS month,
        SUM(amount) AS revenue
    FROM orders
    WHERE order_date >= '2025-01-01'
    GROUP BY DATE_TRUNC('month', order_date)
),

revenue_growth AS (
    SELECT
        month,
        revenue,
        LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
        (revenue - LAG(revenue) OVER (ORDER BY month))
            / NULLIF(LAG(revenue) OVER (ORDER BY month), 0) AS growth_rate
    FROM monthly_revenue
)

SELECT
    month,
    revenue,
    prev_revenue,
    ROUND(growth_rate * 100, 1) AS growth_pct
FROM revenue_growth
ORDER BY month;

Key principles:

  • Each CTE gets a blank line separator
  • CTE body is indented inside the parentheses
  • The final SELECT is at the same level as WITH
  • CTE names describe what the intermediate result represents

Window Functions

Window functions with complex frame specifications need careful line breaking:

sql
SELECT
    employee_id,
    department,
    salary,
    AVG(salary) OVER (
        PARTITION BY department
        ORDER BY hire_date
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS rolling_avg,
    RANK() OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS salary_rank,
    salary - FIRST_VALUE(salary) OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS diff_from_max
FROM employees;

When the OVER clause fits on one line, keep it inline:

sql
ROW_NUMBER() OVER (ORDER BY id) AS row_num

When it doesn't, break after OVER and indent the window specification.

CASE Expressions

sql
-- Simple CASE: can be inline if short
SELECT status, CASE status WHEN 'A' THEN 'Active' WHEN 'I' THEN 'Inactive' END AS label
FROM users;

-- Complex CASE: one WHEN per line
SELECT
    order_id,
    CASE
        WHEN total > 1000 AND customer_type = 'enterprise'
            THEN 'high_value_enterprise'
        WHEN total > 1000
            THEN 'high_value'
        WHEN total > 100
            THEN 'medium_value'
        ELSE 'standard'
    END AS order_tier
FROM orders;

Correlated Subqueries

sql
SELECT
    d.name AS department,
    d.budget,
    (
        SELECT COUNT(*)
        FROM employees e
        WHERE e.department_id = d.id
            AND e.status = 'active'
    ) AS headcount,
    (
        SELECT AVG(e.salary)
        FROM employees e
        WHERE e.department_id = d.id
    ) AS avg_salary
FROM departments d
WHERE d.budget > 100000;

Subqueries in the SELECT list should be wrapped in parentheses on their own lines, with the body indented.

Complex JOIN Conditions

sql
SELECT o.id, o.total
FROM orders o
INNER JOIN customers c
    ON c.id = o.customer_id
    AND c.region = o.shipping_region
LEFT JOIN promotions p
    ON p.id = o.promo_id
    AND p.valid_from <= o.order_date
    AND p.valid_until >= o.order_date
WHERE o.status = 'completed';

When a JOIN has multiple conditions, place ON on the same line as the JOIN and indent additional conditions with AND.

Formatting vs Linting: The Critical Distinction

Formatting is cosmetic: whitespace, indentation, line breaks, keyword case. It changes how code looks, never what it does.

Linting detects correctness and style issues that affect behavior or maintainability:

Category Formatting (cosmetic) Linting (semantic)
Keyword case selectSELECT
Indentation Fix spacing
Unused aliases Detect unreferenced alias
Implicit joins Warn on comma-join syntax
SELECT * Warn on wildcard select
Unqualified columns Require table prefix in joins
Inconsistent quoting Enforce quote style
Missing WHERE on UPDATE/DELETE Warn on unguarded mutations

sqlfluff handles both. Most other tools (sqlfmt, pg_format, sql-formatter) handle only formatting.

Linting Rules That Prevent Bugs

ini
# sqlfluff rules that catch real bugs
[sqlfluff:rules:ambiguous.column_references]
# Require qualified column names in multi-table queries

[sqlfluff:rules:convention.select_trailing_comma]
# Prevent trailing commas that cause syntax errors

[sqlfluff:rules:structure.subquery]
# Require aliases for subqueries

CI/CD Enforcement

Formatting enforcement belongs in CI, not in code review. Humans should not spend review time on whitespace.

Pre-commit Hook

yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/sqlfluff/sqlfluff
    rev: 3.0.0
    hooks:
      - id: sqlfluff-lint
        args: [--dialect, postgres]
      - id: sqlfluff-fix
        args: [--dialect, postgres]

GitHub Actions

yaml
# .github/workflows/sql-lint.yml
name: SQL Lint
on: [pull_request]
jobs:
  sqlfluff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install sqlfluff
      - run: sqlfluff lint --dialect postgres sql/

Editor Integration

  • VS Code: SQLFluff extension (real-time linting + format-on-save)
  • JetBrains: Built-in SQL formatter (configurable per dialect)
  • Vim/Neovim: via ALE or null-ls with sqlfluff

Dialect-Specific Formatting Considerations

PostgreSQL: Dollar-Quoted Functions

sql
CREATE OR REPLACE FUNCTION calculate_discount(
    p_customer_id INTEGER,
    p_order_total NUMERIC
)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
    v_discount NUMERIC := 0;
    v_tier TEXT;
BEGIN
    SELECT loyalty_tier INTO v_tier
    FROM customers
    WHERE id = p_customer_id;

    v_discount := CASE v_tier
        WHEN 'gold' THEN p_order_total * 0.15
        WHEN 'silver' THEN p_order_total * 0.10
        ELSE 0
    END;

    RETURN v_discount;
END;
$$;

MySQL: Stored Procedures

sql
DELIMITER //

CREATE PROCEDURE get_user_orders(
    IN p_user_id INT,
    IN p_status VARCHAR(20),
    OUT p_total DECIMAL(10, 2)
)
BEGIN
    SELECT COALESCE(SUM(amount), 0) INTO p_total
    FROM orders
    WHERE user_id = p_user_id
        AND status = COALESCE(p_status, status);
END //

DELIMITER ;

BigQuery: Nested Structs and Arrays

sql
SELECT
    user_id,
    STRUCT(
        first_name,
        last_name,
        STRUCT(
            street,
            city,
            state
        ) AS address
    ) AS user_info,
    ARRAY_AGG(
        STRUCT(order_id, amount, order_date)
        ORDER BY order_date DESC
        LIMIT 10
    ) AS recent_orders
FROM users
LEFT JOIN orders USING (user_id)
GROUP BY user_id, first_name, last_name, street, city, state;

Summary

SQL formatting is a solved problem at the tool level. The remaining human decisions are:

  1. Choose a formatter (sqlfluff for comprehensive linting + formatting, sqlfmt for zero-config)
  2. Configure the contested style choices (keyword case, comma position, indentation)
  3. Enforce in CI (pre-commit hooks or GitHub Actions)
  4. Learn the complex patterns that formatters handle imperfectly (deeply nested CTEs, multi-window-function queries)

Time spent manually formatting SQL is time wasted. Time spent configuring automated enforcement is an investment that pays off on every future commit.