You find the slow query in the logs and it's one line, 300 characters long. Before you can work out why it's slow, you have to work out what it does, and that's a lot harder when the WHERE clause is buried in the middle.
Here's a query from a telecom billing report that we'll use throughout. It lists active subscribers and how much they used the network in October:
It's valid SQL. It's also hard to review. Is the status filter on the subscriber or on the call? Which columns are aggregated? You can answer both questions, but you shouldn't have to squint.
A Layout That Works
Most SQL style guides agree on the basics. Simon Holywell's SQL Style Guide is worth reading if you want the full argument, but it boils down to four habits:
- One clause per line:
SELECT,FROM,JOIN,WHERE,GROUP BY,ORDER BY. - One column per line in a long
SELECT, so adding or removing a column is a one-line change in a code review. - One condition per line in a
WHERE, withANDorORat the start of the line. - Keywords in uppercase, so they stand out from your table and column names.
Here's the same query with that layout applied. It now reads from top to bottom:
Now the easy questions answer themselves. status belongs to s, the subscriber. Both count and sum run over the call records. And there's a harder question the layout has just put in front of us.
The Bug the Layout Exposed
Look at the WHERE clause again. c.started_at comes from call_records, which is the right-hand side of the LEFT JOIN. For a subscriber who made no calls, every c column is NULL, and NULL >= '2026-10-01' is not true. So those subscribers get filtered out, and the LEFT JOIN quietly behaves like an inner join.
In the one-line version that's almost invisible. With each condition on its own line, you can see which table each one touches. The PostgreSQL docs on joined tables explain the rule: a condition in ON decides which rows match, while a condition in WHERE filters the joined result afterwards. If the report should include subscribers with no calls, the date filter belongs in the join:
Those subscribers now come back with calls set to 0. sum returns NULL for them, so wrap it in COALESCE(sum(c.duration_sec), 0) if the report needs a zero there too.
This is why I format a query before I debug it. It isn't about looks. A good layout puts each piece of logic on its own line, and that's where mistakes become easy to spot.
Formatting It Automatically
You can do all of that by hand, but for a query someone else wrote, paste it into our SQL Formatter. It's worth knowing exactly what it does:
- Uppercases common keywords such as
SELECT,LEFT JOIN,GROUP BYandDESC. Function names likecountandsumare left as you typed them. - Starts each major clause on a new line, and keeps each
ONon the same line as itsJOIN. - Splits a multi-column
SELECTinto one column per line, and aWHEREinto one condition per line at each top-levelANDandOR. - Leaves everything in quotes alone.
'set on fire'stays exactly as it is, even thoughSETandONare keywords. The same goes for"quoted identifiers"and comments.
That last point matters more than it sounds. In standard SQL a quote inside a string is written twice, as in 'it''s' (the PostgreSQL lexical rules cover the details), and a formatter has to track that correctly to know where a string ends. One that rewrites text inside a string literal has changed your data, not just your layout.
Where an Automatic Formatter Stops
Subqueries and CTEs are where simple formatters, ours included, reach their limit. Here's a CTE that finds subscribers who made more calls than average:
And here's what the formatter gives you:
Every clause has its own line, which already helps, but the lines inside the WITH block and the subquery aren't indented. For a query like this I format it first, then indent the bracketed parts by hand, two spaces per level:
If your team writes a lot of nested SQL, a dialect-aware linter that runs in your editor or CI, such as SQLFluff, is worth setting up. It can indent nested queries and enforce one style across the whole repository.
Minifying: Going the Other Way
Sometimes you want the one-line version back, for example to paste a query into a log message, a config value or a shell command. The formatter's Minify mode removes -- and /* */ comments and puts everything on one line. Quoted strings keep their exact contents. This:
becomes this, with both spaces inside the string still there:
If you're pasting a value with an apostrophe into a query by hand, like O'Brien, the SQL Escape tool doubles the quote for you (O''Brien). For anything that runs inside an application, though, use parameterised queries instead. The OWASP SQL Injection Prevention Cheat Sheet explains why escaping on its own isn't enough.
Habits Worth Keeping
- Format a query before you debug it. The layout shows you which table each condition touches.
- Put conditions on the right-hand table of a
LEFT JOINin theON, not theWHERE, unless you really want an inner join. - Indent subqueries and CTEs by hand, or let a dialect-aware linter do it.
- Don't trust a formatter that changes what's inside your quotes.
If your data starts life as a spreadsheet or an API response, the CSV to SQL and JSON to SQL converters will write the INSERT statements for you, and the formatter can tidy them up afterwards.