Après quelques mois de développement, il n’est pas rare d’accumuler 30, 50, voire 100 fichiers de migration sur une application Django. Chaque lancement de test qui repart d’une base vide les rejoue toutes. Chaque déploiement sur un nouvel environnement aussi. squashmigrations permet de fusionner plusieurs migrations en une seule, sans perdre la compatibilité avec les environnements déjà déployés.
La commande et ce qu’elle génère
La syntaxe prend une plage de migrations :
python manage.py squashmigrations <app_label> [migration_début] <migration_fin>
Le paramètre migration_début est optionnel. Sans lui, Django part de la première migration de l’application. Exemples concrets :
# Squasher de 0001 à 0020
python manage.py squashmigrations myapp 0001 0020
# Squasher depuis le début jusqu'à 0020
python manage.py squashmigrations myapp 0020
# Nommer le fichier squashed
python manage.py squashmigrations myapp 0001 0020 --squashed-name initial_clean
Django génère un fichier 0001_squashed_0020_*.py. L’optimiseur intégré élimine les opérations redondantes : une colonne ajoutée puis supprimée disparaît complètement du fichier final, une colonne renommée deux fois est réduite à une seule opération.
L’attribut replaces : le mécanisme central
Le fichier squashed contient un attribut replaces que Django ajoute automatiquement :
class Migration(migrations.Migration):
replaces = [
("myapp", "0001_initial"),
("myapp", "0002_add_slug"),
("myapp", "0003_add_index"),
# ...
("myapp", "0020_add_status"),
]
operations = [
migrations.CreateModel(
name="Article",
fields=[
("id", models.BigAutoField(primary_key=True)),
("slug", models.SlugField(unique=True)),
("status", models.CharField(max_length=20, default="draft")),
],
),
# ... autres opérations optimisées
]
C’est cet attribut qui permet à Django de gérer la coexistence du fichier squashed et des originaux. À chaque migrate, Django consulte replaces et décide quoi faire selon l’état de la table django_migrations :
- Nouvel environnement (aucune migration appliquée) : Django exécute le fichier squashed directement comme une migration normale.
- Environnement à jour (toutes les migrations 0001 à 0020 présentes dans
django_migrations) : Django marque le squashed comme appliqué sans l’exécuter. - Environnement partiellement migré (0001 à 0012 appliquées, 0013 à 0020 manquantes) : Django exécute les migrations originales manquantes, puis marque le squashed comme appliqué.
Ce mécanisme garantit qu’on peut déployer le fichier squashed sans casser les environnements qui ont déjà appliqué une partie des migrations originales.
Le processus de nettoyage en trois étapes
Squasher génère un nouveau fichier, mais ne supprime pas les originaux. Le vrai objectif, alléger le projet durablement, demande trois étapes.
Étape 1 : squasher
python manage.py squashmigrations myapp 0001 0020
Le fichier 0001_squashed_0020_*.py est créé. Les fichiers originaux sont conservés, ils restent nécessaires pour les environnements partiellement migrés.
Étape 2 : déployer et vérifier
Déployer le code incluant le fichier squashed sur tous les environnements (production, staging, machines de développement). Vérifier que l’entrée du squashed est présente dans django_migrations sur chaque environnement :
SELECT app, name FROM django_migrations WHERE app = 'myapp' ORDER BY id;
Une fois que tous les environnements ont l’entrée 0001_squashed_0020_*, les migrations originales ne sont plus utilisées.
Étape 3 : nettoyer
Supprimer les fichiers originaux (0001 à 0020), puis vider l’attribut replaces dans le fichier squashed pour qu’il devienne une migration ordinaire :
class Migration(migrations.Migration):
replaces = [] # vider la liste, ou supprimer l'attribut
operations = [...]
À partir de là, le projet n’a plus qu’un seul fichier de migration pour cette plage, sans dépendance aux originaux supprimés.
Ce que l’optimiseur ne peut pas faire
L’optimiseur Django est efficace sur les opérations de schéma standard : CreateModel, AddField, RemoveField, AlterField, RenameField. Il fusionne, réordonne et élimine les redondances automatiquement.
Mais RunPython et RunSQL sont préservées telles quelles dans le fichier squashed. Django ne peut pas déduire si deux fonctions Python sont équivalentes ou si leur ordre peut être modifié. Le fichier généré les inclut avec un commentaire indiquant leur provenance :
migrations.RunPython(
populate_slugs,
reverse_code=migrations.RunPython.noop,
),
Si plusieurs RunPython s’enchaînent dans le squash et manipulent les mêmes données, il faut les réviser manuellement pour éviter les effets de bord. C’est souvent là que le squash demande le plus de travail.
Le flag --no-optimize désactive complètement l’optimiseur quand les opérations sont trop interdépendantes pour être réordonnées sans risque :
python manage.py squashmigrations myapp 0001 0020 --no-optimize
Quand squasher, et quand attendre
Squasher est sans risque quand les migrations à fusionner ne sont pas encore appliquées en production : on fusionne du code de développement, sans contrainte de compatibilité.
La situation se complique quand certaines migrations sont en production et d’autres non. Exemple : la prod est à la migration 0015, et on veut squasher 0001 à 0020. Le fichier squashed couvre 0001 à 0020, mais la prod a encore besoin des originaux 0016 à 0020 pour atteindre l’état final. Django les cherche, les trouve, les applique, puis marque le squashed comme fait. Tout fonctionne, à condition de ne pas supprimer les originaux avant que la prod soit à jour.
Supprimer les fichiers originaux avant l’étape 2 casse les environnements partiellement migrés : Django cherche les migrations manquantes, ne les trouve plus, et lève une erreur.
Impact sur les tests
L’un des bénéfices les plus immédiats est la vitesse de setup des tests. Django rejoue toutes les migrations pour créer la base de test (--keepdb conserve la base entre les runs, mais la première création reste complète). Passer de 80 migrations à 5 ou 6 peut réduire le temps de setup de plusieurs dizaines de secondes selon la complexité du schéma.
Pour mesurer l’impact avant et après :
time python manage.py migrate
squashmigrations est une opération de maintenance, pas une urgence. Le faire trop tard complique le nettoyage ; le faire trop tôt, avant que tous les environnements soient synchronisés, est risqué. Le bon moment : quand une phase de développement se termine et avant que les migrations atteignent la production.
Pour aller plus loin sur l’ORM Django, lire Django select_for_update() : verrouillage de lignes et concurrence.
