Database 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.
| Fase | O que acontece no banco | O que acontece no código |
|---|---|---|
| Expand | Adiciona coluna/tabela nova, sem remover nada | Código novo escreve nos dois lugares (antigo e novo) |
| Migrate | Backfill de dados, criação de índices | Código novo lê do lugar novo, escreve nos dois |
| Contract | Remove coluna/tabela antiga | Có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:
// 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.
# 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 deployExpand-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)
// 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())
}npx prisma migrate dev --name add_display_name_to_userO código da aplicação passa a escrever nas duas colunas:
// 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:
npx prisma migrate dev --create-only --name backfill_display_nameIsso gera o arquivo de migration vazio. Edite manualmente:
-- 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:
// 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ção | Lock adquirido | Impacto em tabela grande |
|---|---|---|
ADD COLUMN com default (Postgres 11+) | ACCESS EXCLUSIVE (breve) | Milissegundos: o default é metadata-only |
ADD COLUMN NOT NULL sem default | ACCESS EXCLUSIVE | Reescreve a tabela inteira. Minutos em tabelas grandes |
DROP COLUMN | ACCESS EXCLUSIVE (breve) | Marca como invisível, não reescreve |
CREATE INDEX | SHARE lock | Bloqueia writes durante toda a criação |
CREATE INDEX CONCURRENTLY | Não bloqueia writes | 2-3x mais lento, mas seguro |
ALTER COLUMN TYPE | ACCESS EXCLUSIVE | Reescreve 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:
npx prisma migrate dev --create-only --name add_index_orders_status-- 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):
-- 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;$ 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 exist2. Ler o estado antes de mexer
O Prisma diz exatamente as duas saídas possíveis:
$ 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.
$ 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 | fDetalhe 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
$ 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:
$ 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 resolvesó 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 deploypassa 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
UPDATEde backfill, por exemplo), nem--rolled-backnem--applieddevolvem 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
// 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
# 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 deploymigrate 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
-- 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-- 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.
npx prisma migrate dev --create-only --name revert_add_tracking_code-- 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:
# .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çãoSeparar 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:
- A migration é compatível com o código que está rodando agora? (expand-and-contract)
- Existe
CREATE INDEXsemCONCURRENTLY? Se sim, reescreva - Existe
ADD COLUMN ... NOT NULLsemDEFAULT? Se sim, divida em duas migrations - O backfill roda em batches com
SKIP LOCKED? - A migration foi testada em um banco com volume de dados similar ao de produção?
- Existe migration reversa pronta (mesmo que não vá usar)?
- 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ério | Prisma Migrate | golang-migrate | Flyway |
|---|---|---|---|
| Geração automática de SQL a partir do schema | Sim | Não | Não |
| Controle de lock por migration | Parcial (via --create-only + edição manual) | Total | Total |
| Rollback automático | Não | Sim (arquivos down.sql) | Sim (edição paga) |
Suporte a CONCURRENTLY | Manual | Nativo | Nativo |
| Integração com Prisma Client | Nativa | Nenhuma | Nenhuma |
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.

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

Zero-Downtime Migrations: Como Alterar Colunas e Tabelas Sem Derrubar Sua Aplicação

Versionamento de Schema com TypeORM: Migrations do Desenvolvimento ao Deploy

Migrações de Banco de Dados com PostgreSQL: Prisma Migrate vs Drizzle Kit
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.