Introduction

SwiftData has matured significantly since its introduction at WWDC 2023. With iOS 18 and macOS 15, SwiftData now handles the complex scenarios that kept teams on Core Data: CloudKit sync, efficient batch operations, and reliable migrations. The question has shifted from “Is SwiftData ready?” to “How do we migrate?”

This guide walks through migrating a production Core Data app to SwiftData. We will cover data model conversion, migration strategies that preserve user data, and patterns for running both frameworks during transition.

Why Migrate to SwiftData

Code Reduction

The most immediate benefit is dramatically less boilerplate:

// Core Data: 50+ lines for a basic model
// NSManagedObject subclass + extension + fetch request setup

// SwiftData: 15 lines
@Model
final class Task {
    var title: String
    var isCompleted: Bool
    var dueDate: Date?
    var priority: Priority
    @Relationship(deleteRule: .cascade) var subtasks: [Subtask]

    init(title: String, priority: Priority = .medium) {
        self.title = title
        self.isCompleted = false
        self.priority = priority
        self.subtasks = []
    }
}

SwiftUI Integration

SwiftData integrates naturally with SwiftUI:

// Query replaces @FetchRequest
@Query(filter: #Predicate<Task> { !$0.isCompleted },
       sort: \.dueDate)
var pendingTasks: [Task]

// Changes automatically update views
// No need for NSFetchedResultsController

Type Safety

Predicates are now type-checked at compile time:

// Core Data: Runtime errors possible
let predicate = NSPredicate(format: "tittle == %@", "Test") // Typo undetected

// SwiftData: Compile-time checking
let predicate = #Predicate<Task> { $0.tittle == "Test" } // Compiler error

Assessing Migration Complexity

Before

starting, audit your Core Data usage:

Simple Migration (1-2 weeks)

  • Single model container
  • Basic relationships (one-to-many)
  • No CloudKit sync
  • Minimal custom fetch requests
  • iOS 17+ deployment target

Moderate Migration (3-4 weeks)

  • Multiple entities with relationships
  • Background context usage
  • Custom migrations
  • Basic CloudKit sync
  • Need to support iOS 16 (Core Data) alongside iOS 17+ (SwiftData)

Complex Migration (6+ weeks)

  • Heavy background processing
  • Complex many-to-many relationships
  • Derived attributes
  • Custom Core Data extensions
  • Critical CloudKit sync with conflict resolution
  • Must preserve years of user data

Converting Data Models

Ba

sic Model Conversion

Core Data:

// TaskEntity.xcdatamodeld
// Entity: Task
// Attributes:
//   - id: UUID
//   - title: String
//   - isCompleted: Boolean
//   - createdAt: Date
//   - priority: Integer 16

// NSManagedObject subclass (auto-generated or manual)
extension Task {
    @nonobjc public class func fetchRequest() -> NSFetchRequest<Task> {
        return NSFetchRequest<Task>(entityName: "Task")
    }

    @NSManaged public var id: UUID?
    @NSManaged public var title: String?
    @NSManaged public var isCompleted: Bool
    @NSManaged public var createdAt: Date?
    @NSManaged public var priority: Int16
}

SwiftData:

import SwiftData

@Model
final class Task {
    @Attribute(.unique) var id: UUID
    var title: String
    var isCompleted: Bool
    var createdAt: Date
    var priority: Priority

    enum Priority: Int, Codable {
        case low = 0
        case medium = 1
        case high = 2
        case urgent = 3
    }

    init(title: String, priority: Priority = .medium) {
        self.id = UUID()
        self.title = title
        self.isCompleted = false
        self.createdAt = Date()
        self.priority = priority
    }
}

Relationship Conversion

Core Data:

// Project Entity
extension Project {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
    @NSManaged public var tasks: NSSet?  // To-many relationship
}

extension Project {
    @objc(addTasksObject:)
    @NSManaged public func addToTasks(_ value: Task)

    @objc(removeTasksObject:)
    @NSManaged public func removeFromTasks(_ value: Task)
}

// Task Entity
extension Task {
    @NSManaged public var project: Project?  // To-one inverse
}

