SQL is one of those languages where the same query can look completely different depending on who wrote it and where it came from. A query hand-crafted by an experienced developer looks nothing like one emitted by an ORM, copy-pasted from a Stack Overflow answer, or spat out by a query builder. Learning to read SQL confidently — regardless of how it arrives — is a skill that pays off every day.
Why SQL gets messy
SQL queries accumulate messiness from a few predictable sources:
- ORMs and query builders generate syntactically correct but aesthetically hostile SQL. Column aliases are GUIDs, indentation is non-existent, and everything ends up on one line.
- Copied queries often arrive with their original formatting stripped out by a terminal, a Slack message, or a CSV export.
- Incremental edits — a WHERE clause added here, an extra JOIN bolted on there — gradually destroy whatever structure existed.
- Database logs and slow query logs record queries as a single compressed string with no whitespace at all.
The solution is always the same: format the query before you try to read it. A properly formatted query makes structure visible and bugs obvious.
The anatomy of a SELECT statement
Every SQL SELECT query follows the same clause order. Understanding what each clause does and where it sits makes even a long query approachable:
u.id,
u.name,
COUNT(o.id) AS order_count
FROM -- the primary table
users u
JOIN -- additional tables
orders o ON o.user_id = u.id
WHERE -- filter rows before grouping
u.active = 1
GROUP BY -- collapse rows into groups
u.id, u.name
HAVING -- filter groups after aggregation
COUNT(o.id) > 2
ORDER BY -- sort the final result
order_count DESC
LIMIT 50 -- cap the number of rows returned
Clauses always execute in this logical order: FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. This matters when debugging: a WHERE filter runs before aggregation, so you cannot filter on an aggregate alias in a WHERE clause — that is what HAVING is for.
FROM and JOIN to understand the data sources, then read WHERE to see what's being filtered, then finally look at SELECT to see what comes out.
Understanding JOINs at a glance
JOINs are where most of the logic in a complex query lives. There are four main types, and each produces a different set of rows. When you see a JOIN in a formatted query, the type tells you immediately what the author wanted:
| JOIN type | Returns | Use when |
|---|---|---|
INNER JOIN |
Only rows that match in both tables | You only want records with a counterpart on both sides |
LEFT JOIN |
All rows from the left table, matched or not | You want all left-side records even if the right side is empty |
RIGHT JOIN |
All rows from the right table, matched or not | Rarely used; a LEFT JOIN with tables swapped is more readable |
FULL OUTER JOIN |
All rows from both tables, matched or not | You need to see every record regardless of whether it matches |
A formatted query makes JOIN type immediately visible because each join sits on its own line with the keyword left-aligned. In a compressed query, the type often gets lost in the noise. Here is how a multi-join query looks before and after formatting:
SELECT u.name, p.title, c.body FROM users u LEFT JOIN posts p ON p.user_id=u.id INNER JOIN comments c ON c.post_id=p.id WHERE u.active=1
SELECT
u.name,
p.title,
c.body
FROM
users u
LEFT JOIN posts p ON p.user_id = u.id
INNER JOIN comments c ON c.post_id = p.id
WHERE
u.active = 1
Subqueries and CTEs — how to spot them in formatted SQL
Complex queries often embed queries inside queries. There are two main patterns: subqueries and CTEs (Common Table Expressions). Both achieve similar goals but have very different readability profiles.
Subqueries
A subquery is a SELECT statement nested inside parentheses within another query. It can appear in the FROM clause (as a derived table), in a WHERE clause (as a filter), or in the SELECT list (as a scalar subquery). In a formatted query, the inner SELECT is indented one level deeper than the surrounding clause:
u.name,
(
SELECT COUNT(*)
FROM orders o
WHERE o.user_id = u.id
) AS total_orders
FROM
users u
CTEs (WITH clauses)
CTEs use the WITH keyword to define named sub-results at the top of a query. They are far more readable than deeply nested subqueries because each logical step gets a descriptive name and stands alone. When you see a query that starts with WITH, read each named block top-to-bottom before reading the final SELECT:
SELECT id, name
FROM users
WHERE active = 1
),
order_counts AS (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
)
SELECT
au.name,
oc.cnt
FROM
active_users au
LEFT JOIN order_counts oc ON oc.user_id = au.id
Formatting conventions
Good SQL formatting follows a small set of conventions that, once learned, let you scan a query almost as fast as reading plain prose.
Keyword casing
SQL keywords — SELECT, FROM, WHERE, JOIN, GROUP BY, and so on — are conventionally written in UPPERCASE. Table names, column names, and aliases use lowercase or snake_case. This contrast makes it effortless to distinguish SQL structure from the data it operates on. Some teams use lowercase keywords throughout; either is fine as long as the codebase is consistent.
Indentation rules
The most readable SQL style aligns clause keywords on the left margin and indents their content by two or four spaces. Each clause keyword starts a new line. Column lists in a SELECT go one per line, which makes adding, removing, or commenting out a column trivially easy:
SELECT id, name, email, created_at, status FROM users
-- Easy to edit: one column per line
SELECT
id,
name,
email,
created_at,
status
FROM
users
Comma placement
There are two schools of thought: trailing commas (comma at the end of each line) and leading commas (comma at the start of the next line). Trailing commas are more natural to write; leading commas make it easier to spot a missing comma at a glance. Choose one style and be consistent — a formatter will enforce it for you automatically.
How a SQL formatter helps: before and after
Here is a real-world example of what a query looks like arriving from an ORM log, versus what it looks like after formatting:
select u.id,u.name,u.email,count(o.id) as orders,sum(o.total) as revenue from users u left join orders o on o.user_id=u.id where u.created_at>'2025-01-01' and u.active=1 group by u.id,u.name,u.email having sum(o.total)>1000 order by revenue desc limit 20
SELECT
u.id,
u.name,
u.email,
COUNT(o.id) AS orders,
SUM(o.total) AS revenue
FROM
users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE
u.created_at > '2025-01-01'
AND u.active = 1
GROUP BY
u.id,
u.name,
u.email
HAVING
SUM(o.total) > 1000
ORDER BY
revenue DESC
LIMIT 20
In the formatted version you can instantly see: there are two aggregate functions (COUNT and SUM), the join is a LEFT JOIN, the WHERE clause has two conditions, and the HAVING filters on the aggregate after grouping. None of that was obvious in the compressed version.
Format your SQL instantly
Paste any SQL query — compressed, messy, or ORM-generated — and get it formatted and readable in one click.
Open SQL Formatter