SQL avance et PostgreSQL - 12 - Migrations : versionner ton schema de base de donnees

Gerer les migrations de schema PostgreSQL. Versionning, rollback, zero-downtime migrations et les outils disponibles.

12 - Migrations : versionner ton schema de base de donnees

Ce que tu vas apprendre

  • Pourquoi versionner ton schema de base de donnees
  • Les outils de migration (Prisma Migrate, Drizzle Kit, golang-migrate, raw SQL)
  • UP et DOWN, la table de suivi des migrations
  • Zero-downtime migrations et les opérations dangereuses

Prerequisites

Avoir lu les articles sur les index et les transactions.


Le problème

Tu travailles seul, tu fais un ALTER TABLE users ADD COLUMN phone TEXT en prod, ca marche. Tu bosses a deux, ton collegue fait un ALTER TABLE de son cote. Vous mergez. Qui a applique quoi ? Dans quel ordre ? La base de dev a-t-elle le meme schema que la prod ? Personne ne sait.

Les migrations resolvent ca. C'est le "git pour ton schema de base de donnees". Chaque changement est un fichier avec un timestamp ou un numero de version. On sait exactement quel schema correspond a quel commit de code.

Un système de migrations simple

migrations/
  001_create_users.sql
  002_create_orders.sql
  003_add_phone_to_users.sql
  004_create_order_items.sql
  005_add_index_orders_created_at.sql

Chaque fichier contient le SQL du changement :

sql-- 003_add_phone_to_users.sql

-- UP
ALTER TABLE users ADD COLUMN phone TEXT;

-- DOWN
ALTER TABLE users DROP COLUMN phone;

La partie UP applique le changement. La partie DOWN l'annule (rollback). En pratique, les DOWN sont rarement utilises en production (tu preferes aller de l'avant), mais ils sont precieux en développement pour revenir en arriere rapidement.

La table de suivi

Les outils de migration utilisent une table pour savoir quelles migrations ont deja ete appliquees :

sqlCREATE TABLE _migrations (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  applied_at TIMESTAMP DEFAULT NOW()
);

-- Apres avoir applique les 3 premieres migrations :
SELECT * FROM _migrations;
 id |           name            |       applied_at
----+---------------------------+------------------------
  1 | 001_create_users.sql      | 2025-01-15 10:00:00
  2 | 002_create_orders.sql     | 2025-01-15 10:00:01
  3 | 003_add_phone_to_users.sql| 2025-02-20 14:30:00

Quand tu deploies, l'outil compare les fichiers du dossier migrations/ avec le contenu de _migrations. Il n'exécuté que les fichiers manquants.

Les outils

Prisma Migrate

bash# Creer une migration a partir du schema Prisma
npx prisma migrate dev --name add_phone_to_users

# Appliquer en production
npx prisma migrate deploy

Prisma généré le SQL à partir de ton schema.prisma. C'est pratique mais tu perds le contrôle sur le SQL exact. Parfois Prisma généré des trucs surprenants (des DROP TABLE + CREATE TABLE au lieu d'un simple ALTER TABLE). Verifie toujours le SQL généré dans le dossier prisma/migrations/.

Drizzle Kit

bash# Generer les migrations
npx drizzle-kit generate

# Appliquer
npx drizzle-kit migrate

Drizzle généré aussi le SQL à partir du schema TypeScript. L'avantage sur Prisma : le SQL généré est plus previsible et tu peux le modifier avant de l'appliquer.

golang-migrate

Un outil générique, pas lie a un ORM :

bash# Creer une migration vide
migrate create -ext sql -dir migrations add_phone_to_users

# Appliquer
migrate -database "postgres://user:pass@localhost:5432/mydb?sslmode=disable" -path migrations up

Genere deux fichiers : ..._add_phone_to_users.up.sql et ..._add_phone_to_users.down.sql. Tu ecris le SQL toi-meme. C'est mon approche préférée pour les projets ou le schema est complexe et ou je veux contrôler exactement ce qui se passe.

Raw SQL avec un script maison

Pour les petits projets, un script bash suffit :

