ITSkillsCenter
Blog

Django ORM et migrations : guide pratique avancé

13 min de lecture

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

  1. QuerySets et lazy evaluation
  2. select_related vs prefetch_related
  3. Aggregations et annotations
  4. Q objects et requêtes complexes
  5. Transactions atomic
  6. Signals : utiliser avec parcimonie
  7. Managers et QuerySets custom
  8. Migrations en production
  9. Performance : indexes, EXPLAIN, raw
  10. 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.


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

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')

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 NULL sans valeur par défaut
  • ALTER 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

  1. Migration 1 : ajouter colonne nullable
  2. Backfill via job ou script (UPDATE table SET col = ... WHERE col IS NULL)
  3. Migration 2 : ajouter contrainte NOT NULL
  4. 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.

  1. Ajouter nouvelle colonne
  2. Backfill (script qui copie ancienne → nouvelle)
  3. Code écrit dans les deux
  4. Code lit nouvelle
  5. 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)


Article mis à jour le 25 avril 2026. Pour signaler une erreur ou suggérer une amélioration, écrivez-nous.

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité