← Back to Blog

SQL Formatting Best Practices: Write Readable Queries

March 9, 2026 8 min read By CodeTidy Team

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

ElementConventionExample
TablesPlural snake_caseusers, order_items
ColumnsSingular snake_casefirst_name, created_at
Primary keysidusers.id
Foreign keystable_idorders.user_id
BooleansPrefix: is_, has_is_active, has_verified_email
TimestampsSuffix: _atcreated_at, deleted_at
AliasesMeaningful abbreviationsu 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

ToolTypeBest For
sqlfluffCLI linter + formatterCI/CD enforcement, team standards
pg_formatCLI formatterPostgreSQL-specific formatting
sql-formatter (npm)LibraryJavaScript/Node.js integration
DBeaver / DataGripIDEFormat 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.

Drop file to load