Quand Doctrine ou Eloquent rejoignent une table à elle-même sans raison valable
Tout développeur qui a activé le logging SQL de son ORM favori a déjà eu une légère sueur froide en voyant les requêtes générées. Jointures redondantes, sous-requêtes imbriquées, conditions répétées… les ORM comme Doctrine (Symfony) ou Eloquent (Laravel) ne sont pas toujours frugaux. Parmi leurs travers les plus courants : joindre une table sur elle-même via sa clé primaire — une opération qui, par définition, ne change rien au résultat. PostgreSQL 18 s'attaque exactement à ce problème avec un nouveau paramètre GUC : enable_self_join_elimination.
Ce qu'est une auto-jointure inutile (et pourquoi vos ORM en produisent)
Une auto-jointure (self-join) se produit lorsqu'une table est jointe à elle-même. Dans certains cas, c'est voulu — par exemple pour comparer des lignes entre elles, ou naviguer dans une hiérarchie. Mais dans d'autres cas, c'est purement accidentel.
Cela arrive régulièrement avec les ORM, les vues imbriquées, ou les query builders complexes. Imaginez une vue qui référence une table, combinée à une autre vue qui référence la même table, et un ORM qui les assemble sans se poser de question. On se retrouve avec quelque chose comme :
SELECT t1.id, t1.name
FROM users t1
INNER JOIN users t1bis ON t1.id = t1bis.id;
Cette jointure est mathématiquement neutre : pour chaque ligne de t1, il existe exactement une ligne correspondante dans t1bis (c'est la même), et le résultat est strictement identique à un simple SELECT id, name FROM users. La jointure ne filtre rien, ne duplique rien, n'ajoute rien. Elle consomme du CPU et de la mémoire pour rien.
Comment enable_self_join_elimination fonctionne dans PostgreSQL 18
Cette optimisation, activée par défaut dans PostgreSQL 18, permet au planificateur de requêtes de détecter et supprimer automatiquement ces jointures superflues avant même l'exécution.
La logique est précise et mathématiquement prouvable. Le planificateur intervient lorsque :
- Une table est jointe à elle-même via une jointure interne (
INNER JOIN) - La condition de jointure est une égalité sur une colonne unique (typiquement la clé primaire)
Dans ce cas, deux propriétés sont garanties pour chaque ligne : au plus une ligne correspondante existe dans la seconde occurrence, et cette ligne correspondante est physiquement identique. La jointure peut donc être supprimée en toute sécurité.
Ce que ça donne concrètement sur les plans d'exécution
Sur PostgreSQL 17, la requête d'auto-jointure ci-dessus sur une table d'un million de lignes génère un plan de ce type :
Hash Join
Hash Cond: (t1.c1 = t1bis.c1)
-> Seq Scan on t1
-> Hash
-> Seq Scan on t1bis
Deux parcours séquentiels complets, une table de hachage en mémoire, un opérateur de jointure. Sur PostgreSQL 18, avec c1 comme clé primaire, le plan devient :
Seq Scan on t1 t1bis
Un seul parcours. La jointure a tout simplement disparu.
Selon les mesures publiées par Dalibo (source originale de cette analyse), le temps d'exécution passe d'environ 530 ms à 185 ms — soit une réduction de 65 % sans toucher à une seule ligne de code applicatif. La planification elle-même est également accélérée, car le planificateur a moins de tables à considérer dans son arbre de décision.
Le cas des contraintes UNIQUE nullables
L'optimisation gère aussi le cas d'une colonne couverte par une contrainte UNIQUE mais nullable (pas une clé primaire). Dans ce cas, le planificateur ne peut pas simplement supprimer la jointure : les valeurs NULL ne satisfont jamais une condition d'égalité, donc elles auraient été exclues par la jointure originale. PostgreSQL ajoute alors un filtre explicite pour conserver un résultat identique :
Seq Scan on t1 t1bis
Filter: (c1 IS NOT NULL)
Le comportement reste correct et transparent.
Ce que ça change pour les projets Symfony/PHP
Pour les applications Symfony utilisant Doctrine ORM, l'impact est direct et immédiat après une mise à jour de PostgreSQL vers la version 18.
Pas de modification de code requise. Doctrine peut générer des requêtes avec des auto-jointures redondantes dans plusieurs situations :
- Requêtes DQL construites par composition de repositories
- Utilisation de
QueryBuilderavec des jointures conditionnelles - Vues de base de données référencées plusieurs fois dans des associations
- Agrégation de critères complexes via des
Specificationpatterns
Dans tous ces cas, si la jointure concernée est une auto-jointure sur clé primaire ou unique, PostgreSQL 18 l'éliminera automatiquement au moment de la planification.
Faut-il désactiver cette optimisation ?
Le paramètre est de type user, ce qui signifie qu'il peut être modifié par session ou par requête, sans redémarrage du serveur :
-- Désactiver pour la session courante
SET enable_self_join_elimination = off;
-- Ou pour une requête spécifique
SET LOCAL enable_self_join_elimination = off;
Dans quels cas voudrait-on le désactiver ? Essentiellement pour le débogage : si vous souhaitez observer le plan d'exécution "brut" tel qu'il existait avant l'optimisation, ou si vous suspectez un bug dans le planificateur (ce qui reste très improbable pour une fonctionnalité aussi circonscrite). En production, il n'y a aucune raison de le désactiver.
Conclusion : une optimisation gratuite, mais pas anodine
enable_self_join_elimination est l'exemple type de l'amélioration que l'on obtient simplement en maintenant ses dépendances à jour. Aucun refactoring, aucune migration de schéma, aucune modification de requête : juste une mise à jour de moteur.
Cela dit, cette fonctionnalité a mis plus de sept ans à être développée et intégrée dans PostgreSQL — ce qui témoigne de la rigueur avec laquelle l'équipe PostgreSQL traite les optimisations du planificateur. Chaque cas limite (colonnes nullables, vues, CTEs) a dû être analysé et couvert.
Pour les équipes qui utilisent PostgreSQL avec Symfony/Doctrine, c'est une bonne raison supplémentaire de planifier la migration vers PostgreSQL 18 dès sa disponibilité dans votre environnement de production. Et si vous souhaitez auditer les requêtes que votre ORM génère aujourd'hui, activer le logging avec log_min_duration_statement et analyser les plans avec EXPLAIN ANALYZE reste la meilleure façon d'identifier ce type de redondance — avant ou après la mise à jour.
Source originale : Christophe Pettus — All Your GUCs in a Row: enable_self_join_elimination