SwiftData:

@Model
final class Project {
    @Attribute(.unique) var id: UUID
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Task.project)
    var tasks: [Task]

    init(name: String) {
        self.id = UUID()
        self.name = name
        self.tasks = []
    }
}

@Model
final class Task {
    @Attribute(.unique) var id: UUID
    var title: String
    var isCompleted: Bool
    var project: Project?

    init(title: String, project: Project? = nil) {
        self.id = UUID()
        self.title = title
        self.isCompleted = false
        self.project = project
    }
}

Complex Relationship: Many-to-Many

Core Data:

// Tag Entity with many-to-many to Task
extension Tag {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
    @NSManaged public var tasks: NSSet?
}

extension Task {
    @NSManaged public var tags: NSSet?
}

SwiftData:

@Model
final class Tag {
    @Attribute(.unique) var id: UUID
    var name: String
    var tasks: [Task]

    init(name: String) {
        self.id = UUID()
        self.name = name
        self.tasks = []
    }
}

@Model
final class Task {
    @Attribute(.unique) var id: UUID
    var title: String
    @Relationship(inverse: \Tag.tasks)
    var tags: [Tag]

    init(title: String) {
        self.id = UUID()
        self.title = title
        self.tags = []
    }
}

Migration Strategies

Run both Core Data and SwiftData simultaneously, migrate data on first launch, then deprecate Core Data.

import SwiftData
import CoreData

class MigrationManager {
    static let shared = MigrationManager()

    private let userDefaults = UserDefaults.standard
    private let migrationKey = "swiftdata_migration_completed"

    var needsMigration: Bool {
        !userDefaults.bool(forKey: migrationKey) && coreDataStoreExists()
    }

    private func coreDataStoreExists() -> Bool {
        let storeURL = FileManager.default
            .urls(for: .applicationSupportDirectory, in: .userDomainMask)
            .first?
            .appendingPathComponent("TaskManager.sqlite")

        return FileManager.default.fileExists(atPath: storeURL?.path ?? "")
    }

    func migrateIfNeeded(modelContainer: ModelContainer) async throws {
        guard needsMigration else { return }

        // Set up Core Data stack for reading
        let coreDataStack = CoreDataStack()

        // Perform migration
        try await performMigration(
            from: coreDataStack,
            to: modelContainer
        )

        // Mark migration complete
        userDefaults.set(true, forKey: migrationKey)

        // Optionally delete Core Data store
        // deleteOldCoreDataStore()
    }

    private func performMigration(
        from coreData: CoreDataStack,
        to swiftData: ModelContainer
    ) async throws {
        let context = coreData.viewContext

        // Fetch all Core Data entities
        let projectRequest: NSFetchRequest<CDProject> = CDProject.fetchRequest()
        let cdProjects = try context.fetch(projectRequest)

        let taskRequest: NSFetchRequest<CDTask> = CDTask.fetchRequest()
        let cdTasks = try context.fetch(taskRequest)

        let tagRequest: NSFetchRequest<CDTag> = CDTag.fetchRequest()
        let cdTags = try context.fetch(tagRequest)

        // Migrate in SwiftData context
        let swiftDataContext = ModelContext(swiftData)

        // First pass: Create all entities without relationships
        var projectMap: [UUID: Project] = [:]
        var tagMap: [UUID: Tag] = [:]

        for cdProject in cdProjects {
            guard let id = cdProject.id, let name = cdProject.name else { continue }

            let project = Project(name: name)
            project.id = id
            swiftDataContext.insert(project)
            projectMap[id] = project
        }

        for cdTag in cdTags {
            guard let id = cdTag.id, let name = cdTag.name else { continue }

            let tag = Tag(name: name)
            tag.id = id
            swiftDataContext.insert(tag)
            tagMap[id] = tag
        }

        // Second pass: Create tasks with relationships
        for cdTask in cdTasks {
            guard let id = cdTask.id, let title = cdTask.title else { continue }

            let task = Task(title: title)
            task.id = id
            task.isCompleted = cdTask.isCompleted
            task.createdAt = cdTask.createdAt ?? Date()
            task.priority = Task.Priority(rawValue: Int(cdTask.priority)) ?? .medium

            // Link to project
            if let projectId = cdTask.project?.id,
               let project = projectMap[projectId] {
                task.project = project
            }

            // Link to tags
            if let cdTags = cdTask.tags as? Set<CDTag> {
                for cdTag in cdTags {
                    if let tagId = cdTag.id, let tag = tagMap[tagId] {
                        task.tags.append(tag)
                    }
                }
            }

            swiftDataContext.insert(task)
        }

        try swiftDataContext.save()
    }
}

