Ir para o conteúdo
Backend

Database Migrations Seguras em Produção com Prisma

Marcos Soares
Atualizado em 
15 minutos de leitura
Ilustracao 3D de blocos de vidro empilhados com luz esmeralda representando database migrations seguras em producao
Ouça este artigo
0:00Database Migrations Seguras em Produção com Prisma--:--

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

Uma coluna NOT NULL adicionada sem valor default em uma tabela com 12 milhões de linhas trava o banco por minutos. O Prisma gera a migration correta do ponto de vista de schema, mas não tem como saber que aquela ALTER TABLE vai adquirir um ACCESS EXCLUSIVE lock no Postgres e bloquear todas as queries enquanto reescreve a tabela inteira.

Esse é o tipo de problema que este post resolve: a distância entre o que o Prisma gera automaticamente e o que produção exige de verdade.

O modelo mental: migrations são deploys de infraestrutura

Tratar migration como "arquivo SQL que roda antes do app subir" é o primeiro erro. Migrations alteram a estrutura de um sistema stateful. Diferente de código aplicação (que você faz rollback com um revert de commit), uma migration que dropa uma coluna destrói dados de forma irreversível.

A regra operacional é: toda migration em produção deve ser compatível com a versão anterior E a versão seguinte do código da aplicação. Isso tem nome: expand-and-contract.

FaseO que acontece no bancoO que acontece no código
ExpandAdiciona coluna/tabela nova, sem remover nadaCódigo novo escreve nos dois lugares (antigo e novo)
MigrateBackfill de dados, criação de índicesCódigo novo lê do lugar novo, escreve nos dois
ContractRemove coluna/tabela antigaCódigo antigo já não existe em produção

Cada fase é um deploy separado. Tentar fazer as três em uma única migration é o caminho para downtime.

Configuração base do Prisma para ambientes reais

Antes de falar de migrations, o schema.prisma precisa estar configurado para separar ambientes corretamente:

Prisma
// schema.prisma
// O shadowDatabaseUrl é obrigatório para prisma migrate dev em ambientes
// onde o usuário do banco não tem permissão CREATE DATABASE (quase todo ambiente gerenciado)
datasource db {
  provider          = "postgresql"
  url               = env("DATABASE_URL")
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}
 
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
  binaryTargets   = ["native", "linux-musl-openssl-3.0.x"]
}

A variável SHADOW_DATABASE_URL aponta para um banco descartável que o Prisma usa para calcular diffs. Em produção, o comando prisma migrate deploy nunca usa shadow database: ele aplica as migrations pendentes sequencialmente. A confusão entre migrate dev (desenvolvimento) e migrate deploy (produção) causa metade dos problemas que vejo em projetos.

Bash
# Desenvolvimento: gera migration a partir do diff entre schema.prisma e shadow database
npx prisma migrate dev --name add_status_to_orders
 
# Produção: aplica migrations pendentes na ordem, sem gerar nada novo
npx prisma migrate deploy

Expand-and-contract na prática com Prisma

Cenário concreto: renomear a coluna userName para displayName na tabela User.

Fase 1: Expand (adicionar coluna nova)

Prisma
// schema.prisma - fase expand
model User {
  id          String  @id @default(cuid())
  email       String  @unique
  userName    String  // mantém a coluna antiga
  displayName String? // nova coluna, nullable para não quebrar inserts existentes
  createdAt   DateTime @default(now())
}
Bash
npx prisma migrate dev --name add_display_name_to_user

O código da aplicação passa a escrever nas duas colunas:

TYPESCRIPT
// user-service.ts - fase expand
// Escrita dual garante que tanto código antigo quanto novo encontram dados válidos
import { PrismaClient } from "@prisma/client";
 
const prisma = new PrismaClient();
 
async function updateUserName(userId: string, newName: string) {
  return prisma.user.update({
    where: { id: userId },
    data: {
      userName: newName,
      displayName: newName, // escrita dual: popula a coluna nova em paralelo
    },
  });
}

