Le vrai coût de SwiftData, c'est la discipline du schéma
Le ShoppingItem de Get Bananas est l’exemple canonique de l’importance de la discipline du schéma dans SwiftData. Le schéma d’origine n’incluait pas d’horodatage lastModified ; l’ajouter par la suite a exigé une forme de migration spécifique, car des données existantes se trouvaient déjà sur disque, et le champ a été rendu optionnel précisément pour corriger un plantage de migration survenu lorsqu’il avait été ajouté la première fois en non optionnel.1
L’API de SwiftData se résume à deux macros. @Model sur une classe en fait un type persistant. @Attribute(.unique) sur une propriété lui confère une contrainte d’unicité. Le framework masque la gestion de la pile de Core Data, la chorégraphie des value-transformers et le boilerplate de NSManagedObjectContext. Ce que le framework ne masque pas, c’est la migration de schéma ; il la rend simplement déclarative plutôt qu’impérative. Le prix à payer quand on néglige les migrations, c’est le bug qui efface les données d’un utilisateur lors d’une mise à jour de routine.
La thèse : SwiftData coûte peu au démarrage et coûte cher quand on migre à la va-vite. La discipline tient au nommage, à l’optionalité et à VersionedSchema dès le premier jour, pas le jour où vous comprenez que vous auriez dû.
En bref
- La macro
@Modeltransforme une classe en type SwiftData persistant. Le framework génère le schéma à la compilation à partir des déclarations de propriétés. - Ajouter une nouvelle propriété optionnelle est une migration sans effort : la migration légère de SwiftData s’en charge. Ajouter une propriété non optionnelle à un schéma existant exige un
VersionedSchemaaccompagné d’unMigrationPlanqui indique au framework comment remplir le nouveau champ pour les lignes existantes. - Faire l’économie de
VersionedSchemadès le premier jour se paie ainsi : tout changement de schéma non trivial en v2 risque de faire disparaître la base de données d’un utilisateur, car le chemin léger est conservateur et abandonne dès qu’il ne peut pas inférer la migration. @Attribute(.unique)est le bon outil pour les clés naturelles (unUUIDque vous avez généré, un identifiant externe que vous avez importé).@Relationshipest le bon outil pour les références parent/enfant. Les deux sont des macros qui génèrent la tuyauterie Core Data adéquate sous le capot.2
Ce que fait réellement @Model
Un type SwiftData est une classe Swift à laquelle on applique la macro @Model. Le ShoppingItem de Get Bananas en montre la forme canonique :
import Foundation
import SwiftData
@Model
final class ShoppingItem {
@Attribute(.unique) var id: UUID
var name: String
var amount: String
var section: String
var isChecked: Bool
var isOptional: Bool
var sortOrder: Int
var lastModified: Date?
init(id: UUID = UUID(), name: String, amount: String, section: String,
isOptional: Bool = false, sortOrder: Int = 0) {
self.id = id
self.name = name
self.amount = amount
self.section = section
self.isChecked = false
self.isOptional = isOptional
self.sortOrder = sortOrder
self.lastModified = Date()
}
}
Trois détails de cette forme que l’API masque.
@Model n’exige aucune déclaration de schéma séparée pour le store persistant. SwiftData lit la définition de la classe à la compilation et en synthétise le schéma. Les propriétés de la classe deviennent les attributs du modèle ; leurs types Swift deviennent les types de colonnes. Aucun fichier .xcdatamodeld à maintenir (même si le NSManagedObjectModel sous-jacent de Core Data existe toujours et porte le schéma à l’exécution).2
@Attribute(.unique) est une contrainte sur une seule colonne, pas une déclaration de PRIMARY KEY. L’identité persistante de SwiftData, c’est le PersistentIdentifier, généré automatiquement pour chaque ligne. La déclaration @Attribute(.unique) dit au framework : « cette colonne stocke au plus une ligne par valeur ». Lorsque vous insérez un modèle dont la valeur .unique existe déjà, SwiftData effectue un upsert : la ligne existante est mise à jour au lieu d’être rejetée. Cette sémantique compte pour le code produit : .unique n’est pas une validation au niveau de l’interface qui empêche l’envoi de doublons ; c’est une garantie de stockage « au plus un » qui fusionne en silence. Le motif id: UUID ci-dessus est celui que l’on recommande pour la synchronisation entre processus (quand il vous faut un identifiant stable qui survit à la disparition du PersistentIdentifier interne au processus), et le comportement d’upsert est exactement ce que vous voulez quand le même UUID arrive par deux chemins de synchronisation.
Les classes @Model sont des types référence, pas des types valeur. Modifier une propriété sur une instance de ShoppingItem déclenche le suivi des changements de SwiftData ; le framework enregistre le changement et le persiste à la prochaine sauvegarde du contexte. L’intégration SwiftUI via @Query réaffiche toute vue qui observe le prédicat correspondant. Le motif ressemble à @Observable (traité dans What SwiftUI Is Made Of), avec la persistance ajoutée par-dessus.
Les champs optionnels sont la migration bon marché
Le champ lastModified: Date? de ShoppingItem est optionnel, et cette optionalité porte tout le poids. Le champ a été ajouté après la sortie de la v1 pour prendre en charge la synchronisation multi-appareils et la résolution de conflits ; les lignes déjà présentes sur les appareils des utilisateurs n’avaient aucune valeur lastModified. Un champ optionnel sans valeur par défaut laisse la migration légère de SwiftData gérer l’ajout sans écrire la moindre ligne de code de migration : les lignes existantes reçoivent nil ; les nouvelles reçoivent ce que fixe l’initialiseur.3
Le chemin de migration légère est le chemin poli du framework. SwiftData inspecte le nouveau schéma et le store persistant, infère le plus petit changement compatible et l’applique. La migration est automatique ; l’utilisateur ne voit rien ; l’application démarre normalement sur les données existantes. Les cas que le chemin léger traite proprement :
- Ajouter une propriété optionnelle
- Supprimer une propriété (les données disparaissent ; les lectures existantes ne voient plus la colonne)
- Renommer un attribut que le framework peut faire correspondre grâce à un indice (avec
@Attribute(originalName: ...)) - Renommer une classe
@Modelque le framework peut faire correspondre (avec@Model.originalNameou un indice)
Les cas où le chemin léger abandonne :
- Ajouter à un schéma existant une propriété non optionnelle sans valeur par défaut (les lignes existantes n’ont aucune valeur pour la remplir)
- Changer le type d’une propriété (p. ex.
Int→String) - Scinder un modèle en deux, ou en fusionner deux en un seul
- Tout ce qui demande une logique sur mesure pour migrer
Quand le chemin léger abandonne, le comportement sûr consiste à faire échouer la migration. Le comportement dangereux serait de supprimer la base et de repartir de zéro ; le framework est conservateur et refuse de le faire en silence. L’utilisateur voit l’application planter au lancement avec une erreur de migration ; le développeur voit une pile d’appels qui pointe vers l’incohérence de schéma ; personne ne perd de données, mais tout le monde perd confiance.
Le prix de l’impasse faite sur VersionedSchema dès le premier jour se révèle à la frontière v2 → v3, quand vous ajoutez la troisième fonctionnalité dont le changement de schéma dépasse ce que gère le chemin léger.
VersionedSchema et MigrationPlan : la discipline du premier jour
VersionedSchema déclare une version précise du schéma de modèle. MigrationPlan déclare comment migrer d’une version à la suivante.4 La forme :
import SwiftData
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] = [ShoppingItemV1.self]
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] = [ShoppingItemV2.self]
}
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] = [
SchemaV1.self,
SchemaV2.self,
]
static var stages: [MigrationStage] = [
MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
]
}
Les classes de modèle elles-mêmes rejoignent l’espace de noms du schéma versionné :
extension SchemaV1 {
@Model
final class ShoppingItemV1 { /* v1 fields */ }
}
extension SchemaV2 {
@Model
final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}
Le ModelContainer est construit avec le plan de migration :
let container = try ModelContainer(
for: ShoppingItemV2.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration("ShoppingList")
)
Le plan de migration donne au framework un graphe typé de l’évolution du schéma. Quand l’application en v2 démarre face à une base en v1, le framework parcourt le plan de migration, applique les étapes nommées et amène la base en v2. Quand vous publiez la v3, vous ajoutez SchemaV3.self à schemas ainsi qu’une nouvelle MigrationStage entre v2 et v3. Le modèle de migration complet — quels changements sont automatiques, lesquels réclament une étape déclarée, et le plantage de somme de contrôle que vous décrochez en déclarant un V2 dont vous n’aviez pas besoin — fait l’objet de l’article compagnon SwiftData migrations : lightweight vs custom.
La discipline consiste à livrer VersionedSchema dès la v1, même quand il n’existe qu’une seule version. Cela coûte un fichier de plus et une déclaration enum de plus. Ne pas le faire coûte ceci : le premier changement de schéma non trivial de la v2 impose d’envelopper rétroactivement la v1 dans un VersionedSchema, ce qui est faisable mais demande du soin pour retrouver la forme exacte de la v1, afin que le framework identifie les données existantes comme SchemaV1. Le vous de demain, occupé à la v2, paiera la taxe ; le vous d’aujourd’hui peut la payer une fois et l’oublier.
MigrationStage sur mesure pour les cas difficiles
Les migrations légères couvrent la plupart des changements additifs. Les changements de type, les scissions, les fusions et les remplissages conditionnels réclament une MigrationStage.custom :
static var stages: [MigrationStage] = [
MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Read v1 rows; stage any derived state to a transient store
// (UserDefaults / temp file) since the v1 and v2 contexts do
// not share state, and didMigrate cannot read v1.
let v1Items = try context.fetch(FetchDescriptor<ShoppingItemV1>())
stageDerivedState(from: v1Items)
},
didMigrate: { context in
// Populate v2-only fields on existing rows
let v2Items = try context.fetch(FetchDescriptor<ShoppingItemV2>())
for item in v2Items where item.lastModified == nil {
item.lastModified = Date()
}
try context.save()
}
)
]
Les deux closures se déclenchent avant et après que le framework applique la migration structurelle. willMigrate s’exécute face au schéma v1 ; didMigrate s’exécute face au schéma v2. Le corps de la closure est du code SwiftData ordinaire (descripteurs de fetch, sauvegardes du contexte de modèle, les mêmes API que dans l’application en cours d’exécution), qui opère sur un contexte transitoire propre à la migration.
Le motif qui survit à la production garde willMigrate vide et place toute la logique de remplissage dans didMigrate. Lire les données v1 depuis willMigrate est permis, mais du point de vue du framework le schéma v2 n’existe pas encore : tout calcul doit donc être déposé dans un stockage transitoire que la closure didMigrate pourra relire. La règle plus simple : les migrations structurelles sont l’affaire du framework ; remplir les champs propres à la v2 sur les lignes existantes est l’affaire de didMigrate.
Quand @Attribute et @Relationship méritent leur nom
Deux macros font l’essentiel du travail de décoration du schéma dans les classes @Model.
@Attribute décore une propriété unique d’une contrainte ou d’un indice :
@Attribute(.unique)impose l’unicité, comme surShoppingItem.id@Attribute(.externalStorage)stocke les gros blobsDataen dehors de la base (données d’image, tampons audio)@Attribute(originalName: "old_field_name")fait correspondre une propriété à une colonne renommée pendant la migration@Attribute(.transformable(by: ...))applique unValueTransformerà un type non Codable
La bonne discipline : utiliser .unique pour les champs qui doivent réellement être uniques (un UUID que vous avez généré, un identifiant externe), .externalStorage pour tout blob dépassant quelques Ko, et originalName quand un renommage de propriété en v2 ferait autrement perdre les données de la v1.
@Relationship décore une propriété qui pointe vers une autre classe @Model ou vers une collection de celles-ci :
@Model
final class List {
var name: String
@Relationship(deleteRule: .cascade, inverse: \ShoppingItem.list)
var items: [ShoppingItem] = []
}
@Model
final class ShoppingItem {
var name: String
var list: List?
}
deleteRule: .cascade signifie que supprimer la List parente supprime toutes les lignes ShoppingItem enfants. Le paramètre inverse: indique au framework quelle propriété de l’enfant pointe en retour vers le parent ; le framework s’en sert pour une maintenance bidirectionnelle prévisible. SwiftData sait parfois inférer l’inverse tout seul, et inverse: nil est pris en charge pour les relations explicitement unidirectionnelles, mais la valeur sûre par défaut consiste à déclarer inverse: dès que l’inférence serait ambiguë.5
La bonne discipline : déclarer les relations avec un deleteRule explicite (la valeur par défaut est .nullify, rarement celle que vous voulez) et déclarer inverse: dès que la relation est bidirectionnelle (plutôt que de compter sur l’inférence du framework). Les valeurs implicites par défaut se trompent le plus souvent ; la forme explicite, c’est un paramètre de plus et un bug évité pour toujours.
Franchir une frontière d’acteur : envoyez l’identifiant, pas le graphe
Une classe @Model n’est pas Sendable, et la bonne réaction est de renoncer à l’y forcer. L’instance est une référence vers un graphe d’objets vivant, détenu par un ModelContext ; le framework ne peut pas promettre que ce graphe se lit sans danger depuis un autre acteur, aussi le type reste-t-il délibérément non Sendable. Forcer la conformité ne fait pas disparaître la course aux données ; cela la dissimule.7
Le motif qui fonctionne consiste à envoyer l’identité et les valeurs simples, puis à recharger de l’autre côté. PersistentIdentifier est Sendable : il franchit donc la frontière proprement. Extrayez les valeurs scalaires dont la destination a besoin (un nom, un drapeau, un delta dans une petite structure), transmettez-les avec l’identifiant, et laissez l’acteur destinataire recharger le modèle depuis son propre contexte à partir de cet identifiant :
// On the source actor: extract identity + plain values, never the model.
let id: PersistentIdentifier = item.persistentModelID
let snapshot = ItemSnapshot(name: item.name, isChecked: item.isChecked)
// On the destination actor: re-fetch from this context, then mutate.
let fetched = destinationContext.model(for: id) as? ShoppingItem
Le mode de défaillance à éviter, c’est de transmettre le graphe de modèle lui-même. Quand une partie du graphe franchit la frontière, le destinataire obtient un modèle qui ne s’hydrate que partiellement de l’autre côté : les relations et les propriétés chargées paresseusement, jamais résolues dans le contexte source, se résolvent face au mauvais contexte (ou pas du tout), et les bugs qui suivent sont du genre silencieux. L’identifiant plus les valeurs extraites, voilà le contrat sûr ; le graphe, non. Un ModelActor encapsule cette discipline en possédant un contexte et en distribuant des valeurs plutôt que des instances.7
La synchronisation CloudKit et le piège de l’entitlement App Group
Déplacer un store SwiftData dans un conteneur d’App Group pour qu’un widget ou une extension puisse le lire interagit avec la synchronisation CloudKit d’une manière qui mord les applications après leur sortie. Deux faits suffisent à dérouler le reste.
D’abord, l’emplacement du store. Avec la ModelConfiguration par défaut, SwiftData recopie pour vous le store existant dans le conteneur d’App Group lorsqu’une application passe du « sans groupe » à l’App Group ; la formulation d’Apple est que SwiftData « copies the existing store to the app group container ».8 Avec une URL de store personnalisée, l’emplacement vous appartient : c’est vous qui copiez le fichier dans le nouveau conteneur et qui y pointez la configuration. Le chemin par défaut est le chemin commode précisément parce que le framework fait la copie ; le chemin personnalisé échange cette commodité contre du contrôle.
Ensuite, l’entitlement. Chaque membre de l’App Group qui lit un store synchronisé par CloudKit doit porter le même entitlement CloudKit, car chacun de ces processus synchronisera ce conteneur pour son propre compte. C’est là qu’est le piège : un widget ou une extension n’a ni le budget d’exécution ni la fenêtre au premier plan qu’exige une synchronisation verbeuse, et lui confier l’entitlement CloudKit l’oblige à essayer quand même. La solution consiste à scinder en deux instances de ModelConfiguration : un store synchronisé (entitlement CloudKit, propriété de l’application principale) et un store local dans le conteneur d’App Group que le widget et les extensions lisent sans jamais se synchroniser. Placez la synchronisation là où une application au premier plan peut la mener correctement, et gardez hors du chemin de synchronisation les données partagées en lecture.8
Ce que je construirais autrement
Trois motifs que les applications du cluster livrent, ou auraient aimé livrer.
Livrez VersionedSchema dès la v1. Toute classe @Model mise en production devrait vivre dans un VersionedSchema dès le premier jour. Le coût, c’est un enum enveloppant par version de schéma. Le bénéfice, c’est que le premier changement non trivial de la v2 devient un ajout d’une ligne à MigrationPlan.schemas au lieu d’un refactoring rétroactif de deux jours.
Rendez chaque horodatage optionnel. Des champs comme lastModified, createdAt et updatedAt, qui existent pour la synchronisation multi-appareils ou la résolution de conflits, devraient être optionnels en v1 si le produit v1 n’en a pas besoin. L’optionalité maintient bon marché la migration vers la v2 (le moment où vous en aurez besoin). Les remplir sur les lignes existantes pendant didMigrate tient en une boucle ; les rendre non optionnels dès la v1 est une contrainte qui peut casser le remplissage rétroactif sur les données des utilisateurs.
Utilisez des UUID comme clé naturelle, pas le PersistentIdentifier. Le PersistentIdentifier de SwiftData vit à l’intérieur du processus. La synchronisation multi-appareils, l’intégration MCP (traitée dans Two Agent Ecosystems, One Shopping List) et toute référence hors processus réclament un identifiant stable. Un UUID avec @Attribute(.unique) a la bonne forme ; le PersistentIdentifier interne au processus a la mauvaise forme pour tout ce qui franchit une frontière de processus.
Quand @Model est la mauvaise réponse
Trois cas où SwiftData n’est pas le bon outil :
État clé/valeur à enregistrement unique. Les réglages de l’application, la langue choisie par l’utilisateur, l’horodatage de la dernière synchronisation. Utilisez UserDefaults ou NSUbiquitousKeyValueStore (traités dans Five Apple Platforms, Three Shared Files). Le surcoût de SwiftData pour une seule ligne, c’est du cérémonial gaspillé ; les stores clé-valeur sont le bon substrat.
Données faisant autorité côté serveur, sans écriture hors ligne. Une liste récupérée depuis une API REST et affichée en lecture seule. SwiftData est disproportionné si la source de vérité est le serveur et si le cache local n’est qu’un cache. Un simple instantané Codable dans Documents/ plus un tableau gardé en mémoire suffit ; la taxe de migration de SwiftData ne vaut pas d’être payée si les données n’ont pas à survivre à une réinitialisation complète.
Coordination entre processus. SwiftData opère à l’intérieur d’un processus. Un serveur MCP qui tourne en dehors de l’application iOS ne peut ni lire ni écrire le conteneur SwiftData de l’application. L’état partagé entre processus réclame une autre forme : un fichier JSON sur iCloud Drive, un conteneur d’App Group partagé, ou une couche de synchronisation explicite qui fait le pont entre les processus. (Get Bananas associe SwiftData au JSON d’iCloud Drive exactement pour cette raison.)6
Les données sont de gros blobs qui changent rarement. Un fichier audio de 10 Mo, un jeu d’images de 50 Mo. Utilisez @Attribute(.externalStorage) si les blobs vivent dans des lignes SwiftData ; sinon, passez directement par le système de fichiers, avec dans SwiftData des métadonnées qui pointent vers des URL de fichiers.
Quand Core Data l’emporte encore
SwiftData est une couche au-dessus de Core Data, pas un remplaçant de tout ce qu’il fait, et trois ans plus tard un ensemble précis de tâches appartient toujours au framework le plus ancien. Choisir Core Data pour l’une d’elles n’est pas une décision d’héritage ; c’est la décision correcte aujourd’hui.
Les agrégats calculés côté base. SwiftData n’offre aucun équivalent aux fetchs de Core Data fondés sur NSExpression, ceux qui poussent sum, average, min et max jusque dans SQLite pour que la base les calcule sans charger de lignes9. Dans SwiftData, vous récupérez puis réduisez en mémoire, ce qui ruine l’intérêt sur une grande table. La porte de sortie documentée, c’est la coexistence : Apple décrit l’exécution de « two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store », ce qui permet au côté Core Data d’exécuter l’agrégat poussé en SQL sur le fichier que possède SwiftData9. La mécanique, y compris l’exigence de NSPersistentHistoryTrackingKey, est traitée dans SwiftData performance is a storage problem.
Le partage et la base CloudKit publique. La synchronisation iCloud automatique de SwiftData s’appuie sous le capot sur NSPersistentCloudKitContainer, et le conteneur qu’elle configure réplique votre store vers la base CloudKit privée de l’utilisateur10. La collaboration entre différents utilisateurs iCloud via CKShare, comme la publication vers la base publique, sont des capacités documentées de Core Data + CloudKit, sans aucune API au niveau SwiftData à l’heure des bêtas d’iOS 2710. Une application dont la fonctionnalité centrale repose sur des listes partagées ou des documents collaboratifs redescend soit à Core Data pour les stores synchronisés, soit construit la couche CloudKit à la main.
Les mises à jour par lot au niveau du store. Le NSBatchUpdateRequest de Core Data réécrit directement dans le store les lignes correspondantes, sans charger d’objets11. Le ModelContext de SwiftData possède la moitié « suppression » (delete(model:where:) accepte un prédicat) mais aucun équivalent pour la mise à jour par lot : réécrire massivement un champ dans SwiftData revient donc à matérialiser chaque modèle concerné.
Une cible de déploiement inférieure à iOS 17. SwiftData exige iOS 17 ; Core Data remonte aussi loin que ce qu’une application prend encore en charge, et son conteneur CloudKit jusqu’à iOS 131012. Une base de code à longue traîne d’OS n’a pas le choix.
Il vaut la peine de nommer ce qui a quitté cette liste : « il vous faut NSFetchedResultsController en dehors d’une vue » était un point pour Core Data jusqu’à ce que les bêtas d’iOS 27 ajoutent ResultsObserver, qui observe un fetch n’importe où dans l’application via Swift Observation13. La liste des manques rétrécit à chaque version. Le pari à tenir : démarrer les nouvelles applications sur SwiftData, adopter la coexistence pour les tâches ci-dessus, et ne considérer une pile Core Data complète comme la réponse que lorsque le partage, la base publique ou la cible de déploiement l’imposent.
Ce que ce motif signifie pour les applications livrées sur iOS 26+
Trois enseignements.
-
Les macros sont la partie facile. Les migrations sont le coût.
@Modelet@Attributesont des déclarations de deux lignes qui masquent beaucoup de tuyauterie Core Data. La discipline de migration, voilà ce que vous payez vraiment sur la durée de vie de l’application ; concevez la v1 en pensant à la v2. -
VersionedSchemadès le premier jour n’est pas négociable pour une application publiée. L’enumenveloppant, c’est un fichier de plus. Le coût rétroactif de son ajout plus tard est bien supérieur. -
Les champs optionnels et les relations explicites sont l’assurance bon marché. Horodatages optionnels pour les métadonnées de synchronisation,
deleteRuleetinverse:explicites sur les relations. Ce sont deux déclarations minuscules qui achètent beaucoup de souplesse pour la v2.
Le cluster complet sur l’écosystème Apple : les App Intents typés pour Apple Intelligence ; les serveurs MCP pour les agents inter-LLM ; la question du routage entre les deux ; les Foundation Models pour le LLM sur appareil et le protocole Tool ; les Live Activities pour la machine à états de l’écran verrouillé sur iOS ; le contrat d’exécution watchOS sur l’Apple Watch ; les rouages internes de SwiftUI pour le substrat du framework ; le modèle mental spatial de RealityKit pour les scènes visionOS ; les motifs Liquid Glass pour la couche visuelle ; la livraison multiplateforme pour la portée multi-appareils. Le hub se trouve dans l’Apple Ecosystem Series. Pour un contexte plus large sur iOS et les agents IA, voyez le iOS Agent Development guide.
Questions
Quelle est la différence entre @Model et le NSManagedObject de Core Data ?
@Model est une macro Swift qui génère la tuyauterie NSManagedObject sous le capot. SwiftData s’appuie sur Core Data comme store de sauvegarde : le modèle d’exécution est donc le même, la différence tient à la surface. @Model supprime le fichier .xcdatamodeld, le cérémonial des value-transformers et la gestion du cycle de vie du NSManagedObjectContext. Vous obtenez le même store persistant avec une API à la forme de Swift.
Ai-je besoin de VersionedSchema si je ne compte jamais changer le schéma ?
Si votre application est susceptible de sortir une v2, oui. Si c’est une démo sans lendemain, non. VersionedSchema dès la v1 coûte une déclaration enum de plus. L’ajouter rétroactivement en v2 coûte de retrouver la forme exacte du schéma v1 pour que le framework reconnaisse les données existantes : faisable, mais propice aux erreurs. La plupart des applications publiées finiront par avoir besoin d’un changement de schéma ; budgétez-le dès la v1.
Quand faut-il utiliser @Attribute(.unique) ?
Quand le champ est une clé naturelle de la ligne : un UUID que vous avez généré, un identifiant externe importé, un slug que vous avez attribué. SwiftData traite .unique comme un upsert : si vous insérez un modèle dont la valeur .unique existe déjà, la ligne existante est mise à jour au lieu d’en ajouter une nouvelle. C’est cette sémantique qui rend sûrs les chemins de synchronisation de type upsert (le même UUID venant de deux appareils) ; c’est aussi pourquoi .unique est le mauvais outil sur des champs d’affichage comme title, car deux utilisateurs qui tapent le même titre fusionneraient leurs lignes en silence au lieu de produire deux enregistrements distincts.
Comment traiter un champ non optionnel ajouté à un schéma existant ?
Utilisez une MigrationStage.custom avec une closure didMigrate qui remplit le champ sur les lignes existantes. Ou, plus simple : déclarez le champ optionnel dans la nouvelle version du schéma et remplissez-le paresseusement à l’accès. L’optionalité est la migration la moins chère ; les ajouts non optionnels réclament une logique de remplissage explicite.
PersistentIdentifier ou mon propre UUID ?
PersistentIdentifier est l’identifiant de ligne interne au processus de SwiftData ; il est généré automatiquement et survit le temps du processus en cours. Votre propre UUID avec @Attribute(.unique) est un identifiant stable entre processus et entre appareils. Utilisez PersistentIdentifier pour les références internes au processus, à l’intérieur de l’application. Utilisez un UUID pour tout ce qui franchit une frontière de processus (synchronisation multi-appareils, intégrations externes, outils MCP, appels réseau).
Quand faut-il encore choisir Core Data plutôt que SwiftData ?
Quatre cas à l’heure des bêtas d’iOS 27 : les agrégats côté base (les fetchs NSExpression qui manquent à SwiftData), le partage entre utilisateurs iCloud via CKShare ou la base CloudKit publique (la synchronisation de SwiftData couvre la base privée), les mises à jour par lot au niveau du store (NSBatchUpdateRequest), et les cibles de déploiement sous iOS 1791011. Pour le cas des agrégats, vous n’avez pas à abandonner SwiftData : faites tourner une pile Core Data coexistante sur le même fichier de store.
Références
-
Get Bananas de l’auteur, une application SwiftUI de liste de courses qui associe SwiftData à une synchronisation JSON via iCloud Drive et à un serveur MCP. Le modèle
ShoppingItema évolué au fil du premier cycle de développement ; le champlastModified: Date?a été ajouté après le schéma initial (commit268a00ddu 1er décembre 2025, « Make lastModified optional to fix migration crash ») parce que le rendre non optionnel cassait la migration dès lors que les lignes existantes n’avaient aucune valeur pour le remplir. ↩ -
Apple Developer, “SwiftData” et “Adding and editing persistent data in your app”. La macro
@Model, la surface de contraintes de@Attributeet le lien avec leNSManagedObjectModelde Core Data. ↩↩ -
Apple Developer, “Preserving your app’s model data across launches” et “Adopting SwiftData for a Core Data app”. La sémantique de la migration légère et ce qui pousse le framework à abandonner. ↩
-
Apple Developer, “VersionedSchema” et “SchemaMigrationPlan”. Les déclarations de schémas versionnés, les définitions d’étapes de migration et le constructeur de
ModelContainerqui accepte un plan de migration. ↩ -
Apple Developer, “Defining data relationships with enumerations and model classes” et “Schema.Relationship”. La macro
@Relationship, les options dedeleteRule(.cascade,.nullify,.deny,.noAction) et le rôle du paramètreinverse:dans la maintenance des relations bidirectionnelles. ↩ -
Analyse de l’auteur dans Two Agent Ecosystems, One Shopping List, 29 avril 2026, et Five Apple Platforms, Three Shared Files. Les motifs de synchronisation entre processus et entre appareils de Get Bananas et Return, qui complètent (et parfois remplacent) SwiftData dans un flux de travail multi-processus. ↩
-
Apple Developer, “PersistentIdentifier” (conforme à
Sendable) et “ModelActor”. L’équipe SwiftData a confirmé lors du SwiftData Group Lab de la WWDC 2026 que les objets@Modelne sont pasSendableet qu’il ne faut pas les forcer à l’être, car ils forment un graphe de références vivant à l’intérieur d’un contexte ; le contrat de frontière recommandé consiste à transmettre lePersistentIdentifier, qui estSendable, accompagné des valeurs simples extraites, puis à recharger dans le contexte de destination, et transmettre le graphe de modèle laisse au destinataire un objet partiellement hydraté. Paraphrasé d’après un enregistrement transcrit localement du SwiftData Group Lab de la WWDC 2026 ; Apple ne publie aucun sous-titrage officiel pour les labs. ↩↩ -
Apple Developer, “Adopting SwiftData for a Core Data app”, qui indique qu’avec la configuration par défaut « SwiftData copies the existing store to the app group container », tandis qu’une URL de store personnalisée vous laisse gérer l’emplacement. L’exigence d’entitlement CloudKit pour les membres d’une App Group et la scission en deux
ModelConfiguration(l’une synchronisée, l’autre locale) pour tenir widgets et extensions hors du chemin de synchronisation ont été décrites lors du SwiftData Group Lab de la WWDC 2026. Paraphrasé d’après un enregistrement transcrit localement du SwiftData Group Lab de la WWDC 2026 ; Apple ne publie aucun sous-titrage officiel pour les labs. ↩↩ -
Apple, session 10189 de la WWDC 2023, “Migrate to SwiftData”, source du cadrage sur la coexistence (« two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store »), et Apple Developer, “NSExpression”, le mécanisme derrière les fetchs d’agrégats poussés en SQL de Core Data, auxquels SwiftData n’offre aucun équivalent. Ce manque a été confirmé par le panel d’ingénierie SwiftData au SwiftData Group Lab de la WWDC 2026 (paraphrasé d’après un enregistrement transcrit localement). ↩↩↩
-
Apple Developer, “Syncing model data across a person’s devices”, qui indique que « SwiftData uses the
NSPersistentCloudKitContainerclass from Core Data to handle CloudKit synchronization » ; “NSPersistentCloudKitContainer” (iOS 13.0+), dont le résumé décrit la réplication de « select persistent stores to a CloudKit private database » ; et “Sharing Core Data objects between iCloud users”, le chemin Core Data documenté pour la collaboration fondée surCKShare. La documentation SwiftData n’expose aucune API de partage ni de base publique à l’heure des bêtas d’iOS 27. ↩↩↩↩ -
Apple Developer, “NSBatchUpdateRequest” et “ModelContext.delete(model:where:includeSubclasses:)”, la suppression par lot fondée sur un prédicat de SwiftData. La documentation du
ModelContextde SwiftData ne mentionne aucun équivalent pour la mise à jour par lot. ↩↩ -
Disponibilité par plateforme selon la documentation Apple Developer : SwiftData (iOS 17.0+) et Core Data (iOS 3.0+). ↩
-
Apple Developer, “ResultsObserver” (bêta iOS 27.0), qui « observes and tracks changes to a collection of persistent models in a model context » et se conforme à
Observable, remplissant le rôle d’observation hors d’une vue qui réclamait auparavant leNSFetchedResultsControllerde Core Data. ↩