DEV Community

SwiftData vs Core Data: Complete Migration Guide for 2026

If you're an iOS developer still using Core Data, it's time to seriously consider SwiftData. Introduced at WWDC 2023, SwiftData is Apple's modern persistence framework built on top of Core Data but with a completely Swift-native API. In this guide, I'll walk you through everything you need to know to migrate from Core Data to SwiftData in 2026.

What is SwiftData?

SwiftData is Apple's declarative data persistence framework for Swift. It replaces the verbose, Objective-C-era APIs of Core Data with clean, macro-driven Swift code. Think of it as what Core Data would look like if it were designed from scratch for SwiftUI.

Key features:

  • Swift-native -- no more NSManagedObject subclasses
  • Macro-based -- uses @Model instead of .xcdatamodeld files
  • SwiftUI integration -- works seamlessly with @Query property wrapper
  • Type-safe queries -- #Predicate replaces NSPredicate
  • Automatic schema migration -- handles simple migrations out of the box

SwiftData vs Core Data: Side-by-Side Comparison

Feature Core Data SwiftData
Model Definition .xcdatamodeld + NSManagedObject @Model macro
Context NSManagedObjectContext ModelContext
Container NSPersistentContainer ModelContainer
Queries NSFetchRequest + NSPredicate @Query + #Predicate
Sorting NSSortDescriptor SortDescriptor
Relationships Manual configuration Automatic inference
SwiftUI Integration @FetchRequest @Query
Minimum Target iOS 3+ iOS 17+
Schema Editor Visual editor in Xcode Pure Swift code
Migration Mapping models Automatic / VersionedSchema

Setting Up SwiftData

With Core Data, you needed a .xcdatamodeld file, entity configurations, and NSManagedObject subclasses. With SwiftData, everything is pure Swift.

Defining a Model

import SwiftData

@Model
final class Task {
    var title: String
    var notes: String
    var isCompleted: Bool
    var dueDate: Date?
    var priority: Int
    var createdAt: Date

    init(title: String, notes: String = "", isCompleted: Bool = false, dueDate: Date? = nil, priority: Int = 0) {
        self.title = title
        self.notes = notes
        self.isCompleted = isCompleted
        self.dueDate = dueDate
        self.priority = priority
        self.createdAt = Date()
    }
}
Enter fullscreen mode Exit fullscreen mode

The @Model macro automatically makes your class persistable. No more NSManagedObject subclassing or XML schema files.

Setting Up ModelContainer

import SwiftUI
import SwiftData

@main
struct MyTaskApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Task.self, Category.self])
    }
}
Enter fullscreen mode Exit fullscreen mode

Compare this to the old Core Data stack setup that required 20+ lines of boilerplate with NSPersistentContainer, loading persistent stores, and error handling. SwiftData does it in one line.

Accessing ModelContext

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        // modelContext is ready to use
        Text("Tasks")
    }
}
Enter fullscreen mode Exit fullscreen mode

CRUD Operations with SwiftData

Create

func addTask(title: String, priority: Int) {
    let task = Task(title: title, priority: priority)
    modelContext.insert(task)
    // SwiftData auto-saves. No need to call save() manually.
}
Enter fullscreen mode Exit fullscreen mode

In Core Data, you had to create an object via the context, set properties individually, and explicitly call try context.save(). SwiftData handles saving automatically.

Read with @Query

struct TaskListView: View {
    @Query(sort: \Task.createdAt, order: .reverse) 
    private var tasks: [Task]

    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Update

func toggleCompletion(for task: Task) {
    task.isCompleted.toggle()
    // That's it. SwiftData tracks changes automatically.
}
Enter fullscreen mode Exit fullscreen mode

No context.save() needed -- SwiftData observes property changes and persists them.

Delete

func deleteTask(_ task: Task) {
    modelContext.delete(task)
}

// Delete multiple tasks
func deleteCompleted() {
    try? modelContext.delete(model: Task.self, where: #Predicate<Task> { task in
        task.isCompleted == true
    })
}
Enter fullscreen mode Exit fullscreen mode

Relationships

SwiftData makes relationships straightforward. Just use standard Swift properties.

One-to-Many

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

    @Relationship(deleteRule: .cascade)
    var tasks: [Task] = []

    init(name: String, color: String) {
        self.name = name
        self.color = color
    }
}

@Model
final class Task {
    var title: String
    var isCompleted: Bool
    var category: Category?

    init(title: String, isCompleted: Bool = false, category: Category? = nil) {
        self.title = title
        self.isCompleted = isCompleted
        self.category = category
    }
}
Enter fullscreen mode Exit fullscreen mode

Many-to-Many

@Model
final class Task {
    var title: String

    @Relationship
    var tags: [Tag] = []

    init(title: String) {
        self.title = title
    }
}

@Model
final class Tag {
    var name: String

    @Relationship(inverse: \Task.tags)
    var tasks: [Task] = []

    init(name: String) {
        self.name = name
    }
}
Enter fullscreen mode Exit fullscreen mode

SwiftData infers the inverse relationship automatically, but you can be explicit with the inverse parameter.

Querying with #Predicate

The #Predicate macro brings type-safe, compile-time-checked queries. No more string-based NSPredicate that crashes at runtime.

