# Your API Is Slow and It's Probably N+1 Queries

Someone reports that a page takes four seconds to load. You open the controller expecting to find something horrifying — a nested loop, a missing index, a synchronous HTTP call to a third-party API.

Instead you find eight lines of perfectly ordinary code. It's clean. It's readable. You'd approve it in a review without a second thought.

That's the thing about N+1 queries. They don't look like bugs. They look like good code.

## What an N+1 actually is

Say you have a `/posts` endpoint that returns the 50 most recent posts, each with its author's name.

```php
public function index()
{
    $posts = Post::latest()->take(50)->get();

    return PostResource::collection($posts);
}
```

And the resource:

```php
public function toArray($request)
{
    return [
        'id'     => $this->id,
        'title'  => $this->title,
        'author' => $this->author->name,   // ← here
    ];
}
```

That single line is the whole problem.

`Post::latest()->take(50)->get()` is **one query**. It fetches 50 posts. Fine.

Then the resource runs once per post. Each time it hits `$this->author`, Eloquent notices the relation hasn't been loaded and quietly goes and fetches it. That's **50 more queries**, one at a time, each one a round trip to the database.

```sql
select * from posts order by created_at desc limit 50
select * from users where id = 7 limit 1
select * from users where id = 3 limit 1
select * from users where id = 7 limit 1   -- yes, again
select * from users where id = 12 limit 1
... 46 more
```

1 + 50 = **51 queries** for a page that needs two. And notice it re-fetches user 7 twice, because each lazy load is independent — Eloquent has no idea it already has that row in memory.

That's the N+1: one query to get the list, then N queries to get something related for each item.

