Migrations SwiftData : légères vs personnalisées, et quand vous n'avez pas besoin d'un V2
La gestion des migrations de schéma de SwiftData constitue une amélioration structurelle par rapport à celle de Core Data, avec un piège dans lequel les équipes ne cessent de tomber : déclarer un nouveau VersionedSchema pour des changements que SwiftData prendrait en charge automatiquement via des valeurs par défaut en ligne. Le résultat est un crash « Duplicate version checksums across stages detected » sur l’appareil, alors même que le code paraissait correct et se compilait proprement. Le véritable modèle de migration du framework s’appuie sur trois éléments (VersionedSchema, MigrationStage, SchemaMigrationPlan) et trois types de migration (légère automatique, légère déclarée, personnalisée)1. La plupart des changements de schéma sont automatiques. Certains nécessitent une étape légère déclarée. Une petite minorité réclame une étape personnalisée avec des closures willMigrate et didMigrate.
L’article confronte le modèle de migration à la documentation d’Apple, nomme les cas que chaque type de migration prend en charge et couvre la nouvelle prise en charge de l’héritage de classes dans iOS 26. La grille de lecture est « qu’est-ce que je déclare par rapport à ce que SwiftData gère pour moi », car cette décision détermine si la migration se déploie proprement ou plante au premier lancement.
TL;DR
- Les migrations SwiftData composent trois protocoles :
VersionedSchema(un instantané des types de modèle à une version donnée),MigrationStage(une transition unique de fromVersion à toVersion avec les cas.lightweightou.custom) etSchemaMigrationPlan(liste ordonnée d’étapes)1. - Ajouter une nouvelle propriété
@Modelavec une valeur par défaut en ligne (var foo: Bool = false) n’exige pas de nouveauVersionedSchema. SwiftData prend en charge cet ajout automatiquement sous forme de migration légère. Déclarer un V2 pour cela produit des crashs « Duplicate version checksums across stages detected ». - Les migrations légères prennent en charge : l’ajout, le renommage et la suppression d’entités, d’attributs et de relations ; le changement de type de relation ; la déclaration de
@Attribute(originalName:)pour suivre les renommages ; la spécification des règles de suppression. La plupart des changements de schéma entrent dans cette catégorie. - Les migrations personnalisées (
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)) prennent en charge les transformations de données : scinder une colonne en deux, calculer des champs dérivés, déplacer des données entre modèles.willMigratedispose de l’ancien contexte ;didMigratedispose du nouveau contexte. - iOS 26 ajoute l’héritage de classes pour les types
@Model2. Les schémas adoptant l’héritage passent à une nouvelle version avec une étape légère depuis la version à modèle plat précédente.
Le modèle en trois éléments
Une migration SwiftData se compose de trois éléments.
VersionedSchema
Un instantané des types de modèle à une version de schéma précise1. Le protocole exige :
static var versionIdentifier: Schema.Version. Un triplet de version sémantique (Schema.Version(1, 0, 0)).static var models: [any PersistentModel.Type]. Le tableau des types@Modelprésents dans cette version.
enum SchemaV1: VersionedSchema {
static let versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
final class Item {
var name: String
var createdAt: Date
init(name: String, createdAt: Date) {
self.name = name
self.createdAt = createdAt
}
}
}
Le motif enum-avec-types-imbriqués est la convention. Chaque VersionedSchema place ses classes de modèle dans un espace de noms afin que plusieurs schémas portant le même nom de modèle puissent coexister dans le code durant une migration.
MigrationStage
Une transition unique entre deux types VersionedSchema3. Deux cas :
.lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type). Déclarez une transition que SwiftData prend en charge sans code applicatif. Les paramètres sont les typesVersionedSchemaeux-mêmes (par exempleSchemaV1.self), et non des valeursSchema.Versionbrutes..custom(fromVersion:toVersion:willMigrate:didMigrate:). Déclarez une transition avec du code qui s’exécute avant et/ou après la migration des données. Mêmes types de paramètres que.lightweightpour les arguments de version.
SchemaMigrationPlan
La liste ordonnée des étapes qui fait passer le schéma de n’importe quelle version antérieure à la version actuelle1.
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
static let migrateV2toV3 = MigrationStage.custom(
fromVersion: SchemaV2.self,
toVersion: SchemaV3.self,
willMigrate: { context in
// Pre-migration: read old data, prepare it
try context.save()
},
didMigrate: { context in
// Post-migration: backfill new fields
let descriptor = FetchDescriptor<SchemaV3.Item>()
let items = try context.fetch(descriptor)
for item in items {
item.computedField = computeFromExisting(item)
}
try context.save()
}
)
}
Le ModelContainer est configuré à la fois avec le schéma actuel et le plan de migration :
let container = try ModelContainer(
for: SchemaV3.Item.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(...)
)
À la création du conteneur, SwiftData lit la version de schéma actuelle du magasin persistant, parcourt les étapes du plan depuis cette version jusqu’à la version actuelle, et applique chaque étape dans l’ordre.
Ce que les migrations légères gèrent automatiquement
La plupart des changements de schéma n’exigent pas d’étape personnalisée1 :
- Ajouter un attribut avec une valeur par défaut.
var foo: Bool = falsesur un@Modelexistant est automatique. - Ajouter une nouvelle entité (classe de modèle). Les nouveaux types apparaissent lorsque leur
VersionedSchemaest le schéma actuel ; les données existantes sont préservées. - Supprimer un attribut ou une entité. SwiftData supprime la colonne ou la table.
- Renommer un attribut ou une entité. Ajoutez
@Attribute(originalName: "oldName")à la propriété pour préserver les données ; SwiftData fait correspondre l’ancien au nouveau. - Changer un type de relation. Un-vers-plusieurs, plusieurs-vers-plusieurs, etc.
- Spécifier des règles de suppression.
@Relationship(deleteRule: .cascade)et les ajouts similaires sont légers.
Pour les changements de cette liste, le bon motif consiste à ne pas déclarer de nouveau VersionedSchema du tout si les types de modèle sont par ailleurs inchangés. SwiftData effectue la migration légère automatiquement sur le schéma existant.
Le piège : ajouter un champ n’exige pas de V2
L’erreur de migration SwiftData la plus courante : un développeur ajoute une nouvelle propriété avec une valeur par défaut en ligne (var foo: Bool = false), puis déclare un SchemaV2 référençant les mêmes types de modèle que SchemaV1. La compilation est propre. Le premier lancement sur un appareil contenant des données V1 existantes plante avec Duplicate version checksums across stages detected, parce que SchemaV1 et SchemaV2 se résolvent tous deux vers le même checksum (les types de modèle n’ont pas changé d’une manière que SwiftData remarque comme différente).
Le motif correct : laissez le VersionedSchema existant tel quel, ajoutez la nouvelle propriété au modèle avec une valeur par défaut en ligne, et laissez la migration légère automatique de SwiftData s’en charger. Aucun MigrationPlan, aucun MigrationStage, aucun V2 nécessaire.
// V1 schema
enum SchemaV1: VersionedSchema {
@Model
final class Item {
var name: String
// BEFORE: just these two properties
var createdAt: Date
// AFTER: add a third with inline default
var isFavorite: Bool = false // Lightweight, automatic
}
}
Le changement var isFavorite: Bool = false se déploie sans aucune déclaration de MigrationStage. L’initialiseur de ModelContainer qui ne passe pas migrationPlan: fonctionne :
let container = try ModelContainer(
for: SchemaV1.Item.self,
configurations: ModelConfiguration(...)
)
Le schéma V2 n’est requis que lorsqu’un changement ne peut pas être léger (une transformation de données, une scission de modèle, une restructuration d’héritage qui exige une logique personnalisée). Dans ces cas, le V2 est réel et un SchemaMigrationPlan orchestre la transition.
Quand des migrations personnalisées sont requises
Les migrations personnalisées justifient leur complexité dans trois cas.
1. Scinder un champ en plusieurs. Un champ String qui contient "Last, First" devient deux champs, firstName et lastName. La migration doit lire l’ancienne valeur, l’analyser et écrire les nouveaux champs.
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
let descriptor = FetchDescriptor<SchemaV2.Person>()
let people = try context.fetch(descriptor)
for person in people {
let parts = person.fullName.split(separator: ", ", maxSplits: 1)
person.lastName = String(parts.first ?? "")
person.firstName = String(parts.dropFirst().first ?? "")
}
try context.save()
}
)
La closure didMigrate s’exécute sur le contexte du nouveau schéma, de sorte que les nouveaux champs sont accessibles. L’ancien fullName devra peut-être être conservé pour une suppression différée jusqu’à ce que les nouveaux champs soient renseignés ; le nettoyage est une étape de suivi V2-vers-V3.
2. Calculer des champs dérivés. Un nouvel @Attribute qui dépend de données existantes doit être renseigné au moment de la migration.
3. Déplacer des données entre modèles. Une réorganisation où les données de Item sont réparties entre Item et un nouveau modèle Tag exige une logique personnalisée pour attribuer les tags à partir des anciennes données.
Le motif général : léger lorsque la forme du schéma change ; personnalisé lorsque la forme des données change.
willMigrate vs didMigrate
Les étapes personnalisées comportent deux closures, appelées à des moments différents4.
willMigrate s’exécute avant que SwiftData applique la migration de schéma. Le contexte de modèle que reçoit la closure est celui de l’ancien schéma. Utilisez-le pour capturer des données, les dénormaliser ou préparer un état auxiliaire avant que le schéma ne change sous vos pieds.
didMigrate s’exécute après la migration de schéma. Le contexte de modèle est celui du nouveau schéma. Utilisez-le pour renseigner les nouveaux champs, calculer des données dérivées ou finaliser la migration.
L’une ou l’autre closure peut être nil si elle n’est pas nécessaire. La plupart des migrations personnalisées n’utilisent que didMigrate ; willMigrate est utile lorsque la migration doit lire d’anciennes données qui ne seront plus accessibles après le changement de schéma.
La closure reçoit un ModelContext et peut récupérer, modifier et enregistrer des données. La closure peut lever une erreur ; les erreurs se propagent hors de la migration et l’interrompent.
iOS 26 : héritage de classes pour @Model
iOS 26 introduit l’héritage de classes pour les modèles SwiftData2. Les modèles peuvent désormais avoir des relations parent-enfant :
@Model
class Vehicle {
var make: String
var year: Int
init(make: String, year: Int) {
self.make = make
self.year = year
}
}
@Model
final class Car: Vehicle {
var doorCount: Int
init(make: String, year: Int, doorCount: Int) {
self.doorCount = doorCount
super.init(make: make, year: year)
}
}
Les schémas adoptant l’héritage passent à une nouvelle version avec une étape de migration légère depuis la version à modèle plat précédente. La transition est automatique si l’héritage préserve les propriétés existantes ; les nouveaux champs sur la sous-classe suivent le motif standard de valeur par défaut en ligne.
Le motif convient aux cas où plusieurs types @Model partagent des caractéristiques : un parent Vehicle avec des enfants Car, Truck, Motorcycle ; un parent Account avec des enfants CheckingAccount, SavingsAccount. Les propriétés partagées résident sur le parent ; les spécificités résident sur les enfants.
Tester les migrations
Une migration qui compile n’est pas une migration qui se déploie. Trois motifs de test à exécuter avant la mise en production.
1. Test aller-retour sur une copie de la base de données de production. Récupérez une base de données récente à la forme de production (ou générez des données V1 synthétiques via des tests), ouvrez-la avec le conteneur conscient du V2, et vérifiez que les données migrent correctement. Le test détecte les bugs de migration personnalisée que le vérificateur de types ne peut pas attraper.
2. L’ancienne version se lance toujours. Compilez la version précédente de l’application, exécutez-la une fois pour produire des données V1, puis compilez la nouvelle version et vérifiez qu’elle se lance sans planter. Le test détecte le piège « Duplicate version checksums » et les erreurs de déclaration similaires.
3. Récupération après échec de migration. Que se passe-t-il si la migration lève une erreur ? Le comportement de SwiftData dépend de la configuration du conteneur ; pour les applications de production, une erreur de migration non gérée ne doit pas supprimer silencieusement les données utilisateur. Testez explicitement le chemin d’échec et décidez ce que fait l’application (rollback, invite, récupération depuis une sauvegarde).
Le post Source unique de vérité du cluster traite de la question connexe de ce qui se produit lorsqu’un magasin SwiftData est remplacé par une synchronisation inter-processus. Les migrations sont l’analogue, à l’échelle de l’évolution locale, de ce motif.
Déployer les migrations entre processus et faire remonter la progression
Deux détails opérationnels que la documentation ne met pas en avant, mais que l’équipe SwiftData a soulignés à la WWDC 20265 : où s’exécutent les migrations lorsqu’une application possède des widgets ou des extensions, et comment piloter une UI de progression lorsque l’une d’elles s’exécute.
Un seul processus est propriétaire de la migration. Les widgets et les extensions ne disposent pas des mêmes ressources d’exécution que l’application principale, de sorte qu’ils ne peuvent pas effectuer une migration en toute sécurité. La recommandation est de tenir le SchemaMigrationPlan1 entièrement à l’écart des cibles widget et extension, et de ne jamais migrer depuis celles-ci. Choisissez un processus, normalement l’application principale, comme propriétaire de la base de données. Si un widget ouvre le conteneur et que le magasin sur disque est à un schéma non versionné (plus ancien), l’ouverture échoue. Traitez cette erreur comme le signal qu’une migration est requise : affichez une UI qui demande à l’utilisateur d’ouvrir l’application principale, laissez l’application effectuer la migration, et faites-lui écrire la version de schéma migrée dans un UserDefault partagé. Le widget lit cette valeur la fois suivante et ouvre le conteneur à la version vers laquelle l’application a déjà migré. Le motif maintient un unique rédacteur aux commandes et évite que deux processus se disputent l’évolution du même fichier.
La progression se calcule à partir du nombre d’étapes, et non du temps réel. SwiftData n’expose aucune API dédiée à la progression de migration5. Pour piloter un indicateur de progression, comptez le nombre total d’étapes de migration personnalisées dans le plan et surchargez le gestionnaire didMigrate4 de chaque étape afin que chacune signale sa position, « étape N sur M ». Le nombre reflète les étapes terminées, et non le temps écoulé, de sorte que la barre avance par paliers discrets plutôt que de façon continue. La décision de conception qui l’accompagne porte sur ce que l’application affiche pendant la migration : un simple spinner se lit comme un blocage et les utilisateurs s’en détournent. Gardez l’application partiellement utilisable là où les données le permettent, ou décrivez au minimum ce que chaque étape ajoute (les nouvelles fonctionnalités que la migration débloque), afin que l’attente se lise comme une progression vers quelque chose plutôt que comme un temps mort.
Modes de défaillance courants
Trois motifs tirés des journaux de défaillances SwiftData.
Déclarer un V2 pour un changement que SwiftData gérerait automatiquement. Le crash « Duplicate version checksums ». Correctif : ne déclarez pas de nouveau schéma pour les ajouts de propriétés à valeur par défaut en ligne ; laissez SwiftData les gérer automatiquement.
Du code de migration personnalisée qui n’enregistre pas. Une closure didMigrate qui modifie des entités mais n’appelle pas context.save() produit une migration qui s’exécute une fois, abandonne son travail et se relance à chaque lancement (parce que la migration paraît inachevée). Correctif : toute closure qui modifie des données doit faire try context.save() avant de retourner.
Renommer une propriété sans @Attribute(originalName:). SwiftData traite la nouvelle propriété comme nouvelle et l’ancienne comme supprimée ; les données existantes sur l’ancienne propriété sont perdues. Correctif : déclarez @Attribute(originalName: "oldName") var newName: ... afin que SwiftData fasse transiter les données à travers le renommage.
Ce que ce motif signifie pour les apps iOS 26+
Trois enseignements.
-
Par défaut, pas d’échelle de
VersionedSchema. Ajouter des propriétés avec des valeurs par défaut en ligne, supprimer des champs inutilisés, renommer avec@Attribute(originalName:). Tout cela est léger et automatique. L’échelle deVersionedSchemaest réservée aux changements que SwiftData ne peut véritablement pas gérer automatiquement (transformations de données, logique personnalisée, restructurations d’héritage). -
Utilisez
MigrationStage.custompour les transformations de données, pas pour les changements de forme de schéma. Les closureswillMigrateetdidMigratesont destinées au code qui opère sur les données, et non à déclarer que le schéma a changé. Les changements de forme de schéma passent par des étapes légères. -
Testez les migrations avec de vraies données V1, pas seulement des données de test synthétiques. Les migrations qui réussissent sur des allers-retours synthétiques peuvent quand même échouer sur des données à la forme de production avec des cas limites (champs nullables que le schéma ne couvrait pas, grands jeux de données qui atteignent le timeout, etc.). Le coût des tests est faible ; le coût d’un crash de migration au premier lancement est réel.
Le cluster Apple Ecosystem complet : les App Intents typés ; les serveurs MCP ; la question du routage ; les Foundation Models ; la distinction LLM d’exécution vs LLM d’outillage ; les trois surfaces ; le motif de source unique de vérité ; les deux serveurs MCP ; les hooks pour le développement Apple ; les Live Activities ; le runtime watchOS ; les rouages internes de SwiftUI ; le modèle mental spatial de RealityKit ; la discipline de schéma SwiftData ; les motifs Liquid Glass ; le déploiement multiplateforme ; la matrice des plateformes ; le framework Vision ; les Symbol Effects ; l’inférence Core ML ; l’API Writing Tools ; Swift Testing ; le Privacy Manifest ; l’accessibilité en tant que plateforme ; la typographie SF Pro ; les motifs spatiaux visionOS ; le framework Speech ; ce sur quoi je refuse d’écrire. Le hub se trouve sur la série Apple Ecosystem. Pour un contexte plus large sur iOS avec des agents IA, consultez le guide de développement d’agents iOS.
FAQ
Ai-je toujours besoin d’un SchemaMigrationPlan ?
Non. Les applications avec une version de schéma unique (la version initiale, ou les applications qui n’ont jamais fait que des changements légers) n’ont pas besoin d’un SchemaMigrationPlan. L’initialiseur de ModelContainer accepte directement les modèles du schéma. Le paramètre migrationPlan: devient nécessaire la première fois qu’une étape de migration personnalisée est déclarée (ou la première fois que le développeur souhaite déclarer une échelle de versions explicite).
Comment savoir si mon changement est léger ?
La liste des cas éligibles au léger établie par Apple1 : ajouter des entités, attributs et relations, les supprimer, renommer avec @Attribute(originalName:), changer la cardinalité d’une relation, spécifier des règles de suppression. Si le changement correspond à l’un de ces cas et que la structure de la classe de modèle est par ailleurs inchangée, la migration est automatique et aucune échelle de VersionedSchema n’est requise. Si le changement exige une transformation de données (calculer, scinder, déplacer des données), il est personnalisé.
willMigrate et didMigrate peuvent-ils être définis tous les deux ?
Oui. Les deux closures sont individuellement facultatives mais peuvent être fournies toutes les deux. willMigrate s’exécute sur le contexte de l’ancien schéma avant que SwiftData ne migre ; didMigrate s’exécute sur le contexte du nouveau schéma après. Les deux couvrent respectivement la préparation et la finalisation.
Que se passe-t-il si une migration lève une erreur ?
L’erreur se propage hors de l’initialisation du ModelContainer. Le conteneur ne parvient pas à s’ouvrir. Le comportement de l’application dépend de la manière dont le développeur gère l’erreur : certaines applications affichent une UI de récupération, certaines tentent de restaurer depuis une sauvegarde, certaines suppriment le magasin corrompu et repartent de zéro. SwiftData ne supprime pas silencieusement les données utilisateur en cas d’échec de migration ; l’échec est à la charge de l’application.
Comment tester une migration sans affecter les données de production ?
Construisez une cible de test qui crée un ModelContainer pointant vers une URL de fichier temporaire, la peuple avec des données V1, puis l’ouvre avec le nouveau conteneur incluant le plan de migration. Vérifiez que les données migrées correspondent aux attentes. Le motif fonctionne aussi bien dans les tests unitaires que d’intégration ; pour des résultats les plus réalistes, utilisez une copie d’une véritable base de données à la forme de production.
L’héritage de classes d’iOS 26 fonctionne-t-il avec les schémas existants ?
Oui, avec une migration légère. Les applications qui adoptent l’héritage passent à une nouvelle version de schéma (par exemple V4) et déclarent un MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self). Les propriétés plates de la classe parente subsistent, et les propriétés spécifiques à la sous-classe sont ajoutées avec des valeurs par défaut en ligne. La migration légère de SwiftData prend en charge le changement structurel.
Références
-
Documentation pour développeurs Apple : références des protocoles
VersionedSchemaetSchemaMigrationPlan. Le modèle de migration. Voir aussi le guide connexe Adopting SwiftData for a Core Data app pour le récit complet de l’évolution de schéma. ↩↩↩↩↩↩↩↩ -
Apple Developer : SwiftData: Dive into inheritance and schema migration (session WWDC 2025 numéro 291). L’introduction de l’héritage de classes SwiftData dans iOS 26. ↩↩
-
Documentation pour développeurs Apple :
MigrationStageavec les cas.lightweight(fromVersion:toVersion:)et.custom(fromVersion:toVersion:willMigrate:didMigrate:). ↩ -
Documentation pour développeurs Apple :
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)pour la signature du cas. La sémantique selon laquelle willMigrate s’exécute sur l’ancien contexte et didMigrate sur le nouveau est documentée dans la session WWDC 2025 numéro 291 SwiftData: Dive into inheritance and schema migration, la même session référencée pour l’ajout de l’héritage dans iOS 26. ↩↩↩ -
WWDC 2026 SwiftData Group Lab (session 8017). Paraphrasé à partir d’un enregistrement transcrit localement du SwiftData Group Lab de la WWDC 2026 ; Apple ne publie pas de sous-titres officiels pour les labs. Le verrouillage de la migration pour les widgets et extensions (un seul processus est propriétaire de la migration, le chemin d’erreur est le signal de migration, la version migrée est stockée dans un
UserDefault) et la technique de progression fondée sur le nombre d’étapes (surcharger le gestionnairedidMigratede chaque étape pour signaler l’étape N sur M, puisqu’aucune API de progression dédiée n’existe) ont été décrits par le panel d’ingénierie SwiftData. Les symbolesSchemaMigrationPlanetdidMigratedeMigrationStage.customsont confirmés par rapport à la documentation pour développeurs Apple citée en 1 et 4 ; l’absence d’API de progression dédiée reflète le cadrage du panel lui-même durant le lab. ↩↩