Retour au cours

data / sqlalchemy

Migrations avec Alembic

Leçon 81 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi Base.metadata.create_all ne suffit plus dès qu'une base contient des données réelles
  • Générer une migration automatiquement avec alembic revision --autogenerate
  • Repérer et corriger l'angle mort de l'autogénération sur les renommages de colonnes
  • Ajouter une colonne NOT NULL sur une table déjà peuplée sans casser la production
  • Écrire des fonctions upgrade()/downgrade() symétriques pour pouvoir revenir en arrière

Dans quel contexte ?

Une équipe backend doit ajouter un champ telephone obligatoire à la table utilisateurs d'une application déjà en production, avec des dizaines de milliers de lignes existantes. Une migration mal pensée qui ajoute directement la contrainte NOT NULL planterait immédiatement, car aucune des lignes existantes n'a de valeur pour cette colonne. Cette leçon montre la démarche en plusieurs étapes qui évite cet incident.

D'abord, pourquoi create_all ne suffit plus

Base.metadata.create_all(engine), vu en leçon 1, ne fonctionne bien qu'en développement, quand la base est vide ou jetable. En production, la base contient déjà des données réelles : impossible de simplement "recréer" les tables à chaque changement de modèle sans tout perdre.

La solution : un historique versionné du schéma

Alembic tient un historique de "migrations", un peu comme Git tient un historique de commits pour du code. Chaque migration est un petit script Python avec deux fonctions symétriques : upgrade() pour appliquer le changement, downgrade() pour l'annuler proprement.

Étape 1 : laisser Alembic proposer un script

Alembic peut comparer les modèles SQLAlchemy à l'état réel de la base et proposer automatiquement un script de migration (--autogenerate). C'est un gain de temps énorme sur la majorité des changements simples.

Il reste un piège : l'autogénération n'est qu'une proposition

L'autogénération a un angle mort célèbre : un renommage de colonne est vu comme une suppression suivie d'une création. Si le script généré n'est pas relu et corrigé à la main, appliquer cette migration effacerait silencieusement toutes les données existantes de cette colonne.

Piège fréquent

alembic revision --autogenerate après avoir renommé nom en nom_complet dans le modèle génère un drop_column("nom") + add_column("nom_complet"), ce qui EFFACE toutes les valeurs existantes. Corrigez toujours manuellement en alter_column(..., new_column_name=...) pour préserver les données.

Étape 2 : le cas délicat des colonnes obligatoires

Ajouter une colonne NOT NULL sur une table déjà peuplée casse immédiatement si on le fait en une seule étape, puisque les lignes existantes n'ont aucune valeur pour cette colonne.

La bonne pratique, en deux temps

Il faut d'abord ajouter la colonne en nullable=True, remplir les données pour toutes les lignes existantes, puis seulement à ce moment-là ajouter la contrainte NOT NULL. C'est une compétence essentielle à maîtriser avant de toucher une base de production.

Commande AlembicEffet
alembic revision --autogenerate -m "..."propose un script de migration
alembic upgrade headapplique toutes les migrations en attente
alembic downgrade -1annule la dernière migration
alembic current / alembic historyversion courante / historique complet

Vers la suite

Une fois le schéma sous contrôle, la prochaine leçon revient à un sujet déjà entrevu avec les sessions : les transactions, et ce qu'il se passe précisément entre un commit() et un rollback().

Commandes & code

Migrations avec Alembic

bash
# Initialiser Alembic dans le projet (crée alembic/ et alembic.ini)
alembic init alembic
python
# alembic/env.py : connecter Alembic aux modèles SQLAlchemy pour l'autogénération
from app.core.database import Base
from app.core.models import utilisateur, commande, produit  # importer TOUS les modèles

target_metadata = Base.metadata  # Alembic compare ceci à l'état réel de la base
bash
# Générer une migration automatiquement à partir de la différence modèles <-> base
alembic revision --autogenerate -m "ajoute la table produits"

# Appliquer les migrations en attente
alembic upgrade head

# Revenir en arrière d'une migration
alembic downgrade -1

# Voir l'historique et la version courante
alembic history
alembic current
python
# Exemple de fichier de migration généré (alembic/versions/xxxx_ajoute_produits.py)
from alembic import op
import sqlalchemy as sa

revision = "a1b2c3d4"
down_revision = "9f8e7d6c"

def upgrade() -> None:
    op.create_table(
        "produits",
        sa.Column("id", sa.Integer(), primary_key=True),
        sa.Column("nom", sa.String(200), nullable=False),
        sa.Column("prix", sa.Numeric(10, 2), nullable=False),
    )
    op.create_index("ix_produits_nom", "produits", ["nom"])

def downgrade() -> None:
    op.drop_index("ix_produits_nom", table_name="produits")
    op.drop_table("produits")

# Migration manuelle : ajouter une colonne NOT NULL sur une table déjà peuplée
def upgrade() -> None:
    op.add_column("utilisateurs", sa.Column("telephone", sa.String(20), nullable=True))
    # Étape 1 : ajouter en nullable, backfiller les données existantes
    op.execute("UPDATE utilisateurs SET telephone = '' WHERE telephone IS NULL")
    # Étape 2 : rendre la colonne NOT NULL une fois toutes les lignes remplies
    op.alter_column("utilisateurs", "telephone", nullable=False)

def downgrade() -> None:
    op.drop_column("utilisateurs", "telephone")

# Renommer une colonne (Alembic ne le détecte pas automatiquement -> l'autogénération
# proposerait un drop + add, ce qui PERD les données -- corriger manuellement)
def upgrade() -> None:
    op.alter_column("utilisateurs", "nom", new_column_name="nom_complet")

def downgrade() -> None:
    op.alter_column("utilisateurs", "nom_complet", new_column_name="nom")

# Migration de données (pas seulement de schéma)
def upgrade() -> None:
    connexion = op.get_bind()
    connexion.execute(
        sa.text("UPDATE commandes SET statut = 'en_attente' WHERE statut IS NULL")
    )

Résumé

  • alembic revision --autogenerate compare Base.metadata à l'état réel de la base et propose un diff.
  • TOUJOURS relire une migration autogénérée : les renommages sont détectés comme un drop + add (perte de données).
  • Ajouter une colonne NOT NULL sur une table peuplée se fait en 2 étapes : nullable + backfill, puis contrainte.
  • Chaque migration a un upgrade() et un downgrade() symétrique, pour pouvoir revenir en arrière en production.

Exercices pratiques

1 disponible
1

Mission : sauver un renommage de colonne avant qu'il n'efface des données

Objectif : Repérer l'angle mort de l'autogénération sur un renommage de colonne et corriger le script avant application.

Contexte

Une équipe renomme la colonne nom en nom_complet dans le modèle Utilisateur, puis lance alembic revision --autogenerate -m "renomme nom". Le script généré n'a pas encore été appliqué, mais doit être relu avant alembic upgrade head, sur une table qui contient déjà des dizaines de milliers de lignes réelles.

Résoudre l’exercice →