ORM-Generated SQL Gotchas: N+1, Hidden Casts, and Formatting Traps
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 log | Meaning | Fix |
|---|---|---|
Same SELECT … FROM posts WHERE author_id = $1 repeated N times with different bind values | lazy-loaded relation in a loop | INCLUDE authors / joinedload / withRelated |
SELECT … WHERE id IN ($1) immediately after a list query, once per page | correct batch loading — one query per page, not per row | leave it alone |
Two queries where one JOIN would do, called from the same request | relationship traversal in a serializer | eager-load the specific fields you serialise |
SELECT COUNT(*) next to a SELECT … LIMIT 20 on every page | paginator that counts unindexed | cached/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:
- Implicit casts from bind-parameter typing. A driver sends
idas text; Postgres comparestexttoint4and your index onidbecomes unavailable — seq scan with no warning in the ORM's output.EXPLAINis the only place you see it; see reading plans. - 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 orORDER BYreference silently points at a different relation. SELECT *expanded at runtime. Most ORMs materialise the column list from the model, so adding atextcolumn to a model changes every generated query's byte width without a code diff. The analyzer warns onSELECT *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:
| Input | Output | What 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 minified | select id from t -- tenant filter where a = 1 | the 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
;, soWHERE 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.