Ir para o conteúdo
Backend

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

Marcos Soares
Atualizado em 
13 minutos de leitura
Ilustracao 3D de camadas de vidro translucido empilhadas representando reversao segura de migrations em banco de dados
Ouça este artigo
0:00Como 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çãoRisco de perda de dadosReversível automaticamente?Estratégia recomendada
Adicionar coluna nullableNenhumSim (drop column)Rollback direto
Adicionar índiceNenhumSim (drop index)Rollback direto
Remover colunaAltoNão (dados perdidos)Backup da coluna antes
Renomear coluna/tabelaMédioParcial (renomear de volta)Deploy em fases
Alterar tipo de colunaAltoParcial (cast pode falhar)Coluna shadow + backfill
Dropar tabelaCríticoNãoDump da tabela antes
Migrar dados entre tabelasAltoNã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

SQL
-- 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)

TYPESCRIPT
// 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)

SQL
-- 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.

Bash
# 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
Bash
# 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;
SQL

Esse 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.

TYPESCRIPT
// 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:

TYPESCRIPT
// 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());
Bash
# Execute o script de rollback
npx tsx scripts/rollback-migration.ts

A 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

TYPESCRIPT
// 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.
}
TYPESCRIPT
// 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

Bash
# 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.
Bash
# 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.sql

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

  1. Verifique se a versão da aplicação que está rodando é compatível com o schema anterior
  2. Confirme que existe backup dos dados que serão afetados (não do banco inteiro, dos dados específicos)
  3. Teste o script de rollback em staging com dados representativos
  4. Documente o comando exato que será executado e o resultado esperado
  5. Tenha um segundo par de olhos revisando o SQL antes de rodar
  6. 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.

SQL
-- 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 falhou

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

TYPESCRIPT
// 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.

Marcos Soares

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.