bash#!/bin/bash
for f in migrations/*.sql; do
  name=$(basename "$f")
  already=$(psql -t -c "SELECT COUNT(*) FROM _migrations WHERE name='$name'" "$DB_URL")
  if [ "$already" -eq 0 ]; then
    echo "Applying $name..."
    psql "$DB_URL" -f "$f"
    psql "$DB_URL" -c "INSERT INTO _migrations (name) VALUES ('$name')"
  fi
done

Ca fait le job. Pas de dépendance externe, juste psql et du bash.

Zero-downtime migrations

C'est là où ca se complique. En production, tu ne peux pas bloquer la base pendant 30 secondes pour une migration. Les utilisateurs sont connectes, les requêtes arrivent en continu.

Opérations safe (pas de lock prolonge)

  • ALTER TABLE ADD COLUMN avec une valeur par défaut (depuis PostgreSQL 11, c'est instantane)
  • CREATE INDEX CONCURRENTLY (ne bloque pas les ecritures)
  • ALTER TABLE ADD CONSTRAINT ... NOT VALID (ajoute la contrainte sans vérifier les donnees existantes)

Opérations dangereuses

  • ALTER TABLE ADD COLUMN ... DEFAULT ... NOT NULL sur PostgreSQL < 11 (reecrit toute la table)
  • ALTER TABLE DROP COLUMN (prend un lock ACCESS EXCLUSIVE)
  • ALTER TABLE ALTER COLUMN TYPE (reecrit la table)
  • ALTER TABLE RENAME COLUMN (casse le code qui référencé l'ancien nom)
  • CREATE INDEX sans CONCURRENTLY (bloque les ecritures)

Le pattern safe pour ajouter un NOT NULL

On ne peut pas faire ALTER TABLE ADD COLUMN x TEXT NOT NULL DEFAULT 'foo' si on a besoin de backfiller avec des valeurs calculees. Voici le pattern safe, en 3 deploys :

sql-- Deploy 1 : ajouter la colonne nullable
ALTER TABLE users ADD COLUMN phone TEXT;

-- Deploy 2 : backfill les donnees (en batch pour ne pas bloquer)
UPDATE users SET phone = 'unknown' WHERE phone IS NULL AND id BETWEEN 1 AND 10000;
UPDATE users SET phone = 'unknown' WHERE phone IS NULL AND id BETWEEN 10001 AND 20000;
-- ... par lots de 10000

-- Deploy 3 : ajouter la contrainte NOT NULL
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;

Trois deploys separes. C'est plus long mais ca ne bloque personne.

lock_timeout

Toujours mettre un timeout sur les locks en production :

sqlSET lock_timeout = '5s';
ALTER TABLE orders ADD COLUMN priority INT;
-- Si le lock n'est pas obtenu en 5 secondes, la migration echoue
-- au lieu de bloquer indefiniment

Sans lock_timeout, un ALTER TABLE peut attendre des heures qu'une transaction longue se termine. Pendant ce temps, toutes les nouvelles requêtes sur cette table s'empilent derrière. C'est comme ca qu'on provoque un incident en production avec une migration innocente.

CREATE INDEX CONCURRENTLY

sql-- MAUVAIS : bloque les ecritures pendant la construction de l'index
CREATE INDEX idx_orders_created ON orders (created_at);

-- BON : ne bloque pas les ecritures
CREATE INDEX CONCURRENTLY idx_orders_created ON orders (created_at);

CONCURRENTLY est plus lent (il fait deux passes sur la table au lieu d'une), mais il ne bloque pas les INSERT/UPDATE/DELETE. Sur une table de 10 millions de lignes, la différence entre 2 secondes de downtime et 0 est significative.

Attention : CREATE INDEX CONCURRENTLY ne peut pas etre exécuté dans une transaction. Si ton outil de migration wrappe tout dans un BEGIN/COMMIT, ca va échouer. Certains outils (comme golang-migrate) supportent les migrations "non-transactionnelles" pour ce cas precis.

Bonnes pratiques

  • Une migration fait UNE chose. Pas de migration qui créé 3 tables, ajoute 5 colonnes et créé 8 index.
  • Nomme tes migrations explicitement : 005_add_index_orders_created_at.sql, pas 005_changes.sql.
  • Ne modifie jamais une migration deja déployée. Si tu as fait une erreur, créé une nouvelle migration qui corrige.
  • Teste tes migrations sur une copie de la base de production (avec les volumes de donnees réels).
  • En équipe, mets les migrations sous Git et revois-les en code review comme n'importe quel code.

Sur paltemps.fr, chaque pull request qui touche au schema inclut la migration correspondante. Le reviewer vérifié le SQL généré, pas juste le schema ORM.


Résumé

  • Les migrations versionnent ton schema comme Git versionne ton code
  • Prisma Migrate et Drizzle Kit generent le SQL, golang-migrate te laisse l'écrire
  • Toujours vérifier le SQL généré par les ORM avant de déployer
  • Zero-downtime : ADD COLUMN nullable d'abord, backfill, puis SET NOT NULL
  • SET lock_timeout et CREATE INDEX CONCURRENTLY sont obligatoires en production

Article précédent : 11 - Le problème N+1 Article suivant : 13 - Performance

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.