SwiftData Migrations: Lightweight vs Custom, And When You Don't Need a V2
TITLE : Migrations SwiftData : Lightweight vs personnalisée, et quand vous n’avez pas besoin d’une V2
DESCRIPTION : Le modèle de migration de SwiftData utilise VersionedSchema, MigrationStage et SchemaMigrationPlan. La plupart des changements de schéma n’ont pas besoin d’une V2 ; les cas qui en ont besoin, en ont vraiment besoin.
BODY:
La gestion des migrations de schéma par 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 gérerait 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 semblait correct et compilait sans erreur. Le véritable modèle de migration du framework repose sur trois éléments (VersionedSchema, MigrationStage, SchemaMigrationPlan) et trois types de migration (lightweight automatique, lightweight déclarée, personnalisée)1. La plupart des changements de schéma sont automatiques. Certains nécessitent une étape lightweight déclarée. Une petite minorité nécessite une étape personnalisée avec des closures willMigrate et didMigrate.
Cet article confronte le modèle de migration à la documentation d’Apple, identifie les cas que chaque type de migration prend en charge et couvre la nouvelle prise en charge de l’héritage de classe sous iOS 26. Le cadrage est « ce que je déclare versus ce que SwiftData gère pour moi », car cette décision détermine si la migration s’expédie proprement ou plante au premier lancement.
TL;DR
- Les migrations SwiftData composent trois protocoles :
VersionedSchema(un instantané des types de modèles à une version),MigrationStage(une transition unique fromVersion-vers-toVersion avec les cas.lightweightou.custom), etSchemaMigrationPlan(liste ordonnée d’étapes)1. - L’ajout d’une nouvelle propriété
@Modelavec une valeur par défaut en ligne (var foo: Bool = false) ne nécessite pas un nouveauVersionedSchema. SwiftData gère l’ajout automatiquement comme une migration lightweight. En déclarer une V2 pour cela produit des crashs « Duplicate version checksums across stages detected ». - Les migrations lightweight gèrent : ajouter/renommer/supprimer des entités, attributs, relations ; changer le type des relations ; déclarer
@Attribute(originalName:)pour suivre les renommages ; spécifier 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:)) gèrent les transformations de données : diviser 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 classe pour les types
@Model2. Les schémas adoptant l’héritage passent à une nouvelle version avec une étape lightweight depuis la version flat-model précédente.
Le modèle en trois pièces
Une migration SwiftData se compose de trois pièces.
VersionedSchema
Un instantané des types de modèles à une version de schéma spécifique1. 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@Modeldans 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 espace de noms ses classes de modèle pour que plusieurs schémas portant le même nom de modèle puissent coexister dans la base de code pendant une migration.
MigrationStage
Une transition unique entre deux types VersionedSchema3. Deux cas :
.lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type). Déclare une transition que SwiftData gère 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éclare 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(...)
)
SwiftData lit la version actuelle du schéma du magasin persistant lors de la création du conteneur, parcourt les étapes du plan depuis cette version jusqu’à la version actuelle, et applique chaque étape dans l’ordre.
Ce que les migrations lightweight gèrent automatiquement
La plupart des changements de schéma ne nécessitent 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). De nouveaux types apparaissent lorsque leur
VersionedSchemaest l’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 associe l’ancien au nouveau. - Changer le type d’une relation. Un-vers-plusieurs, plusieurs-vers-plusieurs, etc.
- Spécifier des règles de suppression.
@Relationship(deleteRule: .cascade)et autres ajouts similaires sont lightweight.
Pour les changements de cette liste, le bon motif est de ne pas déclarer de nouveau VersionedSchema du tout si les types de modèle sont par ailleurs inchangés. SwiftData effectue automatiquement la migration lightweight contre le schéma existant.
Le piège : ajouter un champ ne nécessite 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 faisant référence aux mêmes types de modèles 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 au même checksum (les types de modèles n’ont pas changé d’une manière que SwiftData remarque comme différente).
Le bon motif : laisser le VersionedSchema existant tel quel, ajouter la nouvelle propriété au modèle avec une valeur par défaut en ligne, et laisser la migration lightweight automatique de SwiftData s’en occuper. Pas de MigrationPlan, pas de MigrationStage, pas de 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
}
}
La modification var isFavorite: Bool = false est expédiée 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 lightweight (une transformation de données, une scission de modèle, une restructuration d’héritage qui exige une logique personnalisée). Dans ces cas-là, la V2 est réelle et un SchemaMigrationPlan orchestre la transition.
Quand les migrations personnalisées sont requises
Les migrations personnalisées justifient leur complexité dans trois cas :
1. Diviser 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 contre le contexte du nouveau schéma, donc les nouveaux champs sont accessibles. L’ancien fullName peut nécessiter d’être différé pour suppression 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é rétroactivement 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 assigner les tags depuis les anciennes données.
Le motif général : lightweight quand la forme du schéma change ; personnalisée quand 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 n’applique la migration de schéma. Le contexte de modèle reçu par la closure est celui de l’ancien schéma. Utilisez-la pour capturer des données, les dénormaliser ou préparer un état auxiliaire avant que le schéma ne change en dessous.
didMigrate s’exécute après la migration de schéma. Le contexte de modèle est celui du nouveau schéma. Utilisez-la pour renseigner rétroactivement les nouveaux champs, calculer des données dérivées ou finaliser la migration.
L’une ou l’autre des closures 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 sauvegarder. La closure est throwing ; les erreurs se propagent hors de la migration et l’interrompent.
iOS 26 : héritage de classe pour @Model
iOS 26 introduit l’héritage de classe 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 lightweight depuis la version flat-model 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 vivent sur le parent ; les spécificités vivent sur les enfants.
Tester les migrations
Une migration qui compile n’est pas une migration qui s’expédie. Trois motifs de test à exécuter avant la sortie :
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 au format de production (ou générez des données V1 synthétiques via des tests), ouvrez-la avec le conteneur compatible V2 et vérifiez que les données migrent correctement. Le test attrape les bugs de migration personnalisée que le vérificateur de types ne peut pas détecter.
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 de l’application et vérifiez qu’elle se lance sans planter. Le test attrape le piège des « Duplicate version checksums » et autres erreurs de déclaration similaires.
3. Récupération en cas d’échec de migration. Que se passe-t-il si la migration lance 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 devrait 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).
L’article du cluster Single Source of Truth pattern couvre la question connexe de ce qui se passe lorsqu’un magasin SwiftData est remplacé via une synchronisation inter-processus. Les migrations sont l’analogue d’évolution locale de ce motif.
Modes d’échec courants
Trois motifs issus des journaux d’échec SwiftData :
Déclarer une V2 pour un changement que SwiftData gérerait automatiquement. Le crash « Duplicate version checksums ». Correction : 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.
Code de migration personnalisée qui ne sauvegarde 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 réexécute à chaque lancement (parce que la migration apparaît inachevée). Correction : chaque closure qui modifie des données doit appeler 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. Correction : déclarez @Attribute(originalName: "oldName") var newName: ... pour que SwiftData mappe 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. Ajout de propriétés avec valeurs par défaut en ligne, suppression de champs inutilisés, renommage avec@Attribute(originalName:). Tout cela est lightweight 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 du schéma. Les closureswillMigrateetdidMigrateservent à du 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 les étapes lightweight. -
Testez les migrations avec de vraies données V1, pas seulement des données de test synthétiques. Les migrations qui passent sur des allers-retours synthétiques peuvent toujours échouer sur des données au format de production avec des cas limites (champs nullables que le schéma ne couvrait pas, grands jeux de données qui dépassent le délai d’attente, 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 : App Intents typés ; serveurs TERM_17 ; la question du routage ; Foundation Models ; la distinction runtime vs outillage TERM_23 ; trois surfaces ; le single source of truth pattern ; Two TERM_17 Servers ; hooks pour le développement Apple ; Live Activities ; le contrat runtime watchOS ; les internes de SwiftUI ; le modèle mental spatial de RealityKit ; la discipline de schéma SwiftData ; les motifs Liquid Glass ; l’expédition multi-plateforme ; la matrice des plateformes ; le framework Vision ; le vocabulaire Symbol Effects ; l’inférence Core ML ; Writing Tools TERM_18 ; Swift Testing ; Privacy Manifest ; l’accessibilité comme plateforme ; la typographie SF Pro ; les motifs spatiaux visionOS ; le framework Speech ; ce que je refuse d’écrire. Le hub se trouve à la série Apple Ecosystem. Pour un contexte plus large iOS-avec-agents-IA, consultez le guide iOS Agent Development.
FAQ
Ai-je toujours besoin d’un SchemaMigrationPlan ?
Non. Les applications avec une seule version de schéma (la version initiale, ou les applications qui n’ont jamais effectué que des changements lightweight) 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 version explicite).
Comment savoir si mon changement est lightweight ?
La liste éligible au lightweight d’Apple1 : ajouter des entités/attributs/relations, les supprimer, renommer avec @Attribute(originalName:), modifier la cardinalité des relations, spécifier des règles de suppression. Si le changement entre dans l’une de ces catégories 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 nécessite une transformation de données (calcul, division, déplacement de données), il est personnalisé.
willMigrate et didMigrate peuvent-ils être tous deux définis ?
Oui. Les deux closures sont individuellement optionnelles mais peuvent être toutes deux fournies. willMigrate s’exécute contre le contexte de l’ancien schéma avant que SwiftData ne migre ; didMigrate s’exécute contre 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 lance 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 façon dont le développeur gère l’erreur : certaines apps affichent une UI de récupération, certaines tentent une restauration depuis une sauvegarde, certaines suppriment le magasin corrompu et repartent à 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, peuplez-le avec des données V1, puis ouvrez-le avec le nouveau conteneur qui inclut 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 les résultats les plus réalistes, utilisez une copie d’une véritable base de données au format de production.
L’héritage de classe d’iOS 26 fonctionne-t-il avec les schémas existants ?
Oui, avec une migration lightweight. Les apps 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 flat de la classe parent demeurent, et les propriétés spécifiques à la sous-classe sont ajoutées avec des valeurs par défaut en ligne. La migration lightweight de SwiftData gère le changement structurel.
Références
-
Documentation Apple Developer : références des protocoles
VersionedSchemaetSchemaMigrationPlan. Le modèle de migration. Voir aussi le guide associé 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 291). L’introduction de l’héritage de classe SwiftData dans iOS 26. ↩↩
-
Documentation Apple Developer :
MigrationStageavec les cas.lightweight(fromVersion:toVersion:)et.custom(fromVersion:toVersion:willMigrate:didMigrate:). ↩ -
Documentation Apple Developer :
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)pour la signature du cas. Les sémantiques willMigrate-s’exécute-contre-l’ancien-contexte et didMigrate-s’exécute-contre-le-nouveau-contexte sont documentées dans la session WWDC 2025 291 SwiftData: Dive into inheritance and schema migration, la même session référencée pour l’ajout de l’héritage iOS 26. ↩