Transacciones de Postgres para flujos de trabajo multipaso: patrones prácticos
Aprende las transacciones de Postgres para flujos multipaso: cómo agrupar actualizaciones de forma segura, evitar escrituras parciales, manejar reintentos y mantener los datos consistentes.

Por qué las actualizaciones multipaso suelen quedar inconsistentes
La mayoría de las características reales no son una única actualización de base de datos. Son una cadena corta: insertar una fila, actualizar un saldo, marcar un estado, escribir un registro de auditoría, quizá encolar un trabajo. Una escritura parcial ocurre cuando solo algunos de esos pasos llegan a la base de datos.
Esto aparece cuando algo interrumpe la cadena: un error del servidor, un timeout entre tu app y Postgres, un crash después del paso 2, o un reintento que vuelve a ejecutar el paso 1. Cada sentencia está bien por sí sola. El flujo se rompe cuando se detiene a mitad.
Suele ser fácil de detectar:
- Existe una fila, pero falta una fila relacionada (orden creada, sin items)
- El dinero se movió, pero el estado no cambió (pagado, sigue marcado como impago)
- Dos registros que deberían ser uno (suscripciones duplicadas tras un reintento)
- Flags que no concuerdan (el usuario está "activo" pero no tiene plan)
- Estados que solo aparecen bajo carga o fallos
Un ejemplo concreto: una subida de plan actualiza el plan del cliente, añade un registro de pago y aumenta créditos disponibles. Si la app se cae después de guardar el pago pero antes de añadir los créditos, soporte ve "pagado" en una tabla y "sin créditos" en otra. Si el cliente reintenta, incluso puedes registrar el pago dos veces.
El objetivo es simple: trata el flujo como un único interruptor. O todos los pasos tienen éxito, o ninguno, para que nunca almacenes trabajo a medio hacer.
Transacciones, en términos sencillos
Una transacción es la forma de la base de datos de decir: trata estos pasos como una unidad de trabajo. O todos los cambios ocurren, o ninguno. Esto importa siempre que tu flujo necesite más de una actualización, como crear una fila, actualizar un saldo y escribir un registro de auditoría.
Piensa en mover dinero entre dos cuentas. Debes restar de la Cuenta A y sumar a la Cuenta B. Si la app se cae después del primer paso, no quieres que el sistema "recuerde" solo la resta.
Commit vs rollback
Cuando haces un commit, le indicas a Postgres: conserva todo lo que hice en esta transacción. Todos los cambios se vuelven permanentes y visibles para otras sesiones.
Cuando haces un rollback, le indicas a Postgres: olvida todo lo que hice en esta transacción. Postgres deshace los cambios como si la transacción nunca hubiera ocurrido.
Qué garantiza Postgres (y qué no)
Dentro de una transacción, Postgres garantiza que no expondrás resultados a medio hacer a otras sesiones antes de hacer commit. Si algo falla y haces rollback, la base de datos limpia las escrituras de esa transacción.
Una transacción no arregla un mal diseño del flujo. Si restas la cantidad equivocada, usas el ID de usuario incorrecto o te saltas una comprobación necesaria, Postgres hará commit del resultado erróneo. Las transacciones tampoco previenen automáticamente todos los conflictos a nivel de negocio (como vender de más) a menos que las combines con las restricciones, locks o el nivel de aislamiento adecuados.
Flujos que deberían agruparse
Siempre que actualices más de una tabla (o más de una fila) para completar una sola acción del mundo real, tienes un candidato para una transacción. La idea sigue igual: o todo se hace, o nada.
Un flujo de pedido es el caso clásico. Puedes crear una fila de orden, reservar inventario, cobrar un pago y luego marcar la orden como pagada. Si el pago tiene éxito pero la actualización de estado falla, tienes dinero capturado con una orden que todavía parece impaga. Si la fila de la orden se crea pero no se reserva stock, puedes vender artículos que en realidad no tienes.
El onboarding de usuarios se rompe silenciosamente de la misma manera. Crear el usuario, insertar un registro de perfil, asignar roles y registrar que debe enviarse un email de bienvenida es una acción lógica. Sin agrupar, puedes acabar con un usuario que puede iniciar sesión pero no tiene permisos, o con un perfil que existe sin usuario.
Las acciones de back-office suelen necesitar comportamiento estricto de "registro + cambio de estado". Aprobar una solicitud, escribir una entrada de auditoría y actualizar un saldo deberían tener éxito juntos. Si el saldo cambia pero falta el log de auditoría, pierdes evidencia de quién cambió qué y por qué.
Los jobs en background también se benefician, especialmente cuando procesas un ítem de trabajo con varios pasos: reclamar el ítem para que dos workers no lo procesen, aplicar la actualización de negocio, registrar un resultado para reporting y reintentos, y luego marcar el ítem como hecho (o fallido con razón). Si esos pasos se separan, los reintentos y la concurrencia generan un lío.
Diseña el flujo antes de escribir SQL
Las características multipaso fallan cuando las tratas como un montón de actualizaciones independientes. Antes de abrir un cliente de base de datos, escribe el flujo como una historia corta con un final claro: ¿qué cuenta exactamente como "hecho" para el usuario?
Empieza listando los pasos en lenguaje simple y luego define una única condición de éxito. Por ejemplo: "La orden está creada, el inventario reservado y el usuario ve un número de confirmación de pedido." Cualquier cosa por debajo de eso no es éxito, aunque algunas tablas se hayan actualizado.
A continuación, dibuja una línea clara entre el trabajo de base de datos y el trabajo externo. Los pasos de base de datos son los que puedes proteger con transacciones. Llamadas externas como pagos con tarjeta, enviar emails o llamar a APIs de terceros pueden fallar de forma lenta e impredecible y normalmente no puedes revertirlas.
Un enfoque de planificación simple: separa los pasos en (1) deben ser todo-o-nada, (2) pueden ocurrir después del commit.
Decide qué pertenece dentro de la transacción
Dentro de la transacción, mantén solo los pasos que deben permanecer consistentes juntos:
- Crear o actualizar filas centrales (orden, factura, balance de cuenta)
- Reservar recursos compartidos (inventario, asientos, cuota)
- Registrar un evento duradero de "qué hacer después" (tabla outbox)
- Hacer cumplir reglas con restricciones (claves únicas, claves foráneas)
Mueve los efectos secundarios fuera. Por ejemplo, haz commit de la orden primero y luego envía el email de confirmación basándote en un registro outbox.
Escribe expectativas de rollback por paso
Para cada paso, escribe qué debe ocurrir si el siguiente paso falla. "Rollback" puede significar un rollback de base de datos o una acción compensatoria.
Ejemplo: si el pago tiene éxito pero la reserva de inventario falla, decide de antemano si devuelves el dinero inmediatamente o marcas la orden como "pago capturado, a la espera de stock" y lo manejas de forma asíncrona.
Paso a paso: envolver un flujo en una transacción
Una transacción le dice a Postgres: trata estos pasos como una unidad. O todos ocurren, o ninguno. Esa es la forma más simple de evitar escrituras parciales.
El flujo básico
Usa una conexión de base de datos (una sesión) de principio a fin. Si repartes pasos en diferentes conexiones, Postgres no puede garantizar el resultado todo-o-nada.
La secuencia es sencilla: begin, ejecuta las lecturas y escrituras necesarias, commit si todo va bien, de lo contrario rollback y devuelve un error claro.
Aquí hay un ejemplo mínimo en SQL:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
Mantenla corta (y fácil de depurar)
Las transacciones mantienen locks mientras se ejecutan. Cuanto más tiempo las mantengas abiertas, más bloqueas otro trabajo y más probable es que encuentres timeouts o deadlocks. Haz lo esencial dentro de la transacción y mueve tareas lentas (enviar emails, llamar a proveedores de pago, generar PDFs) fuera.
Cuando algo falle, registra suficiente contexto para reproducir el problema sin filtrar datos sensibles: nombre del flujo, order_id o user_id, parámetros clave (monto, moneda) y el código de error de Postgres. Evita registrar payloads completos, datos de tarjeta o detalles personales.
Conceptos básicos de concurrencia: locks y aislamiento sin jerga
Concurrencia son solo dos cosas ocurriendo al mismo tiempo. Imagina a dos clientes intentando comprar la última entrada de un concierto. Ambas pantallas muestran "1 restante", ambos hacen clic en Pagar, y ahora tu app tiene que decidir quién la consigue.
Sin protección, ambas peticiones pueden leer el mismo valor antiguo y ambas escribir una actualización. Ahí es donde terminas con inventario negativo, reservas duplicadas o un pago sin orden.
Los locks por fila son la barrera más simple. Bloqueas la fila que vas a cambiar, haces tus comprobaciones y luego la actualizas. Otras transacciones que toquen la misma fila deben esperar hasta que hagas commit o rollback, lo que evita doble actualización.
Un patrón común: iniciar una transacción, seleccionar la fila de inventario con FOR UPDATE, verificar que hay stock, decrementarlo y luego insertar la orden. Eso "mantiene la puerta cerrada" mientras terminas los pasos críticos.
Los niveles de aislamiento controlan cuánto raro solapamiento permites entre transacciones concurrentes. El equilibrio suele ser seguridad vs velocidad:
- Read Committed (por defecto): rápido, pero puedes ver cambios comprometidos por otros entre sentencias.
- Repeatable Read: tu transacción ve una instantánea estable, bueno para lecturas consistentes, puede causar más reintentos.
- Serializable: máxima seguridad, Postgres puede abortar una transacción para mantener resultados como si se ejecutaran una a una.
Mantén los locks cortos. Si una transacción queda abierta mientras llamas a una API externa o esperas acción del usuario, crearás esperas largas y timeouts. Prefiere una vía de fallo clara: establece un lock timeout, captura el error y devuelve "por favor reintente" en lugar de dejar peticiones colgadas.
Si necesitas trabajo fuera de la base de datos (como cobrar una tarjeta), divide el flujo: reserva rápido, haz commit, luego haz la parte lenta y finaliza con otra transacción corta.
Reintentos que no crean duplicados
Los reintentos son normales en apps con Postgres. Una petición puede fallar incluso cuando tu código es correcto: deadlocks, timeouts de sentencia, cortes breves de red o un error de serialización con niveles de aislamiento altos. Si vuelves a ejecutar el mismo handler, corres el riesgo de crear una segunda orden, cobrar dos veces o insertar filas de "evento" duplicadas.
La solución es idempotencia: la operación debe ser segura de ejecutar dos veces con la misma entrada. La base de datos debe ser capaz de reconocer "esta es la misma petición" y responder de forma consistente.
Un patrón práctico es adjuntar una clave de idempotencia (a menudo un request_id generado por el cliente) a cada flujo multipaso y guardarla en el registro principal, luego añadir una restricción única sobre esa clave.
Por ejemplo: en checkout, genera request_id cuando el usuario hace clic en Pagar y luego inserta la orden con ese request_id. Si hay un reintento, el segundo intento choca con la restricción única y devuelves la orden existente en vez de crear una nueva.
Lo que suele importar:
- Usa una restricción única en (request_id) o (user_id, request_id) para bloquear duplicados.
- Cuando la restricción se activa, busca la fila existente y devuelve el mismo resultado.
- Haz que los efectos secundarios sigan la misma regla: solo un payment intent por orden, solo un evento "orden confirmada" por orden.
- Registra el request_id para que soporte pueda rastrear lo ocurrido.
Mantén el bucle de reintento fuera de la transacción. Cada intento debe empezar una transacción nueva y volver a ejecutar la unidad de trabajo desde el principio. Reintentar dentro de una transacción fallida no ayuda porque Postgres la marca como abortada.
Un pequeño ejemplo: tu app intenta crear una orden y reservar inventario, pero hace timeout justo después del COMMIT. El cliente reintenta. Con una clave de idempotencia, la segunda petición devuelve la orden ya creada y evita una segunda reserva en lugar de duplicar el trabajo.
Usa la base de datos para hacer cumplir reglas, no solo tu código
Las transacciones mantienen unido un flujo multipaso, pero no hacen que los datos sean correctos por sí solos. Una forma potente de evitar estados erróneos es hacer que los estados "incorrectos" sean difíciles o imposibles en la base de datos, incluso si hay un bug en el código.
Empieza con barandas básicas de seguridad. Las claves foráneas aseguran que las referencias sean reales (una línea de pedido no puede apuntar a una orden inexistente). NOT NULL evita filas a medio llenar. CHECK constraints atrapan valores que no tienen sentido (por ejemplo, quantity > 0, total_cents >= 0). Estas reglas se ejecutan en cada escritura, sin importar qué servicio o script toque la base de datos.
Para flujos más largos, modela los cambios de estado explícitamente. En lugar de muchos flags booleanos, usa una columna de estado única (pending, paid, shipped, canceled) y solo permite transiciones válidas. Puedes imponer esto con constraints o triggers para que la base de datos rechace saltos ilegales como shipped -> pending.
La unicidad es otra forma de corrección. Añade restricciones únicas donde los duplicados romperían tu flujo: order_number, invoice_number o una idempotency_key usada para reintentos. Entonces, si tu app reintenta la misma petición, Postgres bloquea la segunda inserción y puedes devolver "ya procesado" en lugar de crear una segunda orden.
Cuando necesitas trazabilidad, almacénala explícitamente. Una tabla de auditoría (o historial) que registre quién cambió qué y cuándo convierte las "actualizaciones misteriosas" en hechos que puedes consultar durante incidentes.
Errores comunes que causan escrituras parciales
La mayoría de las escrituras parciales no son por "mal SQL." Vienen de decisiones de flujo que hacen fácil cometer solo la mitad de la historia.
Las trampas que aparecen en apps reales
- Hacer trabajo externo lento con la transacción abierta. Llamar a un proveedor de pagos, enviar un email o subir un archivo dentro de la transacción mantiene locks más tiempo del necesario. Si la API es lenta o hace timeout, otros usuarios quedan en cola detrás de tu transacción abierta.
- Leer fuera de la transacción y luego escribir más tarde. Ejemplo: obtienes el saldo de un usuario, lo muestras en pantalla y luego deduces basándote en ese valor antiguo. Otra sesión pudo haber cambiado el saldo entre medias.
- Capturar un error pero aun así hacer commit de algo. Un patrón común es "intenta paso 1, intenta paso 2, registra el error, devuelve éxito de todas formas." Si el código llega a COMMIT después de una falla, has hecho la base de datos inconsistente a propósito.
- Actualizar tablas en órdenes diferentes según el camino de código. Si una petición actualiza
accountsluegoorders, pero otra actualizaordersluegoaccounts, aumentas la probabilidad de deadlocks bajo carga. - Mantener transacciones abiertas demasiado tiempo. Las transacciones largas pueden bloquear escrituras, retrasar la limpieza de vacuum y crear timeouts confusos.
Un ejemplo concreto: en checkout reservas inventario, creas una orden y luego cobras una tarjeta. Si cobras la tarjeta dentro de la misma transacción, puedes mantener un lock de inventario mientras esperas la red. Si el cargo tiene éxito pero tu transacción luego hace rollback, cobraste al cliente sin una orden.
Un patrón más seguro es: céntrate en el estado de la base de datos (reservar inventario, crear orden, registrar pago pendiente), commit, luego llama a la API externa y escribe el resultado en una nueva transacción corta. Muchos equipos implementan esto con un estado pending y un job en background.
Lista rápida de verificación para operaciones todo-o-nada
Cuando un flujo tiene múltiples pasos (insertar, actualizar, cobrar, enviar), el objetivo es simple: o todo queda registrado, o nada.
Límites de transacción
Mantén todas las escrituras de base de datos requeridas dentro de una transacción. Si un paso falla, haz rollback y deja los datos exactamente como estaban.
Haz la condición de éxito explícita. Por ejemplo: "La orden está creada, el stock reservado y el estado de pago registrado." Todo lo demás es una vía de fallo que debe abortar la transacción.
- Todas las escrituras requeridas ocurren dentro de un bloque
BEGIN ... COMMIT. - Hay un único estado de 'hecho' claro en la base de datos (no solo en memoria de la app).
- Cualquier error lleva a
ROLLBACKy el llamador recibe un resultado de fallo claro.
Barreras de seguridad (para que los reintentos no dañen)
Asume que la misma petición puede reintentarse. La base de datos debe ayudarte a imponer reglas de "solo una vez".
- Sustenta acciones "solo una vez" con restricciones únicas (un registro de pago por orden, o una reserva por item por orden).
- Haz que los reintentos sean seguros y repetibles (la misma entrada produce el mismo estado final, no duplicados).
Mantén las transacciones cortas
Haz el trabajo mínimo dentro de la transacción y evita esperar llamadas de red mientras mantienes locks.
- Mantén transacciones cortas y establece un timeout para que no se cuelguen.
- Haz el trabajo lento (como llamar a un proveedor de pagos) fuera de la transacción y luego registra el resultado en una nueva transacción corta.
Observa los fallos
Si no puedes ver dónde se rompe, seguirás adivinando.
- Registra el paso del flujo y un request id para cada fallo.
- Monitoriza tasas de rollback y timeouts de locks para detectar riesgos de escrituras parciales temprano.
Ejemplo: un flujo de checkout que permanece consistente frente a fallos
Un checkout tiene varios pasos que deberían moverse juntos: crear la orden, reservar inventario, registrar el intento de pago y luego marcar el estado de la orden.
Imagina que un usuario hace clic en Comprar 1 artículo.
Un flujo seguro (el trabajo DB es una unidad)
Dentro de una transacción, haz solo cambios en la base de datos:
- Inserta una fila
orderscon estadopending_payment. - Reserva stock (por ejemplo, decrementa
inventory.availableo crea una filareservations). - Inserta una fila
payment_intentscon unaidempotency_keyproporcionada por el cliente (única). - Inserta una fila
outboxcomo "order_created".
Si alguna sentencia falla (sin stock, error de constraint, crash), Postgres hace rollback de toda la transacción. No acabas con una orden sin reserva, ni con una reserva sin orden.
¿Qué pasa si el pago falla a medias?
El proveedor de pago está fuera de tu base de datos, así que trátalo como un paso separado.
Si la llamada al proveedor falla antes de hacer commit, aborta la transacción y no se escribe nada. Si la llamada falla después de hacer commit, ejecuta una nueva transacción que marque el intento de pago como fallido, libere la reserva y ponga la orden en estado cancelado.
Reintentar sin crear una segunda orden
Haz que el cliente envíe una idempotency_key por intento de checkout. Hazla cumplir con un índice único en payment_intents(idempotency_key) (o en orders si lo prefieres). En un reintento, tu código busca las filas existentes y continúa en lugar de insertar una nueva orden.
Emails y notificaciones
No envíes emails dentro de la transacción. Escribe un registro outbox en la misma transacción y deja que un worker en background envíe el email tras el commit. Así nunca envías un email por una orden que fue revertida.
Próximos pasos: aplica esto a un flujo esta semana
Elige un flujo que toque más de una tabla: signup + enqueue de welcome email, checkout + inventario, factura + entrada en ledger, o crear proyecto + ajustes por defecto.
Escribe los pasos primero y luego las reglas que siempre deben cumplirse (tus invariantes). Ejemplo: "Una orden o está totalmente pagada y reservada, o no está pagada y no está reservada. Nunca medio reservada." Convierte esas reglas en una unidad todo-o-nada.
Un plan simple:
- Lista las operaciones SQL exactas en orden (lecturas, inserts, updates, deletes).
- Añade primero las restricciones de base de datos que faltan (claves únicas, foráneas, check constraints).
- Añade una clave de idempotencia para la petición de modo que los reintentos no creen duplicados.
- Envuelve los pasos en una transacción y haz explícito el punto de éxito (commit solo cuando todas las comprobaciones pasen).
- Decide cómo es un retry seguro (misma idempotency_key, mismo resultado).
Luego prueba los casos feos a propósito. Simula un crash después del paso 2, un timeout justo antes del commit y un doble envío desde la UI. El objetivo son resultados aburridos: no filas huérfanas, no cargos dobles, nada pendiente para siempre.
Si estás prototipando rápido, ayuda dibujar el flujo en una herramienta de planificación antes de generar handlers y esquema. Por ejemplo, Koder.ai tiene un Planning Mode y soporta snapshots y rollback, lo que puede ser útil mientras iteras sobre límites de transacción y restricciones.
Haz esto para un flujo esta semana. El segundo irá mucho más rápido.
Preguntas frecuentes
¿Cuándo debo usar una transacción de Postgres?
Usa una transacción cuando una acción del usuario cambia varias filas o tablas que deben mantenerse coherentes. Algunos ejemplos habituales son el proceso de pago, las transferencias entre cuentas, el registro con asignación de roles y las reservas de inventario.
¿Qué hace COMMIT en Postgres?
Un commit hace que todos los cambios de la transacción sean permanentes y visibles para otras sesiones de la base de datos. Haz commit solo después de que todas las comprobaciones y escrituras necesarias se completen correctamente.
¿Qué ocurre cuando hago ROLLBACK?
Un rollback cancela la transacción y elimina los cambios sin confirmar. Tu código debe hacer rollback si falla algún paso obligatorio y luego devolver un error que quien llama pueda gestionar.
¿Todas las consultas de una transacción necesitan la misma conexión?
Mantén las lecturas y escrituras de la base de datos de ese flujo en la misma conexión, desde BEGIN hasta COMMIT o ROLLBACK. Las conexiones independientes crean transacciones independientes.
¿Debo llamar a las API de pago o correo electrónico dentro de una transacción?
No. Las transacciones mantienen juntos los cambios en la base de datos, pero no revierten un cargo en tarjeta, un correo electrónico ni una llamada a una API de terceros. Primero confirma el estado de la base de datos, registra un evento de outbox o un estado pendiente y luego realiza el trabajo externo por separado.
¿Cómo evito que dos usuarios compren el último artículo?
Bloquea la fila antes de comprobar y actualizar un valor compartido, como el inventario disponible. Por ejemplo, selecciona la fila de inventario con FOR UPDATE, verifica las existencias, redúcelas y crea el pedido antes de confirmar la transacción.
¿Cómo pueden los reintentos evitar pedidos duplicados?
Asigna a cada solicitud una clave de idempotencia estable y aplícala con una restricción única. Cuando un reintento choque con esa restricción, carga y devuelve el resultado existente en lugar de insertar otro pedido o registro de pago.
¿Por qué los reintentos deben iniciar una transacción nueva?
Vuelve a intentarlo desde el principio en una transacción nueva. Después de un error en una sentencia, Postgres marca la transacción actual como abortada, por lo que no puede continuar de forma segura hasta que hagas rollback.
¿Qué restricciones de base de datos ayudan a prevenir datos inconsistentes?
Usa claves foráneas para referencias válidas, NOT NULL para campos obligatorios, restricciones CHECK para valores válidos y restricciones únicas para registros que solo deben existir una vez. Estas reglas protegen los datos incluso si el código de la aplicación tiene un error.
¿Por qué las transacciones deben ser cortas?
Mantén las transacciones centradas en operaciones rápidas de la base de datos. Las transacciones largas mantienen bloqueos, aumentan los tiempos de espera y pueden provocar interbloqueos o tiempos de espera agotados, sobre todo si esperan una llamada de red.