Strategy 2: Fresh Start with Export/Import

For apps where data can be exported and reimported:

struct DataExporter {
    struct ExportedData: Codable {
        let version: Int
        let exportDate: Date
        let projects: [ExportedProject]
        let tasks: [ExportedTask]
        let tags: [ExportedTag]
    }

    struct ExportedProject: Codable {
        let id: UUID
        let name: String
    }

    struct ExportedTask: Codable {
        let id: UUID
        let title: String
        let isCompleted: Bool
        let createdAt: Date
        let priority: Int
        let projectId: UUID?
        let tagIds: [UUID]
    }

    struct ExportedTag: Codable {
        let id: UUID
        let name: String
    }

    func exportFromCoreData(context: NSManagedObjectContext) throws -> ExportedData {
        // Fetch and convert all entities
        let projectRequest: NSFetchRequest<CDProject> = CDProject.fetchRequest()
        let projects = try context.fetch(projectRequest).compactMap { cdProject -> ExportedProject? in
            guard let id = cdProject.id, let name = cdProject.name else { return nil }
            return ExportedProject(id: id, name: name)
        }

        let taskRequest: NSFetchRequest<CDTask> = CDTask.fetchRequest()
        let tasks = try context.fetch(taskRequest).compactMap { cdTask -> ExportedTask? in
            guard let id = cdTask.id, let title = cdTask.title else { return nil }

            let tagIds = (cdTask.tags as? Set<CDTag>)?.compactMap { $0.id } ?? []

            return ExportedTask(
                id: id,
                title: title,
                isCompleted: cdTask.isCompleted,
                createdAt: cdTask.createdAt ?? Date(),
                priority: Int(cdTask.priority),
                projectId: cdTask.project?.id,
                tagIds: tagIds
            )
        }

        let tagRequest: NSFetchRequest<CDTag> = CDTag.fetchRequest()
        let tags = try context.fetch(tagRequest).compactMap { cdTag -> ExportedTag? in
            guard let id = cdTag.id, let name = cdTag.name else { return nil }
            return ExportedTag(id: id, name: name)
        }

        return ExportedData(
            version: 1,
            exportDate: Date(),
            projects: projects,
            tasks: tasks,
            tags: tags
        )
    }

    func importToSwiftData(_ data: ExportedData, context: ModelContext) throws {
        var projectMap: [UUID: Project] = [:]
        var tagMap: [UUID: Tag] = [:]

        // Import projects
        for exported in data.projects {
            let project = Project(name: exported.name)
            project.id = exported.id
            context.insert(project)
            projectMap[exported.id] = project
        }

        // Import tags
        for exported in data.tags {
            let tag = Tag(name: exported.name)
            tag.id = exported.id
            context.insert(tag)
            tagMap[exported.id] = tag
        }

        // Import tasks
        for exported in data.tasks {
            let task = Task(title: exported.title)
            task.id = exported.id
            task.isCompleted = exported.isCompleted
            task.createdAt = exported.createdAt
            task.priority = Task.Priority(rawValue: exported.priority) ?? .medium

            if let projectId = exported.projectId {
                task.project = projectMap[projectId]
            }

            for tagId in exported.tagIds {
                if let tag = tagMap[tagId] {
                    task.tags.append(tag)
                }
            }

            context.insert(task)
        }

        try context.save()
    }
}

Strategy 3: Gradual Feature Migration

Migrate feature by feature while maintaining Core Data for existing data:

