data / sqlalchemy
Migrations avec Alembic
Explication
Ce que vous allez apprendre
- Comprendre pourquoi
Base.metadata.create_allne 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 NULLsur 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 Alembic | Effet |
|---|---|
alembic revision --autogenerate -m "..." | propose un script de migration |
alembic upgrade head | applique toutes les migrations en attente |
alembic downgrade -1 | annule la dernière migration |
alembic current / alembic history | version 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
# Initialiser Alembic dans le projet (crée alembic/ et alembic.ini)
alembic init alembic# 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# 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# 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 --autogeneratecompareBase.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 NULLsur une table peuplée se fait en 2 étapes : nullable + backfill, puis contrainte. - Chaque migration a un
upgrade()et undowngrade()symétrique, pour pouvoir revenir en arrière en production.
Exercices pratiques
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.