Pavan Rangani

HomeBlogSwiftData vs Core Data: Migration Guide for iOS Persistence in 2026

SwiftData vs Core Data: Migration Guide for iOS Persistence in 2026

By Pavan Rangani · March 25, 2026 · Mobile Development

SwiftData vs Core Data: Migration Guide for iOS Persistence in 2026

SwiftData Core Data Migration for iOS

SwiftData Core Data migration is one of the most impactful modernization steps for iOS developers in 2026. SwiftData, introduced at WWDC 2023 and now mature in iOS 18+, replaces the verbose and error-prone Core Data API with a declarative, Swift-native persistence framework. It uses macros, property wrappers, and Swift concurrency to deliver a dramatically simpler developer experience while maintaining Core Data’s powerful storage engine underneath. In practice, teams that adopt it report less persistence boilerplate and fewer runtime crashes from stringly-typed fetch requests.

This guide provides a practical migration path from Core Data to SwiftData, covering model conversion, query syntax, relationships, CloudKit integration, and strategies for incremental adoption in existing applications. Moreover, it digs into the parts the WWDC demos gloss over: how concurrency really works, where unique constraints and indexes live, and what breaks when you push the framework past its comfort zone.

Core Data vs SwiftData: What Changes

Core Data vs SwiftData Comparison

┌────────────────────────┬───────────────────┬───────────────────┐
│ Feature                │ Core Data         │ SwiftData         │
├────────────────────────┼───────────────────┼───────────────────┤
│ Model Definition       │ .xcdatamodeld     │ Swift classes     │
│ Schema                 │ Visual editor     │ @Model macro      │
│ Fetch Requests         │ NSFetchRequest    │ #Predicate macro  │
│ Context                │ NSManagedObject   │ ModelContext       │
│                        │ Context           │                   │
│ Concurrency            │ Manual (perform)  │ @ModelActor       │
│ Relationships          │ Visual + code     │ Swift properties  │
│ CloudKit               │ NSPersistentCloud │ Built-in          │
│                        │ KitContainer      │                   │
│ Undo Support           │ Manual            │ Automatic         │
│ Migration              │ Mapping models    │ VersionedSchema   │
│ SwiftUI Integration    │ @FetchRequest     │ @Query            │
└────────────────────────┴───────────────────┴───────────────────┘
SwiftData Core Data iOS persistence comparison
SwiftData simplifies iOS persistence with Swift-native model definitions and queries

The most important thing to understand is that SwiftData is not a rewrite of the storage layer — it is a new API surface over the same SQLite-backed engine. Consequently, both frameworks can read and write the same underlying store file, which is exactly what makes incremental migration possible. The behavioral differences you feel come from the API: compile-time predicates, automatic change tracking, and a context that is far less ceremonial than NSManagedObjectContext.

Converting Core Data Models to SwiftData

The first migration step is converting your Core Data entity definitions to SwiftData model classes. Moreover, SwiftData uses the @Model macro to generate all the persistence boilerplate. The macro rewrites your stored properties into observable, tracked attributes at compile time, so there is no @NSManaged indirection and no separate visual model to keep in sync:

// BEFORE: Core Data NSManagedObject subclass
// (plus .xcdatamodeld visual model file)
class CDTask: NSManagedObject {
    @NSManaged var id: UUID
    @NSManaged var title: String
    @NSManaged var notes: String?
    @NSManaged var isCompleted: Bool
    @NSManaged var dueDate: Date?
    @NSManaged var createdAt: Date
    @NSManaged var priority: Int16
    @NSManaged var category: CDCategory?
    @NSManaged var tags: NSSet?
}

// AFTER: SwiftData @Model class
// No .xcdatamodeld file needed!
@Model
final class Task {
    @Attribute(.unique) var id: UUID
    var title: String
    var notes: String?
    var isCompleted: Bool
    var dueDate: Date?
    var createdAt: Date
    var priority: Int

    // Relationships are just Swift properties
    var category: Category?

    @Relationship(deleteRule: .nullify, inverse: \Tag.tasks)
    var tags: [Tag]

    // Transient properties (not persisted)
    @Transient var isOverdue: Bool {
        guard let dueDate else { return false }
        return !isCompleted && dueDate < Date()
    }

    init(title: String, priority: Int = 0) {
        self.id = UUID()
        self.title = title
        self.isCompleted = false
        self.createdAt = Date()
        self.priority = priority
        self.tags = []
    }
}

