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 COLUMNavec 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 NULLsur 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 INDEXsans 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, pas005_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 COLUMNnullable d'abord, backfill, puisSET NOT NULL SET lock_timeoutetCREATE INDEX CONCURRENTLYsont obligatoires en production
Article précédent : 11 - Le problème N+1 Article suivant : 13 - Performance