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.

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 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.
// 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.
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.
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
}
}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.
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.
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)")
}
}()
}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.
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<CDTask>
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<Task> { !$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
}
}
}Prêt à réussir tes entretiens iOS ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
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.
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<Task>] = []
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<Task>?
if predicates.isEmpty {
combinedPredicate = nil
} else if predicates.count == 1 {
combinedPredicate = predicates[0]
} else {
// Combiner manuellement pour AND logic
combinedPredicate = #Predicate<Task> { 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.
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.
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)
}
}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.
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.
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.
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<Task>()
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<Task>()
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<Task>(
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 :
/*
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
- ✅
@Queryremplace@FetchRequestavec une syntaxe plus simple - ✅ Les prédicats dynamiques nécessitent des patterns alternatifs
- ✅
VersionedSchemagè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
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

Combine vs async/await en Swift : patterns de migration progressive
Guide complet sur la migration de Combine vers async/await en Swift : stratégies progressives, bridging patterns, et coexistence des deux paradigmes dans une codebase iOS.

Questions entretien iOS accessibilité en 2026 : VoiceOver et Dynamic Type
Préparez vos entretiens iOS avec les questions clés sur l'accessibilité : VoiceOver, Dynamic Type, traits sémantiques et audits d'accessibilité.

Swift Macros : exemples pratiques de métaprogrammation
Guide complet sur Swift Macros : création de macros freestanding et attached, manipulation de l'AST avec swift-syntax, et exemples pratiques pour réduire le boilerplate.