·11 min read

N+1 Is the Least of Your ORM Problems

You fixed the N+1 query. Congratulations. Now your eager loading is doing cartesian explosions, memory bloat, and ghost queries you didn't know existed. ORMs give you more ways to shoot yourself.

BackendDatabasesORMPerformancePostgreSQL

Every ORM tutorial teaches you about N+1 queries. Fetch 100 posts, loop to get each post's author, that's 101 queries. Bad. Use eager loading. Problem solved.

And then developers think they've learned ORM performance. They add .include() or .with() everywhere and move on.

I've reviewed production codebases where every data fetch was eager-loaded, every relationship was pre-joined, and the app was still slow. In some cases, slower than if they'd had N+1 problems. Because N+1 is a beginner mistake that junior devs fix. Cartesian explosions and memory bloat are what you get after you've "fixed" everything by the book.

The N+1 You Already Know

Quick recap so we're on the same page:

// Prisma example, this is the N+1 problem
const posts = await prisma.post.findMany(); // 1 query: fetch 100 posts
for (const post of posts) {
  const author = await prisma.user.findUnique({ where: { id: post.authorId } }); // 100 queries
}
// Total: 101 queries

Fix: eager load the relation.

const posts = await prisma.post.findMany({
  include: { author: true }, // 1 query with JOIN, or 2 queries in batch, depending on ORM
});

This is fine. This is the right fix for N+1. But it's where most people stop thinking.

Problem 1: The Cartesian Explosion

Say a post has authors AND tags. Both are one-to-many relationships.

const posts = await prisma.post.findMany({
  include: {
    author: true,
    tags: true,       // posts have multiple tags
    comments: true,   // posts have multiple comments
  },
});

If you're fetching 50 posts, each with 5 tags and 20 comments, how many rows is this JOIN returning?

50 posts × 5 tags × 20 comments = 5,000 rows.

Your database is assembling 5,000 rows just to represent 50 posts. The ORM then collapses them back into 50 objects in memory. You're doing 100x the work at the database level, shipping 100x the data over the wire, and using 100x the memory during deserialization, all to end up with 50 objects.

Most ORMs handle multiple has_many relations by doing separate queries in batch rather than one massive JOIN, specifically to avoid this. Prisma does this. ActiveRecord in Rails does this with preload. But not all ORMs do by default, and developers often don't check what SQL is actually running.

Log your queries. In Prisma: new PrismaClient({ log: ['query'] }). In Sequelize: logging: console.log. In Django ORM: DEBUG=True logs all queries. You should always know what's hitting your database.

The cartesian explosion is worst when you have deeply nested eager loading:

const orders = await prisma.order.findMany({
  include: {
    user: {
      include: {
        address: true,
        paymentMethods: true,
      },
    },
    items: {
      include: {
        product: {
          include: { category: true, images: true },
        },
      },
    },
    shipments: true,
  },
});

If a JOIN is involved anywhere in that chain with a many relationship, you're multiplying rows. Run EXPLAIN ANALYZE on the generated SQL and look at the actual row counts.

Problem 2: Over-Fetching Everything

Eager loading the full relation means fetching every column of every related record. That's rarely what you need.

// Fetches ALL columns from users, posts, and comments
const users = await prisma.user.findMany({
  include: { posts: { include: { comments: true } } },
});

If you're rendering a list of user names and post titles, you just pulled: user passwords (hashed, but still), bios, created_at timestamps, post body text (could be megabytes), comment bodies, comment metadata, all of it.

Use select instead of include:

const users = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    posts: {
      select: {
        id: true,
        title: true,
        _count: { select: { comments: true } }, // count without fetching
      },
    },
  },
});

This fetches exactly what you need. If a user's bio is 2kb and you're fetching 1000 users, that's 2MB of data you're pulling, deserializing, and throwing away. At scale that's real latency and real memory pressure.

Problem 3: Ghost Queries

ORMs do things you don't ask for. This is the one that tends to produce the most debugging confusion.

In Sequelize, accessing a property on an instance can trigger a lazy-load if the association isn't eager-loaded:

const user = await User.findOne({ where: { id: 42 } });
// no include

console.log(await user.getPosts()); // hidden query here
console.log(await user.getProfile()); // another hidden query

This looks like two property accesses. It's two database queries. In a loop over 50 users, that's 100 hidden queries.

TypeORM has lazy relations that trigger queries on property access too. ActiveRecord's lazy loading is famous for this, accessing post.author triggers a query if you didn't eager-load it.

The only defense is knowing your ORM's behavior mode and logging queries during development. I've seen engineers profile slow API responses and find 40+ queries they had no idea were running.

Never trust an ORM to "just work" without seeing the SQL it generates. Log queries in development, always. If query count surprises you, fix it before it reaches production.

Problem 4: The "Eager Load Everything" Antipattern

The natural overcorrection to N+1 is to include every relation everywhere, always:

// "Safe" because we won't have N+1 problems, right?
function getUser(id: string) {
  return prisma.user.findUnique({
    where: { id },
    include: {
      posts: { include: { comments: true, tags: true } },
      followers: true,
      following: true,
      profile: true,
      notifications: true,
    },
  });
}

This function is called from five different API endpoints. Two of them need only the user's name and avatar. One of them is called on every page load.

You're fetching the user's entire social graph, all their posts, all comments, all tags, all notifications, on every page load, because you wanted to avoid N+1.

The right fix is to fetch only what each endpoint needs. Create specific query functions per use case:

function getUserBasic(id: string) {
  return prisma.user.findUnique({
    where: { id },
    select: { id: true, name: true, avatarUrl: true },
  });
}

function getUserWithPosts(id: string) {
  return prisma.user.findUnique({
    where: { id },
    select: {
      id: true,
      name: true,
      posts: { select: { id: true, title: true, date: true } },
    },
  });
}

More code, yes. But each query is precisely scoped to what the caller needs.

Problem 5: Missing Indexes on Foreign Keys

This one isn't about ORMs specifically but it's where ORM users get bitten because migrations hide the SQL.

ORMs create foreign keys but don't always create indexes on them by default.

-- Prisma migration: creates the FK constraint, creates the index
-- Sequelize: creates the FK, may or may not index depending on version and config
-- Hibernate: creates the FK, does NOT automatically create an index

A JOIN on an unindexed foreign key is a sequential scan on every join. If you're joining orders to users on orders.user_id and user_id has no index, every query that filters or sorts by user ends up scanning the whole orders table.

-- Check your indexes
SELECT
  t.relname AS table_name,
  a.attname AS column_name,
  ix.relname AS index_name
FROM pg_class t
JOIN pg_attribute a ON a.attrelid = t.oid
LEFT JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
LEFT JOIN pg_class ix ON ix.oid = i.indexrelid
WHERE t.relname IN ('orders', 'users', 'posts', 'comments')
ORDER BY t.relname, a.attnum;

Check every foreign key column in your schema. Add missing indexes manually if your ORM didn't.

The Checklist

Before you call an ORM query done:

  • Logged the generated SQL, you know what's hitting the database
  • No cartesian explosion, multiple has_many eager loads are in separate queries, not one JOIN
  • Using select not include where you don't need full records
  • No lazy-load magic hidden inside loops
  • Query is scoped to what the caller actually needs, not "everything, just in case"
  • Foreign key indexes exist on all join columns

N+1 is a real problem. Fix it. But an ORM is not a query optimizer, it doesn't know what your endpoint actually needs, it doesn't know what's in your cache, and it doesn't know which of those six nested includes is about to explode your row count. That judgment is yours.