@Model
final class Category {
    var name: String
    var colorHex: String

    @Relationship(deleteRule: .cascade, inverse: \Task.category)
    var tasks: [Task]

    init(name: String, colorHex: String) {
        self.name = name
        self.colorHex = colorHex
        self.tasks = []
    }
}

Notice the @Attribute(.unique) annotation, which maps to a Core Data unique constraint. This detail matters enormously during migration: if your old store enforced uniqueness, you must declare it the same way or SwiftData will happily insert duplicates. Likewise, the deleteRule on each relationship must mirror what the .xcdatamodeld editor configured — a mismatch between .cascade and .nullify is the single most common source of orphaned or unexpectedly deleted rows after a cutover.

Query Migration: NSFetchRequest to #Predicate

SwiftData replaces NSFetchRequest with type-safe Swift predicates. Therefore, your queries are checked at compile time instead of failing at runtime. The trade-off is that #Predicate compiles down to an NSExpression, so not every Swift expression is legal inside it — string methods, custom functions, and computed properties that are not stored will fail to translate:

// BEFORE: Core Data fetch request
let request = NSFetchRequest<CDTask>(entityName: "CDTask")
request.predicate = NSPredicate(
    format: "isCompleted == %@ AND priority >= %d AND category.name == %@",
    NSNumber(value: false), 2, "Work"
)
request.sortDescriptors = [
    NSSortDescriptor(key: "dueDate", ascending: true),
    NSSortDescriptor(key: "priority", ascending: false)
]
request.fetchLimit = 20
let results = try context.fetch(request)

// AFTER: SwiftData query with #Predicate
let isCompleted = false
let minPriority = 2
let categoryName = "Work"

var descriptor = FetchDescriptor<Task>(
    predicate: #Predicate {
        $0.isCompleted == isCompleted &&
        $0.priority >= minPriority &&
        $0.category?.name == categoryName
    },
    sortBy: [
        SortDescriptor(\Task.dueDate),
        SortDescriptor(\Task.priority, order: .reverse)
    ]
)
descriptor.fetchLimit = 20
descriptor.fetchOffset = 0          // pair with fetchLimit for paging
descriptor.relationshipKeyPathsForPrefetching = [\Task.category]
let results = try modelContext.fetch(descriptor)

The relationshipKeyPathsForPrefetching line is worth its own mention. SwiftData lazily faults relationships just like Core Data did, so iterating a list of tasks and reading task.category.name in a loop produces a classic N+1 query problem. Prefetching the relationship batches those loads into a single query, which is the kind of optimization that separates a snappy list from a janky one.

SwiftUI Integration with @Query

// BEFORE: Core Data @FetchRequest in SwiftUI
struct TaskListView: View {
    @FetchRequest(
        sortDescriptors: [SortDescriptor(\CDTask.dueDate)],
        predicate: NSPredicate(format: "isCompleted == false")
    ) var tasks: FetchedResults<CDTask>

    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
    }
}

// AFTER: SwiftData @Query in SwiftUI
struct TaskListView: View {
    @Query(
        filter: #Predicate<Task> { !$0.isCompleted },
        sort: \Task.dueDate
    ) var tasks: [Task]

    @Environment(\.modelContext) var context

    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
        .swipeActions {
            Button("Delete") {
                context.delete(task)
            }
        }
    }
}
SwiftUI integration with SwiftData query macro
SwiftData integrates seamlessly with SwiftUI through the @Query property wrapper

Concurrency with ModelActor

One area where SwiftData genuinely improves on Core Data is background work. Core Data forced you into perform blocks and careful context confinement; SwiftData formalizes this with the @ModelActor macro. The actor owns its own ModelContext, and the Swift compiler enforces that you never touch a model object across actor boundaries — the exact bug that caused so many "this NSManagedObject is not valid" crashes in the old world:

@ModelActor
actor TaskImporter {
    func importTasks(_ payloads: [TaskPayload]) throws {
        for payload in payloads {
            let task = Task(title: payload.title, priority: payload.priority)
            modelContext.insert(task)
        }
        // Batch the save; saving per-row is the usual performance trap
        try modelContext.save()
    }
}

// Usage from anywhere — runs off the main actor
let importer = TaskImporter(modelContainer: container)
try await importer.importTasks(downloadedPayloads)

