How to optimize ORM-generated queries
Quick answer: Eager-load related data instead of looping (Django
select_related/prefetch_related, Railsincludes, Prisma/Sequelizeinclude), add indexes on your foreign-key columns (PostgreSQL does not create them for you), select only the columns you need instead ofSELECT *, and paginate with keyset instead of deepOFFSET. Those four changes fix most ORM performance problems.
1. Eager-load instead of looping (kill the N+1)
The number-one ORM cost is the N+1 query: load a list, then load a related record per row. Fetch the related data up front:
- Django:
select_related('author')(foreign key / one-to-one — a JOIN in one query);prefetch_related('tags')(many-to-many / reverse FK — a second query joined in Python). - Rails (ActiveRecord):
Post.includes(:author). - Prisma:
prisma.post.findMany({ include: { author: true } }). - Sequelize:
Post.findAll({ include: [{ model: Author }] }).
One honest nuance: Prisma and Sequelize don't lazy-load a relation when you
touch a property the way Django and Rails do. Their N+1 comes from querying
inside a loop — a findUnique / findByPk per item. The fix is the same:
pull the related rows in one query with include (or a single where … in).
See how to find N+1 queries for how to detect them.
2. Index your foreign-key columns
A trap people learn the hard way: PostgreSQL does not automatically index foreign-key columns. It indexes primary keys and unique constraints, but the referencing column is left un-indexed. Every join or filter on it is a sequential scan until you add:
CREATE INDEX CONCURRENTLY ON comments (post_id);
(On a live table, always CONCURRENTLY — a plain CREATE INDEX blocks writes
for the whole build. See
adding an index without downtime.)
Your ORM generated the foreign key; it did not generate the index for the column that points at it.
3. Select only the columns you need
SELECT * moves columns the app never reads and defeats index-only scans. Ask
for what you use:
- Django:
.only('id', 'title')or.values('id', 'title'). - Rails:
.select(:id, :title). - Prisma:
select: { id: true, title: true }. - Sequelize:
attributes: ['id', 'title'].
4. Paginate with keyset, not deep OFFSET
LIMIT n OFFSET m still reads and throws away all m skipped rows, so deep pages
get progressively slower. Keyset (cursor) pagination seeks the index instead:
-- slow at depth:
SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 200000;
-- fast at any depth (unique cursor):
SELECT * FROM posts WHERE id > :last_seen_id ORDER BY id LIMIT 20;
If you page by a non-unique column like created_at, add a unique tiebreaker so
rows sharing a timestamp aren't skipped or repeated — and index both sort
columns:
SELECT * FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
CREATE INDEX CONCURRENTLY ON posts (created_at, id);
An index on created_at alone can't seek that cursor. The composite index can
(scanned backward for DESC).
Why does AI-generated ORM code hit all four?
Your AI coding agent (Claude Code, Cursor) writes the idiomatic ORM call — a
loop that reads cleanly, a findMany, a .filter() — because that's what the
training data is full of. It can't see that comments.post_id has no index, that
the table has 50 million rows, or that the endpoint is paginated to page 8,000.
Eager loading, FK indexes, column selection, and keyset pagination are all
decisions that depend on the database and the access pattern — exactly what the
model doesn't have in front of it.
How do I keep ORM queries fast?
- Default to eager loading for any relation you render in a list.
- Add an index for every foreign key you filter or join on.
- Select explicit columns on hot paths.
- Use keyset pagination anywhere users can page deep.
How DBGorilla helps
DBGorilla connects read-only and gives your AI coding agent (Claude Code, Cursor)
the database context an ORM hides — which foreign keys lack indexes, the real row
counts, and the pg_stat_statements load behind an endpoint — so it can turn a
loop into an eager-load, flag the missing FK index, and explain the fix in your
ORM's terms. It surfaces and explains through your agent; it does not touch
production. Get started free →
FAQ
What is the difference between select_related and prefetch_related in Django?
select_related follows to-one relations (FK, one-to-one) with a JOIN in one
query; prefetch_related handles to-many relations with a second query joined in
Python.
Do Prisma and Sequelize have N+1 problems?
Yes — but from querying inside a loop, not from lazy property access. They don't
transparently load a relation on access; use include to fetch related rows up
front.
Does PostgreSQL automatically index foreign key columns? No. It indexes primary keys and unique constraints, not the referencing FK column. Add that index yourself or joins on it stay slow.
Why is deep OFFSET pagination slow?
OFFSET reads and discards every skipped row. Keyset pagination
(WHERE id > :last ORDER BY id LIMIT n, or a composite (created_at, id) cursor
with a matching composite index) seeks the index and stays fast at any depth.