Les migrations de base de données Room en 2026 : faire évoluer un schéma sans perdre une seule ligne
Un guide pratique des migrations de base de données Room sur Android — AutoMigration, objets Migration écrits à la main, et comment tester une migration avant que vos utilisateurs ne trouvent le bug.
Toute application local-first finit par avoir besoin d’un schéma qu’elle n’avait pas à la version 1. Une nouvelle colonne, une table renommée, un champ qui était un String et doit devenir un Int. Dès que cela arrive, une annotation négligente vous sépare de la suppression des données de chaque utilisateur à sa prochaine mise à jour. Les migrations Room existent pour éviter cela, et la plupart des douleurs qu’on rencontre viennent du fait de sauter les parties qui semblent optionnelles mais ne le sont pas.
Le piège : fallbackToDestructiveMigration()
Room lève une IllegalStateException dès que le numéro de version de votre @Database augmente sans chemin de migration correspondant. La correction qui apparaît dans chaque réponse Stack Overflow tient en une ligne :
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()
Cela compile, se déploie, et fonctionne parfaitement — en build debug, sur votre propre appareil, là où perdre des données de test ne vous dérange pas. En production, cela signifie : la prochaine fois que vous augmentez la version du schéma, Room supprime toutes les tables et les recrée vides. Pour Granyn, cela veut dire que deux ans d’entrées budgétaires de quelqu’un disparaissent silencieusement lors d’une mise à jour qu’il n’a pas demandée. Pas de boîte de dialogue, pas d’avertissement, pas d’annulation. Cela arrive simplement, silencieusement, la prochaine fois que l’application s’ouvre. Si cette ligne existe en dehors d’une configuration de build debug, traitez-la comme un bug, pas comme une commodité.
AutoMigration couvre plus de cas qu’on ne le pense
Pour les cas courants — ajouter une colonne avec une valeur par défaut, ajouter ou supprimer une table, renommer une colonne — l’@AutoMigration de Room peut générer la migration pour vous à partir de deux instantanés de schéma :
@Database(
version = 2,
entities = [Entry::class],
autoMigrations = [
AutoMigration(from = 1, to = 2)
]
)
abstract class AppDatabase : RoomDatabase()
Cela ne fonctionne que si vous avez activé l’export de schéma (room.schemaLocation dans votre configuration Gradle), afin que Room dispose d’un instantané JSON de chaque version à comparer. Sauter cette configuration et AutoMigration n’a rien à comparer — vous le découvrirez à la compilation, pas à 2h du matin dans un rapport de crash. Pour une colonne renommée ou supprimée, vous avez aussi besoin d’une petite classe de spécification @RenameColumn ou @DeleteColumn indiquant à Room les anciens et nouveaux noms — il ne peut pas déduire l’intention d’un simple diff.
Là où AutoMigration s’arrête
Tout ce qui nécessite de remodeler des données existantes — diviser une colonne en deux, convertir un montant String stocké en centimes sous forme d’Int, remplir un nouveau champ obligatoire à partir d’autres lignes — dépasse le champ d’AutoMigration. Ce n’est pas une limitation à contourner ; c’est le signal qu’il vous faut un vrai objet Migration avec du vrai SQL dedans.
Écrire la migration à la main
Une Migration écrite à la main est simplement une paire de versions et un bloc de SQL dont vous êtes responsable :
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE entries ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'"
)
db.execSQL(
"UPDATE entries SET currency = (SELECT default_currency FROM user_prefs LIMIT 1)"
)
}
}
Enregistrez-la aux côtés des éventuelles AutoMigration via .addMigrations(MIGRATION_2_3) sur le builder de base de données. Le ALTER TABLE de SQLite est limité — pas de suppression de colonne, pas de changement de type de colonne en place — donc tout ce qui va au-delà d’un ajout de colonne demande généralement la danse classique en trois temps : créer une nouvelle table avec la forme voulue, copier les données avec INSERT INTO ... SELECT, supprimer l’ancienne table, renommer la nouvelle. C’est plus de SQL qu’on aimerait en écrire, et c’est exactement le SQL qui doit être correct, puisqu’il s’exécute une fois, silencieusement, sur chaque appareil au prochain lancement.
Testez la migration, pas seulement le schéma final
L’erreur qui génère le plus de bugs ici n’est pas dans le SQL — c’est de ne jamais l’exécuter contre une vraie base de données vieillie. Le MigrationTestHelper de Room existe exactement pour ça :
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
fun migrate2To3_preservesExistingRows() {
helper.createDatabase(TEST_DB, 2).apply {
execSQL("INSERT INTO entries (id, amount) VALUES (1, 4200)")
close()
}
helper.runMigrationsAndValidate(TEST_DB, 3, true, MIGRATION_2_3)
}
Cela construit une base de données en version 2, l’alimente comme le ferait l’appareil d’un vrai utilisateur, puis exécute votre migration dessus et valide que le schéma résultant correspond à ce que Room attend. Cela capture les deux modes d’échec qui se produisent réellement en production : une migration qui fonctionne sur une base vide mais échoue sur une base avec des lignes, et une migration dont le SQL est correct mais dont le schéma résultant ne correspond pas à vos définitions d’entités (Room vérifie cela strictement, jusqu’à l’ordre des colonnes et les valeurs par défaut).
La checklist
Avant qu’une augmentation de version de schéma ne soit déployée : l’export de schéma est activé, chaque saut de version a soit une AutoMigration soit une Migration écrite à la main enregistrée, fallbackToDestructiveMigration() n’apparaît nulle part en dehors du code de test, et au moins un test MigrationTestHelper alimente de vraies lignes et vérifie qu’elles survivent. Rien de tout cela n’est un travail excitant. C’est aussi la différence entre un changement de schéma que personne ne remarque et une boîte de support pleine de « mes données ont disparu » le lendemain d’une sortie.
// À lire aussi
D’autres notes du journal
Jetpack Glance en 2026 : construire un widget d'écran d'accueil qui ne ment jamais sur vos données
Un guide pratique des widgets Jetpack Glance sur Android — état, actions de clic, et le piège du quota de mise à jour qui affiche des données périmées.
Construire le calendrier d'observance d'OldSchool : transformer des prises en un jour fiable
Un calendrier d'observance mensuel ressemble à un simple point coloré par jour. Voici le modèle d'événements, les règles de statut journalier et le calcul de ponctualité, sur l'appareil.
Construire Subly : les calculs de calendrier derrière les dates de renouvellement d'abonnement
Prédire la prochaine date de prélèvement d'un abonnement semble trivial jusqu'à ce qu'on tombe sur la facturation en fin de mois, les années bissextiles et les conversions d'essai. Voici comment Subly y arrive, sur l'appareil.