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 INDEX verrouille les écritures sur toute la table le temps de la construction. Sur une grosse table, ça peut durer des minutes.
  • Ajouter une colonne NOT NULL avec un DEFAULT volatile, ou SET NOT NULL sur une colonne existante, force PostgreSQL à scanner (voire réécrire) toute la table.
  • Un UPDATE sur 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’autovacuum devra 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 CONSTRAINT en 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 DEFAULT constant prend un ACCESS 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 :

1 · ADD COLUMN nullable 2 · Backfill des données 3 · CREATE INDEX CONCURRENTLY 4 · SET NOT NULL

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/migrate qui n’appelle que RunMigrations, 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