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
-- 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)
-- 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:
# Format a file
sqlfluff fix query.sql --dialect postgres
# Lint without fixing
sqlfluff lint query.sql --dialect postgres
# Configuration (.sqlfluff)
cat .sqlfluff
[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:
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:
-- 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:
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:
ROW_NUMBER() OVER (ORDER BY id) AS row_num
When it doesn't, break after OVER and indent the window specification.
CASE Expressions
-- 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
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
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 | select → SELECT |
— |
| 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
# 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
# .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
# .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
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
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
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:
- Choose a formatter (sqlfluff for comprehensive linting + formatting, sqlfmt for zero-config)
- Configure the contested style choices (keyword case, comma position, indentation)
- Enforce in CI (pre-commit hooks or GitHub Actions)
- 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.