Lecture : 13 minutes · Niveau : intermédiaire-avancé · Mise à jour : avril 2026
L’ORM Django est l’un des plus matures de l’écosystème Python. Il permet d’écrire des requêtes complexes avec une API lisible, gère les relations naturellement, et fournit un système de migrations robuste. Mais bien l’utiliser demande de connaître ses pièges classiques (N+1, requêtes inutiles) et ses outils avancés. Ce guide rassemble ce qui compte vraiment pour livrer du Django performant en production.
L’ORM est souvent la première source de problèmes de performance dans une application Django mal calibrée. Une vue qui marche en local avec 10 enregistrements en base devient insupportable en production avec 10 000. Le diagnostic est presque toujours le même : un N+1 caché, une absence d’index, ou une requête mal pensée qui scanne toute la table. Connaître les outils de l’ORM pour détecter et corriger ces problèmes est ce qui distingue un dev Django junior d’un dev expérimenté.
Voir aussi → Django pour PME : guide backend Python.
Sommaire
- QuerySets et lazy evaluation
- select_related vs prefetch_related
- Aggregations et annotations
- Q objects et requêtes complexes
- Transactions atomic
- Signals : utiliser avec parcimonie
- Managers et QuerySets custom
- Migrations en production
- Performance : indexes, EXPLAIN, raw
- FAQ
1. QuerySets et lazy evaluation
Les QuerySets Django sont lazy : ils ne touchent pas la base avant d’être évalués (itération, slicing, len, list, etc.).
qs = Client.objects.filter(actif=True) # 0 requête
qs = qs.exclude(secteur='services') # 0 requête
qs = qs.order_by('-created_at') # 0 requête
for client in qs: # 1 requête ICI
print(client.nom)
Cette laziness permet de chaîner les filtres sans pénalité de performance. Mais piège classique : itérer plusieurs fois sur le même queryset = plusieurs requêtes.
# Mauvais : 2 requêtes
qs = Client.objects.filter(actif=True)
print(len(qs))
for c in qs:
pass
# Bon : 1 requête
clients = list(Client.objects.filter(actif=True))
print(len(clients))
for c in clients:
pass
Pour vérifier le nombre de requêtes en dev : Django Debug Toolbar.
Méthodes utiles
# Existence (plus rapide que len() ou count())
if Client.objects.filter(email=email).exists():
pass
# Premier ou None
client = Client.objects.filter(email=email).first()
# get_or_create : récupère ou crée
client, created = Client.objects.get_or_create(
email=email,
defaults={'nom': 'Default'},
)
# update_or_create : update si existe, create sinon
client, created = Client.objects.update_or_create(
email=email,
defaults={'nom': new_nom, 'actif': True},
)
# in_bulk pour récupération massive par PK
clients_dict = Client.objects.in_bulk([1, 2, 3]) # {1: <Client>, ...}
# values() et values_list() : sans hydration des objets
emails = Client.objects.values_list('email', flat=True)
data = Client.objects.values('id', 'nom', 'email')
values() est très rapide quand on n’a pas besoin des méthodes du modèle. Préférer pour des exports massifs.
2. select_related vs prefetch_related
Le piège N+1 le plus classique en Django.
Le problème
# 1 + N requêtes
clients = Client.objects.all()
for client in clients:
print(client.country.name) # 1 requête PAR client pour récupérer country
select_related (1-N et 1-1, JOIN SQL)
Pour des ForeignKey et OneToOneField :
# 1 seule requête avec JOIN
clients = Client.objects.select_related('country').all()
for client in clients:
print(client.country.name)
Plusieurs niveaux :
clients = Client.objects.select_related('country__continent', 'address__city')
prefetch_related (M2M et reverse, requêtes séparées)
Pour des relations many-to-many ou reverse foreign keys :
clients = Client.objects.prefetch_related('orders')
for client in clients:
for order in client.orders.all(): # déjà chargé, 0 requête
...
prefetch_related fait 2 requêtes : une pour clients, une pour toutes les orders avec WHERE IN. Pas de JOIN.
Combinaison
clients = Client.objects.select_related('country').prefetch_related(
'orders',
'orders__items',
)
Prefetch avec filtre
from django.db.models import Prefetch
clients = Client.objects.prefetch_related(
Prefetch('orders', queryset=Order.objects.filter(status='paid'), to_attr='paid_orders')
)
for client in clients:
print(client.paid_orders) # liste filtrée
Permet de filtrer la relation pré-chargée.
3. Aggregations et annotations
from django.db.models import Count, Sum, Avg, Min, Max, F, Q
# Aggregate global (1 ligne en sortie)
stats = Order.objects.aggregate(
total=Sum('amount'),
count=Count('id'),
average=Avg('amount'),
)
# {'total': 1234567, 'count': 89, 'average': 13871.32}
# Annotate (ajoute un champ calculé à chaque ligne)
clients = Client.objects.annotate(
orders_count=Count('orders'),
total_ca=Sum('orders__amount'),
last_order=Max('orders__created_at'),
).filter(orders_count__gt=0).order_by('-total_ca')
for client in clients:
print(client.nom, client.orders_count, client.total_ca)
F expressions : référencer un autre champ
# Update sans race condition
Product.objects.filter(id=pk).update(stock=F('stock') - 1)
# Filtres comparant deux champs
Order.objects.filter(amount_paid__lt=F('amount_total'))
Conditional aggregates
from django.db.models import Case, When, IntegerField
stats = Order.objects.aggregate(
paid_count=Count('id', filter=Q(status='paid')),
pending_count=Count('id', filter=Q(status='pending')),
)
Une seule requête, plusieurs comptages conditionnels.
4. Q objects et requêtes complexes
from django.db.models import Q
# OR : ()|()
clients = Client.objects.filter(
Q(secteur='agro') | Q(secteur='commerce')
)
# AND avec groupes
clients = Client.objects.filter(
Q(actif=True) & (Q(secteur='agro') | Q(secteur='btp'))
)
# NOT
clients = Client.objects.filter(~Q(secteur='services'))
Construire dynamiquement
filters = Q()
if request.GET.get('actif'):
filters &= Q(actif=True)
if search := request.GET.get('search'):
filters &= Q(nom__icontains=search) | Q(email__icontains=search)
clients = Client.objects.filter(filters)
Pratique pour des recherches multi-critères.
Lookups avancés
Client.objects.filter(nom__icontains='acme') # contient (insensible casse)
Client.objects.filter(email__startswith='admin@')
Client.objects.filter(created_at__year=2026)
Client.objects.filter(created_at__date=date.today())
Client.objects.filter(orders__amount__gte=1000) # à travers une relation
Client.objects.filter(metadata__has_key='preferred') # JSONField
Client.objects.filter(metadata__source='import') # JSONField path
JSONField + lookups = puissance énorme pour des données semi-structurées.
5. Transactions atomic
from django.db import transaction
@transaction.atomic
def transfer(source_id, target_id, amount):
source = Account.objects.select_for_update().get(pk=source_id)
target = Account.objects.select_for_update().get(pk=target_id)
if source.balance < amount:
raise InsufficientFunds()
source.balance -= amount
target.balance += amount
source.save()
target.save()
Transfer.objects.create(source=source, target=target, amount=amount)
@transaction.atomic : tout ou rien. Si une exception est levée, tout est rollback.
select_for_update() : verrou row-level pour éviter les races conditions concurrentes (l’autre transaction attend que celle-ci soit commit).
atomic avec savepoint
@transaction.atomic
def process_batch(items):
for item in items:
try:
with transaction.atomic(): # savepoint imbriqué
process_item(item)
except ItemError:
log.warning(f"Skipped {item}")
# Le savepoint est rollback, mais l'atomic externe continue
Permet des « try sans tout casser » dans une transaction plus large.
on_commit : action après commit réussi
def order_paid(order):
transaction.on_commit(lambda: send_confirmation_email.delay(order.id))
Garantit que l’email n’est envoyé QUE si la transaction est committée. Évite le cas pénible où l’email part puis la transaction rollback.
6. Signals : utiliser avec parcimonie
Django émet des signaux à différents points (pre_save, post_save, pre_delete, post_delete, m2m_changed).
# clients/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Client
@receiver(post_save, sender=Client)
def client_created(sender, instance, created, **kwargs):
if created:
AuditLog.objects.create(
action='client_created',
client=instance,
)
Quand utiliser des signaux
- Logique transverse à plusieurs apps (audit log)
- Cache invalidation centralisée
- Hooks d’extension
Quand ne pas en utiliser
- Logique métier qui aurait sa place dans le modèle ou un service : signaux rendent le code « magique » et difficile à tracer
- Effets de bord visibles ailleurs : préférer une méthode explicite sur le modèle ou un service métier
Beaucoup de bases Django legacy souffrent d’avoir trop de signaux : on ne sait plus pourquoi tel side effect arrive. Préférer du code explicite quand possible.
7. Managers et QuerySets custom
class ClientQuerySet(models.QuerySet):
def actif(self):
return self.filter(actif=True)
def with_orders_count(self):
return self.annotate(orders_count=Count('orders'))
def from_secteur(self, secteur):
return self.filter(secteur=secteur)
class ClientManager(models.Manager):
def get_queryset(self):
return ClientQuerySet(self.model, using=self._db)
def actif(self):
return self.get_queryset().actif()
def with_orders_count(self):
return self.get_queryset().with_orders_count()
class Client(models.Model):
# ...
objects = ClientManager()
# Usage
Client.objects.actif().from_secteur('agro').with_orders_count()
Composable, lisible, réutilisable. Centralise la logique de query au bon endroit.
Manager avec from_queryset
Plus court :
class Client(models.Model):
# ...
objects = ClientQuerySet.as_manager()
Une ligne, méthodes du QuerySet directement disponibles sur le manager.
8. Migrations en production
Les migrations Django génèrent du SQL depuis les changements de modèles. En production, certaines précautions s’imposent.
Workflow standard
python manage.py makemigrations # détecte les changements, génère les fichiers
python manage.py migrate # applique
Toujours commiter les fichiers de migration dans Git.
Migrations risquées
Sur une base de production, ces opérations bloquent les écritures (verrou table) :
ALTER TABLE ADD COLUMN NOT NULLsans valeur par défautALTER TABLE DROP COLUMN(court mais bloquant)CREATE INDEX(peut bloquer plusieurs minutes sur grosses tables)
Stratégies
Index concurrent (PostgreSQL)
class Migration(migrations.Migration):
atomic = False # important pour CONCURRENTLY
operations = [
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY idx_client_email ON clients(email);",
reverse_sql="DROP INDEX idx_client_email;",
),
]
CONCURRENTLY créé l’index sans bloquer les écritures. Indispensable sur grosses tables.
Ajout colonne NOT NULL en plusieurs étapes
- Migration 1 : ajouter colonne nullable
- Backfill via job ou script (
UPDATE table SET col = ... WHERE col IS NULL) - Migration 2 : ajouter contrainte NOT NULL
- Si la valeur par défaut suffit :
default=...directement, mais Django remplit toutes les lignes au moment du ALTER (peut prendre du temps)
Renommer une colonne
Ne jamais renommer en une seule étape avec du code en cours d’exécution.
- Ajouter nouvelle colonne
- Backfill (script qui copie ancienne → nouvelle)
- Code écrit dans les deux
- Code lit nouvelle
- Drop ancienne
Fake migrations
Pour synchroniser l’état Django avec une base existante sans rien appliquer :
python manage.py migrate --fake app_name 0042
Utile pour réparer des incidents ou intégrer du legacy.
Squash migrations
Quand le dossier migrations devient encombré :
python manage.py squashmigrations app_name 0001 0042
Combine plusieurs migrations en une, accélère les tests et les nouvelles installations.
9. Performance : indexes, EXPLAIN, raw
Indexes
Premier réflexe d’optimisation. À déclarer dans le modèle :
class Client(models.Model):
email = models.EmailField(unique=True) # crée un index unique
secteur = models.CharField(max_length=20)
class Meta:
indexes = [
models.Index(fields=['secteur', 'actif']), # index composé
models.Index(fields=['-created_at']), # index DESC
]
EXPLAIN sur PostgreSQL
print(Client.objects.filter(email='x@test.com').explain(analyze=True))
Affiche le plan d’exécution. Si Seq Scan sur grosse table = il manque un index.
Queries lentes
Configurer Django/PostgreSQL pour logger les queries lentes :
-- PostgreSQL
ALTER SYSTEM SET log_min_duration_statement = '1000'; -- log queries > 1 sec
Raw SQL quand nécessaire
clients = Client.objects.raw(
'SELECT * FROM clients WHERE secteur = %s AND actif = true',
['agro']
)
# Retourne des Client (avec hydration)
# Sans hydration
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT secteur, COUNT(*) FROM clients GROUP BY secteur")
rows = cursor.fetchall()
Pour des aggregations très complexes ou window functions, raw SQL reste plus simple que d’essayer de tout faire avec l’ORM.
Bulk operations
# bulk_create : insert massif
Client.objects.bulk_create([Client(nom=n) for n in noms], batch_size=1000)
# bulk_update : update massif
Client.objects.bulk_update(clients_modified, ['actif'], batch_size=1000)
# update massif via QuerySet
Client.objects.filter(actif=False).update(archived=True)
Pour traiter des milliers de lignes : ces méthodes sont mille fois plus rapides qu’une boucle Python avec save().
10. FAQ
Comment détecter les N+1 ?
Django Debug Toolbar en dev affiche le nombre de requêtes par vue. Pour la prod : django-silk ou intégration Sentry/APM. Larastan-équivalent Python : peu mature, donc inspection manuelle reste la règle.
only() vs defer() ?
only('field1', 'field2') : ne charge QUE ces champs. defer('big_field') : charge tout SAUF ce champ. Utile pour des modèles avec des gros champs (TextField, JSONField) qu’on n’utilise pas toujours.
Q objects ou filtres chainés ?
Filtres chainés = AND implicite. Q objects pour OR ou logique complexe. Les deux peuvent se combiner : qs.filter(Q(a) | Q(b)).filter(c=1).
Migrations conflict en équipe ?
Quand deux PRs créent des migrations 0042 simultanément : python manage.py makemigrations --merge génère une migration de fusion. À tester avant de merger.
Comment seed la DB ?
Fixtures (python manage.py loaddata initial.json) ou commande de management custom + factory_boy pour générer des données réalistes. Factory_boy est plus flexible et maintenable que des JSON statiques.
Soft delete dans Django ?
Pas natif. Packages : django-softdelete, django-paranoid. Ou implémenter manuellement avec un champ deleted_at et un Manager qui filtre par défaut. À utiliser avec prudence : multiplie les pièges (relations, unique constraints).
Database routing pour multi-DB ?
Django supporte multi-DB via DATABASE_ROUTERS. Utile pour read-replica, sharding, ou multi-tenancy. Configuration non-triviale, à n’aborder que si vrai besoin.
Articles liés (cluster Django)
- 👉 Django pour PME : guide backend Python (pillar)
- 👉 Django REST Framework en pratique
- 👉 Django déploiement production
Article mis à jour le 25 avril 2026. Pour signaler une erreur ou suggérer une amélioration, écrivez-nous.