#Dos peticiones pueden tener razón y aun así romper el sistema
Imagina una entrada disponible. Dos peticiones leen disponibles = 1, ambas deciden que pueden comprar y ambas guardan 0. La cifra final parece correcta, pero has aceptado dos compras. La validación individual no coordinó las decisiones.
Una transacción agrupa operaciones de base de datos que se confirman juntas o se revierten juntas. Un bloqueo de fila ordena operaciones que necesitan leer y decidir sobre esa fila mientras otras intentan cambiarla. Una restricción impide guardar un estado inválido. Son herramientas complementarias: envolver dos lecturas en atomic() no significa automáticamente que solo una vea la última entrada.
#Autocommit y el límite de atomic
Por defecto, Django confirma cada consulta cuando no existe una transacción activa. Con with transaction.atomic() delimita una unidad de trabajo: si sales sin excepción, confirma; si una excepción sale del bloque, revierte los cambios SQL de ese bloque. El contexto with asegura esa salida incluso si aparece un error.
from django.db import transaction
with transaction.atomic():
# Consultas que deben confirmarse juntas.
# No incluimos llamadas HTTP ni espera a una persona.
pass
Este es un esquema, no una operación real. Un atomic interior normalmente crea un punto de recuperación, o savepoint; terminarlo no confirma definitivamente si todavía existe una transacción exterior. Activar ATOMIC_REQUESTS envuelve views, pero no convierte automáticamente middleware, streaming o tareas externas en parte de la misma transacción. Prefiere límites explícitos alrededor de operaciones breves que puedas explicar.
La atomicidad no significa «solo una petición a la vez». El nivel de aislamiento decide qué datos pueden ver las consultas concurrentes. PostgreSQL utiliza normalmente READ COMMITTED: cada sentencia observa datos confirmados al empezar esa sentencia, además de sus propios cambios. Para decisiones que dependen de varias lecturas, hay que elegir el mecanismo de coordinación adecuado.
#Una actualización condicional evita leer-modificar-guardar
Para practicar por separado, puedes añadir este modelo Producto a articulos/models.py, generar/aplicar su migración y usarlo desde la shell. No modifica los modelos anteriores:
from django.db import models
class Producto(models.Model):
nombre = models.CharField(max_length=80)
disponibles = models.IntegerField(default=0)
class Meta:
constraints = [models.CheckConstraint(
condition=models.Q(disponibles__gte=0), name="producto_stock_no_negativo"
)]
Una compra de cantidad n puede expresarse en una sola sentencia:
from django.db.models import F
from articulos.models import Producto
def descontar(producto_id, cantidad):
if type(cantidad) is not int or cantidad <= 0:
raise ValueError("La cantidad debe ser un entero positivo.")
cambiadas = Producto.objects.filter(
pk=producto_id, disponibles__gte=cantidad,
).update(disponibles=F("disponibles") - cantidad)
return cambiadas == 1
F("disponibles") refiere al valor en la base, no a una copia leída antes en Python. El filtro y la resta ocurren en la misma operación SQL; el resultado informa cuántas filas cambiaron. Si dos peticiones compiten por una unidad, la base coordina la actualización y solo una satisface la condición. La otra recibe False: puede significar producto inexistente o stock insuficiente, una distinción que puedes resolver con una lectura si el contrato lo necesita.
Prueba secuencialmente con un producto de una unidad: descontar(id, 1) devuelve True, el siguiente intento False, y refresh_from_db() permite ver el valor actual de una instancia ya cargada. La prueba concurrente del proyecto final muestra conexiones independientes; dos llamadas secuenciales por sí solas no son evidencia de una carrera.
QuerySet.update() no ejecuta save() de cada objeto ni sus señales. Si tu regla depende de efectos escondidos allí, esta optimización cambia el comportamiento. Un registro de pedido y el descuento deben compartir una transacción: si crear el pedido falla, también debe revertirse el descuento.
#Cuando la decisión necesita leer varias cosas
Reservar un intervalo exige comprobar solapes, actividad de sala y permisos. Una sola resta no representa esa operación. Tomamos un bloqueo sobre un objeto estable —la sala— antes de comprobar el conjunto de reservas:
# Patrón sobre Sala y Reserva del proyecto final.
from django.db import transaction
with transaction.atomic():
sala = Sala.objects.select_for_update().get(pk=sala_id)
existe = Reserva.objects.filter(
sala=sala, cancelada=False, inicio__lt=fin, fin__gt=inicio,
).exists()
if existe:
raise ValueError("Intervalo ocupado")
# La creación y la autorización completa aparecen en el proyecto final.
select_for_update() solo toma el bloqueo cuando se ejecuta el QuerySet, aquí con get. Debe ejecutarse dentro de una transacción. Bloquear una búsqueda que devuelve cero reservas no bloquea una «fila futura» inexistente; por eso usamos la sala. Todas las rutas que dependan de ese convenio deben tomar el mismo bloqueo. Una restricción de exclusión en PostgreSQL añade una protección que también cubre escrituras que no siguen el convenio.
Si necesitas bloquear varios objetos, usa un orden estable —por ejemplo, ID ascendente— en todos los caminos. Bloquear A y luego B en una petición, y B y luego A en otra, puede producir un deadlock. PostgreSQL aborta una transacción para romperlo; un reintento debe repetir la operación completa con límite, observación e idempotencia de sus efectos externos. Reintentar una sentencia aislada dentro de una transacción rota no lo arregla.
nowait=True convierte la espera por bloqueo en error inmediato; skip_locked=True salta filas ocupadas, útil para reclamar trabajo de una cola. Ninguno significa «reserva confirmada sin esperar»: saltarse la sala ocupada y continuar como si estuviera libre sería un error de negocio.
#Restricciones y excepciones: salir antes de continuar
from django.db import IntegrityError, transaction
try:
with transaction.atomic():
# Escritura susceptible de chocar con una restricción.
pass
except IntegrityError:
# Aquí el bloque ya se ha revertido; se puede decidir la respuesta.
pass
Después de ciertos errores SQL, la transacción queda marcada como fallida. Capturarlos dentro del mismo bloque y seguir consultando provoca más errores y puede ocultar el original. El patrón exterior deja terminar la recuperación primero. En PostgreSQL puedes reconocer la restricción esperada por el diagnóstico del driver; no traduzcas todos los IntegrityError a «ya existe».
El rollback restaura la base, no tus objetos Python, una caché ni un fichero que escribiste. Si cambiaste producto.disponibles en memoria y la transacción falla, esa instancia puede conservar el valor que nunca se confirmó. Relee cuando necesites el estado persistente.
#Lo que debe ocurrir después del commit
Enviar un correo antes de confirmar una reserva puede anunciar algo que después se revierte. transaction.on_commit(funcion) retrasa la llamada hasta que la transacción exterior se confirma. Si se revierte, el callback se descarta. Si no había transacción, se ejecuta inmediatamente.
from functools import partial
from django.core.cache import cache
from django.db import transaction
with transaction.atomic():
# Aquí se haría una actualización de artículos.
transaction.on_commit(partial(cache.delete, "articulos:publicados:v1"))
partial crea una función pendiente con argumentos ya fijados: no borra la caché al registrarla. También puedes usar una función lambda, cuidando que no capture por accidente una variable que cambia antes de ejecutarse. on_commit es adecuado para invalidaciones o avisos cuya pérdida puedas tolerar o reconciliar.
#El hueco entre confirmar y enviar
Un proceso puede morir después del commit y antes de ejecutar el callback. Por tanto, on_commit no es una cola durable. Si es obligatorio enviar un aviso, guarda un evento de salida en la misma transacción que la reserva; un worker lo leerá, enviará e irá marcando su estado. Este patrón suele llamarse outbox: los datos y la intención de publicar se confirman juntos en la misma base.
El worker también puede caer después de enviar pero antes de marcar «enviado». El receptor necesita idempotencia o el proceso debe tolerar duplicados. La transacción local no convierte dos sistemas independientes en un único commit. El siguiente capítulo conecta esta idea con tareas, timeouts y respuestas HTTP.
#Elegir la herramienta por la invariante
| Necesidad | Mecanismo inicial | Límite que debes recordar |
|---|---|---|
| Slug único por organización | UniqueConstraint |
Validar antes mejora el mensaje; la base decide al escribir |
| Descontar stock sin quedar negativo | UPDATE condicional con F + CHECK |
Crear el pedido exige agrupar operaciones |
| Decidir tras leer estado de una fila | atomic + select_for_update |
Transacción breve y orden de bloqueos consistente |
| Evitar intervalos solapados | Restricción de exclusión + política de servicio | Es específica de PostgreSQL y necesita diseño del intervalo |
| No pisar una edición antigua | Versión y UPDATE condicionado | Un conflicto debe devolverse al editor, no ignorarse |
| Aviso externo obligatorio | Evento durable + worker + idempotencia | Puede haber entrega repetida; hay que diseñarla |
Comprobación: dibuja dos peticiones que leen el mismo estado. Marca exactamente qué sentencia las ordena y qué impide un estado inválido si una de ellas no usa tu formulario. Si no puedes señalar ese punto, atomic() probablemente está dando una falsa sensación de protección.