MÓDULO 03 · CONCEITO 05 DE 8

Migrations & evolução de schema

Versionamento, rollback, expand-contract, zero-downtime — como evoluir banco em produção sem janela de manutenção.

Tempo de leitura ~22 min Pré-requisito Modelagem + transações Próximo NoSQL: quando faz sentido

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:

  1. 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.
  2. 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.
  3. 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:

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:

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:

  1. Adicione CHECK (status IS NOT NULL) NOT VALID. Não trava.
  2. Backfill nulos (se houver).
  3. ALTER TABLE ... VALIDATE CONSTRAINT. Valida sem lock exclusivo.
  4. Ali, SET NOT NULL é instantâneo (Postgres 12+) — usa o constraint validado.
  5. 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:

  1. Expand: adiciona o novo (coluna, tabela, constraint), mantendo o velho. Aplicação ainda lê e escreve no velho. Schema agora suporta os dois.
  2. 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.
  3. 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.

heurística do sênior

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 é:

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

C# — EF Core 9 Migrations
// 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.

Python — Alembic
# 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.

Go — golang-migrate
// 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

armadilha clássica

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

  1. 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.
  2. Faça backfill em lote em uma tabela com 1M+ linhas. Implemente o loop com SKIP LOCKED, pg_sleep e progresso reportado. Compare com a versão "tudo de uma vez" — meça lock, bloat e tempo.
  3. 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

  1. livro Refactoring Databases — Scott Ambler & Pramod Sadalage (2006). A bíblia do refactoring de schema. Catálogo de "transition periods" — exatamente o tema do expand-contract antes do termo virar moda.
  2. livro Database Reliability Engineering — Laine Campbell & Charity Majors (2017). Cap. sobre release engineering em bancos. Mostra processos de mudança em produção em grandes empresas.
  3. livro The Art of PostgreSQL — Dimitri Fontaine (2ª ed., 2022). Capítulo sobre schema evolution com casos do mundo real e gotchas específicos do Postgres.
  4. artigo Strong Migrations — Andrew Kane. github.com/ankane/strong_migrations — checklist exaustivo do que NÃO fazer em migrations Postgres. Aplicável a qualquer stack.
  5. artigo Online migrations at scale — Stripe Engineering. stripe.com/blog — case study de migrations sem downtime em escala. Mostra padrão expand-contract aplicado a transformações enormes.
  6. artigo Postgres at scale: lessons from migrations — Gitlab Engineering. about.gitlab.com/blog — Gitlab tem um dos blogs mais didáticos sobre operação de Postgres em produção, incluindo migrations.
  7. artigo Zero-downtime Postgres schema migrations need this lock — pganalyze. pganalyze.com/blog — explicação técnica de lock_timeout e por que é essencial. Curto e definitivo.
  8. artigo Don't run migrations from the application server — Brandur Leach. brandur.org — argumento contra rodar migrations no startup. Defesa de gates separados.
  9. docs PostgreSQL — Concurrency Control / DDL Locks. postgresql.org/docs/current/explicit-locking.html — quais comandos pegam quais locks. Fundamento para entender o que trava o quê.
  10. docs Alembic Documentation. alembic.sqlalchemy.org — autogenerate, branches, scripting. Mike Bayer escreve a documentação como livro técnico.
  11. vídeo Postgres Schema Migrations Without Downtime — PGConf. YouTube. Várias palestras anuais cobrindo casos práticos. Procure pelos talks de empresas como Heroku, Citus, Supabase.
  12. vídeo Database Migrations: The Hard Parts — Hussein Nasser. YouTube. Whiteboarding visual dos padrões expand-contract e backfill.