Une application qui parle à une base de données finit toujours par devoir la faire évoluer : une colonne à ajouter, un index à créer, une table à découper.
La vraie question n’est pas comment écrire le SQL, mais quand et par qui il est appliqué — et comment le faire sans réveiller personne à 3h du matin parce que la base est verrouillée.
Il n’y a pas une seule bonne réponse.
Selon l’équipe, l’infra et le niveau de risque, la même migration peut se gérer de trois façons très différentes.
Cet article n’est pas un dogme, mais un retour d’expérience. Je vais présenter les trois stratégies que je croise le plus souvent, ce que je fais personnellement, et pourquoi je me suis récemment reposé la question chez mon client actuel.
🎯 Deux problèmes qu’on confond trop souvent
Avant même de parler d’outillage, il faut séparer deux choses qu’on range à tort dans le même sac « migration » :
- La migration de schéma 👉 on change la structure :
ADD COLUMN,CREATE TABLE,ADD CONSTRAINT. C’est du DDL, souvent rapide. - La migration de données 👉 on change le contenu : remplir une nouvelle colonne sur des millions de lignes, recalculer un champ, rétro-remplir une table. C’est du DML, potentiellement long et lourd.
Le vrai piège
La difficulté n'est presque jamais d'écrire le bon ALTER TABLE. Elle est de l'appliquer sur une base en pleine activité sans bloquer les requêtes des utilisateurs. Un DDL qui prend un verrou trop fort, ou un UPDATE massif dans une seule transaction, et c'est la production qui se fige le temps de la migration.
Pourquoi certaines migrations bloquent la base
PostgreSQL prend des verrous pour garantir la cohérence. Le problème, c’est que certains DDL prennent un ACCESS EXCLUSIVE LOCK — le verrou le plus fort, qui bloque toute lecture et écriture sur la table pendant l’opération. Quelques cas classiques :
- Créer un index avec
CREATE INDEXverrouille les écritures sur toute la table le temps de la construction. Sur une grosse table, ça peut durer des minutes. - Ajouter une colonne
NOT NULLavec unDEFAULTvolatile, ouSET NOT NULLsur une colonne existante, force PostgreSQL à scanner (voire réécrire) toute la table. - Un
UPDATEsur des millions de lignes dans une seule transaction fait gonfler le WAL, tient les verrous longtemps et génère un pic de dead tuples que l’autovacuumdevra nettoyer.
La parade tient en deux idées :
- Utiliser les variantes non bloquantes quand elles existent :
CREATE INDEX CONCURRENTLY, ajout de colonne nullable d’abord,VALIDATE CONSTRAINTen second temps. - Découper une évolution risquée en plusieurs migrations successives — le pattern expand / contract.
Le pattern expand / contract, en vrai
Ajouter une colonne triviale, c’est une seule migration sans histoire :
-- 000002_add_validated_column.up.sql
-- DDL rapide : DEFAULT constant, pas de réécriture de table sous PostgreSQL 11+
ALTER TABLE time_entries ADD COLUMN validated BOOLEAN NOT NULL DEFAULT false;
Quand un NOT NULL DEFAULT reste anodin… et quand il réécrit toute la table
L'exemple ci-dessus est gratuit parce que la valeur par défaut est une constante (false). Depuis PostgreSQL 11, PG range cette valeur dans le catalogue (pg_attribute.attmissingval) et la sert « à la volée » aux anciennes lignes : aucune réécriture, quelle que soit la taille de la table. Le coût ne dépend pas du nombre de lignes.
La bascule ne se joue pas sur constante vs fonction, mais sur la volatilité de l'expression par défaut :
| Valeur par défaut | Volatilité | Effet sur une grosse table |
|---|---|---|
false, 0, 'active' |
constante | Catalogue seul, instantané |
now(), CURRENT_TIMESTAMP |
stable | Évaluée une seule fois, catalogue seul — toutes les anciennes lignes partagent le même instant |
clock_timestamp(), random(), gen_random_uuid() |
volatile | Réécriture complète : PG doit calculer une valeur différente par ligne |
Le piège contre-intuitif : now() n'est pas le problème (il est stable, donc chemin rapide, même si toutes les lignes rétro-remplies héritent du même horodatage). Le vrai danger, c'est toute fonction volatile : un DEFAULT gen_random_uuid() sur des centaines de millions de lignes, et PG réécrit la table entière sous ACCESS EXCLUSIVE, verrou tenu pendant toute l'opération.
Deux réflexes en découlent :
- Une colonne nullable est toujours bon marché — PG ne touche que le catalogue, sans même évaluer de défaut. C'est le repli sûr quand on doute de la volatilité.
- Même un
NOT NULL DEFAULTconstant prend unACCESS EXCLUSIVE: quelques millisecondes une fois obtenu, mais il faut d'abord attendre le verrou. Sur une base à fort trafic, encadrez-le et prévoyez un retry côté déploiement :
SET lock_timeout = '2s';
ALTER TABLE time_entries ADD COLUMN validated BOOLEAN NOT NULL DEFAULT false;
-- en cas d'échec sur le verrou : on rejoue (retry espacé côté script de déploiement)
Ce qui fait mal sur une base chargée, ce n'est presque jamais la durée de l'ALTER anodin — c'est l'attente du verrou et la file de requêtes qui s'accumule derrière pendant cette attente.
Mais introduire une notion d’organization_id obligatoire sur des tables déjà remplies, c’est une tout autre affaire.
On ne peut pas faire ADD COLUMN organization_id NOT NULL d’un coup : les lignes existantes n’ont pas de valeur.
On étale donc l’évolution en phases :
Phase 1 — ajout de la nouvelle colonne nullable :
-- 000004_add_organizations.up.sql
-- Add organization_id (NULLABLE during transition) to existing tables
ALTER TABLE customers ADD COLUMN organization_id UUID REFERENCES organizations(id);
Phase 2 — la migration de données, qui rétro-remplit la nouvelle colonne à partir de l’existant :
-- 000005_migrate_user_data_to_organizations.up.sql (extrait)
-- Chaque ligne récupère l'organisation correspondant à son ancien user_id
UPDATE customers c
SET organization_id = (SELECT id FROM organizations WHERE external_id = c.user_id) -- Ici la condition dépend de l'application existante.
WHERE organization_id IS NULL;
Phase 4 — le durcissement, une fois seulement que toutes les lignes sont remplies. On protège la migration par un garde-fou explicite plutôt que de laisser SET NOT NULL échouer avec un message obscur :
-- 000006_make_organization_id_required.up.sql (extrait)
-- Garde-fou : on échoue tôt et clairement si un backfill a été oublié
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM customers WHERE organization_id IS NULL) THEN
RAISE EXCEPTION 'Found customers with NULL organization_id — run data migration first';
END IF;
END $$;
ALTER TABLE customers ALTER COLUMN organization_id SET NOT NULL;
👉 La leçon : une évolution de schéma anodine et une évolution qui déplace des données n’ont ni le même risque, ni le même rythme de déploiement. Les mélanger dans une seule migration monolithique, c’est se garantir un incident le jour où le volume grossit.
🗺️ Trois stratégies pour appliquer ses migrations
Une fois le SQL écrit, reste à décider qui l’exécute et à quel moment. Trois grandes familles.
1. Migration automatique intégrée à l’application
L’application applique ses migrations elle-même, au démarrage, juste avant d’accepter du trafic.
C’est le RunMigrations() appelé en tête du main().
| ✅ Avantages | ⚠️ Inconvénients |
|---|---|
| Zéro étape manuelle : le schéma est toujours en phase avec le code déployé | Plusieurs réplicas qui démarrent en même temps se battent pour migrer (il faut un verrou) |
| Impossible de faire tourner du code contre un schéma périmé | Une migration longue rallonge — ou bloque — le démarrage |
Simple en dev et en CI : docker compose up et tout est prêt |
L’app a besoin de droits DDL en production (surface de risque) |
| Rollback couplé au déploiement applicatif | Un déploiement blue-green / canary rend le couplage schéma↔code délicat |
2. Migration automatique séparée de l’application
Le même outil, mais exécuté par un artefact distinct : un step de pipeline CI/CD, un init container Kubernetes, un job one-shot. L’application, elle, démarre en supposant le schéma déjà à jour.
| ✅ Avantages | ⚠️ Inconvénients |
|---|---|
| Une seule exécution, orchestrée : plus de course entre réplicas | Une pièce de plus à outiller et à surveiller |
| L’app n’a besoin que des droits DML en production | Risque de désynchronisation si on déploie le code sans lancer la migration |
| On peut valider / faire relire la migration avant le déploiement du code | Nécessite une vraie discipline d’ordonnancement (migration puis app) |
| S’accommode bien du blue-green, du canary et des gros backfills | La séparation impose de gérer la compatibilité N-1 (voir plus bas) |
3. Migration manuelle
Un humain — souvent un DBA — applique le script à la main, dans une fenêtre choisie, éventuellement en dehors des heures de pointe.
| ✅ Avantages | ⚠️ Inconvénients |
|---|---|
| Contrôle total : moment, surveillance, plan de rollback | Ne passe pas à l’échelle, source d’erreurs humaines |
| Adapté aux opérations sensibles (partitionnement, migration de téraoctets) | Dérive facile entre environnements (dev ≠ staging ≠ prod) |
| Aucune exigence de droits DDL pour l’application | Lent, dépendant d’une personne, difficile à tracer |
| Le DBA garde la main sur les verrous et la charge | Pas de source de vérité versionnée si ce n’est pas outillé |
🧭 Ce que je fais, et pourquoi je me repose la question
Personnellement, je pars sur l’option 1 — la migration intégrée à l’application dans la grande majorité des cas.
Sur mes projets en Go, la première ligne utile du main() lance les migrations, point.
Le schéma ne peut jamais être en retard sur le code, l’onboarding se résume à un docker compose up, et je n’ai aucune orchestration à maintenir. Pour une équipe petite avec un déploiement simple, le rapport bénéfice/complexité est imbattable.
Mais chez mon client actuel, on s’est récemment reposé la question et regardé sérieusement l’option 2. Les raisons sont concrètes :
- On tourne avec plusieurs réplicas de l’application : les faire migrer en même temps demande un verrou d’advisory lock, ce qui marche mais ajoute une subtilité qu’on préférerait sortir du code applicatif.
- On veut découpler l’application des migrations lourdes de données, qui peuvent durer et qu’on ne veut pas voir bloquer un rolling update.
- L’équipe OPS aimerait relire et valider les migrations avant qu’elles ne partent, plutôt que de les voir s’appliquer automatiquement au premier pod qui démarre.
Aucune des deux options n’est « la bonne ».
L’option 1 optimise la simplicité.
L’option 2 optimise le contrôle et le passage à l’échelle.Le bon choix dépend de la taille de l’équipe, du nombre de réplicas et de la criticité des données.
Le fil rouge des options 1 et 2 : la compatibilité N-1
Dès qu'on ne peut plus garantir que le schéma et le code changent au même instant (plusieurs réplicas, rolling update, blue-green), chaque migration doit rester compatible avec la version précédente du code, le temps du déploiement.
Concrètement : on ajoute avant de retirer, on ne renomme jamais brutalement une colonne, on déprécie sur un déploiement puis on supprime sur le suivant.
C'est exactement ce que le pattern expand / contract impose.
🛠️ Trois implémentations concrètes
Voici comment brancher tout ça dans trois écosystèmes.
Les extraits se concentrent sur le câblage (le main, l’organisation des fichiers).
Retrouvez le code complet sur github.
Go
A l’aide de golang-migrate, les migrations sont de simples fichiers NNNNNN_description.up.sql / .down.sql numérotés, dans un dossier. Une fonction dédiée applique tout ce qui n’a pas encore été joué :
// internal/database/migrate.go
func RunMigrations(cfg *config.DatabaseConfig, migrationsPath string) error {
dsn := fmt.Sprintf(
"pgx5://%s:%s@%s:%s/%s?sslmode=%s&x-migrations-table=schema_migrations",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName, cfg.SSLMode,
)
m, err := migrate.New(fmt.Sprintf("file://%s", migrationsPath), dsn)
if err != nil {
return fmt.Errorf("failed to create migrate instance: %w", err)
}
defer m.Close()
if err = m.Up(); err != nil {
if errors.Is(err, migrate.ErrNoChange) {
return nil // rien à faire, cas nominal
}
return fmt.Errorf("failed to run migrations: %w", err)
}
return nil
}
Côté option 1, on l’appelle en tête du main(), avant d’ouvrir le pool de connexions applicatif :
// main.go
func main() {
cfg, _ := config.Load("config.yaml")
// Migrations appliquées AVANT tout le reste
if err := database.RunMigrations(&cfg.Database, "migration/postgres"); err != nil {
log.Fatal().Err(err).Msg("Failed to run migrations")
}
db, _ := database.New(ctx, &cfg.Database)
defer db.Close()
// ... repositories, services, serveur HTTP
}
Pour passer en option 2, on ne change quasiment rien : on compile un petit binaire
cmd/migratequi n’appelle queRunMigrations, et on le lance comme init container ou step de CI. Le même code, un point d’entrée différent.
Rust — sqlx
Avec sqlx, les migrations vivent dans un dossier migrations/ et la macro sqlx::migrate! les embarque dans le binaire à la compilation. L’option 1 tient en une ligne dans le main :
// main.rs
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await?;
// Migrations embarquées (dossier ./migrations) appliquées au démarrage
sqlx::migrate!("./migrations").run(&pool).await?;
let app = build_router(pool);
axum::serve(listener, app).await?;
Ok(())
}
Pour l’option 2, sqlx fournit sa CLI : on retire l’appel à la macro et on applique les migrations depuis le pipeline, avant de démarrer l’app.
# Exécuté en amont du déploiement, hors du binaire applicatif
sqlx migrate run --database-url "$DATABASE_URL"
Java / Kotlin — Liquibase
Liquibase décrit les changements dans un changelog (XML, YAML ou SQL), chaque changeSet étant identifié et rejoué une seule fois.
# db/changelog/db.changelog-master.yaml
databaseChangeLog:
- changeSet:
id: 000002-add-validated-column
author: pcavezzan
changes:
- addColumn:
tableName: time_entries
columns:
- column: { name: validated, type: boolean, defaultValueBoolean: false }
En option 1 avec Spring Boot, il suffit d’avoir la dépendance sur le classpath : les migrations s’appliquent automatiquement au démarrage du contexte.
# application.yaml
spring:
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
enabled: true # passer à false pour découpler (option 2)
En option 2, on met enabled: false et on déclenche Liquibase séparément — via le plugin Maven/Gradle (mvn liquibase:update) ou la CLI dans un job dédié.
📊 Tableau récapitulatif
| Stratégie | Qui applique | Idéal pour | Principal risque |
|---|---|---|---|
| 1 · Intégrée à l’app | L’app, au démarrage | Petite équipe, déploiement simple, mono-réplica | Course entre réplicas, démarrage bloqué |
| 2 · Séparée de l’app | Job / CI / init container | Multi-réplicas, backfills lourds, gouvernance data | Désynchronisation code ↔ schéma |
| 3 · Manuelle | Un humain (DBA) | Opérations sensibles, très gros volumes | Erreur humaine, dérive entre environnements |
📝 En résumé
- Séparer les deux natures de migration,
- Ne pas bloquer la base,
- Choisir sa stratégie selon le contexte,
- Penser compatibilité N-1,
- Versionner et outiller.
✅ Le meilleur système de migration est celui que toute l’équipe comprend et applique de la même façon, du poste de dev jusqu’à la production.
Crédits & Ressources
- golang-migrate — l’outil de migration utilisé côté Go
- Introduction à une API REST avec Axum en Rust — migrations
sqlxen pratique - Liquibase — migrations pour l’écosystème Java/Kotlin
- PostgreSQL — Explicit Locking — comprendre les verrous pris par le DDL
- Découverte de Bun en Golang
- Handling Database Migrations in a Microservice Environment

