Using Doctrine DQL JOIN FETCH to Eliminate N+1 Queries
Learn how to load an entity and its associations with a single SQL query using Doctrine's JOIN FETCH, and understand its limits and common pitfalls.
21 Jul 2026, 21:02 UTC

Why use JOIN FETCH?
\nWhen you access a navigation property (e.g., $user->getPhotos()) on many Doctrine entities, Doctrine may issue a separate SELECT for each entity – the classic N+1 problem. By using a JOIN FETCH in DQL (or the QueryBuilder equivalent), you tell Doctrine to load the association in the same SQL statement that fetches the root entity. The result is a single query that populates both the root objects and their related objects, eliminating extra lazy‑load queries.
Worked example with QueryBuilder
\nAssume a User entity with a one‑to‑many collection photos mapped to a Photo entity. The following code builds a query that fetches users and their photos in one SELECT:
// $em is an instance of EntityManager\n$qb = $em->createQueryBuilder();\n$qb->select('u, p') // select User and Photo aliases\n ->from('User', 'u') // root entity\n ->leftJoin('u.photos', 'p') // join the photos association\n ->addSelect('p'); // ensure photos are included in the result set\n\n$query = $qb->getQuery();\n$users = $query->getResult(); // array of User objects\n\nThe generated SQL resembles:
\nSELECT u0_.id AS id0, u0_.name AS name1, p1_.id AS id2, p1_.url AS url3, p1_.user_id AS user_id4\nFROM user u0_\nLEFT JOIN photo p1_ ON p1_.user_id = u0_.id\nWHERE ...\nAfter execution, accessing $user->getPhotos() on any $user from $users will not trigger additional SQL because the photos are already hydrated.
Limits and considerations
\n- \n
- Collection-valued associations (one‑to‑many, many‑to‑many) can cause duplicate rows for the root entity when using JOIN FETCH. Doctrine deduplicates objects during hydration, but the raw SQL result set contains repeats, which affects pagination with OFFSET/LIMIT. \n
- JOIN FETCH works directly only on many‑to‑one and one‑to‑one associations when you want to avoid duplicates; for collections you must handle deduplication (e.g., using
DISTINCTor the DoctrinePaginator). \n - Using
INNER JOINinstead ofLEFT JOINwill exclude root entities that have no associated rows, which may be unintentional if the association is optional. \n - Placing WHERE conditions on the joined alias (e.g.,
WHERE p.size > 100) filters the joined rows and can unintentionally remove root entities that do not meet the condition. \n - When processing large result sets, keep memory usage in check by calling
$em->clear()or detaching processed entities periodically. \n
Common mistakes
\n- \n
- Failing to add the joined alias to the SELECT clause (e.g., only
select('u')) – the association is joined but not hydrated, leading to lazy loads. \n - Using
innerJoinwhen the association may be empty, causing missing parent rows. \n - Applying a WHERE clause on the joined alias without realizing it filters the root set. \n
- Attempting to paginate with simple
setFirstResult/setMaxResultson a collection fetch join and observing extra queries or incorrect counts. \n
Verification steps
\nTo confirm that JOIN FETCH is working as intended:
\n- \n
- Enable Doctrine’s SQL logger. In a Symfony project you can add: \n
- Run the query and inspect the log; you should see a single SELECT statement containing the JOIN. \n
- Iterate over a few returned entities and access the association property; ensure no additional SELECT statements appear in the log. \n
- For pagination tests, fetch a page with
setFirstResultandsetMaxResultsusing the DoctrinePaginator(or a distinct result) and verify that the number of queries does not increase with page size. \n
# config/packages/dev/doctrine.yaml\ndoctrine:\n dbal:\n logger: '%kernel.debug%'\n\n Practical tip for pagination with collections
\nIf you need to paginate a fetch‑joined collection, use the Doctrine Paginator which rewrites the query to handle duplicates correctly:
use Doctrine\\ORM\\Tools\\Pagination\\Paginator;\n\n$qb = $em->createQueryBuilder();\n$qb->select('u, p')\n ->from('User', 'u')\n ->leftJoin('u.photos', 'p')\n ->addSelect('p');\n\n$paginator = new Paginator($qb->getQuery(), $fetchJoinCollection = true);\n$paginator->getQuery()->setFirstResult(0)->setMaxResults(20);\n$page = iterator_to_array($paginator);\n\nThis approach issues two queries (a count query and the data query) but avoids the duplicate‑row problem that plain OFFSET/LIMIT would cause.
\nVersion notes
\nBehavior of JOIN FETCH with result set mapping is stable across Doctrine 2.x and 3.x, but the exact handling of duplicates in pagination changed slightly between releases. Always test with your specific version and consult the upgrade guide if you notice unexpected row counts.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.