// DataManager that abstracts storage backend
protocol TaskStorage {
    func fetchTasks() async throws -> [TaskDTO]
    func save(_ task: TaskDTO) async throws
    func delete(_ task: TaskDTO) async throws
}

struct TaskDTO {
    let id: UUID
    var title: String
    var isCompleted: Bool
    var createdAt: Date
    var priority: Int
}

// Core Data implementation
class CoreDataTaskStorage: TaskStorage {
    private let context: NSManagedObjectContext

    init(context: NSManagedObjectContext) {
        self.context = context
    }

    func fetchTasks() async throws -> [TaskDTO] {
        try await context.perform {
            let request: NSFetchRequest<CDTask> = CDTask.fetchRequest()
            let results = try self.context.fetch(request)

            return results.compactMap { cdTask in
                guard let id = cdTask.id, let title = cdTask.title else { return nil }
                return TaskDTO(
                    id: id,
                    title: title,
                    isCompleted: cdTask.isCompleted,
                    createdAt: cdTask.createdAt ?? Date(),
                    priority: Int(cdTask.priority)
                )
            }
        }
    }

    func save(_ task: TaskDTO) async throws {
        try await context.perform {
            let request: NSFetchRequest<CDTask> = CDTask.fetchRequest()
            request.predicate = NSPredicate(format: "id == %@", task.id as CVarArg)

            let existing = try self.context.fetch(request).first

            let cdTask = existing ?? CDTask(context: self.context)
            cdTask.id = task.id
            cdTask.title = task.title
            cdTask.isCompleted = task.isCompleted
            cdTask.createdAt = task.createdAt
            cdTask.priority = Int16(task.priority)

            try self.context.save()
        }
    }

    func delete(_ task: TaskDTO) async throws {
        try await context.perform {
            let request: NSFetchRequest<CDTask> = CDTask.fetchRequest()
            request.predicate = NSPredicate(format: "id == %@", task.id as CVarArg)

            if let cdTask = try self.context.fetch(request).first {
                self.context.delete(cdTask)
                try self.context.save()
            }
        }
    }
}

// SwiftData implementation
@MainActor
class SwiftDataTaskStorage: TaskStorage {
    private let modelContext: ModelContext

    init(modelContext: ModelContext) {
        self.modelContext = modelContext
    }

    func fetchTasks() async throws -> [TaskDTO] {
        let descriptor = FetchDescriptor<Task>(sortBy: [SortDescriptor(\.createdAt)])
        let tasks = try modelContext.fetch(descriptor)

        return tasks.map { task in
            TaskDTO(
                id: task.id,
                title: task.title,
                isCompleted: task.isCompleted,
                createdAt: task.createdAt,
                priority: task.priority.rawValue
            )
        }
    }

    func save(_ task: TaskDTO) async throws {
        let predicate = #Predicate<Task> { $0.id == task.id }
        var descriptor = FetchDescriptor(predicate: predicate)
        descriptor.fetchLimit = 1

        let existing = try modelContext.fetch(descriptor).first

        if let swiftTask = existing {
            swiftTask.title = task.title
            swiftTask.isCompleted = task.isCompleted
            swiftTask.priority = Task.Priority(rawValue: task.priority) ?? .medium
        } else {
            let swiftTask = Task(title: task.title)
            swiftTask.id = task.id
            swiftTask.isCompleted = task.isCompleted
            swiftTask.createdAt = task.createdAt
            swiftTask.priority = Task.Priority(rawValue: task.priority) ?? .medium
            modelContext.insert(swiftTask)
        }

        try modelContext.save()
    }

    func delete(_ task: TaskDTO) async throws {
        let predicate = #Predicate<Task> { $0.id == task.id }
        var descriptor = FetchDescriptor(predicate: predicate)
        descriptor.fetchLimit = 1

        if let swiftTask = try modelContext.fetch(descriptor).first {
            modelContext.delete(swiftTask)
            try modelContext.save()
        }
    }
}

