Deux requêtes simultanées lisent le stock d’un produit, constatent qu’il en reste un, et toutes les deux valident la commande. Le stock passe à -1. Ce type de condition de course est difficile à reproduire en développement et dévastateur en production. select_for_update() est la réponse de Django : poser un verrou SQL au moment de la lecture pour garantir qu’aucune autre transaction ne peut modifier la ligne avant la fin de l’opération.

Ce que fait select_for_update() en SQL

select_for_update() génère un SELECT ... FOR UPDATE. Le verrou est acquis dès que le queryset est évalué et tenu jusqu’à la fin du bloc transaction.atomic(). Pendant ce temps, toute autre transaction qui tente d’acquérir un verrou sur les mêmes lignes est mise en attente.

from django.db import transaction

with transaction.atomic():
    product = Product.objects.select_for_update().get(pk=pk)
    # Aucune autre transaction ne peut modifier product pendant ce bloc
    product.stock -= quantity
    product.save()

Règle absolue : select_for_update() doit être appelé à l’intérieur d’un bloc transaction.atomic(). Django lève une TransactionManagementError si ce n’est pas le cas.

Les paramètres qui changent le comportement

nowait=True : échouer plutôt qu’attendre

Par défaut, une transaction qui tente de verrouiller des lignes déjà occupées est mise en attente jusqu’à leur libération. Avec nowait=True, Django génère FOR UPDATE NOWAIT et lève immédiatement une OperationalError si les lignes sont verrouillées.

from django.db import OperationalError, transaction

try:
    with transaction.atomic():
        account = Account.objects.select_for_update(nowait=True).get(pk=pk)
        account.balance -= amount
        account.save()
except OperationalError:
    raise AccountLocked("Ce compte est en cours de modification.")

Utile pour les APIs où bloquer la requête HTTP pendant plusieurs secondes n’est pas acceptable.

Support : PostgreSQL, Oracle, MySQL 8.0+.

skip_locked=True : ignorer les lignes verrouillées

FOR UPDATE SKIP LOCKED est le paramètre de référence pour les files de traitement. Au lieu de bloquer sur les lignes verrouillées, le queryset les ignore et retourne seulement les lignes disponibles. Plusieurs workers peuvent ainsi travailler en parallèle sans se bloquer mutuellement.

def claim_next_task(worker_id: int) -> Task | None:
    with transaction.atomic():
        task = (
            Task.objects
            .select_for_update(skip_locked=True)
            .filter(status="pending")
            .order_by("created_at")
            .first()
        )
        if task:
            task.status = "processing"
            task.worker_id = worker_id
            task.save()
        return task

Chaque worker appelle cette fonction et obtient une tâche différente, sans risque de conflit.

Support : PostgreSQL 9.5+, MySQL 8.0.1+.

of=(…) : cibler les tables à verrouiller

Quand on utilise select_related(), Django verrouille par défaut toutes les tables jointes. Le paramètre of (PostgreSQL uniquement) permet de cibler précisément quelles tables verrouiller.

# Verrouille order ET customer par défaut
Order.objects.select_related("customer").select_for_update().get(pk=pk)

# Verrouille seulement la table orders
Order.objects.select_related("customer").select_for_update(of=("self",)).get(pk=pk)

Sans of, des deadlocks peuvent survenir si d’autres transactions verrouillent les tables jointes dans un ordre différent.

Support : PostgreSQL uniquement.

no_key=True : verrou faible (PostgreSQL uniquement)

FOR NO KEY UPDATE est un verrou plus permissif : il bloque les autres FOR UPDATE mais n’interfère pas avec les SELECT FOR SHARE ni avec les INSERTs sur les tables filles référençant cette table par clé étrangère. Disponible depuis Django 3.2.

# Autorise l'insertion d'OrderItem pendant qu'Order est verrouillé
order = Order.objects.select_for_update(no_key=True).get(pk=pk)

Les pièges à éviter

.values() retourne des dicts, pas des instances

.values() génère bien un FOR UPDATE en SQL et le verrou est acquis. Mais le queryset retourne des dictionnaires, ce qui rend impossible l’appel à .save(). Si on veut limiter les champs chargés tout en gardant des instances, utiliser .only().

# Verrou acquis, mais pas d'instance → pas de .save()
Product.objects.select_for_update().values("stock").get(pk=pk)

# Verrou actif + instance + champs limités
Product.objects.select_for_update().only("stock", "pk").get(pk=pk)

Les deadlocks viennent de l’ordre de verrouillage

Si la transaction A verrouille la ligne 1 puis la ligne 2, et que la transaction B verrouille la ligne 2 puis la ligne 1, deadlock garanti. La solution : toujours verrouiller dans le même ordre, par pk croissant par exemple.

# Ordre cohérent pour éviter les deadlocks
accounts = Account.objects.select_for_update().filter(pk__in=ids).order_by("pk")

Les transactions longues bloquent tous les accès concurrents

Le verrou est tenu pendant toute la durée du bloc atomic(). Un appel HTTP externe, un calcul lourd ou une boucle longue à l’intérieur d’une transaction qui tient un verrou bloque tous les accès concurrents pendant ce temps. Garder les transactions courtes.

SQLite ne supporte pas select_for_update()

Django lève une NotSupportedError (sous-classe de DatabaseError) si select_for_update() est utilisé avec SQLite. En développement et dans les tests utilisant SQLite, il faut adapter les tests ou utiliser PostgreSQL dès le départ.

Compatibilité des bases de données

ParamètrePostgreSQLMySQL 8.0+MariaDBSQLite
select_for_update()OuiOuiOuiNon
nowait=TrueOuiOuiOui (10.3+)Non
skip_locked=TrueOuiOuiOui (10.6+)Non
of=(...)OuiNonNonNon
no_key=TrueOuiNonNonNon

Verrouillage pessimiste ou optimiste ?

select_for_update() est du verrouillage pessimiste : on suppose que des conflits vont arriver et on les prévient à la source. L’alternative optimiste consiste à vérifier après coup (champ version, comparaison de valeur) et à retenter si un conflit est détecté.

Choisir pessimiste quand : conflits fréquents, opération multi-étapes sur la même ligne, rollback coûteux (paiement, stock, file de traitement).

Choisir optimiste quand : conflits rares, écritures simples, retry acceptable (profil utilisateur, préférences).

Pour la gestion de stock, les paiements et les files de traitement, select_for_update() est généralement le bon choix.