![A Laravel controller and API resource that look completely clean on the left, and on the right a query log showing one select on the posts table followed by fifty repeated selects on the users table, totalling fifty-one queries and 1.9 seconds](https://cdn.hashnode.com/uploads/covers/6a166d51da253d50d4118b39/92f69d4e-752f-4ef8-9f17-f14c193b9602.svg align="center")

## Why it survives every code review

Here's what makes this bug different from most bugs: **there is nothing wrong with the code.**

`$post->author->name` is exactly how you're supposed to use an ORM. That's the whole selling point — relations feel like object properties. The abstraction is doing its job beautifully, right up until the moment it isn't.

There's no error. No warning. No lint rule fires. The tests pass, because your test fixture has three posts.

And that's the second half of it: **N is small in development and large in production.** You seed five records locally, the endpoint returns in 40ms, and you ship. Six months later there are 5,000 records, someone bumps the page size to 100, and the same code is making 100 sequential round trips per request. At 2ms per round trip that's 200ms of pure latency doing nothing but waiting.

The bug didn't appear. It was always there. Your data just grew into it.

Multiply by concurrent users and you get the classic symptom: the app is fine at 10 users and falls over at 200, and your database CPU graph looks like a wall.

## The fix: tell the ORM what you need up front

Eager loading. You say in advance which relations you're going to touch, and the ORM fetches them all in one extra query using a `WHERE IN`.

```php
$posts = Post::with('author')
    ->latest()
    ->take(50)
    ->get();
```

Two queries. Total.

```sql
select * from posts order by created_at desc limit 50
select * from users where id in (7, 3, 12, 41, 5, ...)
```

Same output, same resource code, 51 queries down to 2. You can load several relations at once:

```php
Post::with(['author', 'comments', 'tags'])->get();
```

That's four queries — one for posts, one each for authors, comments and tags — regardless of whether you're returning 50 posts or 5,000.

![The same controller with a with author call added, and a query log showing exactly two queries: one select on posts and one select on users using a where id in clause, completing in forty milliseconds](https://cdn.hashnode.com/uploads/covers/6a166d51da253d50d4118b39/355e18aa-eea0-4aa5-8d27-157373466d09.svg align="center")

Every mature ORM has this. Different spelling, same idea:

| Stack | Eager load | Nested |
| --- | --- | --- |
| Laravel / Eloquent | `->with('author')` | `->with('comments.user')` |
| Rails / ActiveRecord | `.includes(:author)` | `.includes(comments: :user)` |
| Prisma | `include: { author: true }` | nested `include` |
| TypeORM | `relations: ['author']` | `relations: ['comments.user']` |
| Django | `select_related` / `prefetch_related` | `prefetch_related('comments__user')` |

If you want it enforced rather than remembered, Laravel lets you fail loudly the moment anything lazy-loads:

```php
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
```

In dev and test that throws a `LazyLoadingViolationException` the instant you touch an unloaded relation. In production it stays off, so you get a slow page rather than a 500. I turn this on in every new project now — it turns an invisible performance bug into a loud test failure.

## The nested trap

This is the one that got me the most times. You fix an N+1, feel good about it, and immediately create another one a level down.

```php
$posts = Post::with('comments')->get();   // fixed the comments N+1

foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        echo $comment->user->name;        // ← new N+1, one per comment
    }
}
```

`comments` is eager loaded. `comments.user` is not. So now instead of one query per post, you get one query per *comment* — which is usually a much bigger number than the one you started with. Congratulations, you made it worse.

The fix is dot notation:

```php
Post::with('comments.user')->get();
```

Three queries: posts, comments for those posts, users for those comments. Rails: `includes(comments: :user)`.

The rule I use: **every relation you touch anywhere in the render path needs to appear in the `with()`.** Not just the ones in the controller — the ones in your resource classes, your Blade partials, your accessors. Especially accessors, because a computed attribute quietly touching a relation is the sneakiest version of this bug there is.

## Over-fetching: the trap on the other side

Eager loading is not free, and "just add `with()` to everything" is its own bug.

`with('comments')` on 50 posts that each have 400 comments pulls **20,000 rows into PHP memory** to render a page that shows a comment count. You traded 51 fast queries for 2 queries and an out-of-memory error. That's not a win.

Three things I reach for instead:

**If you only need a number, use a count.** No rows loaded at all.

```php
Post::withCount('comments')->get();
// then: $post->comments_count
```

**If you only need a few columns, ask for a few columns.** Include the foreign key, or the relation silently breaks — Eloquent needs `user_id` on comments to match them back to posts.

```php
Post::with([
    'author:id,name',
    'comments:id,post_id,body',
])->get();
```

**If you only need a slice, constrain the relation.**

```php
Post::with(['comments' => fn ($q) => $q->latest()->limit(3)])->get();
```

And the honest fourth option: sometimes the right answer isn't eager loading at all, it's a `join` with an explicit `select`, or a dedicated read query that returns exactly the shape the endpoint needs. ORMs are excellent until you're fighting them, and a hand-written query for one hot endpoint is not a failure of engineering.

## How to actually catch these

You will not spot N+1s by reading code. I've tried. The code looks fine — that's the entire premise of this post. You catch them by counting.

**Log queries in dev.** Cheap and immediate:

```php
DB::listen(fn ($q) => logger()->debug($q->sql, ['time' => $q->time]));
```

Fifty near-identical lines in your log is the signature. Once you've seen it, you recognise it instantly.

**Use the tools.** Laravel Debugbar puts a live query count in the corner of every page. Rails has Bullet, which pops a warning telling you exactly which `includes` you forgot. Prisma logs queries with `log: ['query']`. Django Debug Toolbar does the same job. All of them make the invisible visible.

**Assert query counts in tests.** This is the one that actually stops regressions, because it fails in CI rather than in production:

```php
public function test_posts_index_stays_within_query_budget(): void
{
    Post::factory()->count(50)->create();

    $queries = 0;
    DB::listen(function () use (&$queries) { $queries++; });

    $this->getJson('/api/posts')->assertOk();

    $this->assertLessThan(6, $queries);
}
```

Seed **50** records, not 3. A test with three fixtures passes happily at 4 queries whether the code is O(1) or O(N). The whole point is to make N big enough that a regression is obvious.

Then set a budget and hold the line: *no endpoint may exceed N queries.* Pick a number — 10 is a reasonable starting point for most read endpoints — and put it in CI. Budgets work because they're boring and automatic, and nobody has to remember anything.

![A PHPUnit test that seeds fifty posts, counts queries with a database listener and asserts the endpoint stays under six queries, next to a debugbar-style panel showing a query counter going from fifty-one queries in red to three queries in green](https://cdn.hashnode.com/uploads/covers/6a166d51da253d50d4118b39/857a9336-6c89-42ad-b7d8-8db82daa66d8.svg align="center")

## The honest caveat

Not every query inside a loop is a bug.

If you're loading five configured payment providers, or the seven days of a week, or three tenant settings — N is bounded, small, and never going to grow. Five extra queries against an indexed primary key is a couple of milliseconds. Rewriting that into a clever eager-load makes the code harder to read and buys you nothing you can measure.

The failure mode I actually see more often in review isn't the missed N+1 — it's someone eager-loading six relations "to be safe" on an endpoint that uses two of them, then wondering why the response got slower.

Optimise the endpoints you can measure. Find the slow one, count its queries, fix that. Then go back to writing features.

## My personal take

What I find interesting about N+1 is that it's not really a database problem. It's an abstraction problem.

ORMs sell you the idea that `$post->author` is just a property. It isn't — it's a network round trip wearing a property's clothes. Ninety-five percent of the time that illusion is exactly what you want, and it makes the code genuinely nicer to write and read. The other five percent, it hands you a 51-query endpoint that no reviewer will ever flag.

So I don't think the lesson is "be careful with ORMs." The lesson is that **you can't reason about this by reading — you have to measure it.** Query count is the single cheapest performance metric in a web app. It's one number, it's trivially available, and it correlates with slowness better than almost anything else you can check that fast.

We had a version of this on the Puratan Ayurveda build — a product listing that felt sluggish and turned out to be doing per-product lookups for related data. It wasn't clever to fix. It was one `with()` call. That's usually how this goes: the diagnosis is the hard part, the fix is one line.

Put a query counter in front of your face in dev, put a budget in CI, and this whole class of bug mostly stops happening to you.

## The short version

- An N+1 is 1 query for the list plus N more for each item's relation. 50 posts + authors = 51 queries where 2 would do.
- It passes review because the code is idiomatic and correct — it only hurts once N grows in production.
- Fix it with eager loading: `with('author')` in Laravel, `includes` in Rails, `include` in Prisma, `relations` in TypeORM.
- Watch the nested case: `with('comments')` then touching `$comment->user` just moves the N+1 down a level. Use `with('comments.user')`.
- Don't over-correct. Use `withCount()` for numbers, `with('author:id,name')` for specific columns, constrained closures for slices.
- Catch it by counting: `DB::listen` in dev, Debugbar or Bullet locally, query-count assertions in tests with 50 seeded records, and a hard budget in CI.
- Turn on `Model::preventLazyLoading()` outside production so lazy loads fail loudly instead of silently.
- If N is genuinely small and bounded, leave it alone. Optimise what you can measure.

---

*Written by the TechKis team — an AI-first engineering studio. [techkis.tech](https://techkis.tech)*

