SQL Formatter — Read 200-Line Query Before Blaming ORM
An ORM log dumps a 200-line JOIN as a single unbroken string. Someone blames “Postgres being slow” or “the ORM being dumb.” Half the time the query is doing exactly what the code asked — including an accidental cross join or a filter applied in memory after fetching too much. Format first, accuse second.
What pretty-printing buys you in review
- JOIN graph — You can see which tables connect and whether an
ONclause is missing (cartesian explosion). - Filter placement —
WHEREversusONmistakes become obvious. - Select lists —
SELECT *across five joins shows up as a wall of columns. - Subqueries / CTEs — Nesting depth is visible; correlated subqueries stop hiding.
- ORDER BY / LIMIT — You notice sorts without indexes or limits applied too late in a wrapper.
Formatting does not make SQL faster. It makes wrong SQL embarrassing enough to fix before you rewrite the data layer.
A review ritual that works
- Reproduce the slow endpoint once with query logging enabled.
- Copy the heaviest query into a formatter.
- Read top-down: FROM/JOIN → WHERE → GROUP/aggregate → ORDER/LIMIT.
- Estimate rows at each step (EXPLAIN when needed).
- Only then change ORM code, indexes, or caching.
Paste monsters into sql-formatter when the log line is unreadable — then drop the pretty version into the PR comment so reviewers share the same picture. Diffing two formatted queries beats arguing about two one-line blobs from the slow-query log.
Patterns formatters help you spot
| Smell | Looks like after format | Likely fix |
|---|---|---|
| Missing join predicate | FROM a, b or JOIN without ON | Add relationship; avoid Cartesian |
| Filter in app | Huge result, tiny usage | Push predicate into WHERE |
| Over-fetch | Dozens of columns selected | Explicit select / partial entity |
| Duplicate joins | Same table aliased thrice | Reshape query or use EXISTS |
| OR across columns | Hard to index | Rewrite, union, or generated column |
| Late LIMIT | Sort of a million rows, keep ten | Push limit earlier or prefilter |
ORM-specific notes
- Eager load everything — Pretty SQL shows the join farm; maybe you wanted two queries.
- N+1 — Logs show many tiny queries; formatting each matters less than counting them and batching.
- Raw SQL fragments — Still format before review; string-built SQL hides typos and injection mistakes.
- Soft-delete global scopes — Formatted SQL reveals surprise
deleted_at IS NULLpredicates that change plans.
EXPLAIN after you can read it
Once the query is legible:
EXPLAIN (ANALYZE, BUFFERS)
SELECT ...
Look for sequential scans on large tables, huge row estimates, and sorts spilling to disk. A formatted query makes mapping EXPLAIN nodes back to clauses faster — especially when aliases collide in the unreadable original.
Share the formatted query in the ticket
Paste the pretty SQL into the incident doc with parameter values redacted (replace literals with $1-style placeholders if needed). Future you will thank present you when the same endpoint regresses after a “small” model change. Include the approximate row counts from EXPLAIN, not only the text.
What not to debate
- Uppercase versus lowercase keywords after the team formatter owns it
- Whether CTEs are “always bad” without measuring
- Reformatting generated migrations by hand every time
- Rewriting the ORM before anyone has read the SQL
Spend energy on predicates, indexes, and data model. Use formatting as the on-ramp. If nobody has read the SQL, you do not yet know whether you need a new ORM, a new database, or a missing WHERE clause. Pretty-print the offender, circle the join that fans out rows, and fix the small thing when it is small. Blame the ORM only after the query’s intent is visible on screen.