Fase 2: Migrate (backfill)

Crie uma migration SQL customizada para preencher os registros antigos:

Bash
npx prisma migrate dev --create-only --name backfill_display_name

Isso gera o arquivo de migration vazio. Edite manualmente:

SQL
-- prisma/migrations/20240115_backfill_display_name/migration.sql
-- Backfill em batches para não travar o banco com um UPDATE de milhões de linhas
-- O DO $$ block permite loop procedural direto no Postgres
DO $$
DECLARE
  batch_size INT := 5000;
  rows_updated INT;
BEGIN
  LOOP
    UPDATE "User"
    SET "displayName" = "userName"
    WHERE "displayName" IS NULL
    AND "id" IN (
      SELECT "id" FROM "User"
      WHERE "displayName" IS NULL
      LIMIT batch_size
      FOR UPDATE SKIP LOCKED  -- evita deadlock com writes concorrentes
    );
 
    GET DIAGNOSTICS rows_updated = ROW_COUNT;
    EXIT WHEN rows_updated = 0;
 
    -- Pausa entre batches para não saturar I/O
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

Fase 3: Contract (remover coluna antiga)

Só depois que o deploy com leitura exclusiva de displayName estiver estável em produção (dias, não minutos), remova userName:

Prisma
// schema.prisma - fase contract
model User {
  id          String   @id @default(cuid())
  email       String   @unique
  displayName String   // agora NOT NULL, coluna antiga removida
  createdAt   DateTime @default(now())
}

Três deploys. Três migrations. Zero downtime.

Locks no Postgres: o que o Prisma não mostra

O Prisma gera SQL válido, mas não otimiza para concorrência. Estas operações adquirem ACCESS EXCLUSIVE lock no Postgres, bloqueando todas as leituras e escritas na tabela:

OperaçãoLock adquiridoImpacto em tabela grande
ADD COLUMN com default (Postgres 11+)ACCESS EXCLUSIVE (breve)Milissegundos: o default é metadata-only
ADD COLUMN NOT NULL sem defaultACCESS EXCLUSIVEReescreve a tabela inteira. Minutos em tabelas grandes
DROP COLUMNACCESS EXCLUSIVE (breve)Marca como invisível, não reescreve
CREATE INDEXSHARE lockBloqueia writes durante toda a criação
CREATE INDEX CONCURRENTLYNão bloqueia writes2-3x mais lento, mas seguro
ALTER COLUMN TYPEACCESS EXCLUSIVEReescreve a tabela inteira

O problema mais comum: o Prisma gera CREATE INDEX padrão (não CONCURRENTLY). Em uma tabela com milhões de linhas, isso trava writes por minutos.

A solução é criar a migration vazia e escrever o SQL manualmente:

Bash
npx prisma migrate dev --create-only --name add_index_orders_status
SQL
-- prisma/migrations/20240120_add_index_orders_status/migration.sql
-- CONCURRENTLY não pode rodar dentro de transaction, e o Prisma
-- por padrão envolve cada migration em uma transaction.
-- Adicione o comentário abaixo para desabilitar isso:
-- prisma:migrate:no-transaction
 
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_orders_status"
ON "Order" ("status");

O comentário -- prisma:migrate:no-transaction é uma diretiva do Prisma que desabilita o wrapper transacional para aquela migration específica. Sem isso, o Postgres rejeita CREATE INDEX CONCURRENTLY dentro de uma transação.

Recuperação de um deploy que falhou, passo a passo (reproduzível)

O cenário mais comum de suporte: prisma migrate deploy parou no meio, a tabela _prisma_migrations ficou com uma linha sem finished_at, e nenhuma migration nova entra até você resolver. O procedimento abaixo foi executado com Prisma 6.19.3 num PostgreSQL 16 descartável (docker run -d -e POSTGRES_PASSWORD=demo -p 5433:5432 postgres:16-alpine), e a saída dos comandos está reproduzida sem edição.

