DEV Community

Cover image for Laravel N+1 Query Problem: Find It, Fix It, Prevent It
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel N+1 Query Problem: Find It, Fix It, Prevent 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
}
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
select "posts".*, (select count(*) from "comments" where "posts"."id" = "comments"."post_id") as "comments_count" from "posts"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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->name 51
Post::with('author') 2
Lazy: author and comments in the loop 101
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());
}
Enter fullscreen mode Exit fullscreen mode

Now the lazy loop throws:

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

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.']');
});
Enter fullscreen mode Exit fullscreen mode

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.


Originally published on DEV Talk.

Top comments (0)