Skip to content

Laravel N+1 Query Problem: Find It, Fix It, Prevent It

Laravel N+1 query problem explained: spot it, fix it with with() and withCount(), and make preventLazyLoading() catch it before production.

Dasun Kalupahana 4 min read 128 views 0 comments
On this page
  1. What it looks like
  2. Count your queries first
  3. Fix 1: eager load with with()
  4. Fix 2: use withCount() when you only need a number
  5. Fix 3: load relations on a collection you already have
  6. Fix 4: automatic eager loading (beta)
  7. Measured results
  8. Make Laravel catch it for you
  9. Common mistakes
  10. Before you ship it

The Laravel N+1 query problem is the most common reason a page that was fast with ten rows becomes slow with a thousand. It happens when you load a list of models and then touch a relationship on each one inside a loop, so Eloquent runs one query for the list plus one more query per row. In a test with 50 posts, a simple loop that prints each post's author ran 51 queries. The same page with one extra word in the query ran 2. This post shows how to spot the problem, the fixes that matter, and how to make Laravel complain when someone introduces it again.

What it looks like

// A Post belongs to an Author, and has many Comments.
foreach (Post::all() as $post) {
    echo $post->author->name;   // lazy loads the author, one query per post
}

This is the example from the Laravel documentation: one query for the posts, then another for each post to fetch its author. Nothing looks wrong in the code, which is why it ships so easily.

Count your queries first

Do not guess. Count the queries for the code path you care about:

use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

// ... run the code you want to measure ...

dump(count(DB::getQueryLog()));

Debug tools such as Laravel Debugbar or Telescope show the same information per request. If the count grows with the number of rows, you have an N+1.

Fix 1: eager load with with()

foreach (Post::with('author')->get() as $post) {
    echo $post->author->name;
}

Eloquent now runs two queries, regardless of how many posts there are:

select * from "posts"
select * from "authors" where "authors"."id" in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

To pull only the columns you need, name them after a colon, and always include the key that links the models: Post::with('author:id,name')->get() produces select "id", "name" from "authors" ....

Fix 2: use withCount() when you only need a number

Calling $post->comments->count() in a loop loads every comment just to count it, and does it once per post (51 queries in my test). If you only need the count, let the database do it:

foreach (Post::withCount('comments')->get() as $post) {
    echo $post->comments_count;
}
select "posts".*, (select count(*) from "comments" where "posts"."id" = "comments"."post_id") as "comments_count" from "posts"

One query, and no comment rows are loaded at all.

Fix 3: load relations on a collection you already have

Sometimes the models come from somewhere you do not control, such as a paginator or a method that returns a collection. Eager load afterwards:

$posts = Post::all();
$posts->load('author');          // 2 queries in total

$posts->loadMissing('author');   // skips relations that are already loaded

Fix 4: automatic eager loading (beta)

Laravel can also eager load for you when a relationship is first accessed on one of the models in a result set. Turn it on in AppServiceProvider::boot():

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::automaticallyEagerLoadRelationships();
}

With it enabled, the original lazy loop that cost 51 queries ran in 2, and the loop that read both the author and the comments of each post ran in 3. The Laravel 12.x documentation marks this feature as beta, saying its behavior may change even in patch releases, so check the current documentation before enabling it globally. Explicit with() is still the clearer choice for code you want to read at a glance.

Measured results

Code (50 posts)Queries
Lazy: $post->author->name51
Post::with('author')2
Lazy: author and comments in the loop101
Post::with(['author', 'comments'])3
Lazy: $post->comments->count()51
Post::withCount('comments')1
Lazy loop with automaticallyEagerLoadRelationships()2

Make Laravel catch it for you

Fixing one N+1 by hand does nothing about the next one. Prevent lazy loading in development so it fails loudly:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

Now the lazy loop throws:

Illuminate\Database\LazyLoadingViolationException: Attempted to lazy load [author] on model [Post] but lazy loading is disabled.

Two details I confirmed by running it. First, the exception only fires for models that came from a result with several models; fetching one model with Post::first() and reading $post->author did not throw. Second, with() keeps working as normal, so only the accidental lazy loads are flagged.

Leaving the guard off in production is deliberate: a missed eager load there costs speed, but an exception costs the request. If you want to see violations in production without breaking pages, log them instead of throwing:

use Illuminate\Database\Eloquent\Model;

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    logger()->warning("Lazy loaded [{$relation}] on [".$model::class.']');
});

Note the method name is handleLazyLoadingViolationUsing, singular "Violation". The prose in the Laravel documentation spells it differently from its own code example, and the source uses the singular form. Model::shouldBeStrict() turns on lazy-loading prevention together with two other strictness checks, for silently discarded attributes and for accessing missing attributes, if you want all three.

Common mistakes

  • Eager loading what you never use. with() loads every related row. If you only need a count, use withCount(); if you only need two columns, select them.
  • Forgetting nested relations. A loop that reads $post->comments and then $comment->user needs with('comments.user'), or you have moved the N+1 one level down.
  • Checking only the first page. With ten test rows, 11 queries feels fine. Seed realistic data or assert on query counts in a test.

Before you ship it

Count the queries on any list page, with realistic data. Replace per-row relationship access with with() or withCount(). Add Model::preventLazyLoading(! $this->app->isProduction()) to your service provider so new N+1s fail in development and in the test suite.

References: Laravel: Eager Loading, Laravel: Preventing Lazy Loading, and Laravel: Automatic Eager Loading.

Free resources for this topic

Code snippets and fixes for this topic

Get one practical tip every week

New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.

Free download: Laravel interview questions (PDF)

Have a question about this post?

Ask the community: other developers and the author can answer.

Ask the community

Get notified about new articles

One click, no email needed. Turn it off anytime with the bell at the top.

Get new articles on your iPhone

Add DEV Talk to your Home Screen, open it, then tap the bell.

Rate this post
0.0
Share

Enjoying the content?

If this article helped you, you can support the author with a coffee. It keeps new posts coming.

Buy me a coffee (opens in a new tab)

Comments 0

Be kind. Comments with links are reviewed before they appear.

No comments yet

Be the first to share your thoughts.

Get new articles in your inbox

Practical tutorials and deep dives, about once a week. No spam, unsubscribe anytime.

Privacy policy

Add DEV Talk to your Home Screen

  1. 1 Tap the Share button in Safari's toolbar.
  2. 2 Scroll down and tap Add to Home Screen.
  3. 3 Tap Add. Open it from the Home Screen to read like an app and to turn on notifications with the bell.

We use cookies to improve your experience and analyze traffic. By continuing, you agree to our Privacy Policy.