Diagnosing and Fixing the N+1 Query Problem in Doctrine ORM
A diagnostic guide to the N+1 query problem in Doctrine ORM: how to recognize lazy-loading overhead in the profiler, when JOIN FETCH is the right fix, and when eager loading makes things worse.
10 Apr 2026, 03:26 UTC

Your page loads 50 products and your profiler shows 51 SQL queries: one for the product list, then one per product to fetch its category. That is the N+1 query problem, and in Doctrine ORM it is almost always caused by lazy loading inside a loop. The fix is usually a single JOIN FETCH in a repository method — but only after you have confirmed which association is responsible and ruled out over-fetching.
The recognizable condition
Doctrine loads associations lazily by default: the related entity or collection is not queried until your code accesses it. This is efficient until you iterate:
$products = $productRepository->findAll(); // 1 query
foreach ($products as $product) {
echo $product->getCategory()->getName(); // 1 query per product
}
With 50 products, that loop issues 50 extra SELECT statements. With 5,000, the page effectively stops working. The pattern is the same for collections: calling $order->getItems() inside a loop over orders triggers one query per order.
Cause and diagnostic table
| Symptom in profiler | Likely cause | First check |
|---|---|---|
| Query count grows linearly with row count | Lazy-loaded association accessed in a loop | Look for repeated identical SELECTs differing only by ID |
| Many queries even without an explicit loop | Template or serializer touches associations (e.g., Twig rendering entity.items) | Inspect the stack trace of the repeated query |
| Huge single query, slow page, high memory | Over-eager JOIN FETCH on multiple to-many collections (Cartesian product) | Check result row count vs. entity count |
| Queries on every page, even simple ones | fetch="EAGER" set in entity mapping | Grep mappings for EAGER |
Ordered checks
- Count the queries. In Symfony, open the Profiler's Doctrine panel for the slow request and note the total query count. Outside Symfony, enable Doctrine's SQL logger (
$config->setSQLLogger(...)in older versions, or middleware-based logging in DBAL 3+) in a development environment. Never enable logging in production — it is expensive. - Identify the repeated statement. N+1 shows up as the same query shape repeated with different parameters, typically selecting one associated row by its foreign key.
- Find the access point. Trace back to the loop, template, or serializer that touches the association. The fix belongs at the query that loaded the parent entities, not at the access point.
- Check the mapping. Confirm the association does not already declare
fetch="EAGER"; if it does and you still see N+1 elsewhere, the eager fetch may be loading data you do not need on unrelated pages.
Fix: JOIN FETCH in the repository
The preferred fix is a custom repository method that fetches the association in one query. This keeps the change local to the one use case that needs it:
// src/Repository/ProductRepository.php
public function findAllWithCategory(): array
{
return $this->createQueryBuilder('p')
->addSelect('c') // hydrate the category, not just join it
->leftJoin('p.category', 'c')
->getQuery()
->getResult();
}
The critical detail is addSelect('c'). A plain leftJoin filters or sorts on the association but still lazy-loads it; adding the alias to the SELECT clause is what makes Doctrine hydrate the related entities in the same pass. After this change, the loop above runs in one query and getCategory() returns an already-initialized entity.
Fixes tied to findings
- Repeated SELECTs on a to-one association (ManyToOne): use
JOIN FETCHas above. To-one joins are safe — they never multiply result rows. - Repeated SELECTs on one to-many collection:
JOIN FETCHworks, but the result set contains one row per child. Doctrine deduplicates during hydration, so this is acceptable for moderate collection sizes. - Two or more to-many collections needed at once: do not join both in one query — the Cartesian product explodes row counts. Fetch one with
JOIN FETCHand let the other lazy-load, or fetch the second collection in a separate query usingWHERE e.id IN (:ids)and let the UnitOfWork wire them up. - EAGER in the mapping causing waste elsewhere: remove
fetch="EAGER"and add targetedJOIN FETCHqueries where the data is actually needed. Global eager loading trades one problem for a harder-to-diagnose one. - Serializer-triggered N+1 on API responses: the same fix applies — build the query that feeds the serializer with the needed joins, rather than letting normalization trigger lazy loads.
What to avoid
Resist setting fetch="EAGER" on the association as a quick fix. It applies to every query for that entity across the whole application, including pages that never display the related data, and it can silently introduce Cartesian products. Similarly, avoid DQL partial objects (SELECT PARTIAL p.{id, name}) as an N+1 workaround: partially hydrated entities can lazy-load unexpectedly later or break services that assume a fully initialized entity.
Verifying the fix
- Reload the same page with the profiler open and compare query counts before and after. The repeated statements should collapse into one join query.
- Check that the returned entities behave identically — run your existing functional test for the page or endpoint.
- If you joined a to-many collection, compare the raw SQL row count against the hydrated entity count to confirm you are not over-fetching beyond what hydration can absorb.
Escalation criteria
Escalate beyond a simple JOIN FETCH when: the page needs several to-many collections (consider multiple queries or a dedicated read model/DTO query); the dataset is large enough that hydration itself dominates (consider getArrayResult() or DBAL for read-only listings); or query count is fine but latency persists, which points at missing indexes rather than hydration strategy. At that point the problem has moved from ORM configuration to query and schema design, and should be profiled at the database level.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.