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 mars 202611 min de lecture2.3k Mots|

Migrations de bases de données sans temps d'arrêt avec Drizzle ORM

Les migrations de bases de données constituent l’opération la plus dangereuse dans les déploiements de production. Une migration qui verrouille une table, même pendant 200 ms, provoque des délais d'attente de connexion qui se transforment en 500 erreurs qui se transforment en tempêtes d'alertes à 2 heures du matin. La plupart des didacticiels de migration ORM vous montrent le chemin « simple » : fermez l'application, exécutez la migration, redémarrez. Il ne s’agit pas d’un temps d’arrêt nul ; c'est la maintenance programmée.

Les véritables migrations sans temps d'arrêt nécessitent un modèle mental différent : votre schéma doit être rétrocompatible avec le code de l'application en cours d'exécution tout au long du processus de déploiement. Cela signifie que l’ancien code et le nouveau code doivent fonctionner simultanément pendant la fenêtre de déploiement. Le modèle d’expansion-contrat rend cela possible. L'approche SQL first de Drizzle ORM vous donne le contrôle nécessaire pour l'implémenter correctement.

Points clés à retenir

  • N'exécutez jamais drizzle-kit push en production - générez et révisez SQL, puis appliquez-le avec contrôle
  • Le modèle étendre-contrat : ajouter (développer) → déployer du nouveau code → supprimer l'ancien (contrat)
  • L'ajout d'une colonne nullable est sécurisé ; ajouter NOT NULL sans valeur par défaut est dangereux
  • Renommer une colonne nécessite : ajouter une nouvelle colonne → remplir → mettre à jour le code → supprimer l'ancienne colonne (4 étapes)
  • Utilisez PostgreSQL CREATE INDEX CONCURRENTLY pour éviter les verrous de table lors de la création d'index
  • Testez les migrations sur une copie de production de votre base de données avant de postuler en production
  • Ayez toujours un plan de rollback : pour chaque migration, écrivez et testez le SQL de rollback
  • Les modifications de schéma qui nécessitent des réécritures de table (ALTER TYPE sur une grande table) nécessitent des fenêtres de maintenance

Configuration du schéma ORM 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),
}));

Générer des migrations :

# 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

Le modèle d'expansion-contrat

Le principe de base : chaque modification de schéma est divisée en plusieurs déploiements rétrocompatibles.

Phase 1 : Développer (Ajouter sans interrompre)

Déployez des modifications de schéma qui sont uniquement additives : nouvelles colonnes avec valeurs par défaut, nouvelles tables, nouveaux index.

-- 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
});

Phase 2 : Déployer un nouveau code d'application

Déployez la nouvelle version de l'application qui lit et écrit les anciennes et les nouvelles colonnes. Lors du déploiement progressif, les anciennes instances (pas de support de l'entreprise) et les nouvelles instances (support de l'entreprise) s'exécutent simultanément : les deux fonctionnent car la colonne est nullable.

Phase 3 : Remblai

Remplissez la nouvelle colonne pour les lignes existantes par petits lots pour éviter les verrous de table :

-- 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 $$;

Phase 4 : Contrat (supprimer l'ancienne colonne)

Une fois que tout le code de l’application est mis à jour et que le remplissage est terminé, supprimez l’ancienne colonne ou contrainte.


Modèles de migration sécurisés

Ajout d'une colonne NON 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';

Renommer une colonne (processus en 4 étapes)

-- 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;

Modification d'un type d'énumération

PostgreSQL ne permet pas de supprimer les valeurs d'énumération sans une réécriture complète de la table. Pour des modifications d'énumération sécurisées :

-- 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;

Création d'index sans verrouillage

Le CREATE INDEX régulier acquiert un ShareLock qui bloque toutes les écritures pour la durée. Sur une grande table, cela peut prendre quelques minutes.

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

Dans votre fichier de migration Drizzle, ajoutez manuellement CONCURRENTLY après génération :

-- 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;

Migration de bruine en production CI/CD

N'utilisez jamais drizzle-kit push en production : il applique les migrations sans étape de révision. Utilisez drizzle-kit migrate avec les fichiers SQL générés dont la version est contrôlée :

# 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();
}

## Stratégies de restauration

Chaque migration doit être accompagnée d'une migration de restauration écrite et testée avant le déploiement :

// 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;

Pour une restauration d'urgence :

# 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

Stratégie de test de migration

// 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();
  });
});

Opérations dangereuses nécessitant des fenêtres de maintenance

