SQLFormat SQL Formatter

ORM-Generated SQL Gotchas: N+1, Hidden Casts, and Formatting Traps

SQLFormat — Free Online SQL Formatter, Beautifier & Minifier Guides · Updated 2026-10-12 · All guides

An ORM does not write SQL, it writes templates that become SQL after your call chain resolves. The gap between the two is where the performance report comes from, and the fix is always the same first step: capture what actually went over the wire.

Capture it, do not infer it

# Postgres: log everything for one minute, then turn it back off
ALTER SYSTEM SET log_min_duration_statement = '0';  SELECT pg_reload_conf();
tail -f $PGDATA/log/postgresql-*.log | grep statement

# MySQL
SET GLOBAL general_log = 1; SET GLOBAL general_log_file = '/tmp/q.log';
# … reproduce the request, then:
SET GLOBAL general_log = 0;

pg_stat_statements is the better tool when you cannot restart: SELECT calls, mean_exec_time, query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20 gives you the real top-N, with call counts, which is the number that identifies an N+1 rather than a slow query.

Or read it from your ORM without enabling anything: Prisma log: ['query'], Drizzle logger: true, SQLAlchemy echo=True, Sequelize logging: console, Django django.db.connection.queries or DEBUG-toolbar. The output is parameterised and dialect-specific, which is exactly why formatting it is a separate step from capturing it.

The shapes that betray an N+1

Shape in the logMeaningFix
Same SELECT … FROM posts WHERE author_id = $1 repeated N times with different bind valueslazy-loaded relation in a loopINCLUDE authors / joinedload / withRelated
SELECT … WHERE id IN ($1) immediately after a list query, once per pagecorrect batch loading — one query per page, not per rowleave it alone
Two queries where one JOIN would do, called from the same requestrelationship traversal in a serializereager-load the specific fields you serialise
SELECT COUNT(*) next to a SELECT … LIMIT 20 on every pagepaginator that counts unindexedcached/estimated count, or keyset pagination with no total

The distinction in row two is the one that gets people fired: a well-configured ORM emits a second query per collection, and that is good design. Three-plus identical statements in one request is the smell. The query analyzer flags exactly that rule — the same table appearing in three or more SELECTs across a pasted script, reported once at the first occurrence — which is why it is worth pasting a captured log, not just a single query.

Generated SQL has a different failure mode than hand-written

Three classes that never appear in tutorials:

  1. Implicit casts from bind-parameter typing. A driver sends id as text; Postgres compares text to int4 and your index on id becomes unavailable — seq scan with no warning in the ORM's output. EXPLAIN is the only place you see it; see reading plans.
  2. Alias collision on self-joins. Generated aliases (t1, posts_1, author__users) are stable per call chain, so a refactor changes them and your hand-written index hint or ORDER BY reference silently points at a different relation.
  3. SELECT * expanded at runtime. Most ORMs materialise the column list from the model, so adding a text column to a model changes every generated query's byte width without a code diff. The analyzer warns on SELECT * for that reason.

Postgres-only, and the single best ORM-related habit: add RETURNING id (or the full row) so a create does not need a follow-up SELECT. It is not an optimisation trick, it removes a round-trip and the race between it.

Before you paste generated SQL into a formatter

A keyword-anchored formatter is regex over text; it does not parse. Measured against the shipped formatter on this site, with the same inputs a log file gives you:

InputOutputWhat happened
where s = 'from here'where s = '⏎FROM here'the FROM clause rule fired inside the string literal, and uppercased it
where x = 'a, b'… 'a,⏎ b'the comma rule inserted a newline inside the literal
any 'a b''a b'the first step is replace(/\s+/g,' ') — whitespace inside values collapses everywhere
select id from t -- tenant filter⏎where a = 1 minifiedselect id from t -- tenant filter where a = 1the whole WHERE is now commented out — a line comment absorbed the rest of the statement

That last one is not cosmetic: minify collapses newlines, and a -- comment runs to end of line, so a log line with an inline comment minifies into a statement with its predicate deleted. It still looks valid and still returns 200 rows.

Rules for pasting captured queries:

  • Strip line comments before formatting (sed 's/--.*//'), or use /* … */ where the generator allows it.
  • Expect string literals to be reformatted; diff the values, not the layout, if a query is going back into code.
  • The formatter on this site is documented as keyword-anchored, not a parser — the query analyzer does track quote state when splitting statements on ;, so WHERE note = 'a;b' splits into one statement, not two. Two tools, two different levels of SQL understanding, both deliberately limited.

For anything that must survive reformatting, use a real parser — sqlglot (Python, transpiles dialects too), node-sql-parser, or sql-formatter from the npm ecosystem — and see libraries for what each is actually for. Dialect porting traps are on postgres vs mysql.

Developer Sponsor / Partner
Copied to clipboard!