1. Provocar a falha

Uma migration com um erro de SQL no meio (a primeira instrução é válida, a segunda referencia uma tabela inexistente):

SQL
-- prisma/migrations/20260908000000_adiciona_coluna_quebrada/migration.sql
ALTER TABLE "Pedido" ADD COLUMN "total" DECIMAL(10,2) NOT NULL;
ALTER TABLE "PedidoInexistente" ADD COLUMN "x" TEXT;
Text
$ npx prisma migrate deploy
2 migrations found in prisma/migrations
Applying migration `20260908000000_adiciona_coluna_quebrada`
Error: P3018
A migration failed to apply. New migrations cannot be applied before the error is recovered from.
Migration name: 20260908000000_adiciona_coluna_quebrada
Database error code: 42P01
Database error:
ERROR: relation "PedidoInexistente" does not exist

2. Ler o estado antes de mexer

O Prisma diz exatamente as duas saídas possíveis:

Text
$ npx prisma migrate status
The failed migration(s) can be marked as rolled back or applied:
- If you rolled back the migration(s) manually:
prisma migrate resolve --rolled-back "20260908000000_adiciona_coluna_quebrada"
- If you fixed the database manually (hotfix):
prisma migrate resolve --applied "20260908000000_adiciona_coluna_quebrada"

E a tabela de controle confirma: a migration está registrada, não terminou e não foi revertida.

Text
$ psql -c "SELECT migration_name, finished_at IS NOT NULL AS aplicada, rolled_back_at IS NOT NULL AS revertida FROM _prisma_migrations ORDER BY started_at"
             migration_name              | aplicada | revertida
-----------------------------------------+----------+-----------
 20260908032242_init                     | t        | f
 20260908000000_adiciona_coluna_quebrada | f        | f

Detalhe que decide o próximo passo: no PostgreSQL cada migration roda dentro de uma transação, então a primeira instrução (ADD COLUMN "total") foi desfeita junto com a falha. Confira com \d "Pedido" antes de escolher entre --rolled-back e --applied; em MySQL, que não tem DDL transacional, a coluna pode ter ficado criada e aí o caminho é o hotfix manual seguido de --applied.

3. Marcar como revertida, corrigir o SQL, aplicar de novo

Text
$ npx prisma migrate resolve --rolled-back 20260908000000_adiciona_coluna_quebrada
Migration 20260908000000_adiciona_coluna_quebrada marked as rolled back.

Corrija o arquivo da migration (aqui, a coluna ganhou DEFAULT 0 e a instrução inválida saiu) e rode o deploy de novo:

Text
$ npx prisma migrate deploy
All migrations have been successfully applied.
 
$ npx prisma migrate status
2 migrations found in prisma/migrations
Database schema is up to date!

Limites do procedimento

  • migrate resolve só altera a tabela _prisma_migrations; ele não executa SQL. Quem garante que o banco está no estado que a marcação diz é você.
  • Editar uma migration já aplicada em outro ambiente quebra o checksum e o migrate deploy passa a reclamar; edite apenas migrations que falharam em todos os ambientes, ou crie uma migration nova de correção.
  • Se a migration falhou depois de alterar dados (um UPDATE de backfill, por exemplo), nem --rolled-back nem --applied devolvem os dados: é para isso que o artigo sobre reverter migrations sem perder dados existe, e a alteração de colunas em uso é o tema de zero-downtime migrations.

O que NÃO fazer

Anti-pattern 1: migration com rename direto

Prisma
// ERRADO: renomear coluna diretamente
// O Prisma interpreta como DROP + ADD, destruindo todos os dados da coluna
model User {
  id          String @id @default(cuid())
  email       String @unique
  displayName String // era userName - Prisma vai dropar userName e criar displayName vazia
}

O Prisma não tem semântica de "rename". Ele vê que userName sumiu e displayName apareceu: gera um DROP COLUMN seguido de ADD COLUMN. Todos os dados da coluna original desaparecem.

