·8 min read·Blog

SQL Formatting as a Debugging Tool: Reading Queries You Didn't Write

Unformatted SQL is where bugs hide in plain sight. Here's how I use formatting as the first step when diagnosing slow queries, wrong JOIN behavior, and logic errors in queries written by ORMs, other developers, or my past self.

What ORM-generated SQL actually looks like

Most applications use an ORM (SQLAlchemy, Prisma, ActiveRecord, TypeORM, Django ORM) that generates SQL from your application code. When something goes wrong — wrong results, unexpected rows, N+1 query problems — you need to read the actual SQL. ORMs often emit queries that look like this in logs:

SELECT "users"."id","users"."name","users"."email","orders"."id" AS "order_id","orders"."total","orders"."created_at" FROM "users" LEFT JOIN "orders" ON "orders"."user_id"="users"."id" WHERE "users"."created_at" > '2025-01-01' AND "orders"."total" > 100 ORDER BY "orders"."created_at" DESC LIMIT 50

That's a single line with no whitespace. It's technically correct SQL. It's also nearly unreadable for debugging. After formatting:

SELECT
  "users"."id",
  "users"."name",
  "users"."email",
  "orders"."id" AS "order_id",
  "orders"."total",
  "orders"."created_at"
FROM "users"
LEFT JOIN "orders"
  ON "orders"."user_id" = "users"."id"
WHERE
  "users"."created_at" > '2025-01-01'
  AND "orders"."total" > 100
ORDER BY "orders"."created_at" DESC
LIMIT 50

Now you can see immediately: this is a LEFT JOIN, which means users with no qualifying orders will still appear in the results (with NULL order columns). If you expected only users with orders, you needed an INNER JOIN. That's a common bug that's invisible in the minified version and obvious in the formatted one.

The three bugs formatting reveals instantly

1. Wrong JOIN type

LEFT JOIN, RIGHT JOIN, INNER JOIN, and CROSS JOIN produce fundamentally different result sets. In a single-line query, JOIN type is easy to miss. In formatted SQL with the JOIN on its own line with the ON condition indented below it, it's the first thing you see.

Rule: any unexpected NULL columns in your result set → check the JOIN types first. A LEFT JOIN that should be INNER is the most common cause of "results include rows I didn't expect" bugs.

2. WHERE vs HAVING confusion

WHERE filters rows before aggregation. HAVING filters groups after aggregation. Using WHERE where you meant HAVING (or vice versa) produces wrong aggregates. In a long unformatted query with GROUP BY and HAVING buried in the middle, this is easy to miss. In formatted SQL, the structure is clear:

SELECT
  department_id,
  COUNT(*) AS employee_count,
  AVG(salary) AS avg_salary
FROM employees
WHERE hire_date > '2020-01-01'   -- filter ROWS before grouping
GROUP BY department_id
HAVING COUNT(*) > 5              -- filter GROUPS after aggregating

3. Subquery scope issues

Correlated subqueries (subqueries that reference the outer query's tables) are particularly prone to scope errors. When the subquery is on a single line, it's hard to see which table aliases belong to which scope. Formatting with consistent indentation per nesting level makes scope boundaries visible.

The performance debugging workflow

When a query is slow, the format-first approach is:

  1. Copy the query from the ORM log or slow query log.
  2. Format it using the SQL formatter.
  3. Read the FROM and JOIN clauses. How many tables are being joined? Any CROSS JOINs (which produce Cartesian products)? Any subqueries in the FROM clause that could be materialized?
  4. Check the WHERE clause. Are the conditions on indexed columns? Are there function calls wrapping indexed columns (which prevent index use)?
  5. Run EXPLAIN (or EXPLAIN ANALYZE in PostgreSQL) with the formatted query to read the execution plan.

Common function-call-on-indexed-column mistake:

-- Slow: YEAR() wrapping prevents index use on created_at
WHERE YEAR(created_at) = 2025

-- Fast: range condition allows index scan
WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01'

Dialect differences that bite you

SQL is not one language. PostgreSQL, MySQL, SQLite, and SQL Server have different syntax for common operations. This matters when you're formatting or copying queries between systems:

OperationPostgreSQLMySQLSQL Server
String concat||CONCAT()+
Auto-incrementSERIAL / GENERATEDAUTO_INCREMENTIDENTITY
Limit rowsLIMIT NLIMIT NTOP N / FETCH FIRST
Case sensitivityCase-sensitive by defaultCase-insensitive by defaultDepends on collation

The SQL formatter handles PostgreSQL and MySQL syntax. Specify your dialect to get accurate keyword casing and formatting for your database.

N+1 queries: the problem you see in logs, not in code

An N+1 query problem means you're running 1 query to fetch a list, then N additional queries to fetch related data for each item — often without realizing it. An ORM that lazily loads a relationship will do this silently.

Signs you have an N+1: your slow query log shows the same query pattern repeated dozens or hundreds of times with different ID values. Formatting one instance of the repeated query shows you exactly which relationship is being lazily loaded, which tells you where to add eager loading.

Related tools

  • SQL Formatter — format and indent SQL queries for MySQL, PostgreSQL, SQLite, and SQL Server. No database connection required.
  • JSON Formatter — format JSON responses from database APIs or query results serialized to JSON.

Written by Achraf A., founder of TheFreeAITools — built in Morocco. The ORM query example above is based on real queries I encountered during a PostgreSQL performance audit in 2025.

☕ Support Us