Migraciones PostgreSQL
Importante. Cada migración es un par up/down, y down revierte completamente up. La migración se ejecuta en su propia transacción. Hay una excepción: el índice en una tabla que ya tiene datos y está recibiendo escrituras — CREATE INDEX CONCURRENTLY en una migración separada sin transacción, un solo comando por archivo.
La verificación de afirmaciones está en CHECKS.md.
Formato de archivos
migrations/
├── 20250524120000_create_users.up.sql
├── 20250524120000_create_users.down.sql
├── 20250525090000_add_users_phone.up.sql
└── 20250525090000_add_users_phone.down.sql
- Prefijo: hora UTC
YYYYMMDDHHmmss, nombre: descripción breve en snake_case. *.up.sqlaplica,*.down.sqlrevierte. Ambos archivos son obligatorios.- No modifiques una migración ya aplicada — escribe una nueva.
Tipos y restricciones
| En lugar de | Escribe | Por qué |
|---|---|---|
bigserial | bigint GENERATED ALWAYS AS IDENTITY | estándar SQL; BY DEFAULT si necesitas insertar tus propios ids (importación) |
varchar(n) | text NOT NULL CHECK (length(x) <= n) | el límite es una regla de datos, cambiarlo es más sencillo |
timestamp | timestamptz | almacena un instante en el tiempo, no "hora de pared" |
flag integer | boolean | |
json | jsonb | formato binario, índices y operadores |
| uuid de extensión | uuid DEFAULT gen_random_uuid() | incorporado desde PostgreSQL 13 |
Limita la longitud de columnas de texto con CHECK siempre, excepto para texto deliberadamente largo (descripción, comentario). Si la longitud máxima no es obvia, pregunta al usuario.
| Datos | Restricción |
|---|---|
CHECK (length(email) <= 320) | |
| nombre | CHECK (length(name) <= 255) |
| teléfono | CHECK (length(phone) <= 50) |
| slug, código | CHECK (length(code) <= 100) |
| URL | CHECK (length(url) <= 2048) |
| descripción | sin restricción o CHECK (length(bio) <= 10000) |
Plantilla de tabla:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL CHECK (length(name) <= 255),
email text NOT NULL CHECK (length(email) <= 320),
is_active boolean NOT NULL DEFAULT true,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ix_users_email ON users (email);
Reversión
| up | down |
|---|---|
CREATE TABLE t | DROP TABLE IF EXISTS t |
ALTER TABLE t ADD COLUMN c | ALTER TABLE t DROP COLUMN IF EXISTS c |
CREATE INDEX ix | DROP INDEX IF EXISTS ix |
CREATE INDEX CONCURRENTLY ix | DROP INDEX CONCURRENTLY IF EXISTS ix — también sin transacción |
CREATE TYPE t AS ENUM (...) | DROP TYPE IF EXISTS t |
ALTER TYPE t ADD VALUE 'v' | No existe DROP VALUE en PostgreSQL — recrear el tipo o down vacío |
ALTER TABLE t ALTER COLUMN c SET NOT NULL | ALTER TABLE t ALTER COLUMN c DROP NOT NULL |
ALTER TABLE t ADD CONSTRAINT c | ALTER TABLE t DROP CONSTRAINT IF EXISTS c |
IF EXISTS en down para que la reversión sea idempotente.
Cambios típicos
Columna con valor por defecto. Con un DEFAULT constante, la tabla no se reescribe (PostgreSQL 11+), el comando es instantáneo. Con un default volátil (gen_random_uuid(), clock_timestamp()), la tabla se reescribe completamente bajo un bloqueo exclusivo — en una tabla grande y activa, agrega la columna sin default y rellénala por lotes.
ALTER TABLE users ADD COLUMN role text NOT NULL DEFAULT 'user' CHECK (length(role) <= 50);
Clave foránea — crea un índice sobre la columna referenciada de inmediato: PostgreSQL no lo crea automáticamente, y sin él, las eliminaciones de padres y los joins se hacen por escaneo secuencial.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id),
total numeric NOT NULL
);
CREATE INDEX ix_orders_user_id ON orders (user_id);
Agregar valor a un enum. ALTER TYPE ... ADD VALUE funciona dentro de una transacción, pero el nuevo valor no se puede usar hasta después del commit (55P04 unsafe use of new value). Si el migrador ejecuta la migración en una transacción, agrega el valor y úsalo (UPDATE, DEFAULT, CHECK) en migraciones separadas.
ALTER TYPE payment_method ADD VALUE 'bank_transfer';
down: No existe DROP VALUE. El valor es inofensivo — deja down vacío con un comentario; si no lo es — recrea el tipo (nuevo tipo, ALTER COLUMN ... TYPE ... USING, eliminar el anterior).
Índices en tablas activas
Si la tabla ya tiene datos y está recibiendo escrituras, usa CREATE INDEX CONCURRENTLY (CIC). Un CREATE INDEX normal bloquea las escrituras durante toda la construcción del índice. Si el índice se crea junto con la tabla en la misma migración, usa CREATE INDEX normal. El tamaño de la base no importa: una transacción pendiente puede romper el índice nuevo sin importar el tamaño (ver abajo).
up:
CREATE INDEX CONCURRENTLY ix_orders_customer_id ON orders (customer_id);
down:
DROP INDEX CONCURRENTLY IF EXISTS ix_orders_customer_id;
- Migración separada sin transacción, un solo comando por archivo. Dentro de un bloque de transacción, CIC está prohibido ("cannot run inside a transaction block"). Varios comandos enviados en una sola solicitud son ejecutados por PostgreSQL como una transacción implícita, y CIC falla con el mismo error.
- Si el migrador no soporta migraciones sin transacción, no pongas CIC en una migración normal. Indica al usuario que se necesita este modo (sección "Qué exigir al migrador"), o ejecuta CIC por separado y registra la migración manualmente.
- CIC espera a que terminen todas las transacciones con instantáneas antiguas, incluso aquellas que solo leen otras tablas. La migración puede tomar mucho tiempo.
- Después de aplicar, verifica el índice. CIC no es transaccional: si falla, queda un índice INVALID, y la migración en sí no lo mostrará.
SELECT c.relname, i.indisvalid, i.indcheckxmin
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = 'ix_orders_customer_id';
indisvalid = false— ejecutaDROP INDEX CONCURRENTLY IF EXISTSy vuelve a crearlo, oREINDEX INDEX CONCURRENTLY.- Reintentar una migración CIC fallida. Sin
IF NOT EXISTS, fallará con "relation already exists" sobre el índice INVALID. ConIF NOT EXISTSes peor: el comando omitirá silenciosamente el índice INVALID y la migración "pasará". Por lo tanto, no escribasIF NOT EXISTSen CIC: primero elimina el índice inválido y luego reintenta.
El índice existe, pero el plan es Seq Scan
El índice es válido, pero el planificador no lo usa: verifica indcheckxmin (consulta anterior). Si es true: las consultas no usarán el índice mientras existan transacciones iniciadas antes de su creación. La causa es un UPDATE: quedaron cadenas de versiones HOT en la tabla a las que el índice no hace referencia. Solo INSERT no produce esto. No recrees el índice ni busques la causa en las estadísticas. Busca transacciones antiguas:
SELECT pid, xact_start, backend_xmin, state, left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY xact_start;
Espera a que terminen o finalízalas (pg_terminate_backend(pid)) — solo después de acordarlo con el usuario. Para reproducir antes de un lanzamiento: en una sesión, una transacción abierta con repeatable read leyendo la tabla; en otra, un UPDATE de filas y un CREATE INDEX; indcheckxmin se volverá true.
Qué exigir al migrador
- Cada migración en su propia transacción, junto con el registro en el diario de migraciones aplicadas: una fallida se revierte por completo, las ya aplicadas permanecen.
- Bloqueo contra ejecución paralela (por ejemplo, advisory lock): réplicas que se inicien juntas no aplican lo mismo dos veces.
- Modo sin transacción para migraciones individuales — una marca en el archivo (por ejemplo, primera línea
-- migrate:no-transaction). Dicho archivo se envía como un solo comando, fuera de la transacción. Costo: la aplicación y el registro en el diario ya no son atómicos. Si el proceso falla entre ambos, el reintento fallará con "already exists"; verificaindisvalidy, si el índice es válido, registra la migración manualmente en el diario. - Creación del par de archivos sin conexión a la base de datos.