Migrações PostgreSQL
Principal. Cada migração é um par up/down, e o down reverte completamente o up. A migração é executada em sua própria transação. Há uma exceção: índice em uma tabela que já possui dados e está recebendo escritas — CREATE INDEX CONCURRENTLY em uma migração separada sem transação, um comando por arquivo.
Verificação de afirmações — CHECKS.md.
Formato dos arquivos
migrations/
├── 20250524120000_create_users.up.sql
├── 20250524120000_create_users.down.sql
├── 20250525090000_add_users_phone.up.sql
└── 20250525090000_add_users_phone.down.sql
- Prefixo — hora UTC
YYYYMMDDHHmmss, nome — descrição curta em snake_case. *.up.sqlaplica,*.down.sqlreverte. Ambos os arquivos são obrigatórios.- Não edite uma migração já aplicada — escreva uma nova.
Tipos e restrições
| Em vez de | Escreva | Por quê |
|---|---|---|
bigserial | bigint GENERATED ALWAYS AS IDENTITY | padrão SQL; BY DEFAULT se precisar inserir seus próprios ids (importação) |
varchar(n) | text NOT NULL CHECK (length(x) <= n) | limite é uma regra de dados, mais fácil de alterar |
timestamp | timestamptz | armazena um momento no tempo, não "horas na parede" |
flag integer | boolean | |
json | jsonb | formato binário, índices e operadores |
| uuid de extensão | uuid DEFAULT gen_random_uuid() | embutido desde PostgreSQL 13 |
Limite o comprimento de colunas de texto com CHECK sempre, exceto para texto sabidamente longo (descrição, comentário). Se o comprimento máximo não for óbvio, pergunte ao usuário.
| Dados | Restrição |
|---|---|
CHECK (length(email) <= 320) | |
| nome | CHECK (length(name) <= 255) |
| telefone | CHECK (length(phone) <= 50) |
| slug, código | CHECK (length(code) <= 100) |
| URL | CHECK (length(url) <= 2048) |
| descrição | sem restrição ou CHECK (length(bio) <= 10000) |
Modelo de tabela:
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);
Reversão
| 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 — também sem transação |
CREATE TYPE t AS ENUM (...) | DROP TYPE IF EXISTS t |
ALTER TYPE t ADD VALUE 'v' | DROP VALUE não existe no PostgreSQL — recriação do tipo ou down vazio |
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 no down — para que a reversão seja idempotente.
Alterações típicas
Coluna com default. Com DEFAULT constante a tabela não é reescrita (PostgreSQL 11+), o comando é instantâneo. Com default volátil (gen_random_uuid(), clock_timestamp()) a tabela é reescrita por inteiro sob bloqueio exclusivo — em uma tabela grande e ativa, adicione a coluna sem default e preencha em lotes.
ALTER TABLE users ADD COLUMN role text NOT NULL DEFAULT 'user' CHECK (length(role) <= 50);
Chave estrangeira — crie imediatamente um índice na coluna referenciadora: o PostgreSQL não o cria automaticamente, e sem ele a exclusão do pai e os joins são feitos por varredura total.
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);
Valor em enum. ALTER TYPE ... ADD VALUE funciona dentro de uma transação, mas o novo valor não pode ser usado até o commit (55P04 unsafe use of new value). Se o migrador executa a migração em uma transação, adicione o valor e use-o (UPDATE, DEFAULT, CHECK) em migrações diferentes.
ALTER TYPE payment_method ADD VALUE 'bank_transfer';
down: DROP VALUE não existe. O valor é inofensivo — deixe o down vazio com um comentário; se não for, recrie o tipo (novo tipo, ALTER COLUMN ... TYPE ... USING, remoção do antigo).
Índices em tabelas ativas
Tabela já com dados e recebendo escritas — índice via CREATE INDEX CONCURRENTLY (CIC). O CREATE INDEX comum bloqueia escritas durante toda a construção. Índice junto com nova tabela na mesma migração — CREATE INDEX comum. O tamanho do banco não salva: uma transação pendente quebra o novo índice em qualquer tamanho (veja abaixo).
up:
CREATE INDEX CONCURRENTLY ix_orders_customer_id ON orders (customer_id);
down:
DROP INDEX CONCURRENTLY IF EXISTS ix_orders_customer_id;
- Migração separada sem transação, um comando por arquivo. Dentro de um bloco de transação, CIC é proibido ("cannot run inside a transaction block"). Vários comandos enviados em uma única requisição são executados pelo PostgreSQL como uma transação implícita, e o CIC falha com o mesmo erro.
- Se o migrador não suporta migração sem transação — não coloque CIC em uma migração normal. Diga ao usuário que esse modo é necessário (seção "O que exigir do migrador"), ou execute o CIC separadamente e registre a migração manualmente.
- CIC aguarda todas as transações com snapshot antigo, mesmo aquelas que leem outras tabelas. A migração pode demorar.
- Após aplicar, verifique o índice. CIC não é transacional: em caso de falha, um índice INVALID permanece, e a própria migração não mostrará isso.
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—DROP INDEX CONCURRENTLY IF EXISTSe recrie, ouREINDEX INDEX CONCURRENTLY.- Reexecução de migração CIC que falhou. Sem
IF NOT EXISTS, ela falhará com "relation already exists" no índice INVALID. ComIF NOT EXISTS— pior: o comando silenciosamente ignorará o índice INVALID, e a migração "passará". Portanto, não escrevaIF NOT EXISTSno CIC: primeiro remova o índice inválido, depois repita.
O índice existe, mas o plano é Seq Scan
O índice é válido, mas o planejador não o usa — verifique indcheckxmin (consulta acima). true: as consultas não usam o índice enquanto existirem transações iniciadas antes de sua criação. A causa é UPDATE: na tabela restaram cadeias de versões HOT, para as quais o índice não faz referência. Apenas INSERTs não causam isso. Não recrie o índice nem procure a causa nas estatísticas. Encontre transações antigas:
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;
Aguarde sua conclusão ou encerre-as (pg_terminate_backend(pid)) — somente após acordo com o usuário. Para reproduzir antes do lançamento: em uma sessão, uma transação aberta repeatable read com leitura da tabela; em outra, UPDATE de linhas e CREATE INDEX; indcheckxmin se tornará true.
O que exigir do migrador
- Cada migração — sua própria transação, junto com a gravação no log de aplicadas: uma migração que falha é revertida por completo, as já aplicadas permanecem.
- Bloqueio contra execução paralela (por exemplo, advisory lock): réplicas que iniciarem juntas não aplicarão a mesma migração duas vezes.
- Modo sem transação para migrações individuais — uma marcação no arquivo (por exemplo, primeira linha
-- migrate:no-transaction). Esse arquivo é enviado como um único comando, fora da transação. Custo: a aplicação e o registro no log não são mais atômicos. Se o processo falhar entre eles, a repetição falhará com "already exists"; verifiqueindisvalid; se o índice for válido, registre a migração no log manualmente. - Criação do par de arquivos sem conexão com o banco.