Django 6.1 apporte deux évolutions ORM qui visent le même objectif, laisser la base faire ce qu’elle sait faire sans rendre le code opaque. Les fetch modes changent le comportement d’un champ chargé à la demande. DB_CASCADE, lui, déplace une suppression en cascade de Python vers la contrainte SQL. Les deux peuvent supprimer beaucoup de travail, mais les activer sans examiner les effets de bord est une mauvaise idée.
Les notes de version de Django 6.1 présentent ces API comme des optimisations. En pratique, elles changent surtout l’endroit où se déroule le travail. C’est ce qu’il faut comprendre avant une migration.
Les fetch modes traitent les champs réellement absents
Un queryset ne charge pas toujours tout. Une relation ForeignKey n’est résolue qu’au premier accès si elle n’a pas été chargée avec select_related(). De même, only() et defer() laissent volontairement certains champs hors de l’instance.
Jusqu’à Django 6.0, l’accès à une relation manquante se comportait de la même façon dans tous les cas : une requête par instance. Django 6.1 rend ce choix explicite avec QuerySet.fetch_mode().
from django.db import models
books = Book.objects.fetch_mode(models.FETCH_PEERS)
for book in books:
print(book.author.name)
Le mode ne s’applique pas aux seuls ForeignKey. La documentation inclut les OneToOneField, leurs accesseurs inverses, les champs différés avec only() ou defer(), ainsi que les relations génériques. Le mode est aussi propagé aux objets associés chargés par Django, ce qui est utile lorsque le code traverse plusieurs relations.
FETCH_ONE reste le comportement historique
FETCH_ONE est la valeur par défaut. Au premier book.author, Django charge l’auteur de ce seul livre. Une boucle de 100 livres peut donc produire 101 requêtes : une pour les livres, puis une par auteur manquant.
books = Book.objects.fetch_mode(models.FETCH_ONE)
for book in books:
print(book.author.name) # 1 + N requêtes potentielles
Ce comportement reste judicieux lorsqu’on ne consultera qu’une seule instance, ou lorsqu’il est improbable que les autres relations soient utilisées. Il garde aussi une propriété importante : l’accès à une relation reste local et prévisible.
FETCH_PEERS remplace le N+1 par un chargement de lot
FETCH_PEERS part d’une hypothèse simple : si le code lit un champ pour un objet d’un lot, il le lira probablement pour les autres. Au premier accès à book.author, Django récupère alors les auteurs manquants de toutes les instances provenant du même queryset.
Dans l’exemple précédent, cela donne normalement deux requêtes : une pour les livres, puis une pour les auteurs. C’est un chargement à la demande, proche de prefetch_related(), mais déclenché seulement parce que le code a réellement besoin de la relation.
books = Book.objects.fetch_mode(models.FETCH_PEERS)
for book in books:
render_book(book.title, book.author.name)
Le gain est réel dans les couches de rendu, les serializers ou les règles métier où la liste exacte des relations consultées est difficile à anticiper. Django suit les pairs au moyen de références faibles afin de ne pas retenir les instances abandonnées en mémoire.
Ce mode n’est pas une permission pour oublier les querysets. Si l’on sait qu’une vue affichera systématiquement l’auteur, select_related("author") exprime toujours mieux l’intention et évite la seconde requête. FETCH_PEERS est particulièrement intéressant quand l’accès est conditionnel ou dispersé, pas comme remplacement automatique de toute optimisation ciblée. Pour choisir entre jointure et préchargement explicite, consultez aussi select_related() et prefetch_related().
Il ne remplace pas non plus le préchargement des collections. Les requêtes des gestionnaires inverses, par exemple book.review_set.all(), ne sont pas concernées par les fetch modes. Pour une relation un-vers-plusieurs ou plusieurs-vers-plusieurs affichée en boucle, prefetch_related() reste l’outil adapté.
FETCH_RAISE transforme une régression en erreur visible
Dans une section critique, le meilleur N+1 est souvent celui qui fait échouer le test. FETCH_RAISE lève FieldFetchBlocked lorsqu’un accès nécessiterait une requête implicite.
from django.db import models
from django.core.exceptions import FieldFetchBlocked
books = Book.objects.fetch_mode(models.FETCH_RAISE)
for book in books:
print(book.author.name) # FieldFetchBlocked si author n'a pas été chargé
Utilisez-le dans un test de performance, un export volumineux ou une vue où toutes les relations doivent être déclarées. Le correctif consiste à charger explicitement ce qui est nécessaire : select_related("author"), prefetch_related("reviews") ou un champ ajouté à only().
Un manager peut aussi définir une politique par défaut :
class BookManager(models.Manager):
def get_queryset(self):
return super().get_queryset().fetch_mode(models.FETCH_PEERS)
class Book(models.Model):
author = models.ForeignKey("Author", on_delete=models.CASCADE)
objects = BookManager()
Je réserverais cette approche aux modèles dont les relations sont vraiment explorées de manière imprévisible. L’imposer globalement peut charger des données inutiles dans des commandes, des tâches et des API qui n’ont pas le même profil qu’une page HTML.
DB_CASCADE délègue enfin la cascade à la base
Le models.CASCADE historique est piloté par le collecteur Django. Il reproduit le comportement d’une contrainte ON DELETE CASCADE, en chargeant les objets nécessaires avant la suppression. Les signaux pre_delete et post_delete sont envoyés pour chaque objet supprimé, même si Model.delete() n’est pas appelé sur chaque enfant.
Django 6.1 introduit models.DB_CASCADE : la migration crée une contrainte SQL ON DELETE CASCADE et la base supprime elle-même les lignes dépendantes.
from django.db import models
class Order(models.Model):
customer = models.ForeignKey(
"Customer",
on_delete=models.DB_CASCADE,
related_name="orders",
)
Pour supprimer un client et un très grand nombre de commandes, la différence est substantielle. Django n’a plus besoin de charger les commandes pour orchestrer la cascade. La base exécute l’opération au plus près des index et des contraintes.
La contrepartie doit être traitée comme une décision de domaine : DB_CASCADE n’envoie pas les signaux pre_delete ni post_delete pour les lignes supprimées par la base. Un audit, la suppression d’un fichier, l’invalidation d’un cache ou l’émission d’un événement placés dans ces signaux ne s’exécuteront donc plus.
Les trois options DB_* et leurs contraintes
Les options disponibles sont DB_CASCADE, DB_SET_NULL et DB_SET_DEFAULT. Elles correspondent aux stratégies Python CASCADE, SET_NULL et SET_DEFAULT, mais l’action se produit dans la base.
class Ticket(models.Model):
assignee = models.ForeignKey(
"User",
null=True,
on_delete=models.DB_SET_NULL,
)
class Event(models.Model):
project = models.ForeignKey(
"Project",
db_default=1,
on_delete=models.DB_SET_DEFAULT,
)
DB_SET_NULL exige toujours null=True. DB_SET_DEFAULT exige un db_default, pas seulement un default Python, car c’est le serveur SQL qui doit connaître la valeur. Il n’est pas pris en charge par MySQL ni MariaDB.
La restriction la plus facile à manquer est structurelle. Django interdit de mélanger les variantes DB_* et les variantes gérées par Python dans un même modèle et dans les modèles reliés entre eux, à l’exception de DO_NOTHING. Avant de remplacer un CASCADE, cartographiez donc toute la chaîne de clés étrangères, pas seulement le champ modifié.
Une stratégie de migration raisonnable
Commencez par les points chauds mesurés. Pour les lectures, activez FETCH_RAISE dans un test ou sur un queryset d’export afin d’identifier les accès implicites, puis choisissez entre préchargement explicite et FETCH_PEERS. Les fetch modes de Django ne dispensent pas de mesurer le SQL ni la mémoire.
Pour les suppressions, cherchez d’abord les signaux associés aux modèles enfants. Si un signal déclenche un effet métier, conservez CASCADE ou déplacez cet effet dans un service explicite avant d’adopter DB_CASCADE. Vérifiez ensuite la migration générée sur chaque moteur de base pris en charge par votre application.
FETCH_PEERS et DB_CASCADE sont de bons outils parce qu’ils rendent les compromis plus visibles. Le premier choisit quand regrouper des lectures. Le second choisit qui, de Django ou de la base, porte les effets d’une suppression. Ce ne sont pas deux options à activer partout, ce sont deux décisions à prendre au niveau du queryset et du modèle.
