Zero-Downtime Database Migrations with Drizzle ORM

Run database migrations without downtime using Drizzle ORM. Covers expand-contract pattern, backward-compatible schema changes, rollback strategies, and CI/CD integration for PostgreSQL.

E
ECOSIRE Research and Development Team
|19 de março de 202610 min de leitura2.2k Palavras|

Migrações de banco de dados com tempo de inatividade zero com Drizzle ORM

As migrações de banco de dados são a operação mais perigosa nas implantações de produção. Uma migração que bloqueia uma tabela, mesmo que por 200 ms, causa tempos limite de conexão que se transformam em 500 erros, que se transformam em tempestades de alertas às 2h. A maioria dos tutoriais de migração ORM mostra o caminho “fácil”: desligue o aplicativo, execute a migração e reinicie. Isso não é tempo de inatividade zero; isso é manutenção programada.

As migrações reais sem tempo de inatividade exigem um modelo mental diferente: seu esquema deve ser compatível com versões anteriores do código do aplicativo em execução no momento durante todo o processo de implantação. Isso significa que o código antigo e o novo devem funcionar simultaneamente durante a janela de implementação. O padrão de contrato expandido torna isso possível. A abordagem SQL-first do Drizzle ORM oferece o controle para implementá-la corretamente.

Principais conclusões

  • Nunca execute drizzle-kit push em produção — gere e revise SQL e aplique com controle
  • O padrão de contrato de expansão: adicionar (expandir) → implantar novo código → remover antigo (contrato)
  • Adicionar uma coluna anulável é seguro; adicionar NOT NULL sem um padrão é perigoso
  • Renomear uma coluna requer: adicionar nova coluna → preencher → atualizar código → remover coluna antiga (4 etapas)
  • Use PostgreSQL CREATE INDEX CONCURRENTLY para evitar bloqueios de tabela durante a criação do índice
  • Teste as migrações em uma cópia do seu banco de dados em tamanho de produção antes de aplicá-las à produção
  • Sempre tenha um plano de rollback: para cada migração, escreva e teste o SQL de rollback
  • Mudanças de esquema que exigem reescritas de tabela (ALTER TYPE em uma tabela grande) precisam de janelas de manutenção

Configuração do esquema ORM do Drizzle

// packages/db/src/schema/contacts.ts
import { pgTable, uuid, varchar, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core';

export const contactStatusEnum = pgEnum('contact_status', ['active', 'inactive', 'archived']);

export const contacts = pgTable('contacts', {
  id:             uuid('id').primaryKey().defaultRandom(),
  organizationId: uuid('organization_id').notNull(),
  name:           varchar('name', { length: 255 }).notNull(),
  email:          varchar('email', { length: 255 }),
  phone:          varchar('phone', { length: 50 }),
  status:         contactStatusEnum('status').default('active').notNull(),
  notes:          text('notes'),
  createdAt:      timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt:      timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
  orgIdIdx:  index('contacts_org_id_idx').on(table.organizationId),
  emailIdx:  index('contacts_email_idx').on(table.email),
  statusIdx: index('contacts_status_idx').on(table.status),
}));

Gerar migrações:

# Generate SQL migration (never auto-apply to production)
npx drizzle-kit generate

# Review the generated SQL before applying
cat drizzle/0001_add_contact_status.sql

# Apply to development database
npx drizzle-kit migrate

# For production: apply via your deployment pipeline

O padrão de expansão-contrato

The core principle: every schema change is split into multiple backward-compatible deployments.

Fase 1: Expandir (Adicionar sem quebrar)

Implante alterações de esquema apenas aditivas – novas colunas com padrões, novas tabelas, novos índices.

-- Migration: 0010_expand_add_company.sql
-- Safe to apply while old code is running: nullable column with a default

ALTER TABLE contacts
  ADD COLUMN IF NOT EXISTS company_id UUID,
  ADD COLUMN IF NOT EXISTS company_name VARCHAR(255);

-- Old code ignores these columns; new code uses them
-- Both versions coexist during rolling deployment
// Drizzle schema after expansion
export const contacts = pgTable('contacts', {
  // ... existing columns ...
  companyId:   uuid('company_id'),           // nullable — old code ignores it
  companyName: varchar('company_name', { length: 255 }), // nullable — safe
});

Fase 2: Implantar novo código de aplicativo

Implante a nova versão do aplicativo que lê e grava colunas novas e antigas. Durante a implantação contínua, instâncias antigas (sem suporte da empresa) e novas instâncias (suporte da empresa) são executadas simultaneamente — ambas funcionam porque a coluna é anulável.

Fase 3: Preenchimento

Preencha a nova coluna para linhas existentes em pequenos lotes para evitar bloqueios de tabela:

-- Migration: 0011_backfill_company_name.sql
-- Run in small batches to avoid locking

DO $$
DECLARE
  batch_size INTEGER := 1000;
  offset_val INTEGER := 0;
  rows_updated INTEGER;