// Feature flag to switch storage
class StorageFactory {
    static func createTaskStorage() -> TaskStorage {
        if FeatureFlags.useSwiftData {
            let container = try! ModelContainer(for: Task.self)
            return SwiftDataTaskStorage(modelContext: container.mainContext)
        } else {
            let coreDataStack = CoreDataStack.shared
            return CoreDataTaskStorage(context: coreDataStack.viewContext)
        }
    }
}

Handling Schema Changes

SwiftData Schema Versioning

// Define schema versions
enum TaskSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Task.self, Project.self]
    }

    @Model
    final class Task {
        var id: UUID
        var title: String
        var isCompleted: Bool

        init(title: String) {
            self.id = UUID()
            self.title = title
            self.isCompleted = false
        }
    }

    @Model
    final class Project {
        var id: UUID
        var name: String
        var tasks: [Task]

        init(name: String) {
            self.id = UUID()
            self.name = name
            self.tasks = []
        }
    }
}

// Version 2: Added priority and due date
enum TaskSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Task.self, Project.self]
    }

    @Model
    final class Task {
        var id: UUID
        var title: String
        var isCompleted: Bool
        var priority: Int  // New field
        var dueDate: Date? // New field

        init(title: String) {
            self.id = UUID()
            self.title = title
            self.isCompleted = false
            self.priority = 1  // Default to medium
            self.dueDate = nil
        }
    }

    @Model
    final class Project {
        var id: UUID
        var name: String
        var tasks: [Task]

        init(name: String) {
            self.id = UUID()
            self.name = name
            self.tasks = []
        }
    }
}

Migration Plans

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
        // Custom migration logic
        let tasks = try context.fetch(FetchDescriptor<TaskSchemaV2.Task>())

        for task in tasks {
            // Set default values for new fields
            if task.priority == 0 {
                task.priority = 1  // Medium priority
            }
            // dueDate defaults to nil, which is fine
        }

        try context.save()
    }
}

// Configure container with migration plan
let container = try ModelContainer(
    for: Task.self, Project.self,
    migrationPlan: TaskMigrationPlan.self
)

CloudKit Sync Considerations

Migrating CloudKit-Synced Data

// SwiftData with CloudKit
let schema = Schema([Task.self, Project.self, Tag.self])

let configuration = ModelConfiguration(
    "TaskManager",
    schema: schema,
    cloudKitDatabase: .private("iCloud.com.yourapp.taskmanager")
)

let container = try ModelContainer(
    for: schema,
    configurations: [configuration]
)

// Important: CloudKit sync happens automatically
// But you need to handle potential conflicts

Conflict Resolution

@Model
final class Task {
    @Attribute(.unique) var id: UUID
    var title: String
    var isCompleted: Bool
    var lastModified: Date  // Track for conflict resolution

    init(title: String) {
        self.id = UUID()
        self.title = title
        self.isCompleted = false
        self.lastModified = Date()
    }

    func update(title: String? = nil, isCompleted: Bool? = nil) {
        if let title = title {
            self.title = title
        }
        if let isCompleted = isCompleted {
            self.isCompleted = isCompleted
        }
        self.lastModified = Date()
    }
}

// Monitor sync status
class CloudKitSyncMonitor: ObservableObject {
    @Published var syncStatus: SyncStatus = .idle

    enum SyncStatus {
        case idle
        case syncing
        case error(Error)
        case upToDate
    }

    init() {
        observeCloudKitNotifications()
    }

    private func observeCloudKitNotifications() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleImport),
            name: NSPersistentCloudKitContainer.eventChangedNotification,
            object: nil
        )
    }

    @objc private func handleImport(_ notification: Notification) {
        guard let event = notification.userInfo?[
            NSPersistentCloudKitContainer.eventNotificationUserInfoKey
        ] as? NSPersistentCloudKitContainer.Event else { return }

        DispatchQueue.main.async {
            switch event.type {
            case .setup:
                self.syncStatus = .syncing
            case .import:
                if event.succeeded {
                    self.syncStatus = .upToDate
                } else if let error = event.error {
                    self.syncStatus = .error(error)
                }
            case .export:
                if event.succeeded {
                    self.syncStatus = .upToDate
                }
            @unknown default:
                break
            }
        }
    }
}