Correto: use expand-and-contract como descrito acima, ou edite a migration gerada para usar ALTER TABLE ... RENAME COLUMN.

Anti-pattern 2: rodar prisma migrate dev em produção

Bash
# ERRADO: migrate dev em produção
# Isso usa shadow database, reseta o banco se detectar drift, e pode
# gerar migrations inesperadas
npx prisma migrate dev
 
# CORRETO: migrate deploy aplica apenas migrations existentes, sem gerar nada
npx prisma migrate deploy

migrate dev é destrutivo por design. Ele compara o estado atual com o schema desejado e, se encontrar divergência que não consegue resolver, sugere reset. Em produção, isso significa perda total de dados.

Anti-pattern 3: NOT NULL sem default em tabela populada

SQL
-- ERRADO: migration gerada pelo Prisma quando você adiciona campo obrigatório
ALTER TABLE "Order" ADD COLUMN "trackingCode" TEXT NOT NULL;
-- Falha imediatamente: linhas existentes não têm valor para a coluna
SQL
-- CORRETO: adicione como nullable, backfill, depois altere para NOT NULL
ALTER TABLE "Order" ADD COLUMN "trackingCode" TEXT;
 
-- [backfill em batches aqui]
 
ALTER TABLE "Order" ALTER COLUMN "trackingCode" SET NOT NULL;

No Prisma, isso significa duas migrations separadas com backfill entre elas.

Rollback: o que o Prisma oferece (e o que não oferece)

O Prisma não tem comando migrate rollback. A tabela _prisma_migrations registra quais migrations foram aplicadas, mas não armazena SQL reverso.

Opções reais de rollback:

1. Migration reversa manual: crie uma nova migration que desfaz a anterior. É a abordagem mais segura porque passa pelo mesmo pipeline de review.

Bash
npx prisma migrate dev --create-only --name revert_add_tracking_code
SQL
-- prisma/migrations/20240122_revert_add_tracking_code/migration.sql
ALTER TABLE "Order" DROP COLUMN IF EXISTS "trackingCode";

2. Snapshot do banco antes da migration: se você usa Postgres gerenciado como o Neon, branches de banco de dados funcionam como "git branches" para o schema. Crie um branch antes de aplicar a migration e, se algo quebrar, aponte a aplicação de volta para o branch original.

3. Point-in-time recovery (PITR): serviços como RDS e Cloud SQL oferecem restore para um timestamp específico. Funciona, mas o tempo de restore em bancos grandes (50GB+) pode levar mais de 30 minutos.

Pipeline de CI/CD para migrations

Migrations devem rodar como step separado no pipeline, antes do deploy da aplicação e com timeout explícito:

YAML
# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]
 
jobs:
  migrate:
    runs-on: ubuntu-latest
    timeout-minutes: 10  # migrations que demoram mais que isso têm problema
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
 
      - run: npm ci
 
      - name: Run migrations
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
 
      # Validação pós-migration: garante que o Prisma Client
      # consegue se conectar e que o schema está consistente
      - name: Validate schema
        run: npx prisma validate
 
  deploy:
    needs: migrate  # só deploya se migration passou
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # ... steps de deploy da aplicação

Separar migration de deploy garante que, se a migration falhar, o código antigo continua rodando com o schema antigo. Se você misturar os dois no mesmo step, uma falha na migration pode deixar a aplicação em estado inconsistente: código novo esperando schema novo, schema que não mudou.

Para projetos que usam feature flags, a combinação é forte: a migration roda e adiciona a coluna nova, mas o código só começa a usar a coluna quando a flag é ativada. Isso dá uma janela de segurança entre "schema mudou" e "aplicação usa o schema novo".

Checklist de migration segura

