SQL Formatting Best Practices: Write Readable Queries
Poorly formatted SQL is one of the most common sources of confusion in codebases. A 200-line query crammed into a single block is nearly impossible to debug. Good SQL formatting makes queries readable, reviewable, and maintainable. This guide covers the conventions used by most professional teams.
The Basics: Keyword Casing and Alignment
Uppercase keywords
The most widely adopted convention is uppercase SQL keywords with lowercase identifiers:
-- GOOD: Keywords uppercase, identifiers lowercase
SELECT
u.name,
u.email,
COUNT(o.id) AS order_count
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.active = true
GROUP BY u.name, u.email
HAVING COUNT(o.id) > 5
ORDER BY order_count DESC
LIMIT 20;
-- BAD: All lowercase, hard to distinguish structure from data
select u.name, u.email, count(o.id) as order_count
from users u join orders o on o.user_id = u.id
where u.active = true group by u.name, u.email
having count(o.id) > 5 order by order_count desc limit 20; Uppercase keywords create a visual hierarchy — you can scan the left edge of the query and immediately see the structure (SELECT → FROM → WHERE → GROUP BY → ORDER BY).
One Clause Per Line
Major clauses should start on their own line:
SELECT
p.name,
p.price,
c.name AS category_name
FROM
products p
INNER JOIN
categories c ON c.id = p.category_id
WHERE
p.price > 10.00
AND p.active = true
AND c.name IN ('Electronics', 'Books')
ORDER BY
p.price DESC; Indentation Styles
There are two popular styles. Pick one and be consistent across your team:
Style 1: Right-aligned keywords (river style)
SELECT u.name,
u.email,
u.created_at
FROM users u
WHERE u.active = true
AND u.created_at > '2026-01-01'
ORDER BY u.created_at DESC; Style 2: Left-aligned keywords with indented columns (more common)
SELECT
u.name,
u.email,
u.created_at
FROM users u
WHERE
u.active = true
AND u.created_at > '2026-01-01'
ORDER BY u.created_at DESC; Style 2 is more prevalent in modern codebases and easier to maintain — you don't need to re-align everything when a keyword changes.
Formatting JOINs
-- GOOD: Each JOIN on its own line with clear ON conditions
SELECT
o.id AS order_id,
u.name AS customer_name,
p.name AS product_name,
oi.quantity,
oi.unit_price
FROM orders o
INNER JOIN users u
ON u.id = o.user_id
INNER JOIN order_items oi
ON oi.order_id = o.id
INNER JOIN products p
ON p.id = oi.product_id
WHERE
o.status = 'completed'
AND o.created_at >= '2026-01-01';
-- BAD: Everything crammed together
SELECT o.id, u.name, p.name, oi.quantity, oi.unit_price
FROM orders o INNER JOIN users u ON u.id = o.user_id INNER JOIN order_items oi ON oi.order_id = o.id INNER JOIN products p ON p.id = oi.product_id
WHERE o.status = 'completed' AND o.created_at >= '2026-01-01'; Formatting WHERE Conditions
-- GOOD: One condition per line, AND/OR at the start
WHERE
u.active = true
AND u.email_verified = true
AND (
u.role = 'admin'
OR u.role = 'moderator'
)
AND u.created_at >= '2026-01-01'
-- BAD: Conditions on one line
WHERE u.active = true AND u.email_verified = true AND (u.role = 'admin' OR u.role = 'moderator') AND u.created_at >= '2026-01-01' Placing AND/OR at the beginning of the line (not the end) makes it easy to comment out conditions during debugging:
WHERE
u.active = true
-- AND u.email_verified = true ← easy to toggle
AND u.created_at >= '2026-01-01' Subqueries and CTEs
Common Table Expressions (CTEs) over subqueries
CTEs (WITH clauses) are almost always more readable than nested subqueries:
-- GOOD: CTE breaks the query into named steps
WITH monthly_revenue AS (
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
),
revenue_growth AS (
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY month))
/ LAG(revenue) OVER (ORDER BY month) * 100,
1
) AS growth_pct
FROM monthly_revenue
)
SELECT *
FROM revenue_growth
WHERE growth_pct IS NOT NULL
ORDER BY month DESC;
-- BAD: Nested subqueries (same result, much harder to read)
SELECT * FROM (
SELECT month, revenue, prev_revenue,
ROUND((revenue - prev_revenue) / prev_revenue * 100, 1) AS growth_pct
FROM (
SELECT DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue,
LAG(SUM(total)) OVER (ORDER BY DATE_TRUNC('month', created_at)) AS prev_revenue
FROM orders WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
) sub
) sub2 WHERE growth_pct IS NOT NULL ORDER BY month DESC; Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Tables | Plural snake_case | users, order_items |
| Columns | Singular snake_case | first_name, created_at |
| Primary keys | id | users.id |
| Foreign keys | table_id | orders.user_id |
| Booleans | Prefix: is_, has_ | is_active, has_verified_email |
| Timestamps | Suffix: _at | created_at, deleted_at |
| Aliases | Meaningful abbreviations | u for users, oi for order_items |
Formatting INSERT Statements
-- GOOD: Columns and values aligned
INSERT INTO users (
name,
email,
role,
created_at
) VALUES (
'Alice Smith',
'alice@example.com',
'admin',
NOW()
);
-- Multi-row insert
INSERT INTO tags (name, slug) VALUES
('JavaScript', 'javascript'),
('Python', 'python'),
('Rust', 'rust'),
('Go', 'go'); Formatting CREATE TABLE
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total DECIMAL(10,2) NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT orders_status_check
CHECK (status IN ('pending', 'completed', 'cancelled'))
); SQL Formatting Tools
| Tool | Type | Best For |
|---|---|---|
| sqlfluff | CLI linter + formatter | CI/CD enforcement, team standards |
| pg_format | CLI formatter | PostgreSQL-specific formatting |
| sql-formatter (npm) | Library | JavaScript/Node.js integration |
| DBeaver / DataGrip | IDE | Format on save in your database IDE |
Enforcing SQL style in CI
# Install sqlfluff
pip install sqlfluff
# Check formatting
sqlfluff lint queries/
# Auto-fix formatting
sqlfluff fix queries/
# Configure in .sqlfluff
[sqlfluff]
dialect = postgres
max_line_length = 120
indent_unit = space
tab_space_size = 4
[sqlfluff:rules:capitalisation.keywords]
capitalisation_policy = upper When converting query results between formats, you might find our JSON to CSV Converter useful — many database tools export as JSON, and CSV is often needed for spreadsheets and reports.
To compare two versions of a query during code review, paste both into our Diff Checker for a highlighted side-by-side comparison.
Need to quickly format a messy SQL query? Paste it into our SQL Formatter — it handles MySQL, PostgreSQL, SQLite, and standard SQL with customizable indentation and keyword casing.