Schema evolui. Coluna nova surge porque o produto mudou; tabela é renomeada porque o time descobriu que o nome velho confundia; relacionamento 1-N vira N-N porque um cliente passou a ter mais de um endereço. Isso não é exceção — é a regra. O que distingue um sistema sustentável é o como essa evolução acontece: versionada, reproduzível, reversível e — quando o sistema está em produção — sem downtime.
Migrations são o nome da prática que codifica essa evolução.
Cada mudança de schema vira um arquivo numerado, versionado em
git, aplicado por uma ferramenta determinística que sabe
exatamente o que já foi feito e o que falta. Quem nunca usou
migrations sente que escrever
ALTER TABLE ad-hoc no console é mais rápido — até
o dia em que dois ambientes divergem e ninguém sabe a diferença
precisa.
Este conceito cobre o que migrations resolvem, padrões de naming e organização, expand-contract para mudanças sem downtime, gotchas específicos de bloqueios em Postgres, e o cuidado quase paranoico que deveria acompanhar mudanças em produção.
Por que migrations
Antes do conceito existir como prática, mudanças de schema eram
DBAs aplicando scripts manualmente, ou desenvolvedores rodando
ALTER TABLE em horário fora-de-pico. Os problemas
eram crônicos: ambientes divergiam, ninguém sabia se a homolog
estava igual à produção, rollback era reverso mental
improvisado.
Migrations resolvem três problemas:
- Reproduzibilidade: aplicar a sequência de migrations em um banco vazio chega no mesmo schema em qualquer máquina. Dev, staging, prod, máquina nova de quem chegou no time — tudo converge. Schema não é descrito; é construído pelo histórico.
- Versionamento: cada mudança é um commit. Diff entre ambientes é diff entre commits. Code review pega problemas no schema antes de chegar em produção, igual code review de aplicação.
- Coupling com deploy de aplicação: schema e código que dependem dele evoluem juntos, no mesmo PR, no mesmo deploy. Em ferramentas com integração (EF Core, Rails), isso é automático. Em outros stacks, é convenção que vale impor.
Anatomia de uma migration
A maior parte das ferramentas converge em três peças:
-
Identificador: timestamp ou número incremental
(
20260201_120000ou0042). Define a ordem de aplicação. - Comando "up": o que aplicar — DDL ou manipulação de dados.
- Comando "down": como reverter. Em algumas ferramentas opcional; em outras obrigatório.
Uma tabela de controle (schema_migrations em Rails,
__EFMigrationsHistory em EF Core,
alembic_version em Alembic) armazena qual é a
versão atual. A ferramenta consulta isso, calcula migrations
pendentes, aplica em ordem, registra cada uma como aplicada.
Idempotência é parte do contrato: rodar duas vezes não duplica
efeito.
-- Naming sugerido (golang-migrate, similar a Alembic)
20260301140000_create_pedidos.up.sql
20260301140000_create_pedidos.down.sql
20260315093000_add_status_to_pedidos.up.sql
20260315093000_add_status_to_pedidos.down.sql
Ferramentas — princípios em comum, sintaxe diferente
As principais por ecossistema:
-
Flyway (Java, mas usado por qualquer stack
via JDBC ou container). SQL puro versionado, naming
V1__cria_tabelas.sql. Conservador, popular em empresas grandes. - EF Core Migrations (.NET). Geradas a partir do modelo C#. Escrevidas em C# (não SQL). Down implícito gerado.
-
Alembic (Python, ecossistema SQLAlchemy).
Detecta mudanças via
autogenerate; Python para migrations. Down explícito. -
golang-migrate (Go, mas language-agnostic).
SQL puro em
up.sql/down.sql. CLI simples; popular em times Go pela transparência. - Atlas (Go, Ariga). Ferramenta moderna que diff-uja schemas declarativos para gerar migrations. Mais próximo do mental model "Terraform for databases".
- Liquibase (Java). XML/YAML/SQL. Mais verboso que Flyway; útil em organizações que querem descrição declarativa.
A escolha entre "SQL puro" (Flyway, golang-migrate) e "código"
(EF Core, Alembic) é trade-off: SQL é portável e legível por
DBAs; código integra mais com modelo de aplicação e gera diffs
automaticamente. Em ambos os casos, commit a migration
gerada e revise — não confie em
autogenerate sem ler o que foi gerado. Já vi
Alembic gerar DROP COLUMN destrutivo porque uma
coluna foi renomeada do lado Python.
Mudanças seguras em produção — o catálogo
Muitas mudanças parecem simples mas têm efeito de bloqueio em tabelas grandes. Conhecer o catálogo evita incidentes.
Adicionar coluna NULL — seguro
ALTER TABLE pedidos ADD COLUMN observacao TEXT.
No Postgres 11+ isso é instantâneo: a coluna nasce nula sem
reescrever a tabela.
Adicionar coluna NOT NULL com default — atenção
ALTER TABLE pedidos ADD COLUMN status_v2 VARCHAR(20)
NOT NULL DEFAULT 'pendente'. Postgres 11+ otimizou para
não reescrever — armazena o default como metadado e materializa
por linha quando lida. Versões anteriores, e outros bancos,
reescreviam a tabela inteira (lock + tempo proporcional ao
tamanho).
Adicionar NOT NULL a coluna existente — perigoso
ALTER TABLE pedidos ALTER COLUMN status SET NOT NULL
varre a tabela para validar. Em tabelas grandes, lock exclusivo
durante a varredura. Pattern seguro:
- Adicione
CHECK (status IS NOT NULL) NOT VALID. Não trava. - Backfill nulos (se houver).
ALTER TABLE ... VALIDATE CONSTRAINT. Valida sem lock exclusivo.- Ali,
SET NOT NULLé instantâneo (Postgres 12+) — usa o constraint validado. - Drop o constraint, agora redundante.
Adicionar índice — use CONCURRENTLY
CREATE INDEX idx ... trava escrita até terminar.
CREATE INDEX CONCURRENTLY ... não trava. Em
produção, sempre concorrente, sem exceção. A versão
concorrente faz duas varreduras em vez de uma; lê 2× e custa o
dobro de tempo, mas não bloqueia.
Cuidado: CONCURRENTLY não pode rodar dentro de
uma transação. Migration tools que envelopam tudo em
BEGIN precisam de configuração para excluir
certos comandos. Em Alembic, op.execute() com
autocommit_block; em golang-migrate,
-- migrate:no-transaction no topo do arquivo.
Renomear coluna — não tão simples
ALTER TABLE ... RENAME COLUMN nome TO denominacao
é instantâneo no Postgres. Mas se você troca em uma migration,
o código antigo (deploy ainda em rolling) usando o nome
antigo quebra. Pattern seguro: use expand-contract.
Drop coluna — perigoso por outro motivo
DROP COLUMN em Postgres é instantâneo (marca a
coluna como deletada, espaço recupera só em VACUUM
FULL ou rewrite). Mas se algo no código ainda usa a
coluna, falha em runtime. Sempre faça em duas migrations
separadas: primeiro deploy de código que parou de usar; depois
migration que dropa.
Add foreign key — exige varredura
ALTER TABLE ... ADD FOREIGN KEY ... valida
todas as linhas existentes. Em tabela grande, lock exclusivo
durante a varredura. Pattern: ADD FOREIGN KEY ... NOT
VALID + VALIDATE CONSTRAINT em duas
migrations.
Expand-Contract — o padrão para mudanças sem downtime
O padrão expand-contract divide qualquer mudança em três fases, cada uma compatível com a anterior:
- Expand: adiciona o novo (coluna, tabela, constraint), mantendo o velho. Aplicação ainda lê e escreve no velho. Schema agora suporta os dois.
- Migrate: aplicação passa a escrever em ambos (velho + novo), depois passa a ler do novo. Em deploy rolling, instâncias antigas ainda usam o velho; instâncias novas usam o novo. Tudo continua funcionando.
- Contract: depois de garantir que ninguém depende do velho, remove (drop column, drop tabela). Pode ser dias ou semanas depois.
Exemplo concreto: renomear coluna nome para
denominacao:
-- Migration 1 (expand): adiciona denominacao com sync de nome
ALTER TABLE produtos ADD COLUMN denominacao VARCHAR(200);
UPDATE produtos SET denominacao = nome;
-- trigger ou aplicação escreve em ambos
CREATE TRIGGER sync_denominacao
BEFORE INSERT OR UPDATE ON produtos
FOR EACH ROW EXECUTE FUNCTION sync_nome_denominacao();
-- Deploy aplicação versão 2.0 — escreve em ambos, lê de denominacao
-- Migration 2 (contract): remove o velho
ALTER TABLE produtos DROP COLUMN nome;
DROP TRIGGER sync_denominacao ON produtos;
Há overhead: três migrations em vez de uma, código transitório, janela onde os dois coexistem. O ganho é zero downtime e possibilidade de reverter cada passo independentemente.
Toda migration que quebraria o código da versão anterior, em deploy rolling, exige expand-contract. Se a aplicação versão N+1 funciona com schema da versão N, e o schema da versão N+1 funciona com aplicação da versão N — o deploy é seguro. Esse é o invariante.
Backfill — quando há dados a transformar
Algumas migrations não são só DDL — exigem mover ou transformar dados existentes. Casos: split de coluna em duas, preenchimento de coluna nova baseado em colunas antigas, normalização de tabela de junção emergente.
O grande erro é fazer backfill numa migration única para tabela grande:
-- ANTI-PATTERN para tabelas grandes
UPDATE pedidos SET status_normalizado = LOWER(TRIM(status));
-- Trava milhões de linhas, gera bloat, demora horas
O padrão sênior é backfill em lotes, fora de transação principal:
-- Job em background ou script controlado
DO $$
DECLARE
rows_updated INT;
BEGIN
LOOP
WITH lote AS (
SELECT id FROM pedidos
WHERE status_normalizado IS NULL
LIMIT 5000 FOR UPDATE SKIP LOCKED
)
UPDATE pedidos p
SET status_normalizado = LOWER(TRIM(p.status))
FROM lote
WHERE p.id = lote.id;
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
PERFORM pg_sleep(0.5); -- não saturar
END LOOP;
END $$;
Backfills longos não devem rodar dentro do
migrate up. Devem ser scripts separados, com
progresso reportado, idempotentes, e que possam ser
interrompidos. A migration verifica que terminou e segue.
Rollback — a parte que ninguém pratica
Migrations down existem em todas as ferramentas, mas raramente são exercitadas. A reversão é fácil em mudanças aditivas (drop a coluna que adicionou); difícil em mudanças destrutivas (você não recria dados que apagou).
A regra mais útil é desistir do rollback automático para mudanças destrutivas e adotar disciplina diferente: fazer mudanças em camadas reversíveis (expand-contract, backfill em job, drop apenas depois de confirmação) e tratar a migration "down" como exercício de pensamento — não como vai ser executada.
Para deploy de aplicação, a estratégia é:
- Migration aditiva → deploy aplicação. Se aplicação falhar, rollback de aplicação é suficiente (schema continua compatível).
- Migration destrutiva → só depois de aplicação confirmadamente operando sem o que vai ser removido. Idealmente, dias depois.
Cuidados específicos do Postgres
lock_timeout e statement_timeout
Toda migration deveria começar com SET lock_timeout =
'5s'; SET statement_timeout = '30s' ou similar. Sem
isso, uma transação que não consegue lock pode esperar
eternamente, segurando outras. lock_timeout
aborta a tentativa após o tempo; melhor falhar rápido e
retentar do que pendurar produção.
Postgres não suporta DDL transacional total entre extensões
Postgres é raro: muitos DDLs são transacionais.
BEGIN; CREATE TABLE; ALTER TABLE; ROLLBACK
desfaz tudo. MySQL (até versão 8.0) não tinha esse luxo —
DDL commit-ava implicitamente. Aproveite no Postgres: agrupe
mudanças relacionadas em uma transação, mas saiba quais
comandos não podem (CREATE INDEX CONCURRENTLY,
VACUUM, REINDEX com algumas opções).
Cuidado com triggers cascade
Constraints com ON DELETE CASCADE em DELETE
pesado podem se propagar para milhões de linhas. Em migration
que envolve delete em massa, considere desabilitar triggers
temporariamente (ALTER TABLE ... DISABLE TRIGGER),
operar, e re-habilitar.
Migrations nas três linguagens
// dotnet ef migrations add AddObservacaoToPedidos
// gera arquivo C#:
public partial class AddObservacaoToPedidos : Migration {
protected override void Up(MigrationBuilder b) {
b.AddColumn<string>(
name: "observacao",
table: "pedidos",
type: "text",
nullable: true);
}
protected override void Down(MigrationBuilder b) {
b.DropColumn(name: "observacao", table: "pedidos");
}
}
// Para CONCURRENTLY (não suportado nativamente):
b.Sql(@"CREATE INDEX CONCURRENTLY idx_pedidos_status
ON pedidos (status) WHERE status = 'pendente'",
suppressTransaction: true);
// Em produção: dotnet ef migrations script > migration.sql
// Aplica via DBA, não no startup da aplicação
EF Core 9 gera migrations em C# que produzem SQL no apply.
suppressTransaction é vital para CONCURRENTLY.
Em produção, a prática sênior é gerar SQL e aplicar via
ferramenta de deploy — não rodar dotnet ef database
update no startup.
# alembic revision -m "add observacao to pedidos"
"""add observacao to pedidos
Revision ID: a1b2c3d4
Revises: previous_id
Create Date: 2026-03-01 14:00:00
"""
from alembic import op
import sqlalchemy as sa
revision = 'a1b2c3d4'
down_revision = 'previous_id'
def upgrade():
op.add_column('pedidos',
sa.Column('observacao', sa.Text(), nullable=True))
def downgrade():
op.drop_column('pedidos', 'observacao')
# Para CONCURRENTLY:
def upgrade():
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY idx_pedidos_status "
"ON pedidos (status) WHERE status = 'pendente'")
Alembic autogenerate compara o modelo
SQLAlchemy ao schema vivo e gera diffs — útil mas
sempre revise o gerado. Renames são
detectados como drop+add por padrão; ajuste manualmente.
// migrations/20260301140000_add_observacao_to_pedidos.up.sql
ALTER TABLE pedidos ADD COLUMN observacao TEXT;
// migrations/20260301140000_add_observacao_to_pedidos.down.sql
ALTER TABLE pedidos DROP COLUMN observacao;
// Index concurrently (precisa de marca especial):
// migrations/20260315090000_add_status_index.up.sql
-- migrate:no-transaction
CREATE INDEX CONCURRENTLY idx_pedidos_status
ON pedidos (status) WHERE status = 'pendente';
// Aplicação:
// migrate -path ./migrations -database "$DATABASE_URL" up
// Rotineiro em CI/CD:
// migrate -path ./migrations -database "$DATABASE_URL" up 1
// (aplica uma migration de cada vez para revisão)
golang-migrate é minimalista — SQL puro versionado, CLI binário. Não infere down a partir do up; força o autor a pensar. Para times Go, sqlc + golang-migrate é a combinação canônica: ambos opera com SQL como fonte da verdade.
Práticas de organização e revisão
- Uma migration por unidade lógica de mudança. Não agrupe duas adições não-relacionadas. Reverter ficar impossível se elas se misturarem.
- Migration entra em PR. Sempre. Code review em DDL é tão importante quanto em código — talvez mais, dado o impacto.
- Rode em homolog antes de prod. Cada migration deveria ser exercida em ambiente com volume comparável ao de prod antes de aplicar.
- Comente o porquê. Cada migration deveria ter comentário explicando por que existe. Em seis meses, você vai querer entender a motivação.
- Não edite migrations já aplicadas. Aplicada é histórico. Se errou, crie nova migration que conserta. Editar e re-aplicar é como reescrever a história do git em branches compartilhadas.
- Tenha um plano de DR. Backup antes de migration destrutiva. Mesmo migrations supostamente seguras podem virar incidente — saiba como voltar a um snapshot.
Rodar migrate up automaticamente no startup do
container da aplicação. Funciona em dev. Em produção, em deploy
rolling, várias instâncias tentam aplicar ao mesmo tempo,
compete-se por lock, primeira vence outras pegam erro
intermitente. Migrations de schema deveriam ser etapa
separada do pipeline — antes do deploy, com gate humano
para mudanças não-aditivas.
Como praticar
- Implemente expand-contract para um rename real no projeto do módulo. Renomeie uma coluna em três deploys (não em uma migration só). Documente o que está rolando em cada estágio: schema, código, deploy.
-
Faça backfill em lote em uma tabela com 1M+
linhas. Implemente o loop com
SKIP LOCKED,pg_sleepe progresso reportado. Compare com a versão "tudo de uma vez" — meça lock, bloat e tempo. -
Pratique rollback. Em ambiente de dev,
aplique migration; rode aplicação;
migrate down 1; verifique que aplicação ainda roda. Esse exercício revela toda incompatibilidade que migrations destrutivas têm.
Referências para aprofundar
- livro Refactoring Databases — Scott Ambler & Pramod Sadalage (2006).
- livro Database Reliability Engineering — Laine Campbell & Charity Majors (2017).
- livro The Art of PostgreSQL — Dimitri Fontaine (2ª ed., 2022).
- artigo Strong Migrations — Andrew Kane.
- artigo Online migrations at scale — Stripe Engineering.
- artigo Postgres at scale: lessons from migrations — Gitlab Engineering.
- artigo Zero-downtime Postgres schema migrations need this lock — pganalyze.
- artigo Don't run migrations from the application server — Brandur Leach.
- docs PostgreSQL — Concurrency Control / DDL Locks.
- docs Alembic Documentation.
- vídeo Postgres Schema Migrations Without Downtime — PGConf.
- vídeo Database Migrations: The Hard Parts — Hussein Nasser.