SwiftData vs Core Data in a New iOS Project: When to Migrate and When Not To Yet
Every time we start a new iOS project that needs local persistence, the first technical question we settle is no longer "do we use Core Data?" but "SwiftData or Core Data?". SwiftData arrived as a modern layer on top of the same persistence engine Core Data has used for years, with declarative syntax built to sit naturally alongside SwiftUI. The pitch is less boilerplate: no NSManagedObject, no NSFetchRequest, no hand-configured NSPersistentContainer. In practice that pitch holds for a good share of projects and falls short for another, and the split isn''t a matter of taste but of how far we actually need to push the data model.
What a new project gains with SwiftData
The most visible change is how the model is defined. In Core Data, a data model lives in an .xcdatamodeld file edited through a graphical tool separate from the codebase, and every entity needs an NSManagedObject subclass, generated or hand-written. In SwiftData, the model is a plain Swift class annotated with the @Model macro:
import SwiftData
@Model
final class Task {
var title: String
var completed: Bool
var dueDate: Date?
init(title: String, completed: Bool = false, dueDate: Date? = nil) {
self.title = title
self.completed = completed
self.dueDate = dueDate
}
}
There''s no separate model file to keep in sync with the code, and relationships between entities are declared as ordinary Swift properties instead of being wired up in a graphical editor. Queries change shape too: instead of building an NSFetchRequest with string-based predicates, SwiftData uses the #Predicate macro, which validates the expression at compile time:
let cutoff = Date()
let descriptor = FetchDescriptor<Task>(
predicate: #Predicate { $0.completed == false && $0.dueDate ?? .distantFuture < cutoff }
)
let overdue = try context.fetch(descriptor)
A Core Data predicate written as a string ("completed == NO AND dueDate < %@") only breaks at runtime if a field gets renamed and nobody updates the string. With #Predicate, the compiler catches that same mistake before the app ever runs. For a team starting a project from zero, with no legacy data and no Core Data model already in production, that removes an entire category of bugs that used to surface only when the app actually ran.
SwiftUI integration is also more direct. The @Query property wrapper replaces NSFetchedResultsController for keeping a view in sync with changes in the store:
struct TaskList: View {
@Query(sort: \Task.dueDate) private var tasks: [Task]
var body: some View {
List(tasks) { task in
Text(task.title)
}
}
}
Getting the same result with Core Data means wrapping an NSFetchedResultsController inside a custom ObservableObject — code that SwiftData simply doesn''t require you to write.
Where SwiftData still falls short
The place SwiftData loses ground to Core Data is fine-grained control over schema migrations. Core Data explicitly versions its models: you define one .xcdatamodeld per version, an NSMappingModel describing how to move from one version to the next, and you can write custom, field-by-field migration logic when a change isn''t trivial — splitting a free-text field into two structured fields, for instance. SwiftData does support migrations, through SchemaMigrationPlan and automatic lightweight migrations for simple changes, but once a migration needs to transform data in a way that isn''t a direct mapping, the control surface it exposes is narrower, and documented patterns for non-trivial cases are still thinner than what Core Data has accumulated over more than a decade of solving these exact problems.
Another friction point is working with multiple storage configurations on top of the same model, something Core Data handles with several NSPersistentStoreDescription instances pointing at the same NSManagedObjectModel: an encrypted store for sensitive data alongside a disposable, unencrypted cache store, both coexisting under one NSPersistentContainer. SwiftData offers that flexibility in a more limited form, and forcing the issue usually ends up looking like working around the framework rather than working with it.
Finally, SwiftData requires a higher minimum iOS version than Core Data. A project that still needs to support iOS versions older than the ones SwiftData shipped on doesn''t get to make this choice at all — the answer is already fixed by the device range the client needs covered.
What kind of project still gets Core Data from us
We choose Core Data when a project inherits an existing data model with a production migration history, because rewriting that model in SwiftData would also mean migrating every current user, with the risk that carries, for no clear payoff in return. We also choose it when the iOS version range we need to support rules SwiftData out, or when the project needs several storage configurations with different encryption rules coexisting in the same app — a pattern we''ve used in apps that keep sensitive data separate from the rest of the local cache.
For a new project with no such history, a data model we don''t expect to need complex migrations for anytime soon, and SwiftUI as the interface layer, SwiftData saves us real code, and we haven''t yet run into a limitation that forced us to walk it back.
What didn''t work: mixing both frameworks in the same project
On one project we tried using SwiftData for new entities while keeping Core Data for the legacy model, in parallel, planning to migrate entity by entity over time. SwiftData can read an existing SQLite store, and in theory it can coexist with an NSPersistentContainer pointed at the same file. In practice, running two active persistence layers over the same store doubled the surface for sync bugs: changes made from the Core Data side weren''t always immediately visible to @Query results on the SwiftData side, and figuring out which of the two frameworks held the "correct" version of an object at a given moment cost more time than the new SwiftData code was saving us. We ended up reverting that entity back to Core Data and reserving the coexistence approach for migrating an entire model in one shot, not for running both frameworks side by side indefinitely.
The question we use to decide
Before writing the data model for a new project, we ask three things: whether there''s a legacy model to migrate, whether the iOS range we need to support includes SwiftData, and whether the project needs multiple storage configurations or schema migrations with non-trivial data transformation. If all three answers favor SwiftData, we start there. If any one of them doesn''t, we stick with Core Data — not because it''s the safer, older choice by default, but because on that specific project it gives us more control exactly where we already know we''re going to need it.