Testing the Migration

Unit Tests for Migration Logic

import XCTest
import CoreData
@testable import YourApp

final class MigrationTests: XCTestCase {

    var coreDataStack: CoreDataStack!
    var swiftDataContainer: ModelContainer!

    override func setUp() async throws {
        // Set up in-memory Core Data stack
        coreDataStack = CoreDataStack(inMemory: true)

        // Set up in-memory SwiftData container
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        swiftDataContainer = try ModelContainer(
            for: Task.self, Project.self, Tag.self,
            configurations: [config]
        )
    }

    override func tearDown() async throws {
        coreDataStack = nil
        swiftDataContainer = nil
    }

    func testBasicTaskMigration() async throws {
        // Arrange: Create Core Data tasks
        let context = coreDataStack.viewContext
        let cdTask = CDTask(context: context)
        cdTask.id = UUID()
        cdTask.title = "Test Task"
        cdTask.isCompleted = false
        cdTask.createdAt = Date()
        cdTask.priority = 2
        try context.save()

        // Act: Migrate
        let migrationManager = MigrationManager()
        try await migrationManager.performMigration(
            from: coreDataStack,
            to: swiftDataContainer
        )

        // Assert: Verify SwiftData contains migrated data
        let modelContext = ModelContext(swiftDataContainer)
        let descriptor = FetchDescriptor<Task>()
        let tasks = try modelContext.fetch(descriptor)

        XCTAssertEqual(tasks.count, 1)
        XCTAssertEqual(tasks.first?.title, "Test Task")
        XCTAssertEqual(tasks.first?.priority, .high)
    }

    func testProjectTaskRelationshipMigration() async throws {
        // Arrange: Create project with tasks in Core Data
        let context = coreDataStack.viewContext

        let cdProject = CDProject(context: context)
        cdProject.id = UUID()
        cdProject.name = "Test Project"

        let cdTask1 = CDTask(context: context)
        cdTask1.id = UUID()
        cdTask1.title = "Task 1"
        cdTask1.project = cdProject

        let cdTask2 = CDTask(context: context)
        cdTask2.id = UUID()
        cdTask2.title = "Task 2"
        cdTask2.project = cdProject

        try context.save()

        // Act: Migrate
        let migrationManager = MigrationManager()
        try await migrationManager.performMigration(
            from: coreDataStack,
            to: swiftDataContainer
        )

        // Assert: Verify relationships are preserved
        let modelContext = ModelContext(swiftDataContainer)
        let projectDescriptor = FetchDescriptor<Project>()
        let projects = try modelContext.fetch(projectDescriptor)

        XCTAssertEqual(projects.count, 1)
        XCTAssertEqual(projects.first?.tasks.count, 2)
    }

    func testMigrationPreservesUUIDs() async throws {
        // Arrange
        let context = coreDataStack.viewContext
        let originalId = UUID()

        let cdTask = CDTask(context: context)
        cdTask.id = originalId
        cdTask.title = "Test Task"
        try context.save()

        // Act
        let migrationManager = MigrationManager()
        try await migrationManager.performMigration(
            from: coreDataStack,
            to: swiftDataContainer
        )

        // Assert: UUID is preserved
        let modelContext = ModelContext(swiftDataContainer)
        let predicate = #Predicate<Task> { $0.id == originalId }
        let descriptor = FetchDescriptor(predicate: predicate)
        let tasks = try modelContext.fetch(descriptor)

        XCTAssertEqual(tasks.count, 1)
        XCTAssertEqual(tasks.first?.id, originalId)
    }