Certaines opérations ne peuvent pas être effectuées sans temps d'arrêt sur de très grandes tables (des centaines de millions de lignes) :

OpérationRisqueAtténuation
ALTER TYPE USING sur grande colonneRéécriture complète de la table, verrouillage longPlanifier la fenêtre de maintenance
ADD COLUMN NOT NULL sans défautÉchec immédiat si des lignes existentUtiliser un contrat d'extension en trois étapes
CREATE INDEX (sans CONCURRENTEMENT)Verrouillage d'écriture pour la duréeUtilisez toujours CONCURRENTLY
ALTER TABLE ... RENAME TOInstantané, mais brise l'ancien codeUniquement en phase d'expansion après la mise à jour du code
DROP TABLEIrréversibleAyez toujours une sauvegarde ; Politique de suppression logicielle de 30 jours
VACUUM FULLVerrouillage de la table pour la duréeUtiliser régulièrement VACUUM + planning FULL pendant la maintenance

Questions fréquemment posées

Quelle est la différence entre drizzle-kit push et drizzle-kit migrate ?

push applique directement votre schéma actuel à la base de données sans générer de fichiers de migration – utile pour le développement où vous ne vous souciez pas de l'historique de migration. migrate applique les fichiers SQL dans votre répertoire drizzle/ dans l'ordre, en suivant ceux qui ont été appliqués. Utilisez toujours migrate en production afin de disposer d'un historique contrôlé, consultable et contrôlé par la version de chaque modification de schéma.

Comment Drizzle gère-t-il la table de suivi drizzle_migrations ?

Drizzle crée une table drizzle_migrations (ou __drizzle_migrations) qui enregistre quels fichiers de migration ont été appliqués et quand. Avant d'appliquer une migration, il vérifie ce tableau. N'insérez pas ou ne supprimez pas manuellement de lignes de ce tableau - il s'agit du mécanisme de suivi d'état de Drizzle. Si vous devez revenir à un état de migration précédent, exécutez votre rollback SQL et supprimez manuellement la ligne correspondante.

Comment tester les migrations sur une base de données de taille production ?

Restaurez une copie nettoyée (PII supprimée) de votre base de données de production sur un serveur intermédiaire. Appliquez-y la migration et mesurez : l'heure de l'horloge murale, le temps d'attente du verrouillage (à partir de pg_stat_activity), la surcharge de la table (à partir de pg_stat_user_tables) et l'impact sur les performances des requêtes (à partir de EXPLAIN ANALYZE). Si la migration prend plus de 5 secondes lors de la préparation, reconcevez-la.

Puis-je utiliser des transactions pour les migrations de bases de données ?

La plupart des DDL dans PostgreSQL sont transactionnels : vous pouvez encapsuler ALTER TABLE, CREATE TABLE, CREATE INDEX (mais PAS CREATE INDEX CONCURRENTLY) dans une transaction et annuler si une étape échoue. Drizzle encapsule chaque fichier de migration dans une transaction par défaut. Pour les migrations qui utilisent CONCURRENTLY, vous devez les diviser dans un fichier de migration distinct qui s'exécute en dehors d'une transaction.

Comment gérer les comparaisons d'énumérations Drizzle dans TypeScript ?

Les colonnes d'énumération de Drizzle renvoient la valeur de chaîne de la base de données. Lors de la comparaison, TypeScript peut ne pas restreindre correctement le type. Convertissez explicitement : if ((contact.status as ContactStatus) === 'active') ou utilisez une assertion de type dans votre clause Drizzle where : where(eq(contacts.status, 'active' as ContactStatus)). Cela évite les erreurs d'exécution tout en gardant TypeScript satisfait.


Prochaines étapes

Les migrations de bases de données sans temps d'arrêt nécessitent de la discipline, mais l'alternative (temps d'arrêt, corruption des données, restaurations d'urgence à 3 heures du matin) est bien pire. Le modèle d'extension-contrat, CREATE INDEX CONCURRENTLY, et une préparation appropriée de la restauration font des modifications de schéma une opération de routine et sûre.

ECOSIRE gère les schémas de base de données pour les applications NestJS avec Drizzle ORM, PostgreSQL 17 et un pipeline de déploiement sans temps d'arrêt testé sur plus de 65 fichiers de schéma et plus de 300 migrations. Découvrez nos services d'ingénierie backend pour découvrir comment nous gérons les opérations de bases de données en toute sécurité à grande échelle.

E

Rédigé par

ECOSIRE Research and Development Team

Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.

Discutez sur WhatsApp