SwiftData Core Data Migration for iOS
SwiftData Core Data migration is one of the most impactful modernization steps for iOS developers in 2026. SwiftData, introduced at WWDC 2023 and now mature in iOS 18+, replaces the verbose and error-prone Core Data API with a declarative, Swift-native persistence framework. It uses macros, property wrappers, and Swift concurrency to deliver a dramatically simpler developer experience while maintaining Core Data’s powerful storage engine underneath.
This guide provides a practical migration path from Core Data to SwiftData, covering model conversion, query syntax, relationships, CloudKit integration, and strategies for incremental adoption in existing applications.
Core Data vs SwiftData: What Changes
Core Data vs SwiftData Comparison
┌────────────────────────┬───────────────────┬───────────────────┐
│ Feature │ Core Data │ SwiftData │
├────────────────────────┼───────────────────┼───────────────────┤
│ Model Definition │ .xcdatamodeld │ Swift classes │
│ Schema │ Visual editor │ @Model macro │
│ Fetch Requests │ NSFetchRequest │ #Predicate macro │
│ Context │ NSManagedObject │ ModelContext │
│ │ Context │ │
│ Concurrency │ Manual (perform) │ @ModelActor │
│ Relationships │ Visual + code │ Swift properties │
│ CloudKit │ NSPersistentCloud │ Built-in │
│ │ KitContainer │ │
│ Undo Support │ Manual │ Automatic │
│ Migration │ Mapping models │ VersionedSchema │
│ SwiftUI Integration │ @FetchRequest │ @Query │
└────────────────────────┴───────────────────┴───────────────────┘Converting Core Data Models to SwiftData
The first migration step is converting your Core Data entity definitions to SwiftData model classes. Moreover, SwiftData uses the @Model macro to generate all the persistence boilerplate:
// BEFORE: Core Data NSManagedObject subclass
// (plus .xcdatamodeld visual model file)
class CDTask: NSManagedObject {
@NSManaged var id: UUID
@NSManaged var title: String
@NSManaged var notes: String?
@NSManaged var isCompleted: Bool
@NSManaged var dueDate: Date?
@NSManaged var createdAt: Date
@NSManaged var priority: Int16
@NSManaged var category: CDCategory?
@NSManaged var tags: NSSet?
}
// AFTER: SwiftData @Model class
// No .xcdatamodeld file needed!
@Model
final class Task {
var id: UUID
var title: String
var notes: String?
var isCompleted: Bool
var dueDate: Date?
var createdAt: Date
var priority: Int
// Relationships are just Swift properties
var category: Category?
@Relationship(deleteRule: .nullify, inverse: \Tag.tasks)
var tags: [Tag]
// Transient properties (not persisted)
@Transient var isOverdue: Bool {
guard let dueDate else { return false }
return !isCompleted && dueDate < Date()
}
init(title: String, priority: Int = 0) {
self.id = UUID()
self.title = title
self.isCompleted = false
self.createdAt = Date()
self.priority = priority
self.tags = []
}
}
@Model
final class Category {
var name: String
var colorHex: String
@Relationship(deleteRule: .cascade, inverse: \Task.category)
var tasks: [Task]
init(name: String, colorHex: String) {
self.name = name
self.colorHex = colorHex
self.tasks = []
}
}Query Migration: NSFetchRequest to #Predicate
SwiftData replaces NSFetchRequest with type-safe Swift predicates. Therefore, your queries are checked at compile time instead of failing at runtime:
// BEFORE: Core Data fetch request
let request = NSFetchRequest<CDTask>(entityName: "CDTask")
request.predicate = NSPredicate(
format: "isCompleted == %@ AND priority >= %d AND category.name == %@",
NSNumber(value: false), 2, "Work"
)
request.sortDescriptors = [
NSSortDescriptor(key: "dueDate", ascending: true),
NSSortDescriptor(key: "priority", ascending: false)
]
request.fetchLimit = 20
let results = try context.fetch(request)
// AFTER: SwiftData query with #Predicate
let isCompleted = false
let minPriority = 2
let categoryName = "Work"
let descriptor = FetchDescriptor<Task>(
predicate: #Predicate {
$0.isCompleted == isCompleted &&
$0.priority >= minPriority &&
$0.category?.name == categoryName
},
sortBy: [
SortDescriptor(\Task.dueDate),
SortDescriptor(\Task.priority, order: .reverse)
]
)
descriptor.fetchLimit = 20
let results = try modelContext.fetch(descriptor)SwiftUI Integration with @Query
// BEFORE: Core Data @FetchRequest in SwiftUI
struct TaskListView: View {
@FetchRequest(
sortDescriptors: [SortDescriptor(\CDTask.dueDate)],
predicate: NSPredicate(format: "isCompleted == false")
) var tasks: FetchedResults<CDTask>
var body: some View {
List(tasks) { task in
TaskRow(task: task)
}
}
}
// AFTER: SwiftData @Query in SwiftUI
struct TaskListView: View {
@Query(
filter: #Predicate<Task> { !$0.isCompleted },
sort: \Task.dueDate
) var tasks: [Task]
@Environment(\.modelContext) var context
var body: some View {
List(tasks) { task in
TaskRow(task: task)
}
.swipeActions {
Button("Delete") {
context.delete(task)
}
}
}
}Schema Migration with VersionedSchema
SwiftData handles schema evolution through VersionedSchema and SchemaMigrationPlan. Additionally, this approach is safer than Core Data mapping models because migrations are defined in Swift code:
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
var title: String
var isCompleted: Bool
var createdAt: Date
init(title: String) {
self.id = UUID()
self.title = title
self.isCompleted = false
self.createdAt = Date()
}
}
}
enum TaskSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Task.self, Category.self, Tag.self]
}
@Model final class Task {
var id: UUID
var title: String
var isCompleted: Bool
var createdAt: Date
var priority: Int // NEW field
var tags: [Tag] // NEW relationship
init(title: String) {
self.id = UUID()
self.title = title
self.isCompleted = false
self.createdAt = Date()
self.priority = 0
self.tags = []
}
}
}
enum TaskMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[TaskSchemaV1.self, TaskSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: TaskSchemaV1.self,
toVersion: TaskSchemaV2.self
) { context in
// Set default priority for existing tasks
let tasks = try context.fetch(
FetchDescriptor<TaskSchemaV2.Task>())
for task in tasks {
task.priority = 1
}
try context.save()
}
}When NOT to Use SwiftData
SwiftData requires iOS 17+ minimum deployment target. If your app supports iOS 15 or 16, you must continue using Core Data. Furthermore, SwiftData does not yet support all Core Data features — complex fetch request templates, abstract entities, and some advanced CloudKit configurations may still require Core Data. If your app has a heavily battle-tested Core Data stack with extensive migration history, the risk of introducing bugs during migration may outweigh the developer experience benefits. Consequently, consider a gradual migration where new features use SwiftData while existing features remain on Core Data.
Key Takeaways
- SwiftData Core Data migration replaces verbose Objective-C patterns with declarative Swift-native persistence
- The @Model macro eliminates .xcdatamodeld files and NSManagedObject subclasses
- Type-safe #Predicate queries catch errors at compile time instead of runtime
- VersionedSchema provides safe, code-based schema migration without mapping models
- Adopt incrementally — SwiftData and Core Data can coexist in the same application during migration
Related Reading
- Swift 6 Concurrency Actors and Sendable
- Jetpack Compose Performance Optimization
- Expo SDK 52 New Features and Migration