// Filter incomplete high-priority tasks
@Query(filter: #Predicate<Task> { task in
    !task.isCompleted && task.priority >= 3
})
private var urgentTasks: [Task]

// Search tasks by title
func searchTasks(keyword: String) -> [Task] {
    let predicate = #Predicate<Task> { task in
        task.title.localizedStandardContains(keyword)
    }

    let descriptor = FetchDescriptor<Task>(predicate: predicate)
    return (try? modelContext.fetch(descriptor)) ?? []
}

// Complex compound predicates
let predicate = #Predicate<Task> { task in
    task.isCompleted == false &&
    task.priority > 2 &&
    (task.dueDate ?? Date.distantFuture) < Date.now
}
Enter fullscreen mode Exit fullscreen mode

Compare this with Core Data's NSPredicate:

// Old Core Data way -- string-based, error-prone
let predicate = NSPredicate(format: "isCompleted == %@ AND priority > %d", NSNumber(value: false), 2)
Enter fullscreen mode Exit fullscreen mode

Sorting with SortDescriptor

// Single sort
@Query(sort: \Task.dueDate)
private var tasksByDate: [Task]

// Multiple sorts
@Query(sort: [
    SortDescriptor(\Task.priority, order: .reverse),
    SortDescriptor(\Task.createdAt, order: .reverse)
])
private var sortedTasks: [Task]

// Dynamic sorting with FetchDescriptor
func fetchTasks(sortBy keyPath: KeyPath<Task, some Comparable>) -> [Task] {
    var descriptor = FetchDescriptor<Task>()
    descriptor.sortBy = [SortDescriptor(keyPath)]
    descriptor.fetchLimit = 50
    return (try? modelContext.fetch(descriptor)) ?? []
}
Enter fullscreen mode Exit fullscreen mode

Migration from Core Data: Step-by-Step

Step 1: Create SwiftData Models

Convert your NSManagedObject subclasses to @Model classes. Map each entity to a new Swift class:

// Before: Core Data NSManagedObject
class CDTask: NSManagedObject {
    @NSManaged var title: String?
    @NSManaged var isCompleted: Bool
    @NSManaged var createdAt: Date?
}

// After: SwiftData @Model
@Model
final class Task {
    var title: String
    var isCompleted: Bool
    var createdAt: Date

    init(title: String, isCompleted: Bool = false) {
        self.title = title
        self.isCompleted = isCompleted
        self.createdAt = Date()
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Set Up ModelContainer with Migration

@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        do {
            let schema = Schema([Task.self, Category.self])
            let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
            container = try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Versioned Schema for Complex Migrations

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

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

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

    @Model
    final class Task {
        var title: String
        var isCompleted: Bool
        var priority: Int  // New field
        init(title: String, priority: Int = 0) {
            self.title = title
            self.isCompleted = false
            self.priority = priority
        }
    }
}

enum TaskMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [TaskSchemaV1.self, TaskSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: TaskSchemaV1.self,
        toVersion: TaskSchemaV2.self
    )
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Replace @FetchRequest with @Query

// Before
@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \CDTask.createdAt, ascending: false)])
var tasks: FetchedResults<CDTask>

// After
@Query(sort: \Task.createdAt, order: .reverse)
var tasks: [Task]
Enter fullscreen mode Exit fullscreen mode

Step 5: Migrate Existing Data

If you need to transfer existing Core Data records to SwiftData, write a one-time migration:

func migrateFromCoreData(coreDataContext: NSManagedObjectContext, modelContext: ModelContext) {
    let fetchRequest: NSFetchRequest<CDTask> = CDTask.fetchRequest()

    guard let coreDataTasks = try? coreDataContext.fetch(fetchRequest) else { return }

    for cdTask in coreDataTasks {
        let newTask = Task(
            title: cdTask.title ?? "Untitled",
            isCompleted: cdTask.isCompleted
        )
        modelContext.insert(newTask)
    }
}
Enter fullscreen mode Exit fullscreen mode

When to Use Which: Decision Framework

Use SwiftData when:

  • Starting a new project targeting iOS 17+
  • Building a SwiftUI-first app
  • You want minimal boilerplate
  • Your data model is straightforward
  • You need quick prototyping

Stick with Core Data when:

  • You need to support iOS 16 or earlier
  • You have a large existing Core Data codebase
  • You need advanced features like CloudKit public databases
  • Your app uses UIKit primarily
  • You need fine-grained performance control with batch operations

Hybrid approach:

  • Use SwiftData for new features while keeping Core Data for legacy code
  • Both can coexist in the same project since SwiftData is built on Core Data's foundation

Conclusion

SwiftData is the future of data persistence on Apple platforms. It dramatically reduces boilerplate, provides type safety, and integrates beautifully with SwiftUI. If you're starting a new project in 2026, SwiftData should be your default choice.

For existing apps, plan a gradual migration -- start with new features and migrate existing code module by module. The investment pays off in cleaner, more maintainable code.

My SwiftUI iOS 18 App Templates use SwiftData for all persistence. Check them out: https://pease163.github.io/digital-products/


What's your experience with SwiftData? Have you migrated from Core Data? Share your thoughts in the comments below!

Top comments (0)