BEGIN
  LOOP
    UPDATE contacts
    SET company_name = 'Unknown'
    WHERE company_name IS NULL
      AND id IN (
        SELECT id FROM contacts
        WHERE company_name IS NULL
        ORDER BY id
        LIMIT batch_size
        OFFSET offset_val
      );

    GET DIAGNOSTICS rows_updated = ROW_COUNT;
    EXIT WHEN rows_updated = 0;

    offset_val := offset_val + batch_size;
    -- Brief pause between batches to reduce I/O pressure
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

Fase 4: Contrato (remover coluna antiga)

Depois que todo o código do aplicativo for atualizado e o preenchimento for concluído, remova a coluna ou restrição antiga.


Padrões de migração segura

Adicionando uma coluna NOT NULL

-- WRONG — will fail if table has rows (no default, no nullable)
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL;

-- WRONG — locks table while it writes the default to every row
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL DEFAULT 'free';

-- CORRECT — three-step approach
-- Step 1: Add nullable column
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20);

-- Step 2: Backfill existing rows
UPDATE contacts SET tier = 'free' WHERE tier IS NULL;

-- Step 3 (next deployment): Add NOT NULL constraint (instant if no NULLs exist)
ALTER TABLE contacts ALTER COLUMN tier SET NOT NULL;
ALTER TABLE contacts ALTER COLUMN tier SET DEFAULT 'free';

Renomeando uma coluna (processo de 4 etapas)

-- Step 1: Add the new column
ALTER TABLE contacts ADD COLUMN full_name VARCHAR(255);

-- Step 2: Backfill + keep in sync with a trigger
UPDATE contacts SET full_name = name;

CREATE OR REPLACE FUNCTION sync_full_name() RETURNS trigger AS $$
BEGIN
  NEW.full_name := NEW.name;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER contacts_sync_full_name
  BEFORE INSERT OR UPDATE OF name ON contacts
  FOR EACH ROW EXECUTE FUNCTION sync_full_name();

-- Step 3: Deploy new code that writes to full_name, reads from both
-- Step 4 (next deployment, after all instances updated):
DROP TRIGGER contacts_sync_full_name ON contacts;
DROP FUNCTION sync_full_name;
ALTER TABLE contacts DROP COLUMN name;

Alterando um tipo de enumeração

O PostgreSQL não permite a remoção de valores enum sem uma reescrita completa da tabela. Para alterações seguras de enumeração:

-- Adding a new enum value is safe (instant, no lock)
ALTER TYPE contact_status ADD VALUE IF NOT EXISTS 'pending';

-- Removing an enum value requires a full table rewrite
-- Use the expand-contract pattern: add a new enum type, migrate, drop old
CREATE TYPE contact_status_new AS ENUM ('active', 'inactive', 'archived', 'pending');

-- Migrate data
ALTER TABLE contacts
  ALTER COLUMN status TYPE contact_status_new
  USING status::text::contact_status_new;

DROP TYPE contact_status;
ALTER TYPE contact_status_new RENAME TO contact_status;

Criação de índice sem bloqueio

CREATE INDEX regular adquire um ShareLock que bloqueia todas as gravações durante o período. Em uma mesa grande, isso pode levar alguns minutos.

-- WRONG — locks writes during index creation
CREATE INDEX contacts_email_idx ON contacts(email);

-- CORRECT — concurrent index creation; no write lock
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts(email)
  WHERE email IS NOT NULL; -- Partial index for better performance

-- Drizzle note: drizzle-kit does not generate CONCURRENTLY by default
-- Edit the generated SQL migration to add CONCURRENTLY before applying

No arquivo de migração do Drizzle, adicione manualmente CONCURRENTLY após a geração:

-- drizzle/0012_add_email_index.sql (edited after generation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts (email)
  WHERE email IS NOT NULL;

Migração Drizzle em CI/CD de produção

Nunca use drizzle-kit push em produção — ele aplica migrações sem uma etapa de revisão. Use drizzle-kit migrate com arquivos SQL gerados com controle de versão:

# scripts/deploy-production.sh

echo "=== Running database migrations ==="

# Check if there are pending migrations
PENDING=$(npx drizzle-kit migrate --dry-run 2>&1 | grep "pending")
if [ -n "$PENDING" ]; then
  echo "Pending migrations detected:"
  echo "$PENDING"

  # Apply migrations
  npx drizzle-kit migrate
  echo "Migrations applied successfully"
else
  echo "No pending migrations"
fi
// Programmatic migration in main.ts (NestJS)
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';

async function runMigrations() {
  const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
  const db = drizzle(migrationClient);

  await migrate(db, {
    migrationsFolder: join(__dirname, '..', '..', '..', 'drizzle'),
  });

  await migrationClient.end();
}

// In bootstrap(), before app.listen():
if (process.env.RUN_MIGRATIONS === 'true') {
  await runMigrations();
}

Estratégias de reversão

Toda migração deve ter uma migração de reversão complementar escrita e testada antes da implantação:

// drizzle/0013_add_company_id.sql (forward migration)
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS company_id UUID;
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_company_id_idx
  ON contacts(company_id);

// drizzle/rollbacks/0013_rollback_add_company_id.sql
DROP INDEX CONCURRENTLY IF EXISTS contacts_company_id_idx;
ALTER TABLE contacts DROP COLUMN IF EXISTS company_id;

Para reversão de emergência:

# Emergency rollback script
# Run the rollback SQL, then redeploy the previous app version

psql "$DATABASE_URL" < drizzle/rollbacks/0013_rollback_add_company_id.sql
git checkout HEAD~1
pnpm build && pm2 restart ecosystem.config.cjs --update-env

Estratégia de teste de migração

// packages/db/src/tests/migration.spec.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';

describe('Database Migrations', () => {
  let sql: ReturnType<typeof postgres>;

  beforeAll(async () => {
    sql = postgres(process.env.TEST_DATABASE_URL!);
  });

  it('all migrations apply cleanly on a fresh database', async () => {
    const db = drizzle(sql);
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });

  it('migrations are idempotent (can be applied twice safely)', async () => {
    const db = drizzle(sql);
    await migrate(db, { migrationsFolder: './drizzle' });
    // Running again should be a no-op, not an error
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });
});

Operações perigosas que requerem janelas de manutenção

Algumas operações não podem ter tempo de inatividade zero em tabelas muito grandes (centenas de milhões de linhas):

OperaçãoRiscoMitigação
ALTER TYPE USING em coluna grandeReescrita completa da tabela, bloqueio longoJanela de manutenção programada
ADD COLUMN NOT NULL sem padrãoFalha imediata se existirem linhasUse contrato de expansão em três etapas
CREATE INDEX (sem CONCORRENTEMENTE)Bloqueio de gravação por duraçãoSempre use CONCURRENTLY
CÓDIGO0Instantâneo, mas quebra código antigoSomente na fase de expansão após atualização do código
CÓDIGO0IrreversívelTenha sempre um backup; Política de exclusão reversível de 30 dias
CÓDIGO0Bloqueio de tabela por duraçãoUse VACUUM regular + cronograma FULL durante a manutenção

Perguntas frequentes

Qual é a diferença entre drizzle-kit push e drizzle-kit migrate?

push aplica diretamente seu esquema atual ao banco de dados sem gerar arquivos de migração — útil para desenvolvimento onde você não se importa com o histórico de migração. migrate aplica os arquivos SQL em seu diretório drizzle/ em ordem, rastreando quais foram aplicados. Sempre use migrate na produção para ter um histórico controlado, revisável e controlado por versão de cada alteração de esquema.

Como o Drizzle lida com a tabela de rastreamento drizzle_migrations?

Drizzle cria uma tabela drizzle_migrations (ou __drizzle_migrations) que registra quais arquivos de migração foram aplicados e quando. Antes de aplicar uma migração, verifique esta tabela. Não insira ou exclua manualmente linhas desta tabela — é o mecanismo de rastreamento de estado do Drizzle. Se você precisar reverter para um estado de migração anterior, execute o SQL de reversão e exclua manualmente a linha correspondente.

Como testar migrações em um banco de dados de produção?

Restaure uma cópia higienizada (removida de PII) do seu banco de dados de produção para um servidor temporário. Aplique a migração lá e meça: tempo de parede, tempo de espera de bloqueio (de pg_stat_activity), inchaço da tabela (de pg_stat_user_tables) e impacto no desempenho da consulta (de EXPLAIN ANALYZE). Se a migração demorar mais de cinco segundos para ser preparada, redesenhe-a.

Posso usar transações para migrações de banco de dados?

A maior parte do DDL no PostgreSQL é transacional - você pode agrupar ALTER TABLE, CREATE TABLE, CREATE INDEX (mas NÃO CREATE INDEX CONCURRENTLY) em uma transação e reverter se alguma etapa falhar. O Drizzle agrupa cada arquivo de migração em uma transação por padrão. Para migrações que usam CONCURRENTLY, você deve dividi-las em um arquivo de migração separado que é executado fora de uma transação.

Como faço para lidar com comparações de enum do Drizzle no TypeScript?

As colunas enum do Drizzle retornam o valor da string do banco de dados. Ao comparar, o TypeScript pode não restringir o tipo corretamente. Transmita explicitamente: if ((contact.status as ContactStatus) === 'active') ou use uma asserção de tipo em sua cláusula Drizzle where: where(eq(contacts.status, 'active' as ContactStatus)). Isso evita erros de tempo de execução enquanto mantém o TypeScript satisfeito.


Próximas etapas

As migrações de bancos de dados com tempo de inatividade zero exigem disciplina, mas a alternativa (tempo de inatividade, corrupção de dados, reversões de emergência às 3 da manhã) é muito pior. O padrão de contrato de expansão, CREATE INDEX CONCURRENTLY, e a preparação adequada de reversão tornam as alterações de esquema uma operação rotineira e segura.

ECOSIRE gerencia esquemas de banco de dados para aplicativos NestJS com Drizzle ORM, PostgreSQL 17 e um pipeline de implantação com tempo de inatividade zero testado em mais de 65 arquivos de esquema e mais de 300 migrações. Explore nossos serviços de engenharia de back-end para saber como lidamos com operações de banco de dados com segurança e em grande escala.

E

Escrito por

ECOSIRE Research and Development Team

Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.

Converse no WhatsApp