Antes de aplicar qualquer migration em produção, valide:

  1. A migration é compatível com o código que está rodando agora? (expand-and-contract)
  2. Existe CREATE INDEX sem CONCURRENTLY? Se sim, reescreva
  3. Existe ADD COLUMN ... NOT NULL sem DEFAULT? Se sim, divida em duas migrations
  4. O backfill roda em batches com SKIP LOCKED?
  5. A migration foi testada em um banco com volume de dados similar ao de produção?
  6. Existe migration reversa pronta (mesmo que não vá usar)?
  7. O pipeline de CI separa migration de deploy?

Prisma Migrate vs. alternativas para cenários específicos

Se o seu caso exige controle granular sobre locks e transações, considere usar o Prisma para gerar o schema e uma ferramenta dedicada para aplicar migrations:

CritérioPrisma Migrategolang-migrateFlyway
Geração automática de SQL a partir do schemaSimNãoNão
Controle de lock por migrationParcial (via --create-only + edição manual)TotalTotal
Rollback automáticoNãoSim (arquivos down.sql)Sim (edição paga)
Suporte a CONCURRENTLYManualNativoNativo
Integração com Prisma ClientNativaNenhumaNenhuma

Para a maioria dos projetos (tabelas com menos de 10 milhões de linhas, equipe de até 10 devs), o Prisma Migrate com edição manual das migrations críticas é suficiente. Acima disso, manter o Prisma para type safety e usar outra ferramenta para o deploy de migrations reduz risco.

Quem já trabalha com Node.js em produção sabe que a camada de banco é onde a complexidade operacional se concentra. O Prisma abstrai bem a parte de queries, mas migrations são infraestrutura: merecem o mesmo cuidado que você dá para CI/CD e containers.

FAQ

O Prisma Migrate funciona com bancos gerenciados como RDS, Cloud SQL e Neon?

Sim, com uma ressalva: o prisma migrate dev precisa de permissão para criar e dropar o shadow database. Em bancos gerenciados, o usuário padrão normalmente não tem essa permissão. Configure shadowDatabaseUrl apontando para um banco separado (pode ser local ou um segundo banco gerenciado dedicado a desenvolvimento).

Posso editar o SQL gerado pelo Prisma?

Sim, e você deve fazer isso para migrations que envolvem tabelas grandes. Use npx prisma migrate dev --create-only para gerar o arquivo sem aplicar, edite o SQL, e depois rode npx prisma migrate dev para aplicar. O Prisma não valida se o SQL editado corresponde ao schema: ele confia que o arquivo de migration está correto.

Como lidar com migrations em múltiplos ambientes (staging, produção)?

Mantenha os mesmos arquivos de migration em todos os ambientes. A tabela _prisma_migrations registra quais já foram aplicadas. O prisma migrate deploy é idempotente: só aplica as pendentes. O que muda entre ambientes é a DATABASE_URL, nunca os arquivos de migration.

O que acontece se duas migrations conflitantes forem criadas em branches diferentes?

O Prisma detecta o conflito quando você tenta rodar prisma migrate dev após o merge. Ele identifica que a sequência de migrations divergiu e pede resolução manual. A prática segura é: antes de criar uma migration, atualize sua branch com main e rode prisma migrate dev para garantir que o estado local está sincronizado.

Preciso do Prisma Client para usar o Prisma Migrate?

Não. Você pode usar o Prisma Migrate exclusivamente para gerenciar schema e migrations, e usar outro query builder (Knex, Kysely, Drizzle) para as queries. O prisma migrate deploy não depende do Prisma Client: ele lê os arquivos SQL e os aplica diretamente no banco.

Posição final

O Prisma Migrate é bom o suficiente para a maioria dos projetos, desde que você não confie cegamente no SQL gerado. A regra prática: se a tabela tem menos de 1 milhão de linhas e o time tem menos de 5 devs, rode o SQL gerado sem medo. Acima disso, trate cada migration como um deploy de infraestrutura com review, teste em staging com dados reais e rollback planejado. O custo de revisar SQL manualmente é de minutos. O custo de uma tabela travada em produção é medido em dinheiro.

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

Guias de integração relacionados

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.