La migración que tumba producción casi nunca es la que preocupaba a todo el mundo. Rara vez es un DROP TABLE: todos revisan esas con cuidado. Es la que “parece segura”: agregar una columna NOT NULL con un valor por defecto, o construir un índice en una tabla muy ocupada durante el horario laboral, ejecutada por alguien que asumió razonablemente que si la herramienta de migración la generó automáticamente, entonces debía ser segura de ejecutar.
La verdad incómoda es que “seguro”, a ojos de una herramienta de migración, normalmente significa “sintácticamente correcto”, no “no bloqueará la tabla”. Las operaciones DDL de Postgres adquieren bloqueos reales, y varios de los cambios de esquema más comunes adquieren un bloqueo — ACCESS EXCLUSIVE — que bloquea toda lectura y escritura sobre la tabla durante todo el tiempo que tarde la operación. En una tabla pequeña eso son milisegundos, invisible. En una tabla con decenas de millones de filas y tráfico constante, eso es una caída de producción de varios minutos causada por una migración que parecía un cambio de una sola línea.
Aprenderás:
- Qué cambios de esquema comunes toman un bloqueo que bloquea la tabla, y cuáles ya son seguros por defecto en versiones modernas de Postgres
- Cómo agregar una columna con un valor por defecto sin reescribir toda la tabla
- El patrón correcto de tres pasos para agregar una restricción
NOT NULLsin bloquear escrituras - Cómo
CREATE INDEX CONCURRENTLYevita el bloqueo estándar de construcción de índices, y cuál es su modo real de fallo - Cómo leer
pg_locksypg_stat_activitypara ver exactamente qué está a punto de bloquear una migración, antes de ejecutarla - Cómo estructurar una migración de Alembic o Django para aplicar este patrón de forma segura por defecto
Tabla de contenidos
- Por qué algunos DDL bloquean toda la tabla
- Agregar columnas sin bloquear
- Agregar NOT NULL sin bloquear escrituras
- Construir índices con CONCURRENTLY
- Rellenar tablas grandes de forma segura
- Leer pg_locks antes de ejecutar una migración
- Migraciones seguras en Alembic y Django
- Errores comunes
Por qué algunos DDL bloquean toda la tabla
Cada ALTER TABLE en Postgres adquiere un bloqueo sobre la tabla durante toda la operación, y el nivel del bloqueo determina qué más puede ocurrir de forma concurrente. El más estricto, ACCESS EXCLUSIVE, bloquea literalmente todo lo demás — lecturas, escrituras, incluso otro DDL — hasta que se libera. Que un ALTER TABLE determinado necesite ese bloqueo más estricto, o pueda conformarse con algo mucho más liviano, depende específicamente de si Postgres tiene que reescribir las filas existentes de la tabla para realizar el cambio.
Las operaciones que solo tocan metadatos — agregar una columna nullable, agregar una restricción check marcada como NOT VALID, eliminar una columna — también toman ACCESS EXCLUSIVE, pero solo brevemente, porque no hay datos que reescribir; el bloqueo se mantiene solo el tiempo suficiente para actualizar el catálogo. Las operaciones que sí tienen que reescribir cada fila existente — cambiar el tipo de una columna de una manera que no sea binario-compatible, agregar una columna con un valor por defecto volátil en versiones antiguas de Postgres, agregar ciertos tipos de restricciones sin NOT VALID — mantienen ese mismo bloqueo ACCESS EXCLUSIVE durante todo el tiempo que dure la reescritura, algo que escala directamente con el tamaño de la tabla y puede irse a minutos o más en una tabla grande bajo carga real.
La regla práctica: cualquier sentencia DDL cuya duración escale con la cantidad de filas es un riesgo real en una tabla ocupada, y cualquier sentencia cuya duración sea constante independientemente de la cantidad de filas (cambios solo de metadatos) es casi siempre segura. Saber en qué categoría cae un ALTER TABLE dado, antes de ejecutarlo, es exactamente la habilidad de la que trata esta publicación.
Una referencia rápida para las operaciones que aparecen con más frecuencia:
| Operación | Bloqueo adquirido | ¿Bloquea lecturas? | ¿Bloquea escrituras? | ¿La duración escala con las filas? |
|---|---|---|---|---|
| Agregar columna nullable, sin valor por defecto | ACCESS EXCLUSIVE (breve) | brevemente | brevemente | no |
| Agregar columna, valor por defecto literal (PG 11+) | ACCESS EXCLUSIVE (breve) | brevemente | brevemente | no |
| Agregar columna, valor por defecto volátil | ACCESS EXCLUSIVE | sí | sí | sí |
SET NOT NULL directamente | ACCESS EXCLUSIVE | sí | sí | sí |
ADD CONSTRAINT ... NOT VALID | ACCESS EXCLUSIVE (breve) | brevemente | brevemente | no |
VALIDATE CONSTRAINT | SHARE UPDATE EXCLUSIVE | no | no | sí, pero sin bloqueo |
CREATE INDEX normal | SHARE | no | sí | sí |
CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | no | no | sí, pero sin bloqueo |
| Cambiar tipo de columna (incompatible) | ACCESS EXCLUSIVE | sí | sí | sí |
El patrón detrás de cada “no” en las dos últimas columnas es el mismo: Postgres tiene un modo de bloqueo más liviano disponible para casi toda operación que escala con el tamaño de la tabla, y el único costo de usarlo es que la operación en sí tarda más tiempo de reloj en terminar, una compensación que casi siempre vale la pena en una tabla que otras personas están usando activamente.
Agregar columnas sin bloquear
Agregar una columna nullable sin valor por defecto siempre ha sido una operación rápida y solo de metadatos en Postgres; esta en realidad nunca fue peligrosa:
ALTER TABLE orders ADD COLUMN discount_code TEXT;
Agregar una columna con un valor por defecto solía ser la trampa clásica: antes de Postgres 11, esto reescribía cada fila existente para poblar el valor por defecto, tomando ACCESS EXCLUSIVE durante toda la reescritura. Desde Postgres 11, un valor por defecto no volátil (un literal, no now() o random()) se almacena puramente como metadatos y se aplica de forma diferida en lectura para las filas antiguas: no hay reescritura de tabla, no hay bloqueo prolongado, independientemente del tamaño de la tabla:
-- Safe and fast on Postgres 11+, any table size
ALTER TABLE orders ADD COLUMN status TEXT DEFAULT 'pending';
La excepción que sigue importando: un valor por defecto volátil — DEFAULT now(), DEFAULT gen_random_uuid(), o cualquier cosa que tenga que calcular un valor genuinamente distinto por fila — sigue forzando una reescritura completa de la tabla incluso en Postgres moderno, porque un valor por defecto aplicado de forma diferida solo funciona cuando todas las filas pueden compartir exactamente el mismo valor almacenado. Para cualquier cosa que necesite un valor calculado por fila, agrega la columna nullable sin valor por defecto, rellénala en lotes (abajo) y luego agrega el valor por defecto solo para las filas futuras.
Agregar NOT NULL sin bloquear escrituras
Un simple ALTER TABLE orders ALTER COLUMN status SET NOT NULL tiene que escanear toda la tabla para verificar que ninguna fila existente viola la nueva restricción, manteniendo ACCESS EXCLUSIVE durante todo ese escaneo. En una tabla grande y ocupada, ese escaneo compitiendo con tráfico real es exactamente el tipo de migración que causa una caída. El patrón seguro divide esto en tres pasos que nunca mantienen un bloqueo exclusivo prolongado:
-- Step 1: add a CHECK constraint as NOT VALID — instant, metadata-only
ALTER TABLE orders ADD CONSTRAINT orders_status_not_null
CHECK (status IS NOT NULL) NOT VALID;
-- Step 2: validate it separately — takes a lock, but only ROW SHARE,
-- which permits concurrent reads and writes
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null;
-- Step 3 (Postgres 12+): now SET NOT NULL is instant, because
-- the planner can prove it from the already-validated constraint
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
DROP CONSTRAINT orders_status_not_null; -- optional cleanup
El paso 1 es instantáneo porque NOT VALID le dice a Postgres que haga cumplir la restricción solo sobre las filas escritas a partir de ese momento, sin comprobar las filas existentes. El paso 2 realiza la costosa comprobación sobre toda la tabla, pero bajo un bloqueo mucho más liviano que ACCESS EXCLUSIVE: las lecturas y escrituras concurrentes continúan normalmente mientras se ejecuta, a costa de algo más de contención de I/O durante la validación misma. El paso 3, en Postgres 12 y posteriores, es una operación genuinamente solo de metadatos porque el planner puede demostrar que la columna ya no admite nulos a partir de la restricción validada, sin un segundo escaneo de la tabla. Este patrón de tres pasos es el truco de mayor valor de toda esta publicación, y se generaliza a cualquier restricción CHECK o foreign key, no solo a NOT NULL.
Construir índices con CONCURRENTLY
Un CREATE INDEX normal toma un bloqueo SHARE que bloquea escrituras (aunque no lecturas) sobre la tabla durante toda la construcción; en una tabla grande, eso puede significar minutos de sentencias INSERT/UPDATE/DELETE bloqueadas, lo que funcionalmente es una caída para cualquier servicio con muchas escrituras. CREATE INDEX CONCURRENTLY evita eso construyendo el índice en varias pasadas, liberando su bloqueo entre ellas para que las escrituras puedan continuar en todo momento:
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
Vale la pena conocer el costo real con precisión: la construcción tarda notablemente más (a menudo de dos a tres veces más que un CREATE INDEX normal sobre la misma tabla) y, de forma crítica, no puede ejecutarse dentro de un bloque de transacción. La mayoría de los frameworks de migración envuelven cada migración en una transacción por defecto, así que ejecutar CONCURRENTLY correctamente normalmente significa indicarle explícitamente al framework que omita ese envoltorio transaccional para esa migración específica.
El otro modo real de fallo: si la construcción se interrumpe — una caída de conexión, una sentencia cancelada, un reinicio del servidor — puede dejar detrás un índice inválido, visible en \d tablename como INVALID, que sigue consumiendo espacio en disco y sigue manteniéndose en cada escritura, pero nunca se usa para consultas. Postgres no limpia esto automáticamente:
-- Check for invalid indexes across the database
SELECT indexrelid::regclass, indrelid::regclass
FROM pg_index WHERE NOT indisvalid;
-- Drop and rebuild
DROP INDEX CONCURRENTLY idx_orders_status;
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
DROP INDEX CONCURRENTLY existe exactamente para este caso de limpieza y, al igual que la variante de creación, evita el bloqueo que sí tomaría un DROP INDEX normal. Una vez que el índice se haya construido de forma segura, confirma que realmente se esté usando de la manera que esperas con EXPLAIN ANALYZE: la guía para leer planes de consulta de Postgres cubre exactamente cómo verificar que un índice nuevo cambió la forma del plan en lugar de asumir que una consulta más rápida lo demuestra.
Rellenar tablas grandes de forma segura
Un único UPDATE orders SET status = 'pending' WHERE status IS NULL sobre millones de filas hace lo correcto lógicamente pero lo incorrecto operativamente: es una transacción gigante, mantiene bloqueos de fila sobre cada fila tocada hasta el commit, genera una ráfaga de tráfico WAL y arriesga una transacción de larga duración que bloquea a autovacuum para que no limpie tuplas muertas en otras partes de la base de datos durante toda su duración. Los backfills deben hacerse en lotes pequeños con commit entre ellos:
import time
BATCH_SIZE = 5000
while True:
with connection.cursor() as cursor:
cursor.execute("""
UPDATE orders SET status = 'pending'
WHERE id IN (
SELECT id FROM orders
WHERE status IS NULL
LIMIT %s
FOR UPDATE SKIP LOCKED
)
""", [BATCH_SIZE])
rows_updated = cursor.rowcount
if rows_updated == 0:
break
time.sleep(0.1) # let replication and autovacuum keep up
FOR UPDATE SKIP LOCKED significa que los lotes concurrentes de backfill (o las escrituras continuas de la aplicación que toquen esas mismas filas) no se bloquean entre sí: un lote simplemente omite las filas que otro proceso ya tiene bloqueadas y las recoge en una pasada posterior. El sleep corto entre lotes es deliberado: le da margen al retraso de replicación, a autovacuum y a la contención del pool de conexiones para recuperarse entre ráfagas, en lugar de golpear la tabla de forma continua, como de todos modos hace implícitamente una única transacción gigante, solo que distribuido en vez de concentrado.
Leer pg_locks antes de ejecutar una migración
Una cantidad sorprendente de incidentes de bloqueo al agregar columnas en postgres se remonta a que nadie comprobó qué se estaba ejecutando ya contra la tabla antes de que comenzara la migración: una consulta de reportes de larga duración o un job por lotes no relacionado manteniendo un bloqueo detrás del cual la migración queda en cola, convirtiendo lo que debería haber sido un cambio instantáneo de metadatos en una espera de varios minutos, con todo lo demás quedando en cola detrás a su vez. En lugar de adivinar si una migración es segura, Postgres expone exactamente qué está reteniendo y esperando bloqueos ahora mismo:
SELECT
pg_locks.pid,
pg_locks.mode,
pg_locks.granted,
pg_stat_activity.query,
pg_stat_activity.state
FROM pg_locks
JOIN pg_stat_activity ON pg_locks.pid = pg_stat_activity.pid
WHERE pg_locks.relation = 'orders'::regclass;
Las filas con granted = false son las que realmente están atascadas esperando: esa es la señal en vivo de que algo está bloqueado actualmente, no una hipótesis. Ejecutar esta consulta en una segunda sesión inmediatamente después de iniciar una migración, mientras está en curso, es la forma más rápida de confirmar en tiempo real si adquirió el nivel de bloqueo que esperabas o uno más estricto, y si algo más está en cola detrás. Combínalo con pg_stat_activity.query_start para ver cuánto tiempo lleva ejecutándose una sentencia bloqueante, que a menudo es el factor decisivo para dejar terminar una migración o cancelarla antes de que cause daño real. También conviene conocer pg_blocking_pids(pid): pásale el PID de una consulta atascada y devuelve exactamente qué otras sesiones la están bloqueando, lo que convierte “algo está bloqueando mi migración” en un PID específico y accionable para investigar o terminar en lugar de una suposición.
Para una migración genuinamente riesgosa sobre una tabla grande en producción, vale la pena establecer un lock_timeout conservador para la sesión que la ejecuta, de modo que una migración que no pueda adquirir su bloqueo rápidamente falle de forma clara e inmediata en lugar de quedarse silenciosamente en cola detrás de otro tráfico y bloquear a su vez todo lo que se ponga detrás:
SET lock_timeout = '2s';
ALTER TABLE orders ADD CONSTRAINT ...;
Una migración que falla rápido con un error claro de lock not available es muchísimo preferible a una que se queda en cola durante minutos mientras todas las demás consultas sobre esa tabla también se acumulan detrás: una migración fallida simplemente puede reintentarse; una cola de consultas de producción bloqueadas ya es un incidente para cuando alguien lo nota.
Migraciones seguras en Alembic y Django
Alembic no aplica estos patrones automáticamente: un simple op.create_index(...) en una migración autogenerada produce por defecto un CREATE INDEX normal y bloqueante. Escribir una migración segura de alembic para un índice significa agregar tanto postgresql_concurrently=True como marcar la migración explícitamente como no transaccional:
from alembic import op
def upgrade():
op.execute("COMMIT") # end the implicit transaction Alembic opened
op.create_index(
"idx_orders_status", "orders", ["status"],
postgresql_concurrently=True,
)
El framework de migraciones de Django tiene una bandera dedicada y diseñada específicamente para este caso: atomic = False en la clase de migración, combinada con AddIndexConcurrently de django.contrib.postgres.operations:
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
operations = [
AddIndexConcurrently(
"order",
models.Index(fields=["status"], name="idx_orders_status"),
),
]
Ambos frameworks también admiten el patrón de división de restricciones mencionado antes como tres pasos de migración explícitos y separados, en lugar de un único SET NOT NULL autogenerado: divide deliberadamente la migración en la secuencia NOT VALID / VALIDATE / SET NOT NULL en tus archivos de migración en lugar de confiar en la salida predeterminada de cualquiera de las dos herramientas para un cambio NOT NULL en una tabla existente y ya poblada.
Errores comunes
Error: confiar en una migración autogenerada sobre una tabla grande existente sin leer lo que realmente hace. Las herramientas de migración generan SQL correcto, no necesariamente SQL seguro para una tabla que ya soporta tráfico de producción. Solución: revisa cada ALTER TABLE autogenerado con la pregunta “¿esto reescribe la tabla?” antes de ejecutarlo en producción.
Error: ejecutar CREATE INDEX CONCURRENTLY dentro de un bloque de transacción. Postgres lo rechaza directamente: no puede ejecutarse concurrentemente dentro de una transacción. Solución: rompe explícitamente el envoltorio transaccional para ese paso de la migración en el framework que estés usando.
Error: dejar un índice inválido después de una construcción concurrente interrumpida. En silencio cuesta espacio en disco y sobrecarga de escritura sin aportar nada al rendimiento de las consultas. Solución: comprueba periódicamente pg_index.indisvalid y limpia cualquier índice inválido encontrado.
Error: rellenar millones de filas en una sola transacción. Esto mantiene bloqueos y genera presión de WAL durante toda la duración de la transacción, y mientras tanto bloquea a autovacuum. Solución: haz el backfill por lotes con LIMIT y FOR UPDATE SKIP LOCKED, con una breve pausa entre lotes.
Error: agregar una columna con valor por defecto volátil y asumir que la optimización de fast-default de Postgres 11 la cubre. DEFAULT now() o algo similar sigue forzando una reescritura completa de la tabla, a diferencia de un valor por defecto literal simple. Solución: agrega la columna nullable, rellénala en lotes y luego aplica el valor por defecto solo para filas futuras.
Para terminar
Las migraciones sin tiempo de inactividad no son una disciplina aparte de los cambios normales de esquema: son cambios normales de esquema con un hábito extra añadido: saber, antes de ejecutar nada, si la duración de la operación escala con el tamaño de la tabla, y elegir en su lugar el patrón (fast default, NOT VALID/VALIDATE, CONCURRENTLY, backfill por lotes) que la mantiene constante. Cada patrón de esta publicación existe porque Postgres ya te ofrece una ruta más liviana para casi todos los cambios que parecen peligrosos; la habilidad está en saber que existe y recurrir a ella por defecto en cualquier tabla demasiado grande o demasiado ocupada como para arriesgar la versión ingenua.
Antes de que tu próxima migración toque una tabla de producción, ¿sabes qué bloqueo está a punto de tomar, o te vas a enterar por un incidente?
