Développement Web

Drizzle zero-downtime migration : tutoriel 2026

11 min de lecture

Migrer une base PostgreSQL avec Drizzle sans interruption de service en 2026 (informations vérifiées en avril 2026, susceptibles d’évoluer).

Voir notre guide Drizzle migrations.

Patterns dangereux à éviter

  • DROP COLUMN avec données — l’ancienne version d’app crash
  • RENAME COLUMN — l’ancienne version ne trouve plus le nom
  • ALTER COLUMN TYPE non compatible (varchar→int)
  • ADD COLUMN NOT NULL sans default sur grosse table
  • CREATE INDEX sans CONCURRENTLY (bloque les writes)

Pattern : ajouter une colonne

  1. Migration 1 : ADD COLUMN nullable (compatible ancienne app)
  2. Déployer nouvelle version app qui écrit la colonne
  3. Backfill data pour anciennes lignes : UPDATE table SET col = 'default'
  4. Migration 2 : ALTER COLUMN SET NOT NULL

Pattern : renommer une colonne

  1. Migration 1 : ADD COLUMN new_name + trigger qui copie old_name → new_name
  2. Backfill data
  3. Déployer nouvelle version app qui lit/écrit new_name
  4. Migration 2 : DROP COLUMN old_name + DROP TRIGGER

Pattern : changer un type

  1. Migration 1 : ADD COLUMN new_col avec nouveau type
  2. Trigger qui sync ancien → nouveau
  3. App lit/écrit nouveau
  4. Drop ancien

Index sans bloquer

-- Pas
CREATE INDEX users_email_idx ON users(email);

-- Mais
CREATE INDEX CONCURRENTLY users_email_idx ON users(email);

Drizzle-kit ne génère pas CONCURRENTLY automatiquement. Éditez le SQL généré pour les grosses tables.

Tester la migration

  • En staging avec un dump prod récent
  • Mesurer la durée et le lock impact
  • Vérifier compat ancienne version app simultanément

Pour explorer plus loin

Etape 1 : Comprendre les contraintes d’une migration zero-downtime avec Drizzle

Avant de toucher au schema en production, il faut comprendre ce qui rend une migration « zero-downtime » : aucun verrou long sur une table chaude, aucune perte de donnees, et compatibilite ascendante du code applicatif. A Dakar comme a Abidjan, sur une fintech qui traite 200 transactions par minute via Mixx by Yas ou Wave, une migration mal pensee bloque les paiements pendant 30 secondes — assez pour declencher une vague de tickets support.

Drizzle ORM (drizzle-orm 0.36.x) genere des fichiers SQL via drizzle-kit que vous pouvez relire et editer avant application. C’est exactement ce qu’il faut : on garde le controle du SQL final. Le pattern zero-downtime s’appuie sur la regle « expand puis contract » — on ajoute le nouveau schema sans casser l’ancien, on migre les donnees en arriere-plan, puis on retire l’ancien schema dans une seconde release.

Etape 2 : Preparer l’environnement et installer drizzle-kit

Sur un projet Node 22 LTS, installez les paquets necessaires. Drizzle-kit pilote la generation de migrations, drizzle-orm fournit le runtime, et postgres (paquet officiel « postgres » de Porsager) sert de driver leger.

npm install drizzle-orm postgres
npm install -D drizzle-kit @types/pg tsx

Vous devez voir drizzle-orm@0.36 et drizzle-kit@0.28 dans package.json apres l’install. Si npm renvoie une erreur ERESOLVE, supprimez node_modules et package-lock.json puis retentez — c’est souvent du a un ancien lockfile genere sous Node 18.

Etape 3 : Definir le schema source de verite dans schema.ts

Le schema TypeScript est la reference unique. Toute modification passe par ce fichier, jamais directement par psql. Creez src/db/schema.ts avec une table users de depart.

import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

L’objectif ici n’est pas la table elle-meme mais d’avoir un point de depart stable. Le defaultRandom() utilise gen_random_uuid() de Postgres 13+ — verifiez que pgcrypto ou l’extension pg_uuid est active sur votre instance Supabase, Neon ou RDS.

Etape 4 : Generer la premiere migration baseline

Configurez drizzle.config.ts pour pointer sur votre base de dev. La commande drizzle-kit generate cree un fichier SQL incremental dans drizzle/ — relisez-le systematiquement avant de l’appliquer en prod.

npx drizzle-kit generate --name init_users

Vous obtenez drizzle/0000_init_users.sql avec le CREATE TABLE attendu. Sur la base de dev, appliquez avec drizzle-kit push (uniquement en dev, jamais en prod). Pour la prod on utilisera drizzle-kit migrate qui respecte la table __drizzle_migrations__ comme journal.

Etape 5 : Phase EXPAND — ajouter une colonne sans casser l’ancien code

Imaginons qu’on veuille ajouter un champ phone_e164 pour stocker les numeros au format international (+221, +225, +237). En zero-downtime on ajoute la colonne en NULLABLE d’abord, sans contrainte. Le code v1 ignore phone_e164, le code v2 le lit et l’ecrit.

ALTER TABLE users ADD COLUMN phone_e164 text;
CREATE INDEX CONCURRENTLY idx_users_phone_e164 ON users (phone_e164);

CREATE INDEX CONCURRENTLY est obligatoire en prod pour ne pas verrouiller la table. Drizzle-kit ne le genere pas automatiquement — editez le fichier SQL pour ajouter CONCURRENTLY a la main avant le deploiement. Verifiez ensuite avec \d+ users dans psql que la colonne existe et que l’index est en VALID.

Etape 6 : Backfill progressif des donnees existantes

Si la nouvelle colonne doit etre remplie pour les lignes existantes, ne lancez jamais un UPDATE massif sur des millions de lignes. Faites un backfill par batchs de 5000 a 10000 lignes, avec une pause entre chaque batch pour laisser le replica se synchroniser.

UPDATE users SET phone_e164 = format_phone(phone_raw)
WHERE id IN (
  SELECT id FROM users WHERE phone_e164 IS NULL
  ORDER BY created_at LIMIT 5000
);

Boucle ce batch dans un script Node lance via tsx, avec un setTimeout de 200 ms entre iterations. Sur une base de 2 millions de lignes hebergee a Paris (latence Dakar ~80 ms), comptez 15 a 25 minutes de backfill — acceptable car aucune transaction utilisateur n’est bloquee.

Etape 7 : Phase CONTRACT — passer la colonne en NOT NULL

Une fois le backfill termine et le code v2 deploye partout, on resserre les contraintes dans une seconde migration. Postgres 12+ permet d’ajouter NOT NULL via une CHECK constraint validee a posteriori, sans verrou exclusif long.

ALTER TABLE users ADD CONSTRAINT users_phone_e164_check
  CHECK (phone_e164 IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_phone_e164_check;
ALTER TABLE users ALTER COLUMN phone_e164 SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_phone_e164_check;

Le NOT VALID evite le scan complet immediat. VALIDATE CONSTRAINT prend un verrou ROW SHARE seulement, compatible avec lectures et ecritures. Le SET NOT NULL final est instantane car Postgres sait que la contrainte est deja validee.

Etape 8 : Renommer une colonne sans casser l’API

Pour renommer email en email_address (cas frequent quand l’equipe produit demande un schema plus explicite), n’utilisez jamais ALTER TABLE RENAME COLUMN en une seule release. Le pattern est : ajouter email_address, dual-write depuis le code, backfill, basculer les lectures, puis supprimer email.

-- Release 1
ALTER TABLE users ADD COLUMN email_address text;
-- Release 2 : code dual-write, backfill
UPDATE users SET email_address = email WHERE email_address IS NULL;
-- Release 3 : code lit email_address uniquement
ALTER TABLE users DROP COLUMN email;

Trois releases minimum, chacune validee en staging. C’est verbose mais c’est le seul moyen d’eviter qu’un pod kube avec l’ancienne version du code ne crashe pendant le rolling update — exactement ce qui s’est passe sur un projet de e-commerce a Cotonou en 2025 ou un RENAME direct a coute 8 minutes d’indisponibilite en plein vendredi soir.

Etape 9 : Strategie de rollback et journal __drizzle_migrations__

Drizzle maintient une table __drizzle_migrations__ avec hash et timestamp de chaque migration appliquee. Pour rollback une migration cassante, vous devez ecrire un fichier « down » manuel — drizzle-kit ne genere pas automatiquement les rollbacks. Conservez chaque down dans drizzle/down/ avec le meme numero.

-- drizzle/down/0003_add_phone_e164.sql
DROP INDEX IF EXISTS idx_users_phone_e164;
ALTER TABLE users DROP COLUMN IF EXISTS phone_e164;

En cas d’incident, appliquez le down via psql puis supprimez la ligne correspondante de __drizzle_migrations__. Documentez la procedure dans un runbook accessible a toute l’equipe SRE — au Senegal, l’equipe d’astreinte est rarement la meme que celle qui a code la migration.

Etape 10 : Tester la migration en CI avant le merge

Chaque PR qui touche schema.ts doit declencher un job CI qui : 1) part d’un dump production anonymise, 2) applique la nouvelle migration, 3) lance la suite de tests d’integration, 4) mesure la duree des verrous via pg_locks. Sur GitHub Actions avec un service postgres:16, comptez 90 a 120 secondes pour ce job.

- name: Test migration
  run: |
    psql $DB -f tests/seed.sql
    npx drizzle-kit migrate
    npm run test:integration

Si pg_locks revele un AccessExclusiveLock pendant plus de 2 secondes sur une table de plus de 100k lignes, le job echoue. Cette gate evite 90% des incidents en prod. Sur un angle proche sur l’observabilite cote applicatif, voyez notre tutoriel Grafana alertes Discord et notre guide multi-tenancy avec Drizzle.

