Dos peticiones simultáneas leen el stock de un producto, las dos ven que queda una unidad, y las dos confirman el pedido. El stock baja a -1. Este tipo de condición de carrera es casi imposible de reproducir en desarrollo y devastador en producción. select_for_update() es la respuesta de Django: adquirir un bloqueo SQL en el momento de la lectura para garantizar que ninguna otra transacción pueda modificar la fila antes de que termine la operación actual.
Lo que hace select_for_update() en SQL
select_for_update() genera un SELECT ... FOR UPDATE. El bloqueo se adquiere en cuanto el queryset es evaluado y se mantiene hasta el final del bloque transaction.atomic(). Cualquier otra transacción que intente adquirir un bloqueo sobre las mismas filas queda bloqueada hasta que el bloqueo se libere.
from django.db import transaction
with transaction.atomic():
product = Product.objects.select_for_update().get(pk=pk)
# Ninguna otra transacción puede modificar product durante este bloque
product.stock -= quantity
product.save()
Regla absoluta: select_for_update() debe llamarse dentro de un bloque transaction.atomic(). Django lanza TransactionManagementError si no es así.
Los parámetros que cambian el comportamiento
nowait=True: fallar en lugar de esperar
Por defecto, una transacción que intenta bloquear filas ya ocupadas espera hasta que se liberen. Con nowait=True, Django genera FOR UPDATE NOWAIT y lanza inmediatamente una OperationalError si las filas están bloqueadas.
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("Esta cuenta está siendo modificada en este momento.")
Útil para APIs donde bloquear una petición HTTP durante varios segundos no es aceptable.
Soporte: PostgreSQL, Oracle, MySQL 8.0+.
skip_locked=True: ignorar las filas bloqueadas
FOR UPDATE SKIP LOCKED es el patrón estándar para colas de procesamiento. En lugar de bloquearse sobre las filas bloqueadas, el queryset las ignora y devuelve solo las disponibles. Varios workers pueden procesar tareas en paralelo sin bloquearse entre sí.
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
Cada worker llama a esta función y obtiene una tarea diferente, sin ningún riesgo de conflicto.
Soporte: PostgreSQL 9.5+, MySQL 8.0.1+.
of=(…): apuntar a tablas concretas para bloquear
Al usar select_related(), Django bloquea por defecto todas las tablas unidas. El parámetro of (solo PostgreSQL) permite especificar exactamente qué tablas bloquear.
# Bloquea order Y customer por defecto
Order.objects.select_related("customer").select_for_update().get(pk=pk)
# Bloquea solo la tabla orders
Order.objects.select_related("customer").select_for_update(of=("self",)).get(pk=pk)
Sin of, pueden producirse deadlocks si otras transacciones bloquean las tablas unidas en un orden diferente.
Soporte: solo PostgreSQL.
no_key=True: bloqueo débil (solo PostgreSQL)
FOR NO KEY UPDATE es un bloqueo más ligero: bloquea otras adquisiciones de FOR UPDATE pero no interfiere con SELECT FOR SHARE ni con INSERTs en tablas hijas que referencian esta mediante clave foránea. Disponible desde Django 3.2.
# Permite insertar OrderItems mientras Order está bloqueado
order = Order.objects.select_for_update(no_key=True).get(pk=pk)
Los errores comunes a evitar
.values() devuelve dicts, no instancias
.values() sí genera FOR UPDATE en el SQL y el bloqueo se adquiere. Pero el queryset devuelve diccionarios, lo que hace imposible llamar a .save(). Para limitar los campos cargados manteniendo instancias del modelo, usar .only().
# Bloqueo adquirido, pero sin instancia → sin .save()
Product.objects.select_for_update().values("stock").get(pk=pk)
# Bloqueo activo + instancia + campos limitados
Product.objects.select_for_update().only("stock", "pk").get(pk=pk)
Los deadlocks vienen del orden de bloqueo
Si la transacción A bloquea la fila 1 y luego la fila 2, y la transacción B bloquea la fila 2 y luego la fila 1, el deadlock está garantizado. La solución: bloquear siempre en el mismo orden, por ejemplo por pk ascendente.
# Orden consistente para evitar deadlocks
accounts = Account.objects.select_for_update().filter(pk__in=ids).order_by("pk")
Las transacciones largas bloquean todos los accesos concurrentes
El bloqueo se mantiene durante toda la duración del bloque atomic(). Una llamada HTTP externa, un cálculo pesado o un bucle largo dentro de una transacción que mantiene un bloqueo detiene todos los accesos concurrentes durante ese tiempo. Mantener las transacciones cortas.
SQLite no soporta select_for_update()
Django lanza NotSupportedError (subclase de DatabaseError) cuando se usa select_for_update() con SQLite. Para los tests que dependen de este comportamiento, usar PostgreSQL desde el principio o excluir esos tests al ejecutar contra SQLite.
Compatibilidad con bases de datos
| Parámetro | PostgreSQL | MySQL 8.0+ | MariaDB | SQLite |
|---|---|---|---|---|
select_for_update() | Sí | Sí | Sí | No |
nowait=True | Sí | Sí | Sí (10.3+) | No |
skip_locked=True | Sí | Sí | Sí (10.6+) | No |
of=(...) | Sí | No | No | No |
no_key=True | Sí | No | No | No |
¿Bloqueo pesimista u optimista?
select_for_update() es bloqueo pesimista: asume que habrá conflictos y los previene desde el principio. La alternativa optimista comprueba después del hecho (campo version, comparación de valor antes del update) y reintenta si hay conflicto.
Elegir pesimista cuando: los conflictos son frecuentes, la operación tiene varios pasos sobre la misma fila, el rollback es costoso (pago, stock, cola de tareas).
Elegir optimista cuando: los conflictos son raros, las escrituras son simples, reintentar es aceptable (perfil de usuario, preferencias).
Para gestión de stock, pagos y colas de procesamiento, select_for_update() es generalmente la elección correcta.
