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:

sql

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, with AND or OR at 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:

sql

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:

sql

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 BY and DESC. Function names like count and sum are left as you typed them.
  • Starts each major clause on a new line, and keeps each ON on the same line as its JOIN.
  • Splits a multi-column SELECT into one column per line, and a WHERE into one condition per line at each top-level AND and OR.
  • Leaves everything in quotes alone. 'set on fire' stays exactly as it is, even though SET and ON are 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:

sql

And here's what the formatter gives you:

sql

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:

sql

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:

sql

becomes this, with both spaces inside the string still there:

sql

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 JOIN in the ON, not the WHERE, 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.