Como Reverter Migrations com Segurança Sem Perder Dados em Produção

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema real: migrations não são simétricas
Uma migration que adiciona uma coluna é trivial de reverter: basta dropar a coluna. Uma migration que dropa uma coluna, renomeia uma tabela ou altera o tipo de um campo não tem caminho de volta automático. Os dados que existiam antes da migration desaparecem no momento em que ela roda.
A maioria dos ORMs gera o método down() como espelho do up(), e isso cria uma falsa sensação de segurança. O down() reverte a estrutura do schema, não os dados. Se você dropou a coluna legacy_email e precisa reverter, o down() recria a coluna vazia. Os 200 mil emails que estavam lá sumiram.
Esse post trata de como planejar migrations que permitem rollback seguro, como executar reversões quando o plano falhou, e quando a resposta correta é não reverter.
Se você ainda não tem um fluxo maduro de migrations, vale ler o post sobre versionamento de schema com TypeORM e o guia de migrations seguras com Prisma antes de continuar.
Classificação de risco: nem toda migration precisa do mesmo cuidado
Antes de decidir a estratégia de rollback, classifique a migration:
| Tipo de operação | Risco de perda de dados | Reversível automaticamente? | Estratégia recomendada |
|---|---|---|---|
| Adicionar coluna nullable | Nenhum | Sim (drop column) | Rollback direto |
| Adicionar índice | Nenhum | Sim (drop index) | Rollback direto |
| Remover coluna | Alto | Não (dados perdidos) | Backup da coluna antes |
| Renomear coluna/tabela | Médio | Parcial (renomear de volta) | Deploy em fases |
| Alterar tipo de coluna | Alto | Parcial (cast pode falhar) | Coluna shadow + backfill |
| Dropar tabela | Crítico | Não | Dump da tabela antes |
| Migrar dados entre tabelas | Alto | Não (depende da lógica) | Migration reversível com tabela auxiliar |
A regra é: se a migration destrói informação, o rollback precisa de uma etapa extra antes da execução. Não depois.
A técnica de coluna shadow para alterações destrutivas
Renomear uma coluna ou alterar seu tipo parece simples. Na prática, qualquer deploy que muda o nome de uma coluna quebra queries da versão anterior da aplicação que ainda estão rodando. Se você usa estratégias de deploy como blue-green ou rolling updates, a versão antiga e a nova coexistem por alguns segundos ou minutos.
A solução é o deploy em três fases com coluna shadow:
Fase 1: adicionar a coluna nova sem remover a antiga
-- Fase 1: cria a nova coluna e copia os dados existentes
-- POR QUE nullable: a aplicação antiga ainda escreve na coluna original,
-- e a nova coluna não pode ter constraint NOT NULL antes do backfill completar
ALTER TABLE users ADD COLUMN display_name VARCHAR(255);
UPDATE users SET display_name = username WHERE display_name IS NULL;Fase 2: aplicação escreve nas duas colunas (dual-write)
// src/repositories/user.repository.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function updateUsername(userId: string, newName: string) {
// POR QUE dual-write: garante que ambas as colunas ficam sincronizadas
// durante o período de transição entre deploys
await prisma.user.update({
where: { id: userId },
data: {
username: newName,
displayName: newName,
},
});
}Fase 3: remover a coluna antiga (só depois que nenhuma versão antiga está rodando)
-- Fase 3: só execute quando TODAS as instâncias rodam a versão nova
-- POR QUE esperar: se uma instância antiga ainda lê "username",
-- dropar a coluna causa erro 500 nessa instância
ALTER TABLE users DROP COLUMN username;Se algo der errado na fase 2 ou 3, o rollback é trivial: a coluna original ainda existe com os dados intactos. Você reverte o deploy da aplicação e a coluna shadow fica órfã até o próximo ciclo.
Backup cirúrgico antes de migrations destrutivas
Quando a migration precisa dropar uma coluna ou tabela, faça backup granular antes de executar. Não dependa do backup completo do banco: restaurar um dump de 50 GB para recuperar uma coluna é desproporcional.
# Exporta apenas os dados da coluna que será removida
# POR QUE COPY e não pg_dump: COPY gera CSV plano, mais rápido
# para reimportar uma coluna específica
psql -h db-prod.internal -U app_user -d myapp -c \
"COPY (SELECT id, legacy_email FROM users) TO STDOUT WITH CSV HEADER" \
> /backups/users_legacy_email_$(date +%Y%m%d_%H%M%S).csv# Para restaurar, crie a coluna e reimporte
psql -h db-prod.internal -U app_user -d myapp <<'SQL'
ALTER TABLE users ADD COLUMN legacy_email VARCHAR(255);
CREATE TEMP TABLE legacy_restore (
id UUID,
legacy_email VARCHAR(255)
);
\copy legacy_restore FROM '/backups/users_legacy_email_20250115_143000.csv' CSV HEADER
UPDATE users u
SET legacy_email = lr.legacy_email
FROM legacy_restore lr
WHERE u.id = lr.id;
DROP TABLE legacy_restore;
SQLEsse processo leva segundos para tabelas de até 1 milhão de linhas. Para tabelas maiores, considere usar pg_dump --table com formato custom para compressão.
Rollback com TypeORM: o método down() que funciona de verdade
O TypeORM gera o down() automaticamente, mas ele só reverte DDL. Para migrations que envolvem dados, você precisa escrever o down() manualmente com a lógica de restauração.
// src/migrations/1705312800000-MoveEmailToProfile.ts
import { MigrationInterface, QueryRunner } from "typeorm";
export class MoveEmailToProfile1705312800000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
// Cria a coluna no destino
await queryRunner.query(`
ALTER TABLE profiles ADD COLUMN email VARCHAR(255)
`);
// Copia os dados
await queryRunner.query(`
UPDATE profiles p
SET email = u.email
FROM users u
WHERE p.user_id = u.id
`);
// POR QUE não dropar users.email aqui: o rollback precisa dos dados
// Dropar acontece em uma migration separada, na próxima release
}
async down(queryRunner: QueryRunner): Promise<void> {
// POR QUE copiar de volta antes de dropar: se profiles.email
// foi modificado depois da migration, queremos preservar o estado mais recente
await queryRunner.query(`
UPDATE users u
SET email = p.email
FROM profiles p
WHERE u.id = p.user_id
AND u.email IS DISTINCT FROM p.email
`);
await queryRunner.query(`
ALTER TABLE profiles DROP COLUMN email
`);
}
}O ponto crítico: a migration que move dados e a que remove a coluna original devem ser migrations separadas. Se estiverem na mesma migration, o down() não tem de onde recuperar os dados.
Rollback com Prisma: a limitação que você precisa contornar
O Prisma não gera método down(). O comando prisma migrate reset destrói o banco e recria do zero, algo útil em desenvolvimento e proibido em produção.
Para reverter uma migration Prisma em produção, o caminho é SQL direto:
// scripts/rollback-migration.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function rollbackAddProfileEmail() {
await prisma.$transaction(async (tx) => {
// Reverte os dados antes de alterar a estrutura
await tx.$executeRaw`
UPDATE users u
SET email = p.email
FROM profiles p
WHERE u.id = p.user_id
AND u.email IS NULL
`;
await tx.$executeRaw`
ALTER TABLE profiles DROP COLUMN IF EXISTS email
`;
// POR QUE atualizar _prisma_migrations: sem isso, o Prisma
// acha que a migration ainda está aplicada e não permite
// reaplicar depois de corrigir
await tx.$executeRaw`
DELETE FROM _prisma_migrations
WHERE migration_name = '20250115_move_email_to_profile'
`;
});
console.log("Rollback concluído");
}
rollbackAddProfileEmail()
.catch(console.error)
.finally(() => prisma.$disconnect());# Execute o script de rollback
npx tsx scripts/rollback-migration.tsA tabela _prisma_migrations registra quais migrations foram aplicadas. Se você reverte o schema manualmente sem limpar essa tabela, o Prisma fica em estado inconsistente. Para mais detalhes sobre o fluxo do Prisma em produção, veja o post sobre migrations seguras com Prisma.
O que NÃO fazer
Anti-pattern 1: reverter migration destrutiva sem backup prévio
// ERRADO: o down() recria a coluna, mas os dados sumiram
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE users ADD COLUMN legacy_email VARCHAR(255)
`);
// Coluna existe de novo, mas está vazia.
// 200 mil registros perdidos permanentemente.
}// CORRETO: backup antes do up(), restauração no down()
async up(queryRunner: QueryRunner): Promise<void> {
// Salva os dados em tabela auxiliar antes de destruir
await queryRunner.query(`
CREATE TABLE _backup_users_legacy_email AS
SELECT id, legacy_email FROM users
`);
await queryRunner.query(`
ALTER TABLE users DROP COLUMN legacy_email
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE users ADD COLUMN legacy_email VARCHAR(255)
`);
await queryRunner.query(`
UPDATE users u
SET legacy_email = b.legacy_email
FROM _backup_users_legacy_email b
WHERE u.id = b.id
`);
await queryRunner.query(`
DROP TABLE _backup_users_legacy_email
`);
}A tabela _backup_* ocupa espaço, mas é temporária. Drope-a em uma migration futura, depois de confirmar que o rollback não será necessário (duas ou três releases depois).
Anti-pattern 2: rodar migrate reset em produção
# NUNCA faça isso em produção
npx prisma migrate reset --force
# Isso DROPA o banco inteiro e recria do zero.
# Todos os dados de todos os usuários desaparecem.# CORRETO: reverta a migration específica com script SQL
npx tsx scripts/rollback-migration.ts
# Ou aplique SQL direto com psql
psql -h db-prod.internal -U app_user -d myapp -f rollback.sqlAnti-pattern 3: reverter múltiplas migrations de uma vez
Cada migration pode depender do estado deixado pela anterior. Reverter três migrations de uma vez sem testar a ordem inversa causa erros de constraint, foreign keys órfãs e dados inconsistentes.
Reverta uma por vez. Valide o estado do banco entre cada reversão.
Checklist pré-rollback em produção
Antes de executar qualquer reversão em banco de produção:
- Verifique se a versão da aplicação que está rodando é compatível com o schema anterior
- Confirme que existe backup dos dados que serão afetados (não do banco inteiro, dos dados específicos)
- Teste o script de rollback em staging com dados representativos
- Documente o comando exato que será executado e o resultado esperado
- Tenha um segundo par de olhos revisando o SQL antes de rodar
- Execute dentro de uma transaction quando possível (DDL transacional funciona no PostgreSQL, não no MySQL)
Esse último ponto merece atenção: no PostgreSQL, ALTER TABLE dentro de BEGIN/COMMIT funciona. No MySQL, DDL causa commit implícito. Se você usa MySQL, cada statement DDL é irreversível no nível de transaction.
-- PostgreSQL: DDL transacional permite rollback atômico
BEGIN;
ALTER TABLE users DROP COLUMN legacy_email;
-- Se algo der errado aqui, ROLLBACK desfaz o DROP
UPDATE profiles SET migrated = true;
COMMIT;
-- Ou ROLLBACK; se algo falhouQuando a resposta certa é não reverter
Nem toda migration problemática deve ser revertida. Se a migration já rodou, a aplicação nova já está em produção e os dados já foram transformados, reverter pode causar mais dano que avançar com uma correção.
Cenários onde avançar é melhor que reverter:
- A migration rodou há mais de 24 horas e dados novos já foram escritos no formato novo. Reverter perderia esses dados novos.
- A migration envolve mais de 5 tabelas com foreign keys cruzadas. A complexidade do rollback supera a da correção.
- O problema não está na migration, mas no código da aplicação. Corrija o código, não o schema.
Nesses casos, crie uma nova migration que corrige o problema. Trate como "migration de correção", não como rollback. Isso mantém o histórico de migrations linear e auditável.
Se você tem feature flags implementadas, pode usar a flag para direcionar tráfego para o código compatível com o schema atual enquanto prepara a correção. Isso evita a pressão de reverter sob estresse.
Automatizando a validação pós-rollback
Depois de reverter, valide que o schema está no estado esperado:
// scripts/validate-schema.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function validatePostRollback() {
const checks = [
{
name: "coluna legacy_email existe em users",
query: `
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'legacy_email'
`,
expectRows: 1,
},
{
name: "coluna email não existe em profiles",
query: `
SELECT column_name FROM information_schema.columns
WHERE table_name = 'profiles' AND column_name = 'email'
`,
expectRows: 0,
},
{
name: "nenhum registro com legacy_email NULL",
query: `SELECT count(*) as cnt FROM users WHERE legacy_email IS NULL`,
// POR QUE verificar NULLs: se o backup não cobriu todos os registros,
// o rollback deixa linhas sem dados
expectZeroCount: true,
},
];
for (const check of checks) {
const result: any[] = await prisma.$queryRawUnsafe(check.query);
if (check.expectRows !== undefined && result.length !== check.expectRows) {
console.error(`FALHA: ${check.name} - esperava ${check.expectRows} linhas, obteve ${result.length}`);
process.exit(1);
}
if (check.expectZeroCount && Number(result[0]?.cnt) > 0) {
console.error(`FALHA: ${check.name} - encontrou ${result[0].cnt} registros com NULL`);
process.exit(1);
}
console.log(`OK: ${check.name}`);
}
}
validatePostRollback()
.catch(console.error)
.finally(() => prisma.$disconnect());Integre esse script no pipeline de CI/CD para rodar automaticamente após qualquer rollback. Se alguma verificação falhar, o alerta precisa ser imediato. Se você já tem um pipeline automatizado, adicionar essa etapa é questão de configurar mais um step no workflow.
FAQ
Posso usar pg_dump para fazer backup antes de cada migration?
Pode, mas é desproporcional para migrations que afetam uma ou duas colunas. Um pg_dump completo de um banco de 10 GB leva minutos e consome espaço. Use COPY para exportar apenas os dados que serão afetados, como mostrado na seção de backup cirúrgico. Reserve pg_dump --table para quando a migration dropa uma tabela inteira.
O TypeORM garante que o down() funciona corretamente?
Não. O TypeORM gera o down() como espelho estrutural do up(), mas não tem como inferir lógica de restauração de dados. O down() gerado automaticamente é confiável para operações puramente DDL (criar/dropar coluna, criar/dropar índice). Para qualquer migration que transforma dados, você precisa escrever o down() manualmente e testá-lo.
Como testar o rollback antes de rodar em produção?
Restaure um snapshot do banco de produção em um ambiente de staging, aplique a migration, depois execute o rollback. Compare o schema e uma amostra dos dados antes e depois. Se você usa Docker, suba um container com o dump de produção e rode os testes lá. Nunca teste rollback pela primeira vez em produção.
Migrations de dados grandes (milhões de linhas) podem ser revertidas em transaction?
Depende do banco e do volume. No PostgreSQL, uma transaction que altera 10 milhões de linhas mantém locks por tempo significativo e pode causar timeout em queries concorrentes. Para tabelas com mais de 1 milhão de linhas, processe em batches de 10 mil a 50 mil registros, com commits intermediários. Isso impede rollback atômico, então o script de reversão precisa ser idempotente (usar WHERE NOT migrated ou similar).
Devo manter as tabelas de backup (_backup_*) para sempre?
Não. Mantenha por duas ou três releases (o tempo que leva para confirmar que o rollback não será necessário). Depois, crie uma migration que dropa as tabelas de backup. Tabelas de backup esquecidas acumulam e confundem quem lê o schema meses depois.
A posição que defendo
Toda migration destrutiva deveria ser duas migrations: uma que prepara o rollback (cria backup, adiciona coluna shadow) e outra que executa a destruição. Se a segunda migration falha, a primeira já garantiu o caminho de volta. Se a segunda migration funciona, uma terceira migration futura limpa os artefatos de backup.
Isso adiciona complexidade ao fluxo de migrations? Sim. Mas a alternativa é descobrir às 3h da manhã que o rollback de uma coluna dropada não tem como recuperar dados de 200 mil usuários. O custo de uma migration a mais é negligível comparado ao custo de perda de dados em produção.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