Etape 11 : Gerer les enums Postgres en zero-downtime

Les enums sont un piege classique. ALTER TYPE ADD VALUE ne peut pas etre execute dans une transaction qui contient d’autres DDL et l’ancien code qui ne connait pas la nouvelle valeur peut crasher au cast. Le pattern recommande est de migrer vers une colonne text + CHECK constraint, plus souple a faire evoluer.

-- Au lieu de pgEnum, utilisez text + check
status: text('status').notNull().$type<'pending'|'paid'|'failed'>()
-- SQL genere : ajoutez une CHECK constraint
ALTER TABLE orders ADD CONSTRAINT orders_status_check
  CHECK (status IN ('pending','paid','failed','refunded'));

Pour ajouter une nouvelle valeur, on droppe la contrainte et on la recree avec la nouvelle liste — operation instantanee sur des millions de lignes car la contrainte est validee NOT VALID puis VALIDATE separement. Au passage, gerez le statut « refunded » cote code avant de l’autoriser cote schema, sinon les vieux pods crasheront.

Etape 12 : Coordonner avec les replicas en lecture

Si vous avez des read replicas (cas frequent sur Aurora Postgres ou Neon), une migration appliquee sur le primary se propage avec un lag de quelques secondes a quelques minutes. Pendant ce delta, certains pods qui pointent sur replicas peuvent recevoir des requetes faisant reference a une colonne qui n’existe pas encore cote replica. Le code doit donc tolerer ce cas — try/catch autour des SELECT incluant la nouvelle colonne, fallback sur l’ancienne logique.

try {
  return await db.select({ id: users.id, phone: users.phoneE164 }).from(users);
} catch (e) {
  if (e.message.includes('column') && e.message.includes('does not exist')) {
    return await db.select({ id: users.id }).from(users);
  }
  throw e;
}

Cette double lecture n’est temporaire — une fois tous les replicas synchronises (verifiez avec SELECT pg_last_wal_replay_lsn()), supprimez le fallback dans la release suivante. Sur un cluster avec replicas a Frankfurt et primary a Paris, le lag depasse rarement 500 ms hors incidents.

Etape 13 : Mesurer l’impact reel d’une migration

Avant chaque deploiement de migration en prod, lancez EXPLAIN (ANALYZE, BUFFERS) sur les requetes critiques de l’API avec la nouvelle structure. L’objectif : detecter une regression de plan d’execution avant que les utilisateurs ne la subissent. Un index manquant transforme un Index Scan a 2 ms en Seq Scan a 800 ms — invisible en staging avec 10k lignes mais catastrophique en prod avec 5M lignes.

EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM users WHERE phone_e164 = '+221771234567';

Comparez le cost et le execution time avant/apres. Si le cost augmente de plus de 20%, ajoutez un index dedie via CREATE INDEX CONCURRENTLY avant de merger la PR. Au Cameroun, une boutique en ligne avait vu son temps de reponse passer de 80 ms a 2,3 s apres une migration « innocente » qui avait fait perdre un index — l’EXPLAIN systematique l’aurait detecte en 30 secondes.

Etape 14 : Automatiser la verification post-deploiement

Apres chaque drizzle-kit migrate en prod, lancez automatiquement un check qui valide : 1) la table __drizzle_migrations__ contient bien le hash attendu, 2) les nouvelles contraintes sont VALID, 3) les nouveaux index sont en READY (pas INVALID). Un script Node de 30 lignes dans le pipeline CD suffit.

const result = await sql`SELECT pg_index.indisvalid
  FROM pg_index JOIN pg_class ON pg_class.oid = pg_index.indexrelid
  WHERE pg_class.relname = 'idx_users_phone_e164'`;
if (!result[0].indisvalid) throw new Error('Index INVALID, rollback');

Si le check echoue, le pipeline declenche automatiquement le down correspondant et alerte l’equipe via webhook Discord ou Slack. C’est la garantie que zero migration cassee ne reste en prod sans qu’une humaine soit prevenue dans la minute. Au Mali ou en Guinee ou les equipes tech sont souvent reduites a 2-3 personnes, cette automatisation est vitale pour dormir tranquille.

Etape 15 : Documenter chaque migration dans un journal d’equipe

Chaque migration appliquee en prod doit avoir une entree dans un journal partage (Notion, Confluence, ou simple fichier MIGRATIONS.md dans le repo) precisant : date, auteur, description metier, duree d’execution mesuree, lignes impactees, et lien vers la PR. Ce journal devient inestimable six mois plus tard quand un nouveau dev demande pourquoi telle colonne a un nom bizarre. Sur les fintechs ouest-africaines auditees par BCEAO ou banque centrale du pays concerne, ce journal sert aussi de piste d’audit reglementaire — autant le tenir des le premier jour plutot que de le reconstituer en urgence trois ans plus tard quand l’audit arrive sans prevenir.

Partager