# Migration Core Data vers SwiftData : guide étape par étape 2026 > Guide complet pour migrer une application iOS de Core Data vers SwiftData avec des exemples pratiques, stratégies de coexistence et bonnes pratiques. - Published: 2026-02-27 - Updated: 2026-03-28 - Author: SharpSkill - Tags: swiftdata, core-data, ios, migration, swift - Reading time: 16 min --- SwiftData représente l'avenir de la persistance des données sur les plateformes Apple. Introduit à la WWDC 2023, ce framework offre une syntaxe Swift native et une intégration transparente avec SwiftUI. Pour les applications existantes basées sur Core Data, la migration constitue une étape stratégique vers un code plus moderne et maintenable. > **Ce que couvre cet article** > > Ce guide détaille le processus complet de migration de Core Data vers SwiftData : analyse de compatibilité, conversion des modèles, stratégies de migration des données, et patterns de coexistence pour une transition progressive. ## Évaluer la faisabilité de la migration Avant de commencer la migration, une évaluation approfondie permet d'identifier les obstacles potentiels. Core Data et SwiftData partagent le même moteur de persistance SQLite, ce qui rend les données entièrement compatibles. ```swift // MigrationAssessment.swift // Checklist d'évaluation pour la migration /* FONCTIONNALITÉS SUPPORTÉES PAR SWIFTDATA : ✅ Modèles simples avec propriétés basiques ✅ Relations one-to-one et one-to-many ✅ Propriétés optionnelles et valeurs par défaut ✅ Transformable attributes (via Codable) ✅ CloudKit synchronization (basic) ✅ Lightweight migrations automatiques ✅ Héritage de classe (iOS 26+) FONCTIONNALITÉS NÉCESSITANT ATTENTION : ⚠️ NSFetchedResultsController → @Query + observation manuelle ⚠️ NSCompoundPredicate → #Predicate avec logique combinée ⚠️ Prédicats dynamiques → Solutions de contournement nécessaires FONCTIONNALITÉS NON SUPPORTÉES : ❌ CloudKit Sharing avancé ❌ Derived attributes ❌ Fetched properties */ // Exemple de modèle Core Data typique à migrer import CoreData // Entité Core Data existante class CDTask: NSManagedObject { @NSManaged var id: UUID @NSManaged var title: String @NSManaged var isCompleted: Bool @NSManaged var createdAt: Date @NSManaged var priority: Int16 @NSManaged var category: CDCategory? } class CDCategory: NSManagedObject { @NSManaged var id: UUID @NSManaged var name: String @NSManaged var color: String @NSManaged var tasks: NSSet? } ``` La compatibilité au niveau des données signifie que les utilisateurs conservent leurs informations existantes après la migration. Aucune perte de données ne survient si le processus est correctement exécuté. ## Conversion des modèles Core Data en SwiftData La première étape concrète consiste à convertir les entités Core Data en classes SwiftData. Xcode propose un outil automatique, mais comprendre le processus manuel reste essentiel. ```swift // TaskModel.swift import SwiftData // Équivalent SwiftData de CDTask @Model final class Task { // Propriétés avec valeurs par défaut var id: UUID = UUID() var title: String = "" var isCompleted: Bool = false var createdAt: Date = Date() var priority: Int = 0 // Relation optionnelle vers Category var category: Category? // Initializer explicite recommandé init( id: UUID = UUID(), title: String, isCompleted: Bool = false, createdAt: Date = Date(), priority: Int = 0, category: Category? = nil ) { self.id = id self.title = title self.isCompleted = isCompleted self.createdAt = createdAt self.priority = priority self.category = category } } ``` Les différences clés avec Core Data incluent l'utilisation du macro `@Model` au lieu de `NSManagedObject`, et des types Swift natifs plutôt que des types Objective-C. ```swift // CategoryModel.swift import SwiftData @Model final class Category { var id: UUID = UUID() var name: String = "" var color: String = "blue" // Relation inverse avec delete rule @Relationship(deleteRule: .cascade, inverse: \Task.category) var tasks: [Task] = [] init(id: UUID = UUID(), name: String, color: String = "blue") { self.id = id self.name = name self.color = color } } ``` > **Mapping des types** > > Les types Core Data se convertissent directement : `Int16` devient `Int`, `NSSet` devient `[Model]`, et `Date` reste `Date`. Les attributs `Transformable` nécessitent l'adoption de `Codable`. ## Configurer le ModelContainer Le `ModelContainer` SwiftData remplace le `NSPersistentContainer` de Core Data. La configuration détermine où et comment les données sont stockées. ```swift // ModelContainerSetup.swift import SwiftData import SwiftUI @main struct TaskManagerApp: App { // Configuration du container SwiftData var sharedModelContainer: ModelContainer = { // Schéma incluant tous les modèles let schema = Schema([ Task.self, Category.self ]) // Configuration avec options de stockage let modelConfiguration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: false, // Utiliser le même store que Core Data url: URL.applicationSupportDirectory .appending(path: "TaskManager.sqlite") ) do { return try ModelContainer( for: schema, configurations: [modelConfiguration] ) } catch { fatalError("Impossible de créer ModelContainer: \(error)") } }() var body: some Scene { WindowGroup { ContentView() } .modelContainer(sharedModelContainer) } } ``` Le point crucial réside dans l'URL du store : utiliser le même fichier SQLite que Core Data permet à SwiftData de lire les données existantes. ## Stratégie de coexistence Core Data et SwiftData Pour les applications complexes, une migration progressive via la coexistence des deux frameworks représente l'approche la plus sûre. Les deux stacks peuvent accéder au même fichier SQLite. ```swift // CoexistenceSetup.swift import CoreData import SwiftData // Configuration pour la coexistence class PersistenceController { static let shared = PersistenceController() // Store partagé entre Core Data et SwiftData private let storeURL: URL = { let appSupport = FileManager.default .urls(for: .applicationSupportDirectory, in: .userDomainMask) .first! return appSupport.appending(path: "TaskManager.sqlite") }() // MARK: - Core Data Stack (existant) lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "TaskManager") // Configurer pour utiliser le store partagé let description = NSPersistentStoreDescription(url: storeURL) description.setOption( true as NSNumber, forKey: NSPersistentHistoryTrackingKey ) container.persistentStoreDescriptions = [description] container.loadPersistentStores { _, error in if let error = error as NSError? { fatalError("Core Data error: \(error)") } } return container }() // MARK: - SwiftData Stack (nouveau) lazy var swiftDataContainer: ModelContainer = { let schema = Schema([Task.self, Category.self]) let config = ModelConfiguration( schema: schema, url: storeURL, // Désactiver les migrations automatiques en coexistence allowsSave: true ) do { return try ModelContainer(for: schema, configurations: [config]) } catch { fatalError("SwiftData error: \(error)") } }() } ``` > **Synchronisation des modifications** > > En mode coexistence, les modifications effectuées par un framework ne sont pas immédiatement visibles par l'autre. Un rechargement explicite ou un redémarrage de l'application peut être nécessaire. ## Migration des requêtes : de NSFetchRequest à @Query La différence la plus significative concerne la façon de récupérer les données. SwiftUI utilise le property wrapper `@Query` pour remplacer `@FetchRequest`. ```swift // QueryMigration.swift import SwiftUI import SwiftData // ❌ Ancien pattern avec Core Data struct OldTaskListView: View { @FetchRequest( sortDescriptors: [ NSSortDescriptor(keyPath: \CDTask.createdAt, ascending: false) ], predicate: NSPredicate(format: "isCompleted == NO") ) private var tasks: FetchedResults var body: some View { List(tasks) { task in Text(task.title) } } } // ✅ Nouveau pattern avec SwiftData struct NewTaskListView: View { // @Query avec tri et filtre intégrés @Query( filter: #Predicate { !$0.isCompleted }, sort: \Task.createdAt, order: .reverse ) private var tasks: [Task] var body: some View { List(tasks) { task in TaskRowView(task: task) } } } struct TaskRowView: View { let task: Task var body: some View { HStack { // Indicateur de priorité Circle() .fill(priorityColor) .frame(width: 8, height: 8) VStack(alignment: .leading) { Text(task.title) .font(.headline) if let category = task.category { Text(category.name) .font(.caption) .foregroundStyle(.secondary) } } Spacer() // Badge de date Text(task.createdAt, style: .date) .font(.caption2) .foregroundStyle(.tertiary) } } private var priorityColor: Color { switch task.priority { case 3: return .red case 2: return .orange case 1: return .yellow default: return .gray } } } ``` ## Gestion des prédicats dynamiques Un défi majeur de SwiftData concerne les prédicats dynamiques. Contrairement à Core Data où les prédicats peuvent être modifiés à la volée, `@Query` nécessite des approches alternatives. ```swift // DynamicPredicates.swift import SwiftUI import SwiftData // Solution 1 : Utiliser @Query avec init personnalisé struct FilteredTasksView: View { @Query private var tasks: [Task] // Créer la vue avec un filtre spécifique init(showCompleted: Bool, categoryId: UUID?) { // Construire le prédicat selon les paramètres var predicates: [Predicate] = [] if !showCompleted { predicates.append(#Predicate { !$0.isCompleted }) } if let categoryId { predicates.append(#Predicate { task in task.category?.id == categoryId }) } // Combiner les prédicats let combinedPredicate: Predicate? if predicates.isEmpty { combinedPredicate = nil } else if predicates.count == 1 { combinedPredicate = predicates[0] } else { // Combiner manuellement pour AND logic combinedPredicate = #Predicate { task in !task.isCompleted && task.category?.id == categoryId } } _tasks = Query( filter: combinedPredicate, sort: \Task.createdAt, order: .reverse ) } var body: some View { List(tasks) { task in TaskRowView(task: task) } } } // Solution 2 : Filtrage côté vue avec tous les résultats struct SmartTaskListView: View { // Récupérer toutes les tâches @Query(sort: \Task.createdAt, order: .reverse) private var allTasks: [Task] // État du filtre @State private var searchText = "" @State private var showCompleted = false @State private var selectedCategory: Category? // Filtrage calculé private var filteredTasks: [Task] { allTasks.filter { task in // Filtre par texte let matchesSearch = searchText.isEmpty || task.title.localizedCaseInsensitiveContains(searchText) // Filtre par statut let matchesStatus = showCompleted || !task.isCompleted // Filtre par catégorie let matchesCategory = selectedCategory == nil || task.category?.id == selectedCategory?.id return matchesSearch && matchesStatus && matchesCategory } } var body: some View { NavigationStack { List(filteredTasks) { task in TaskRowView(task: task) } .searchable(text: $searchText) .toolbar { FilterMenu( showCompleted: $showCompleted, selectedCategory: $selectedCategory ) } } } } ``` ## Migrations de schéma versionnées Lorsque le modèle de données évolue, SwiftData utilise `VersionedSchema` pour gérer les migrations complexes. ```swift // VersionedSchemas.swift import SwiftData // Version 1 : Schéma initial enum TaskSchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0) static var models: [any PersistentModel.Type] { [Task.self, Category.self] } @Model final class Task { var id: UUID = UUID() var title: String = "" var isCompleted: Bool = false var createdAt: Date = Date() var category: Category? init(title: String) { self.title = title } } @Model final class Category { var id: UUID = UUID() var name: String = "" @Relationship(deleteRule: .cascade, inverse: \Task.category) var tasks: [Task] = [] init(name: String) { self.name = name } } } // Version 2 : Ajout du champ priority et notes enum TaskSchemaV2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0) static var models: [any PersistentModel.Type] { [Task.self, Category.self] } @Model final class Task { var id: UUID = UUID() var title: String = "" var isCompleted: Bool = false var createdAt: Date = Date() // Nouvelles propriétés avec valeurs par défaut var priority: Int = 0 var notes: String = "" var category: Category? init(title: String, priority: Int = 0) { self.title = title self.priority = priority } } @Model final class Category { var id: UUID = UUID() var name: String = "" // Nouvelle propriété var color: String = "blue" @Relationship(deleteRule: .cascade, inverse: \Task.category) var tasks: [Task] = [] init(name: String, color: String = "blue") { self.name = name self.color = color } } } ``` Le plan de migration définit l'ordre des versions et les éventuelles migrations personnalisées. ```swift // MigrationPlan.swift import SwiftData enum TaskMigrationPlan: SchemaMigrationPlan { // Ordre chronologique des schémas static var schemas: [any VersionedSchema.Type] { [TaskSchemaV1.self, TaskSchemaV2.self] } // Étapes de migration static var stages: [MigrationStage] { [migrateV1toV2] } // Migration V1 → V2 : lightweight (propriétés avec défauts) static let migrateV1toV2 = MigrationStage.lightweight( fromVersion: TaskSchemaV1.self, toVersion: TaskSchemaV2.self ) } // Configuration du container avec migration @main struct TaskManagerApp: App { var sharedModelContainer: ModelContainer = { do { return try ModelContainer( for: TaskSchemaV2.Task.self, TaskSchemaV2.Category.self, migrationPlan: TaskMigrationPlan.self ) } catch { fatalError("Migration échouée: \(error)") } }() var body: some Scene { WindowGroup { ContentView() } .modelContainer(sharedModelContainer) } } ``` > **Migrations lightweight vs custom** > > SwiftData gère automatiquement les migrations lightweight (ajout de propriétés avec défauts, renommage, suppression). Les migrations complexes nécessitant une transformation de données utilisent `MigrationStage.custom`. ## Remplacement de NSFetchedResultsController Pour les listes avec sections ou observation fine des changements, `@Query` combiné avec l'extraction de données remplace `NSFetchedResultsController`. ```swift // SectionedResults.swift import SwiftUI import SwiftData struct SectionedTaskListView: View { @Query(sort: \Task.createdAt, order: .reverse) private var tasks: [Task] // Groupement par catégorie private var tasksByCategory: [(Category?, [Task])] { Dictionary(grouping: tasks) { $0.category } .map { ($0.key, $0.value) } .sorted { first, second in // Tâches sans catégorie en dernier guard let firstName = first.0?.name else { return false } guard let secondName = second.0?.name else { return true } return firstName < secondName } } var body: some View { List { ForEach(tasksByCategory, id: \.0?.id) { category, categoryTasks in Section(header: SectionHeader(category: category)) { ForEach(categoryTasks) { task in TaskRowView(task: task) } } } } } } struct SectionHeader: View { let category: Category? var body: some View { HStack { if let category { Circle() .fill(Color(category.color)) .frame(width: 12, height: 12) Text(category.name) } else { Text("Sans catégorie") .foregroundStyle(.secondary) } } } } // Alternative : Groupement par date struct DateGroupedTasksView: View { @Query(sort: \Task.createdAt, order: .reverse) private var tasks: [Task] private var tasksByDate: [(Date, [Task])] { let calendar = Calendar.current let grouped = Dictionary(grouping: tasks) { task in calendar.startOfDay(for: task.createdAt) } return grouped .map { ($0.key, $0.value) } .sorted { $0.0 > $1.0 } } var body: some View { List { ForEach(tasksByDate, id: \.0) { date, dateTasks in Section(header: Text(date, style: .date)) { ForEach(dateTasks) { task in TaskRowView(task: task) } } } } } } ``` ## Opérations CRUD avec ModelContext Le `ModelContext` remplace le `NSManagedObjectContext` pour toutes les opérations de création, lecture, mise à jour et suppression. ```swift // CRUDOperations.swift import SwiftUI import SwiftData struct TaskManagementView: View { @Environment(\.modelContext) private var modelContext @Query private var tasks: [Task] @Query private var categories: [Category] @State private var newTaskTitle = "" @State private var selectedCategory: Category? var body: some View { NavigationStack { VStack { // Formulaire d'ajout AddTaskForm( title: $newTaskTitle, category: $selectedCategory, categories: categories, onAdd: addTask ) // Liste des tâches List { ForEach(tasks) { task in TaskRowView(task: task) .swipeActions(edge: .trailing) { Button(role: .destructive) { deleteTask(task) } label: { Label("Supprimer", systemImage: "trash") } } .swipeActions(edge: .leading) { Button { toggleCompletion(task) } label: { Label( task.isCompleted ? "À faire" : "Terminé", systemImage: task.isCompleted ? "circle" : "checkmark" ) } .tint(task.isCompleted ? .orange : .green) } } } } .navigationTitle("Tâches") } } // CREATE private func addTask() { guard !newTaskTitle.isEmpty else { return } let task = Task( title: newTaskTitle, category: selectedCategory ) // Insertion dans le contexte modelContext.insert(task) // Sauvegarde explicite (optionnel - autosave activé par défaut) do { try modelContext.save() } catch { print("Erreur de sauvegarde: \(error)") } // Reset du formulaire newTaskTitle = "" selectedCategory = nil } // UPDATE private func toggleCompletion(_ task: Task) { // Modification directe - SwiftData track automatiquement task.isCompleted.toggle() // La sauvegarde automatique gère la persistance } // DELETE private func deleteTask(_ task: Task) { modelContext.delete(task) } } struct AddTaskForm: View { @Binding var title: String @Binding var category: Category? let categories: [Category] let onAdd: () -> Void var body: some View { VStack(spacing: 12) { TextField("Nouvelle tâche...", text: $title) .textFieldStyle(.roundedBorder) HStack { Picker("Catégorie", selection: $category) { Text("Aucune").tag(nil as Category?) ForEach(categories) { cat in Text(cat.name).tag(cat as Category?) } } .pickerStyle(.menu) Button("Ajouter", action: onAdd) .buttonStyle(.borderedProminent) .disabled(title.isEmpty) } } .padding() } } ``` ## Tests unitaires avec SwiftData Une stratégie de test robuste facilite la validation de la migration. SwiftData permet de créer des containers en mémoire pour les tests. ```swift // SwiftDataTests.swift import XCTest import SwiftData @testable import TaskManager final class TaskModelTests: XCTestCase { var container: ModelContainer! var context: ModelContext! override func setUpWithError() throws { // Container en mémoire pour les tests let config = ModelConfiguration(isStoredInMemoryOnly: true) container = try ModelContainer( for: Task.self, Category.self, configurations: config ) context = ModelContext(container) } override func tearDownWithError() throws { container = nil context = nil } func testCreateTask() throws { // Given let task = Task(title: "Test Task") // When context.insert(task) try context.save() // Then let descriptor = FetchDescriptor() let tasks = try context.fetch(descriptor) XCTAssertEqual(tasks.count, 1) XCTAssertEqual(tasks.first?.title, "Test Task") } func testTaskCategoryRelationship() throws { // Given let category = Category(name: "Work", color: "blue") let task = Task(title: "Meeting", category: category) // When context.insert(category) context.insert(task) try context.save() // Then XCTAssertEqual(task.category?.name, "Work") XCTAssertTrue(category.tasks.contains(task)) } func testDeleteCategoryCascade() throws { // Given let category = Category(name: "Personal") let task1 = Task(title: "Task 1", category: category) let task2 = Task(title: "Task 2", category: category) context.insert(category) context.insert(task1) context.insert(task2) try context.save() // When context.delete(category) try context.save() // Then - cascade delete should remove tasks let descriptor = FetchDescriptor() let remainingTasks = try context.fetch(descriptor) XCTAssertEqual(remainingTasks.count, 0) } func testFilteredFetch() throws { // Given let task1 = Task(title: "Completed", isCompleted: true) let task2 = Task(title: "Pending", isCompleted: false) let task3 = Task(title: "Also Pending", isCompleted: false) [task1, task2, task3].forEach { context.insert($0) } try context.save() // When var descriptor = FetchDescriptor( predicate: #Predicate { !$0.isCompleted } ) let pendingTasks = try context.fetch(descriptor) // Then XCTAssertEqual(pendingTasks.count, 2) } } ``` ## Checklist de migration complète Voici un résumé des étapes pour une migration réussie : ```swift // MigrationChecklist.swift /* PHASE 1 : PRÉPARATION □ Auditer les fonctionnalités Core Data utilisées □ Identifier les fonctionnalités non supportées par SwiftData □ Créer une branche de migration dédiée □ Sauvegarder les données de test PHASE 2 : CONVERSION DES MODÈLES □ Convertir les entités NSManagedObject en @Model □ Adapter les relations avec @Relationship □ Configurer les delete rules appropriées □ Ajouter les valeurs par défaut requises PHASE 3 : CONFIGURATION □ Créer le ModelContainer avec l'URL du store existant □ Configurer le schéma versionné si nécessaire □ Définir le plan de migration □ Tester en mode coexistence si applicable PHASE 4 : MIGRATION DU CODE □ Remplacer @FetchRequest par @Query □ Adapter les prédicats en #Predicate □ Migrer NSFetchedResultsController vers groupement manuel □ Convertir les opérations CRUD vers ModelContext PHASE 5 : VALIDATION □ Exécuter tous les tests unitaires □ Tester la migration avec des données réelles □ Vérifier les performances avec Instruments □ Valider la synchronisation CloudKit (si applicable) PHASE 6 : DÉPLOIEMENT □ Documenter les changements breaking □ Préparer un plan de rollback □ Déployer en TestFlight □ Monitorer les crashs post-déploiement */ ``` ## Conclusion La migration de Core Data vers SwiftData représente une évolution naturelle pour les applications iOS modernes. La compatibilité au niveau du store SQLite garantit la préservation des données utilisateur, tandis que la syntaxe Swift native simplifie considérablement le code. ### Points clés à retenir - ✅ SwiftData et Core Data partagent le même moteur SQLite - ✅ La coexistence permet une migration progressive - ✅ `@Query` remplace `@FetchRequest` avec une syntaxe plus simple - ✅ Les prédicats dynamiques nécessitent des patterns alternatifs - ✅ `VersionedSchema` gère les évolutions de schéma - ✅ Les tests en mémoire facilitent la validation - ✅ iOS 26 apporte l'héritage de classes - ✅ Commencer les nouveaux projets avec SwiftData sauf besoins spécifiques Core Data --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/ios/migration-core-data-swiftdata-guide