Home

SwiftData after eight years of Core Data

SwiftData is Core Data with a Swift-shaped API on top. Same SQLite store, same persistent container, same object graph management. That means it inherits the good parts and most of the sharp edges, with about a tenth of the ceremony.

After a year with it, here is what actually changed.

What it fixes

The model is code. No .xcdatamodeld editor, no generated subclasses out of sync with the visual editor, no merge conflicts in an XML file that nobody can read.

@Model
final class Book {
    var title: String
    var author: String
    var addedAt: Date

    @Relationship(deleteRule: .cascade)
    var notes: [Note] = []

    init(title: String, author: String) {
        self.title = title
        self.author = author
        self.addedAt = .now
    }
}

That is the whole model. In Core Data it was an XML file, a generated class, and a set of @NSManaged properties that were all implicitly optional regardless of what the editor said.

Optionality is honest. A non-optional String in SwiftData is non-optional. Core Data’s generated properties were optional in Swift even when marked non-optional in the model, which meant force-unwrapping or ?? at every use site.

Queries are type-checked.

@Query(filter: #Predicate<Book> { $0.author == "Ursula K. Le Guin" },
       sort: \.addedAt, order: .reverse)
private var books: [Book]

#Predicate is compiled and type-checked. NSPredicate(format: "author == %@") was a string that failed at runtime if you misspelled a key path.

The SwiftUI integration is genuinely good. @Query observes the store and updates the view; there is no fetched results controller to configure.

What it hides

It is still Core Data underneath, and that surfaces at the worst times. A crash inside SwiftData gives you a stack trace full of NSManagedObjectContext and NSPersistentStoreCoordinator, and debugging it requires knowing the layer you were told you did not need to learn.

Threading is still context-bound. ModelContext is not Sendable, and a model object belongs to the context that fetched it. The rules are the same as Core Data’s and much less visible:

@ModelActor
actor ImportActor {
    func importBooks(_ items: [BookData]) throws {
        for item in items {
            modelContext.insert(Book(title: item.title, author: item.author))
        }
        try modelContext.save()
    }
}

@ModelActor is the right tool for background work, and passing a Book fetched on one context into another is the same mistake it always was — you pass the PersistentIdentifier and re-fetch.

Migration is less controllable. VersionedSchema and SchemaMigrationPlan handle the common cases, and for anything involving data transformation the Core Data mapping model gave you more control. This is the area I would call genuinely less mature.

Warning

Lightweight migration works for adding and removing properties. Anything else — splitting an entity, transforming values, merging two properties — needs a custom migration stage, and testing it against real user data is essential. A failed migration on launch is unrecoverable for that user without deleting the app.

Where I still drop down

Batch operations. Deleting ten thousand objects through the context loads ten thousand objects into memory. NSBatchDeleteRequest operates in SQL and does not.

Complex aggregate queries. Counting, grouping, summing. @Query fetches objects; if you want a count, fetching everything to call .count is wasteful. fetchCount(_:) covers the simple case, and beyond that I reach for the underlying NSFetchRequest.

Performance debugging. -com.apple.CoreData.SQLDebug 1 as a launch argument prints every SQL statement, and it is still the fastest way to find out that your view is running a query per row.

Would I migrate an existing app

Probably not, unless there is another reason to touch the persistence layer.

SwiftData and Core Data can coexist on the same store, so incremental migration is possible. But the work is real, the benefit is mostly in ergonomics rather than capability, and a working Core Data stack that has been correct for years does not owe you a rewrite.

For a new app, I would use SwiftData without hesitating — the model-as-code and #Predicate alone are worth it, and the SwiftUI integration removes a whole layer of plumbing.

The thing I would tell someone starting

Learn what a ModelContext is and how it relates to a ModelContainer before writing much. The API is friendly enough that you can build a working app without understanding the object graph underneath, and then every threading bug, every “object was deleted” crash, and every unexplained merge conflict is impossible to reason about.

SwiftData made Core Data’s API pleasant. It did not make Core Data’s model go away, and that model is still the thing you are actually programming against.