I moved an 18k monthly active users todo app from Core Data to SwiftData. Today 92% of traffic runs through SwiftData, the rest still talks to the legacy Core Data model. Here’s what the migration actually cost, and the things I wish I’d known going in.
Motivation: what I disliked about Core Data
I’d lived with Core Data for seven years. The real issue wasn’t code complexity; it was that the code worked on hope. NSManagedObject subclass generation, NSFetchedResultsController delegate methods, concurrency errors, context merges. SwiftData lets you declare the model and takes care of the rest. Less code, less surface area for bugs.
Decision: run alongside the existing .xcdatamodeld
Apple’s docs have a recipe that looks like “open the Core Data store with SwiftData”. It sounds nice, but this app had NSFetchRequest subqueries and a custom NSIncrementalStore adapter I didn’t want to give up. My approach: write new features against SwiftData and keep the old ones on NSPersistentContainer.
class DataStack {
let coreDataContainer: NSPersistentContainer
let swiftDataContainer: ModelContainer
init() throws {
coreDataContainer = NSPersistentContainer(name: "AppModel")
coreDataContainer.loadPersistentStores { _, err in
if let err { fatalError("(err)") }
}
let schema = Schema([Reminder.self])
swiftDataContainer = try ModelContainer(
for: schema,
configurations: ModelConfiguration(schema: schema)
)
}
}Two stores, two sqlite files. New reminders go into SwiftData, old ones stay in Core Data.
Dual-read data access
The list screen shows data from both stores. The repository layer:
class ReminderRepository {
func fetchAll() async throws -> [ReminderDTO] {
async let legacy = fetchFromCoreData()
async let modern = fetchFromSwiftData()
let (a, b) = try await (legacy, modern)
return (a + b).sorted { $0.createdAt > $1.createdAt }
}
}The DTO is important: the view has no reason to know which store the data came from. Both sources map to the same view model.
Writes: SwiftData only
Every new record the user creates goes into SwiftData. Core Data only sees updates to existing records. No new inserts. Six months in, most Core Data records are stale; users archive or delete their older ones. About 8% will remain; I’ll move those programmatically to SwiftData later.
I didn’t do a one-shot migration, because…
Those 18k users average 300 reminders each. Migrating 5.4 million records on launch would take 30+ seconds. Making the user wait that long was unacceptable. The alternatives:
1. Background migration: kick off a migration thread on first launch. But the app may not stay open that long, and handling a half-finished migration is a new bug surface.
2. Staged migration: a record moves into SwiftData the moment the user touches it. Lazy migration. Minimum user impact. The one I picked.
func loadReminder(id: UUID) async -> Reminder? {
if let swift = fetchFromSwiftData(id: id) { return swift }
if let cd = fetchFromCoreData(id: id) {
let swift = migrate(cd)
try? swiftDataContainer.mainContext.save()
deleteFromCoreData(id: id)
return swift
}
return nil
}Analytics: migration progress
I tracked a daily metric for how much data each user was reading from each source. Week one was 45% Core Data, 55% SwiftData. By month four Core Data was down to 5%. Without this metric it’s hard to know whether the migration is actually finishing.
CloudKit sync broke
The biggest surprise: users on Core Data + CloudKit expected iCloud sync. Because SwiftData + CloudKit uses a different schema, during the transition data from the user’s old device didn’t appear on the new one. My fix: keep the Core Data stack CloudKit-synced for a while longer and run the SwiftData stack local-only. That set me back on feature parity; multi-device users got another three months of waiting.
Test coverage: old bugs came back
My old Core Data concurrency bugs reappeared in new places during the SwiftData move. In spots where I hadn’t used @ModelActor, I’d ended up writing through the main context and crashing. Don’t attempt this migration without disciplined tests.
Total time
From planning to production, four months. Two months of prep and prototyping, one month of staged rollout (first 5% of users), one month of full rollout plus removing 80% of the Core Data codebase. That sounds like a lot, but it’s not just the visible work: internal conversations, writing docs, and sifting user feedback all fit in there.
Was it worth it?
New code is 40% fewer lines with SwiftData. Onboarding a junior dev went from three days to one. Concurrency bugs were gone within two months. Code review got shorter. But the cost I listed above wasn’t zero. With a five-person team I’d do it without thinking; on a one-person team I might wait a bit longer. Mid-size is the sweet spot.