The N+1 Query: How Your ORM Turns 1 Request Into 201

The order list page loads fine with 5 orders in dev and falls over with 10,000 in production. Here's the ORM lazy-loading trap that causes it, how to see it in the query log, and the two real fixes: eager loading and batching.

Saksham Sharma @bytefisk

RedBite’s admin dashboard has an order list page: fetch the latest 200 orders, show each one with its customer’s name. In dev, with a handful of seed orders, it loads instantly. In production, with real volume, the same page takes 4 seconds and shows up in the slow-query dashboard every single time it’s loaded. Nobody changed the code between dev and prod — the data just got big enough to expose what was always there.

The code that looks completely reasonable

const orders = await Order.findAll({ limit: 200, order: [['createdAt', 'DESC']] });

const rows = [];
for (const order of orders) {
  const customer = await order.getCustomer(); // ORM lazy-load
  rows.push({ id: order.id, total: order.total, customerName: customer.name });
}

Nothing here looks wrong reading it top to bottom. order.getCustomer() is a normal, idiomatic ORM call — exactly how the docs show you to access a related record. The problem is invisible at the code level and only shows up in the query log:

SELECT * FROM orders ORDER BY created_at DESC LIMIT 200;
SELECT * FROM customers WHERE id = 4821;
SELECT * FROM customers WHERE id = 7733;
SELECT * FROM customers WHERE id = 1092;
... (197 more, one per order)

One query to fetch 200 orders, then 200 more queries — one per order — to fetch each one’s customer. 201 round trips to Postgres for a page that should need one or two. This is the N+1 query problem: 1 query for the list, N queries for each item’s related data, where N is however many rows happened to come back. At 5 rows in dev, N+1 is 6 queries — not even noticeable. At 200 rows in production, it’s 201 — each one a full network round trip to the database, and that’s the entire 4 seconds.

1 query: 200 orders200 separate queries, one per order:SELECT * FROM customers WHERE id = 4821SELECT * FROM customers WHERE id = 7733… 198 more, each its own round trip201 round trips for a page that needs 2

This is an ORM lazy-loading trap, specifically: order.getCustomer() is lazy by design — it fetches the related row only when you actually call it, rather than fetching every order’s customer up front whether you need it or not. Lazy loading is the right default for a single record view. Inside a loop over 200 records, the same default fires 200 times.

Fix 1: eager loading — tell the ORM up front what you’ll need

Most ORMs let you declare the related data you want at query time, so it comes back in one join instead of N follow-up queries:

const orders = await Order.findAll({
  limit: 200,
  order: [['createdAt', 'DESC']],
  include: [{ model: Customer, as: 'customer' }], // eager load
});

const rows = orders.map((order) => ({
  id: order.id,
  total: order.total,
  customerName: order.customer.name,
}));

This generates one query with a JOIN:

SELECT orders.*, customers.*
FROM orders
LEFT JOIN customers ON customers.id = orders.customer_id
ORDER BY orders.created_at DESC
LIMIT 200;

200 orders, 1 query, every customer already attached. The for loop’s await order.getCustomer() is gone entirely — order.customer is already populated from the join, no further round trip needed. This is the right fix whenever the related data is a single row per parent (order -> customer, one each) and you already know you need it for every row on the page.

Fix 2: batching — when a join would blow up the result set

Eager loading via JOIN works cleanly for one-to-one or one-to-few relations. It gets worse, not better, when the related data is one-to-many — say, showing each order with all of its line items. A JOIN there multiplies rows: 200 orders with an average of 4 items each comes back as 800 duplicated-order rows that the application then has to regroup. For that shape, batch the related lookups into a single WHERE ... IN instead of a join:

const orders = await Order.findAll({ limit: 200, order: [['createdAt', 'DESC']] });

const orderIds = orders.map((o) => o.id);
const items = await OrderItem.findAll({
  where: { orderId: { [Op.in]: orderIds } },
});

const itemsByOrder = new Map();
for (const item of items) {
  if (!itemsByOrder.has(item.orderId)) itemsByOrder.set(item.orderId, []);
  itemsByOrder.get(item.orderId).push(item);
}

const rows = orders.map((order) => ({
  id: order.id,
  items: itemsByOrder.get(order.id) ?? [],
}));

Still 2 queries total — 1 for orders, 1 for all their items via a single WHERE order_id IN (...) — regardless of whether there are 200 orders or 2,000. The grouping happens in application memory afterward, which is cheap compared to a database round trip. This is the general shape behind most “batch loader” / DataLoader-style libraries: collect the IDs you need across a whole page, fetch them in one query, then distribute the results back to each parent.

How to actually catch this before production

The reason RedBite’s dev environment never caught this: 5 seed orders means N+1 is 6 queries, invisible in a local query log nobody’s watching. The fix isn’t “write better code from memory” — it’s making N+1 visible automatically, before it ships:

The underlying rule for RedBite going forward: any endpoint returning a list should fetch its related data in a fixed number of queries, independent of how many items are in the list. If adding more rows to the result set adds more queries too, that’s the N+1 shape, whether or not it’s shown up in a slow-query alert yet.

Found an N+1 your ORM’s eager-loading syntax couldn’t express cleanly? Drop it in #scaling on Discord.

ormpostgresperformancequery-optimization