The critical rule is that objects fetched inside a ModelActor must not be passed back to the main actor directly; instead, return their PersistentIdentifier and re-fetch on the main context. Although this feels like extra ceremony, it eliminates an entire class of threading bugs that Core Data developers fought for years.

Schema Migration with VersionedSchema

SwiftData handles schema evolution through VersionedSchema and SchemaMigrationPlan. Additionally, this approach is safer than Core Data mapping models because migrations are defined in Swift code. There are two flavors to know: a lightweight stage that the framework infers automatically for additive changes, and a custom stage for anything that needs data transformation. Reach for custom only when you must, because custom stages load and rewrite rows:

enum TaskSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Task.self, Category.self]
    }

    @Model final class Task {
        var id: UUID
        var title: String
        var isCompleted: Bool
        var createdAt: Date
        init(title: String) {
            self.id = UUID()
            self.title = title
            self.isCompleted = false
            self.createdAt = Date()
        }
    }
}

enum TaskSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Task.self, Category.self, Tag.self]
    }

    @Model final class Task {
        var id: UUID
        var title: String
        var isCompleted: Bool
        var createdAt: Date
        var priority: Int  // NEW field
        var tags: [Tag]    // NEW relationship
        init(title: String) {
            self.id = UUID()
            self.title = title
            self.isCompleted = false
            self.createdAt = Date()
            self.priority = 0
            self.tags = []
        }
    }
}

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
        // Set default priority for existing tasks
        let tasks = try context.fetch(
            FetchDescriptor<TaskSchemaV2.Task>())
        for task in tasks {
            task.priority = 1
        }
        try context.save()
    }
}

A subtle edge case bites teams here: custom migration stages run synchronously at app launch, before your first window appears. If V1 holds hundreds of thousands of rows, that loop can stall startup long enough to trip the watchdog. For large stores, prefer additive lightweight migrations and backfill defaults lazily in a background ModelActor after launch, rather than transforming everything up front.

CloudKit Sync and Its Constraints

SwiftData's CloudKit integration is impressively turnkey: enable the capability, mark your container's configuration with a CloudKit database, and changes sync automatically. However, CloudKit imposes rules that the local store does not. Every property must be optional or have a default value, unique constraints are not supported when syncing, and .cascade delete rules behave differently because CloudKit cannot guarantee atomic multi-record deletes. Therefore, models you intend to sync often need a slightly looser shape than a local-only schema, and the docs recommend designing for those constraints from day one rather than retrofitting them.

When NOT to Use SwiftData

SwiftData requires iOS 17+ minimum deployment target. If your app supports iOS 15 or 16, you must continue using Core Data. Furthermore, SwiftData does not yet support all Core Data features — complex fetch request templates, abstract entities, derived attributes, and some advanced CloudKit configurations may still require dropping down to Core Data directly. If your app has a heavily battle-tested Core Data stack with extensive migration history, the risk of introducing bugs during migration may outweigh the developer experience benefits.

Consequently, consider a gradual migration where new features use SwiftData while existing features remain on Core Data, both pointed at the same store. Apps with very high write throughput or those that rely on NSBatchUpdateRequest and NSBatchDeleteRequest for bulk operations should also be cautious, because SwiftData's batch story is still maturing and per-object saves cannot match a single batched SQL statement. In short, migrate for the developer experience, but validate performance against your real data volumes before committing the whole app.

iOS app development with SwiftData persistence
Plan your migration timeline based on minimum deployment target and feature requirements

Key Takeaways

  • SwiftData replaces verbose Objective-C patterns with declarative Swift-native persistence over the same SQLite engine
  • The @Model macro eliminates .xcdatamodeld files and NSManagedObject subclasses, but you must still mirror unique constraints and delete rules
  • Type-safe #Predicate queries catch errors at compile time, while prefetching keeps relationship-heavy lists fast
  • @ModelActor formalizes background work and removes a whole class of threading crashes
  • VersionedSchema provides safe migration — prefer lightweight stages, and avoid heavy custom transforms at launch
  • Adopt incrementally — SwiftData and Core Data can coexist against the same store during migration

Related Reading

External Resources

In conclusion, SwiftData Core Data migration is an essential topic for modern software development. By applying the patterns and practices covered in this guide — mirroring constraints, batching saves in actors, and migrating schema additively — you can build more robust, scalable, and maintainable iOS persistence layers. Start with the fundamentals, migrate one feature at a time, and continuously measure results against your real data volumes to ensure you are getting the most value from these approaches.

← Back to all articles