home / skills / yanko-belov / code-craft / n-plus-one-prevention

n-plus-one-prevention skill

/skills/n-plus-one-prevention

This skill helps you prevent N+1 queries by enforcing eager loading and providing patterns to fetch related data in a single query.

npx playbooks add skill yanko-belov/code-craft --skill n-plus-one-prevention

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
4.7 KB
---
name: n-plus-one-prevention
description: Use when fetching related data in loops. Use when seeing multiple queries for one request. Use when database is slow on list endpoints.
---

# N+1 Query Prevention

## Overview

**Never query in a loop. Fetch related data in a single query.**

N+1 is when you fetch N items, then make N more queries to get related data. It's the most common database performance killer.

## When to Use

- Fetching a list with related data
- Loop that contains a database query
- Slow list/index endpoints
- Multiple queries for one API response

## The Iron Rule

```
NEVER put a database query inside a loop.
```

**No exceptions:**
- Not for "it's only a few items"
- Not for "the query is fast"
- Not for "we'll cache it"
- Not for "it's simpler"

## Detection: N+1 Pattern

If you query inside a loop, STOP:

```typescript
// ❌ VIOLATION: N+1 queries
const orders = await Order.findAll();  // 1 query

const ordersWithCustomers = await Promise.all(
  orders.map(async (order) => {
    // N queries (one per order)
    const customer = await Customer.findByPk(order.customerId);
    return { ...order, customerName: customer.name };
  })
);
// Total: 1 + N queries
```

For 100 orders = 101 database queries!

## The Correct Pattern: Eager Loading

```typescript
// ✅ CORRECT: Single query with JOIN

// Sequelize
const orders = await Order.findAll({
  include: [{ model: Customer, attributes: ['name'] }]
});
// 1 query with JOIN

// Prisma
const orders = await prisma.order.findMany({
  include: { customer: { select: { name: true } } }
});

// TypeORM
const orders = await orderRepository.find({
  relations: ['customer']
});

// Raw SQL
const orders = await db.query(`
  SELECT o.*, c.name as customer_name 
  FROM orders o
  JOIN customers c ON o.customer_id = c.id
`);
```

## Common N+1 Scenarios

### 1. Related Entity
```typescript
// ❌ N+1
posts.map(post => await User.findById(post.authorId));

// ✅ Eager load
Post.findAll({ include: [User] });
```

### 2. Aggregates
```typescript
// ❌ N+1
users.map(user => await Order.count({ where: { userId: user.id } }));

// ✅ Subquery or GROUP BY
User.findAll({
  attributes: {
    include: [[sequelize.fn('COUNT', sequelize.col('orders.id')), 'orderCount']]
  },
  include: [{ model: Order, attributes: [] }],
  group: ['User.id']
});
```

### 3. Multiple Relations
```typescript
// ❌ N+1 (multiple)
orders.map(order => {
  await Customer.findByPk(order.customerId);
  await Product.findAll({ where: { orderId: order.id } });
});

// ✅ Eager load all
Order.findAll({
  include: [Customer, Product]
});
```

## Detection Tools

```typescript
// Log query count per request
let queryCount = 0;
db.on('query', () => queryCount++);

app.use((req, res, next) => {
  queryCount = 0;
  res.on('finish', () => {
    if (queryCount > 10) {
      console.warn(`N+1 alert: ${req.path} made ${queryCount} queries`);
    }
  });
  next();
});
```

## Pressure Resistance Protocol

### 1. "It's Only a Few Items"
**Pressure:** "We only have 10 orders"

**Response:** 10 becomes 100 becomes 10,000. Fix it now.

**Action:** Always use eager loading regardless of current data size.

### 2. "The Query Is Fast"
**Pressure:** "Each query takes 1ms"

**Response:** 1ms × 1000 = 1 second. Network overhead adds more.

**Action:** One 5ms query beats 1000 × 1ms queries.

### 3. "We'll Cache It"
**Pressure:** "Redis will cache the results"

**Response:** Cache misses still hit the DB. First requests are slow. Cache adds complexity.

**Action:** Fix the query. Cache if still needed.

### 4. "It's Simpler"
**Pressure:** "Looping is easier to understand"

**Response:** Simple code that's 100x slower isn't simple.

**Action:** Learn your ORM's eager loading syntax.

## Red Flags - STOP and Reconsider

- `await` inside `.map()` or `.forEach()`
- Query count grows with result size
- List endpoints slower than detail endpoints
- "Loading..." takes forever on lists
- ORM lazy loading by default

**All of these mean: Refactor to eager loading.**

## Quick Reference

| N+1 (Bad) | Eager Loading (Good) |
|-----------|---------------------|
| Loop + query | JOIN / include |
| 1 + N queries | 1 query |
| O(N) round trips | O(1) round trips |
| Slower with more data | Constant query count |

## Common Rationalizations (All Invalid)

| Excuse | Reality |
|--------|---------|
| "Few items" | Data grows. Fix now. |
| "Fast query" | N slow > 1 medium. |
| "We'll cache" | Cache doesn't fix bad queries. |
| "It's simpler" | Slow isn't simple. |
| "ORM handles it" | ORMs default to lazy loading. |

## The Bottom Line

**One query for the list. One query for related data. Never query in a loop.**

Use eager loading (include/join) to fetch related data. Watch query counts. Any query inside a loop is a bug waiting to scale.

Overview

This skill prevents N+1 database queries by enforcing eager loading and single-query patterns when fetching related data. I focus on spotting queries inside loops and transforming them into JOINs, includes, or aggregate queries. The goal is predictable, constant query counts and fast list endpoints as data grows.

How this skill works

The skill inspects code for database queries executed inside loops or array iterations and flags patterns that produce 1 + N queries. It suggests concrete replacements using ORM eager loading (include/relations/select), raw SQL joins, or grouped aggregates so related data is fetched in a single query. It also provides runtime detection guidance by counting queries per request and emitting alerts when counts exceed a threshold.

When to use it

  • You see await or DB calls inside map(), forEach(), or any loop.
  • List or index endpoints are slower than single-item endpoints.
  • One API response triggers many database queries for related records.
  • You notice query count growing linearly with result size.
  • Preparing a code review or performance audit for database-heavy routes.

Best practices

  • Never put a database query inside a loop — always refactor to eager loading or a single aggregate query.
  • Learn and use your ORM's include/relations/select syntax for joins and nested relations.
  • Prefer a single optimized query over many tiny ones; measure query count per request.
  • Use GROUP BY or subqueries for counts/aggregates instead of per-item queries.
  • Treat caching as optional; fix the query first, then add cache if needed.

Example use cases

  • Replace per-item Customer.findByPk calls with Order.findAll({ include: [Customer] }).
  • Compute order counts per user with a single GROUP BY instead of calling count() per user.
  • Load products and their suppliers with a single join instead of querying supplier per product.
  • Add middleware to log query count per HTTP request and warn when it exceeds 10.
  • Refactor endpoints that currently make N+1 queries before traffic increases.

FAQ

Is it ever okay to query inside a loop?

No — treat any query inside a loop as a bug. Even small datasets grow and add latency; always refactor to eager loading.

What if my ORM doesn't support eager loading?

Use raw SQL joins or write a subquery that returns related data in one round trip, or switch to an ORM/extension that supports includes.

Can caching solve N+1 issues?

Caching masks symptoms but doesn't fix the underlying pattern. Cache misses still hit the database and complexity increases; fix queries first.