Migrating from Core Data to SwiftData without losing user data

I have eight iOS apps. Several were built on Core Data years ago, when that was the only real option for structured persistence on iOS. Core Data works, but it's verbose. Model files, fetch requests, context juggling, all that boilerplate that accumulates over time. When Apple shipped SwiftData, I wanted to move everything over. Less code, tighter SwiftUI integration, and a unified persistence layer I could share across apps instead of maintaining slightly different Core Data stacks in each one.

The part that kept me up at night

These aren't fresh apps with empty databases. People have real data in them. Study progress, card collections, reading lists, months or years of accumulated state. A migration that wipes someone's data is worse than no migration at all. So I couldn't just swap out the persistence layer and ship.

Running both stacks side by side

My approach was to keep Core Data and SwiftData running simultaneously during a transition period. On first launch after the update, the app reads everything out of the old Core Data store, writes it into SwiftData, and flips a flag so it doesn't run again. Both stacks stay available until the next release, when I remove Core Data entirely.

Migration logic looks roughly like this:

func migrateToSwiftData(
    from context: NSManagedObjectContext,
    to modelContext: ModelContext
) throws {
    let fetchRequest: NSFetchRequest<CDStudyCard> = CDStudyCard.fetchRequest()
    let legacyCards = try context.fetch(fetchRequest)

    for cdCard in legacyCards {
        let card = StudyCard(
            question: cdCard.question ?? "",
            answer: cdCard.answer ?? "",
            nextReviewDate: cdCard.nextReviewDate ?? .now,
            easeFactor: cdCard.easeFactor,
            interval: Int(cdCard.interval)
        )
        modelContext.insert(card)
    }

    try modelContext.save()
    UserDefaults.standard.set(true, forKey: "didMigrateToSwiftData")
}

Each entity type gets its own migration block. Relationships are handled in a second pass after all the objects exist, using a dictionary that maps old NSManagedObjectID values to new SwiftData models.

The bug that almost got me

On iOS 17.2, a lightweight migration failed silently for users who had certain relationship configurations in their Core Data store. No crash, no error. The store just came up corrupted, and reads returned empty results. Only caught it because a beta tester reported all their cards were gone.

That one scared me. A migration that crashes is annoying but recoverable. A migration that silently corrupts data and looks like it succeeded is a different thing entirely.

Backup and recovery

After that scare, I added a pre-migration step that copies the .sqlite file (and its -wal and -shm companions) to a backup directory before touching anything. If the migration throws or the post-migration validation check fails, the app restores from the backup and disables the migration flag so it tries again on next launch.

let storeURL = container.persistentStoreDescriptions.first!.url!
let backupURL = storeURL.deletingLastPathComponent()
    .appendingPathComponent("CoreDataBackup.sqlite")

try FileManager.default.copyItem(at: storeURL, to: backupURL)

A few lines of code that saved me from shipping a data loss bug to production. Should've been there from the start.

Testing against real databases

I kept .sqlite files from every version of every app. My unit test suite runs the migration against each one. If I shipped version 1.0 through 3.1, I have six .sqlite snapshots, and the migration has to produce correct results from all of them. When a new release changes the schema, I add another snapshot. Tedious to maintain, but it's the only way I trust the migration works across the full range of user data shapes.

Do one app at a time

Best decision I made was not migrating everything at once. Picked the app with the simplest data model, migrated it, shipped it, waited two weeks watching crash reports and support emails. When nothing broke, I moved to the next one. Each migration taught me something that made the next one smoother. If I'd tried all eight simultaneously, that iOS 17.2 bug would have hit users across every app instead of just one.

SwiftData is better to work with day to day. Getting there was stressful, but I'm not going back.


-- Daniel