data / sqlalchemy
Eager loading vs lazy loading
Explication
Ce que vous allez apprendre
- Comprendre le lazy loading, le comportement par défaut d'une
relationship() - Identifier le problème N+1 et pourquoi il reste invisible en développement
- Utiliser
selectinloadpour charger efficacement une collection (to-many) - Utiliser
joinedloadpour charger efficacement une relation simple (to-one) - Forcer la détection des N+1 en CI avec
lazy="raise_on_sql"
Dans quel contexte ?
Une API affiche la liste des 100 derniers auteurs d'un blog avec le nombre d'articles de chacun. En test, avec 5 auteurs, tout semble instantané. En production, avec des milliers d'auteurs, la page met soudain plusieurs secondes à charger. Le coupable est presque toujours le même : le problème N+1, causé par un accès à auteur.articles en lazy loading dans une boucle - exactement le sujet de cette leçon.
D'abord, une relation ne se charge pas toute seule
Une relationship() ne charge pas ses données automatiquement en même temps que l'objet parent. Par défaut, SQLAlchemy attend que le code accède réellement à auteur.articles pour déclencher une requête SQL supplémentaire — c'est le "lazy loading", un chargement paresseux, à la demande.
Le problème qui surgit dans une boucle
Ce comportement devient un piège classique dès qu'on boucle sur des résultats. Charger 100 auteurs déclenche une requête ; mais accéder aux articles de chacun dans une boucle déclenche 100 requêtes supplémentaires, soit 101 requêtes au total pour une opération qui pourrait n'en nécessiter que 2.
Ce piège porte un nom : le problème N+1
C'est l'une des causes de lenteur les plus fréquentes dans les applications utilisant un ORM, précisément parce qu'elle reste invisible sur un jeu de test avec peu de données, et n'explose qu'en production avec de vrais volumes.
La solution : dire à l'avance ce dont on aura besoin
L'eager loading consiste à dire explicitement, dès la requête initiale, "je vais aussi avoir besoin des articles, charge-les tout de suite". Deux stratégies existent pour cela.
Étape 1 : selectinload pour les collections
selectinload charge tous les enfants en une seconde requête groupée (WHERE auteur_id IN (...)), ce qui reste efficace même avec beaucoup d'enfants par parent — la stratégie à privilégier pour une collection.
Étape 2 : joinedload pour une relation simple
joinedload charge tout en un seul LEFT JOIN, efficace pour une relation vers un seul objet. Sur une collection, en revanche, il devient dangereux car il duplique des lignes dans le résultat.
| Stratégie | Nombre de requêtes | Idéal pour | Risque |
|---|---|---|---|
| Lazy (défaut) | 1 par accès à la relation | Cas rares, non répétés | Problème N+1 en boucle |
selectinload | 2 (parents + IN groupé) | Collections (to-many) | - |
joinedload | 1 (LEFT JOIN) | Relations simples (to-one) | Duplication de lignes sur du to-many |
lazy="raise_on_sql" | 0 (interdit le lazy) | Détecter les N+1 en CI | Force à toujours préciser une stratégie |
Piège fréquent
Boucler sur 100 auteurs et accéder à auteur.articles sans selectinload déclenche 100 requêtes SQL supplémentaires (une par auteur), en plus de la requête initiale : 101 requêtes au total pour une opération qui pourrait n'en nécessiter que 2. Ce coût est invisible avec 3 auteurs en test, mais devient critique avec des milliers de lignes en production.
Vers la suite
Ce choix de stratégie a un impact direct et mesurable sur les performances en production ; il reviendra explicitement dans les leçons sur la performance et sur les opérations en masse, plus loin dans le cours.
Commandes & code
Eager loading vs lazy loading
from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload, subqueryload, lazyload
# Par défaut, une relationship() est "lazy" : chargée à la première utilisation
with SessionLocal() as session:
auteurs = session.scalars(select(Auteur)).all()
for auteur in auteurs:
print(auteur.articles) # UNE requête SELECT supplémentaire PAR auteur -> problème N+1 !
# selectinload : UNE requête additionnelle pour TOUS les enfants (recommandé pour les listes)
with SessionLocal() as session:
stmt = select(Auteur).options(selectinload(Auteur.articles))
auteurs = session.scalars(stmt).all()
# SQL généré : 1 SELECT sur auteurs + 1 SELECT "WHERE auteur_id IN (1,2,3,...)"
for auteur in auteurs:
print(auteur.articles) # déjà chargé, zéro requête supplémentaire
# joinedload : UN SEUL SELECT avec LEFT JOIN (efficace pour une relation "to-one")
with SessionLocal() as session:
stmt = select(Article).options(joinedload(Article.auteur))
articles = session.scalars(stmt).all()
# SQL généré : 1 SEUL SELECT avec LEFT JOIN auteurs
for article in articles:
print(article.auteur.nom) # déjà chargé
# ATTENTION : joinedload sur une relation "to-many" multiplie les lignes (produit du JOIN)
# -> préférer selectinload pour les collections, joinedload pour les relations to-one
# Chargement imbriqué (relation de relation)
with SessionLocal() as session:
stmt = select(Auteur).options(
selectinload(Auteur.articles).selectinload(Article.commentaires)
)
auteurs = session.scalars(stmt).all()
# subqueryload : alternative à selectinload, utile pour certains cas de LIMIT complexes
stmt = select(Auteur).options(subqueryload(Auteur.articles))
# Désactiver explicitement le lazy loading pour forcer la détection des N+1 en dev
stmt = select(Article).options(lazyload(Article.auteur)) # comportement par défaut, explicite
# Configurer le lazy loading par défaut au niveau du modèle
class Auteur3(Base):
__tablename__ = "auteurs3"
id: Mapped[int] = mapped_column(primary_key=True)
articles: Mapped[list["Article"]] = relationship(
lazy="selectin" # change la stratégie par défaut, sans avoir à le préciser à chaque requête
)
# raiseload : lève une exception si un accès lazy non prévu se produit -- détecte les N+1 en CI
class Auteur4(Base):
__tablename__ = "auteurs4"
id: Mapped[int] = mapped_column(primary_key=True)
articles: Mapped[list["Article"]] = relationship(lazy="raise_on_sql")Résumé
- Lazy loading (par défaut) = une requête par accès -> risque de problème N+1 sur des listes.
selectinload: idéal pour les collections (to-many), une seule requêteINpour tous les parents.joinedload: idéal pour les relationsto-one, un seulLEFT JOINdans la requête principale.lazy="raise_on_sql"sur un modèle force à toujours préciser la stratégie de chargement, ce qui prévient les N+1 en amont.
Exercices pratiques
Mission : une page qui devient lente uniquement en production
Objectif : Diagnostiquer un problème N+1 invisible en test, puis le corriger avec la bonne stratégie de chargement.
Contexte
Une API affiche les 100 derniers auteurs d'un blog avec le nombre d'articles de chacun, via for auteur in auteurs: print(len(auteur.articles)). Avec 5 auteurs en environnement de test, la page répond instantanément. En production, avec des milliers d'auteurs, elle met plusieurs secondes à charger.