Skip to content
All posts

Databases

The N+1 Query You Ship Every Sprint

Md Shamim Hossain October 22, 2026 3 min read 2,842 views Featured Updated Oct 26, 2026

Nine out of ten performance regressions I have diagnosed in production were the same bug wearing different clothes: an unloaded relation inside a loop that nobody noticed because the staging database has 40 rows.

More ways to share

Link copied to clipboard

The bug is almost never a slow query. It is a fast query executed four hundred times.

The shape of the problem

A blog index renders nine posts, each with a category and up to four tags. The naive implementation looks harmless:

$posts = Post::query()->published()->recent()->paginate(9);

foreach ($posts as $post) {
echo $post->category->name; // one query per post
foreach ($post->tags as $tag) { // one query per post
echo $tag->name;
}
}
```

That is one query to fetch the page, then one per post for the category, then one per post for the tags. Twenty-eight queries for a nine-row page. On a staging database with forty posts it completes in 12ms and nobody looks twice.

Why it survives code review

Lazy loading is not a mistake anyone makes deliberately. It is a mistake the ORM actively encourages, because the syntax for the loaded and unloaded cases is identical. $post->category reads the same whether it costs zero queries or one, and review focuses on the algorithm rather than the access pattern.

This is a property of the tool, not a failure of discipline. Design the system as if the developer will not notice — because they will not.

Three defences, in order of reliability

1. Make the query count assertable in tests

The most reliable defence is a test that fails when the query count changes:

test('the blog index does not issue one query per post', function () {
    Post::factory()->count(9)->published()->create();

DB::enableQueryLog();

$this->get(route('blog.index'))->assertOk();

expect(DB::getQueryLog())->toHaveCount(3);
});
```

The exact number matters less than the fact that it is pinned. A change from three to twenty-eight fails the suite, and the failure message names the page.

2. Disable lazy loading outside production

Laravel can be told to throw instead of silently querying:

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

This turns a silent performance defect into an exception with a stack trace pointing at the exact property access. The cost is that some legitimate lazy loads now throw and must be wrapped in ->loadMissing().

That friction is the point. It forces an explicit decision about every relation access in a loop.

3. Push the shape into a query-budget layer

For the highest-traffic endpoints, make the budget part of the contract:

$posts = Post::query()
    ->published()
    ->with(['category:id,name,slug', 'tags:id,name,slug'])
    ->recent()
    ->paginate(9);

Selecting only the columns you render keeps the payload small, and the with() makes the eager load unconditional — it cannot be skipped by a later refactor that removes the loop.

What I do differently now

I stopped treating N+1 as a performance issue and started treating it as a correctness issue. A page that issues twenty-eight queries will behave differently on a database a hundred times larger, which means the staging environment is not testing the production behaviour. That is a correctness gap, not a tuning opportunity.

The concrete change: every listing endpoint gets a query-count assertion on the day it is written, not on the day it becomes slow.

Keep reading

Discussion

3 comments

Sign in to join the discussion. Comments are published under your name.

Markdown-style emphasis and `code` are supported. 0 / 2000
Held for moderation
  1. P

    Priya Raman

    This is the post I keep sending to new hires. The `with()` example finally made eager loading click for one of my teammates.
    1. M

      Marcus Webb

      reply
      Glad it landed. The `with()` vs `load()` distinction is the one I see trip people up most often in review.
  2. C

    Clara Nowak

    The index write-path trade-off is under-discussed. Everyone wants to add indexes; nobody wants to measure the insert cost.
Enter your credentials to continue to your workspace.
Email address
Remember me
Don't have an account? Sign up
Enter your details below to create your account
Name
Email address
Password
Confirm password
Already have an account? Log in
Enter your email and we will send you a reset link
Email address
Or, return to log in