Practical guide
How to solve the N+1 problem in Doctrine
Do not start by adding JOINs blindly. Measure the queries first, then load exactly the data that the use case needs.
First, the short version
Why does one list run dozens of queries?
Doctrine ORM loads the main entities with one query. When a loop then accesses a lazy association on every entity, it runs another query for each row. One query plus N more is the N+1 problem.
The right fix depends on the shape of the data. A fetch JOIN often works for a to-one association, but collections can multiply rows and break pagination. For them, it is often safer to paginate the main entities and load related data with a second batch query.
Get ready
What you need
You need a specific slow page or use case. Without measurements, it is easy to optimise an association that is not causing the problem.
- A Symfony project with Doctrine ORM and representative test data.
- The Symfony profiler or an SQL logger where you can see the number and shape of queries.
- The specific repository method and the place that traverses entities or their collections.
- A test of the expected result; optimisation must not change permissions, ordering, or item counts.
Steps 1 to 3
Find and remove redundant queries
The goal is not one query at any cost. The goal is a small, predictable number of queries without an unnecessarily large result.
1. Confirm N+1 with measurements
- Open the affected page in the dev environment and inspect the Doctrine queries in the profiler.
- Look for one list query followed by the same SQL repeatedly, with only the identifier changing.
- Find the application line that triggers lazy loading. It is often an association getter in a template, serializer, or DTO mapping.
- Record the original query count and result size. You cannot verify the fix reliably without this baseline.
php bin/console debug:config doctrine dbal Official Symfony profiler documentation 2. Choose a loading strategy by association type
- For a many-to-one or one-to-one association, add a JOIN and include the association alias in SELECT. Doctrine then loads it as a fetch JOIN without another query.
- For a read screen that does not need complete entities, consider a DTO projection. It selects only required columns and cannot activate lazy associations later.
- Do not treat a fetch JOIN as a universal solution for a one-to-many collection. It multiplies main-entity rows, uses more memory, and complicates LIMIT and OFFSET.
- Paginate main-entity identifiers first. Load collections for that page with a second query using IN (:ids), then group them in the application.
SELECT o, c FROM App\Entity\Order o JOIN o.customer c WHERE o.id = :id Official Doctrine documentation for DQL JOINs 3. Encapsulate loading in a repository
- Create a repository method named after the use case, such as findOrdersForOverview(). The loading shape then stays out of the template.
- Return entities with explicitly loaded associations or dedicated read DTOs. Do not mix both approaches without a clear reason.
- For a paginated collection fetch JOIN, use Doctrine Paginator and verify its settings; prefer two-phase loading for complex queries.
- Add a repository integration test and a sensible query-count limit to the screen test that previously caused the regression.
php bin/phpunit --filter OrderOverview Official Doctrine pagination documentation Step 4
Verify query count and data correctness
Faster SQL is not enough. The result must remain complete, ordered, and correctly paginated.
-
Compare the profiler before and after
For the same page and data, verify that repeated SQL has disappeared and the query count no longer grows with the item count.
-
Test several page sizes
Try the first, a middle, and the last page. A JOIN must not make a main entity disappear or appear twice.
php bin/phpunit --filter Pagination -
Measure query volume and time
Watch not only the SQL count but also returned rows, memory, and hydration time. One huge JOIN can be worse than two small queries.
If something goes wrong
Common problems
The DQL contains a JOIN, but extra queries remain
A JOIN alone may not load the association. Add the association alias to SELECT for a fetch JOIN and inspect the actual result in the profiler.
Items are missing or pagination is wrong after the JOIN
A to-many collection multiplies SQL rows before the limit is applied. Paginate main entities separately and batch-load collections, or use Doctrine Paginator correctly.
One query uses too much memory
Do not load the entire entity graph. Use a smaller DTO projection or two focused queries instead of a wide JOIN across several collections.
N+1 appears only during serialisation
The serializer is traversing lazy associations that the repository did not prepare. Return an explicit output DTO and map only fields in the response contract.
Done
The query count is now predictable.
Doctrine ORM now loads data for a specific use case instead of accidentally while objects are traversed. Apply the same measurement to every important list screen.