    func testLargeMigrationPerformance() async throws {
        // Arrange: Create 10,000 tasks
        let context = coreDataStack.viewContext

        for i in 0..less than 10_000 {
            let cdTask = CDTask(context: context)
            cdTask.id = UUID()
            cdTask.title = "Task \(i)"
            cdTask.isCompleted = i % 2 == 0
            cdTask.createdAt = Date()
            cdTask.priority = Int16(i % 4)
        }
        try context.save()

        // Act & Assert: Migration completes in reasonable time
        let migrationManager = MigrationManager()

        let start = CFAbsoluteTimeGetCurrent()
        try await migrationManager.performMigration(
            from: coreDataStack,
            to: swiftDataContainer
        )
        let duration = CFAbsoluteTimeGetCurrent() - start

        XCTAssertLessThan(duration, 30.0, "Migration should complete within 30 seconds")

        // Verify count
        let modelContext = ModelContext(swiftDataContainer)
        let descriptor = FetchDescriptor<Task>()
        let tasks = try modelContext.fetch(descriptor)
        XCTAssertEqual(tasks.count, 10_000)
    }
}

Production Migration Checklist

Pre-Migration

  1. Backup Strategy

    • Implement automatic backup before migration
    • Store backup in separate location
    • Test backup restoration
  2. Feature Flags

    • Add flag to enable/disable migration
    • Ability to rollback to Core Data if needed
  3. Analytics

    • Track migration start/completion
    • Log any errors with context
    • Measure migration duration

During Migration

class ProductionMigrationManager {
    func performMigration() async {
        // 1. Track migration start
        Analytics.track("migration_started", properties: [
            "core_data_version": coreDataVersion,
            "task_count": taskCount,
            "project_count": projectCount
        ])

        // 2. Create backup
        do {
            try await createBackup()
        } catch {
            Analytics.track("migration_backup_failed", properties: ["error": error.localizedDescription])
            return  // Don't proceed without backup
        }

        // 3. Perform migration
        let startTime = CFAbsoluteTimeGetCurrent()
        do {
            try await performMigrationInternal()

            let duration = CFAbsoluteTimeGetCurrent() - startTime
            Analytics.track("migration_completed", properties: [
                "duration_seconds": duration,
                "tasks_migrated": migratedTaskCount
            ])
        } catch {
            Analytics.track("migration_failed", properties: [
                "error": error.localizedDescription,
                "duration_seconds": CFAbsoluteTimeGetCurrent() - startTime
            ])

            // Attempt rollback
            await attemptRollback()
        }
    }

    private func createBackup() async throws {
        let fileManager = FileManager.default
        let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
        let backupURL = documentsURL.appendingPathComponent("CoreDataBackup")

        // Copy entire Core Data store
        let storeURL = documentsURL.appendingPathComponent("TaskManager.sqlite")

        if fileManager.fileExists(atPath: backupURL.path) {
            try fileManager.removeItem(at: backupURL)
        }

        try fileManager.createDirectory(at: backupURL, withIntermediateDirectories: true)

        // Copy all store files
        let storeFiles = ["TaskManager.sqlite", "TaskManager.sqlite-shm", "TaskManager.sqlite-wal"]
        for file in storeFiles {
            let sourceURL = documentsURL.appendingPathComponent(file)
            let destURL = backupURL.appendingPathComponent(file)

            if fileManager.fileExists(atPath: sourceURL.path) {
                try fileManager.copyItem(at: sourceURL, to: destURL)
            }
        }
    }
}

Post-Migration

  1. Verification

    • Compare record counts
    • Spot-check relationships
    • Verify CloudKit sync (if applicable)
  2. Cleanup

    • Delete Core Data store after confirmed success
    • Remove migration code after full rollout
    • Archive backup after stability period

Conclusion

Migrating from Core Data to SwiftData requires careful planning but delivers significant long-term benefits: cleaner code, better SwiftUI integration, and compile-time safety. The side-by-side migration strategy provides the safest path forward, allowing you to validate the migration thoroughly before cutting over.

Start with a comprehensive audit of your Core Data usage. Plan for the complexity level that matches your app. Test extensively, especially relationship preservation and edge cases. And always maintain a rollback path until the migration proves stable in production.

The investment in migration pays dividends every time you write a query, add a model property, or debug a data issue. SwiftData is the future of iOS data persistence, and now is the right time to make the transition.


Planning a Core Data to SwiftData migration? We have migrated multiple production apps with millions of records. Contact us for a migration assessment and strategy session.