Migrating from Core Data to SwiftData on a shipping app
The reason you can migrate a live app from Core Data to SwiftData without a scary cutover is the same reason the migration sounds intimidating: SwiftData is Core Data. Same store, same engine, a friendlier face on top.
I had an app with a few years of user data sitting in a Core Data store — call it a habit tracker, tens of thousands of records on some devices, all of it irreplaceable to the person who created it. SwiftData was the obvious place to go: less boilerplate, value-type-ish models, a fetch API that doesn't read like 2010. But "obvious place to go" and "safe to ship the change" are different sentences, and the gap between them is where data-loss bugs live. This is how I closed that gap without anyone losing a single record.
It's the same store underneath
The single most important fact in this whole exercise: SwiftData and Core Data write to the same SQLite store, with a compatible schema, through the same persistence stack. SwiftData's ModelContainer is sitting on top of an NSPersistentContainer-shaped world. That's not an implementation detail to ignore — it's the entire reason a gradual migration is possible.
Because the formats line up, you can point a ModelContainer at the file your old Core Data stack already created and have them coexist. You don't export and re-import. You don't run a one-shot batch job that rewrites every row on launch and prays the device doesn't get backgrounded halfway through. The bytes on disk stay put; you change which API reads them. That reframes the work from "data migration" — terrifying — to "code migration" — tedious, but bounded.
Annotating the models
The mechanical part is translating each NSManagedObject subclass into an @Model class. The shape is familiar enough that it goes faster than you'd expect, but the details matter because they're what determines whether SwiftData recognizes the existing store as the same schema or decides it needs to migrate.
Entity names map to type names. Attributes become stored properties with matching types. To-many relationships become arrays, and you wire up inverses explicitly. The two annotations that earn their keep are @Attribute(.unique) for anything that was a uniqueness constraint, and @Relationship with an inverse and a delete rule, because getting the delete rule wrong is how you orphan rows or cascade-delete a user's history by accident.
import SwiftData
@Model
final class Habit {
@Attribute(.unique) var id: UUID
var name: String
var createdAt: Date
@Relationship(deleteRule: .cascade, inverse: \Entry.habit)
var entries: [Entry] = []
init(id: UUID = UUID(), name: String, createdAt: Date = .now) {
self.id = id
self.name = name
self.createdAt = createdAt
}
}
@Model
final class Entry {
var date: Date
var note: String?
var habit: Habit?
init(date: Date, note: String? = nil) {
self.date = date
self.note = note
}
}
The thing to internalize is that this isn't a fresh schema — it's a description of the schema already on disk. If your old model named the entity Habit with an attribute createdAt, the @Model has to agree, down to optionality. A property the old store had as non-optional that you declare optional (or vice versa) is a schema difference, and SwiftData will treat it as a migration rather than a clean attach. Most of the careful work is just making the new declarations match the old reality exactly.
Versioned migrations, when you actually need them
If the schemas match, there's no migration — SwiftData opens the store and you're done. You only reach for migration machinery when the shapes genuinely diverge: you renamed something, split an entity, changed a type. SwiftData models this with VersionedSchema and a SchemaMigrationPlan, which is the modern equivalent of Core Data's mapping models and the NSMigrationManager dance.
Lightweight migrations — adding an optional attribute, adding a new entity — happen automatically. The custom ones, where data has to be transformed on the way across, get a stage with a willMigrate/didMigrate hook where you do the work in code. My advice, learned the slow way: do everything you possibly can as a lightweight migration. Custom migration stages are where the data-loss bugs hide, and the best custom migration is the one you designed your schema to avoid.
A migration that passes on an empty simulator database tells you the code compiles. Pull a real store from a long-time user's device (with permission), drop it into a test, and run the migration against that. The bug you're looking for — the one row with a null where you assumed a value, the relationship that's been dangling since iOS 14 — only exists in data that's lived a while.
The boundary is what made this small
Here's where the architecture paid for itself. The app never imported Core Data. It talked to a protocol I owned — the same RateStore-style boundary I wrote about in the longevity post. Every screen, every view model, every test went through that protocol; the Core Data specifics lived behind exactly one implementation.
So the migration, from the rest of the app's point of view, was a single new type. I wrote a SwiftDataHabitStore conforming to the same protocol as the old CoreDataHabitStore, and the call sites didn't change — they couldn't even tell. No mass find-and-replace, no recompiling the world, no UI churn. If Core Data had leaked into a hundred views, this would have been a multi-week slog with a hundred chances to break something. Behind a boundary, it was a near one-type change.
protocol HabitStore {
func save(_ habit: Habit) throws
func all() throws -> [Habit]
func delete(_ habit: Habit) throws
}
// Old: CoreDataHabitStore: HabitStore — talks to NSManagedObjectContext
// New: SwiftDataHabitStore: HabitStore — talks to ModelContext
let container = try ModelContainer(
for: Habit.self, Entry.self,
configurations: ModelConfiguration(url: storeURL) // the existing file
)
Pointing ModelConfiguration at the existing storeURL is the whole coexistence trick: SwiftData adopts the file the Core Data stack has been writing all along.
Roll it out behind a flag
I did not flip the whole user base over in one release. The new SwiftDataHabitStore shipped behind a feature flag, dark, for a full version. In that window I had both implementations in the binary and a way to choose between them per user. The first cohort was just me and the TestFlight group — real devices, real accumulated data, with the old store still the source of truth so a problem couldn't cost anyone anything.
What I verified before widening the rollout: that record counts matched after attaching the new store, that relationships resolved the same way, that the fetches the app actually runs returned identical results, and — the one that caught a real bug — that a predicate behaving slightly differently under SwiftData didn't silently drop rows from a filtered list. Only once the flag had been on for a meaningful slice of real users without a single discrepancy did I make SwiftData the default. The flag stayed in for one more release as a kill switch. Migrations are safe when you can turn them off.
The sharp edges
Three things drew blood, and they're worth naming so you budget for them. CloudKit sync is the big one: SwiftData's automatic CloudKit integration is real but opinionated, and it imposes constraints Core Data + NSPersistentCloudKitContainer didn't — no uniqueness constraints synced to CloudKit, every relationship optional, every attribute either optional or with a default. If you sync, design the schema for those rules from the start, because retrofitting them later is its own migration.
Fetch performance is the second. The ergonomic @Query and array-shaped relationships make it very easy to pull more into memory than you mean to. On the big stores I had to reach for FetchDescriptor with explicit fetchLimit and propertiesToFetch — the SwiftData equivalents of the batching and faulting knobs you'd tune in Core Data — rather than trusting the convenient path. Predicate differences are the third: #Predicate is type-checked and pleasant, but it doesn't support everything NSPredicate did, and a string comparison or an optional-chained keypath can compile yet behave subtly differently. Test the predicates against known data; don't assume parity.
If there's one thing to take from this, it's that the migration was never really about SwiftData. It was about blast radius. The store format being shared meant the data never had to move; the protocol boundary meant the change touched one type instead of a hundred; the feature flag meant I could verify against real user data and still walk it back. Each of those shrank the surface area where something could go wrong. A migration on a shipping app isn't safe because the new framework is good — it's safe because, when you've done the boring work first, there's almost nothing left that can break.