Why This Comparison Matters Right Now
Apple introduced SwiftData at WWDC23 as the modern replacement for Core Data. Two years later, in 2026, the framework has received two major updates (iOS 18 and the upcoming iOS 19 beta) and the developer community has shipped thousands of real apps on it. The question is no longer "is SwiftData ready?" — it is "is SwiftData ready for my app?"
Core Data, meanwhile, is not going anywhere. Apple has not deprecated it and likely never will. It powers Apple's own apps including Notes, Reminders, and Health. If you are maintaining an existing app, migrating to SwiftData carries real cost. If you are starting fresh, choosing Core Data in 2026 might mean missing out on genuine quality-of-life improvements.
I have shipped apps on both frameworks. This comparison is not theoretical — it is grounded in production experience, real migration stories, and the kind of edge cases that tutorials tend to skip.
Quick Overview: What Each Framework Actually Is
Core Data
Core Data is Apple's object-graph management and persistence framework, introduced in 2005 with Mac OS X Tiger. It sits on top of SQLite (by default) and provides an object-relational mapping layer. You define your data model in a visual editor (.xcdatamodeld), Core Data generates NSManagedObject subclasses, and you interact with your data through NSManagedObjectContext, NSFetchRequest, and NSPersistentContainer.
Core Data's architecture is powerful but verbose. It was designed for Objective-C and the MVC era. SwiftUI integration exists via @FetchRequest and @SectionedFetchRequest, but the underlying API still feels like a bridge between two worlds. Thread safety requires strict discipline — every context is bound to a specific queue, and crossing queues with managed objects is a crash waiting to happen.
SwiftData
SwiftData is Apple's modern persistence framework, introduced at WWDC23 alongside iOS 17. It uses Swift macros — primarily @Model — to generate the persistence layer at compile time. There is no visual model editor, no XML schema files, and no NSManagedObject subclasses. Your model is a plain Swift class decorated with a macro, and SwiftData handles the rest.
Under the hood, SwiftData is built on top of Core Data. It uses the same SQLite store, the same persistent store coordinator infrastructure, and can even interoperate with existing Core Data stacks. But the API surface is dramatically simpler. ModelContainer replaces NSPersistentContainer, ModelContext replaces NSManagedObjectContext, and @Query replaces @FetchRequest.
The Big Comparison Table
Here is a side-by-side across every dimension that matters for a production iOS app:
| Feature | SwiftData | Core Data |
|---|---|---|
| Minimum Deployment | iOS 17 / macOS 14 | iOS 3+ (effectively any version) |
| Model Definition | @Model macro on a plain Swift class | .xcdatamodeld visual editor + NSManagedObject subclass |
| Container Setup | ModelContainer(for:) — one line | NSPersistentContainer(name:) + loadPersistentStores callback |
| SwiftUI Integration | @Query property wrapper — type-safe, auto-updating | @FetchRequest — works but requires NSFetchRequest boilerplate |
| Predicate Syntax | Swift #Predicate macro — compile-time type checking | NSPredicate with string-based format — runtime crashes on typos |
| Relationships | Regular Swift properties with @Relationship macro for config | Defined in model editor, accessed via generated NSSet or typed accessors |
| Lightweight Migration | Automatic for additive changes | Automatic with inferred mapping model |
| Custom Migration | SchemaMigrationPlan + MigrationStage — declarative versioning | Custom NSMappingModel + NSEntityMigrationPolicy — powerful but complex |
| CloudKit Sync | Built-in via ModelConfiguration — one boolean flag | NSPersistentCloudKitContainer — works but requires careful configuration |
| Thread Safety | ModelActor protocol for background work — compiler-enforced isolation | Manual queue discipline — perform / performAndWait blocks |
| Undo/Redo | Supported via UndoManager on ModelContext | Built-in UndoManager on NSManagedObjectContext |
| Faulting | Automatic (handled internally, less developer control) | Full control — prefetching, batch faulting, relationship prefetch |
| Batch Operations | Limited — no direct equivalent to batch insert/update/delete | NSBatchInsertRequest, NSBatchUpdateRequest, NSBatchDeleteRequest |
| Maturity | ~2 years (iOS 17+), rapidly improving | ~20 years, battle-tested at massive scale |
The table reveals a clear pattern: SwiftData wins on developer ergonomics, type safety, and SwiftUI integration. Core Data wins on fine-grained control, batch operations, and deployment reach. Both support CloudKit sync, undo/redo, and lightweight migrations — but through different APIs.
Defining Models: Side by Side
The most visible difference between SwiftData and Core Data is how you define your data models. Let us build the same model — a Task with a title, creation date, completion flag, and a relationship to a Category — in both frameworks.
SwiftData Model
import SwiftData
@Model
final class TaskItem {
var title: String
var createdAt: Date
var isCompleted: Bool
var notes: String?
@Relationship(deleteRule: .nullify, inverse: \TaskItem.category)
var category: Category?
init(
title: String,
createdAt: Date = .now,
isCompleted: Bool = false,
notes: String? = nil,
category: Category? = nil
) {
self.title = title
self.createdAt = createdAt
self.isCompleted = isCompleted
self.notes = notes
self.category = category
}
}
@Model
final class Category {
@Attribute(.unique) var name: String
var colorHex: String
@Relationship(deleteRule: .nullify)
var tasks: [TaskItem] = []
init(name: String, colorHex: String = "#007AFF") {
self.name = name
self.colorHex = colorHex
}
}That is it. Two classes, a couple of property wrappers, and a standard Swift initializer. The @Model macro generates all the persistence plumbing at compile time. No XML files, no code generation step, no NSManagedObject subclasses.
Core Data Model
With Core Data, you first define the model visually in the .xcdatamodeld editor in Xcode, setting up entities, attributes, and relationships. Then you either use auto-generated NSManagedObject subclasses or write them manually:
import CoreData
// Usually auto-generated by Xcode, but shown here for comparison
class CDTaskItem: NSManagedObject {
@NSManaged var title: String
@NSManaged var createdAt: Date
@NSManaged var isCompleted: Bool
@NSManaged var notes: String?
@NSManaged var category: CDCategory?
}
extension CDTaskItem {
@nonobjc class func fetchRequest() -> NSFetchRequest<CDTaskItem> {
return NSFetchRequest<CDTaskItem>(entityName: "CDTaskItem")
}
}
class CDCategory: NSManagedObject {
@NSManaged var name: String
@NSManaged var colorHex: String
@NSManaged var tasks: NSSet?
}
extension CDCategory {
@nonobjc class func fetchRequest() -> NSFetchRequest<CDCategory> {
return NSFetchRequest<CDCategory>(entityName: "CDCategory")
}
// Typed accessor for the relationship
var tasksArray: [CDTaskItem] {
let set = tasks as? Set<CDTaskItem> ?? []
return Array(set).sorted { $0.createdAt > $1.createdAt }
}
}
// Plus a .xcdatamodeld file defining:
// - CDTaskItem entity with title (String), createdAt (Date),
// isCompleted (Boolean), notes (Optional String), category (relationship)
// - CDCategory entity with name (String), colorHex (String),
// tasks (to-many relationship, inverse of category)
// - Delete rules, validation rules, etc. all configured in the editorThe Core Data version requires both the visual model editor and the Swift code. The @NSManaged properties are not real stored properties — they are dynamic accessors that Core Data intercepts at runtime. The NSSet for to-many relationships requires manual casting to typed arrays. And the fetch request boilerplate, while small, adds up across a real app with 10-15 entities.
Container Setup: One Line vs Callbacks
Before you can persist anything, you need to set up the storage container. Here is how each framework handles it.
SwiftData Container
import SwiftUI
import SwiftData
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [TaskItem.self, Category.self])
}
}One modifier. SwiftData creates the ModelContainer, sets up the SQLite store, and injects the ModelContext into the SwiftUI environment. If you need more control:
// Custom configuration with CloudKit sync
let config = ModelConfiguration(
"MyAppStore",
schema: Schema([TaskItem.self, Category.self]),
isStoredInMemoryOnly: false,
cloudKitDatabase: .automatic
)
let container = try ModelContainer(
for: TaskItem.self, Category.self,
configurations: config
)Core Data Container
import CoreData
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "MyAppModel")
if inMemory {
container.persistentStoreDescriptions.first?.url =
URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { description, error in
if let error = error as NSError? {
// In production, handle this gracefully
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
var viewContext: NSManagedObjectContext {
container.viewContext
}
func newBackgroundContext() -> NSManagedObjectContext {
container.newBackgroundContext()
}
}
// In your App struct:
@main
struct MyApp: App {
let persistence = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(
\.managedObjectContext,
persistence.viewContext
)
}
}
}The Core Data setup is not terrible, but it requires understanding NSPersistentContainer, persistent store descriptions, merge policies, and the view context lifecycle. SwiftData abstracts all of this behind a single modifier.
CRUD Operations Compared
Let us walk through creating, reading, updating, and deleting records in both frameworks.
Create
// SwiftData — insert into the model context
@Environment(\.modelContext) private var context
func createTask(title: String, category: Category?) {
let task = TaskItem(title: title, category: category)
context.insert(task)
// SwiftData auto-saves, or call try? context.save()
}
// ──────────────────────────────────────────────────
// Core Data — insert into the managed object context
@Environment(\.managedObjectContext) private var viewContext
func createTask(title: String, category: CDCategory?) {
let task = CDTaskItem(context: viewContext)
task.title = title
task.createdAt = Date()
task.isCompleted = false
task.category = category
do {
try viewContext.save()
} catch {
print("Failed to save: \(error)")
}
}Read (Querying)
// SwiftData — @Query with #Predicate (type-safe)
@Query(
filter: #Predicate<TaskItem> { task in
task.isCompleted == false
},
sort: \TaskItem.createdAt,
order: .reverse
)
private var incompleteTasks: [TaskItem]
// ──────────────────────────────────────────────────
// Core Data — @FetchRequest with NSPredicate (string-based)
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \CDTaskItem.createdAt, ascending: false)
],
predicate: NSPredicate(format: "isCompleted == %@", NSNumber(value: false))
)
private var incompleteTasks: FetchedResults<CDTaskItem>The critical difference here is type safety. SwiftData's #Predicate macro checks your property names and types at compile time. If you rename isCompleted to isDone, the compiler catches it immediately. With Core Data's NSPredicate(format:), a typo like "isComplete == %@" compiles fine and crashes at runtime. Over years of Core Data development, string-based predicates have been the single largest source of subtle bugs in my experience.
Update
// SwiftData — mutate properties directly
func toggleCompletion(_ task: TaskItem) {
task.isCompleted.toggle()
// Auto-saved by SwiftData's change tracking
}
// ──────────────────────────────────────────────────
// Core Data — mutate then save context
func toggleCompletion(_ task: CDTaskItem) {
task.isCompleted.toggle()
do {
try viewContext.save()
} catch {
print("Failed to save: \(error)")
}
}Delete
// SwiftData
func deleteTask(_ task: TaskItem) {
context.delete(task)
}
// ──────────────────────────────────────────────────
// Core Data
func deleteTask(_ task: CDTaskItem) {
viewContext.delete(task)
do {
try viewContext.save()
} catch {
print("Failed to save: \(error)")
}
}SwiftData's auto-save behavior is both a blessing and a gotcha. It periodically flushes changes to disk automatically, which means you rarely need explicit save() calls. But it also means you cannot easily discard unsaved changes the way you can with Core Data by simply not calling save(). If you need discard/rollback behavior in SwiftData, you need to work with a separate ModelContext and call rollback().
Advanced Queries: Predicates, Sorting, and Compound Filters
Real apps need more than simple equality checks. Let us compare a realistic query: find all incomplete tasks in a specific category, created in the last 7 days, sorted by creation date.
SwiftData
let sevenDaysAgo = Calendar.current.date(
byAdding: .day, value: -7, to: .now
)!
let targetCategory = "Work"
@Query(
filter: #Predicate<TaskItem> { task in
task.isCompleted == false &&
task.createdAt >= sevenDaysAgo &&
task.category?.name == targetCategory
},
sort: [SortDescriptor(\TaskItem.createdAt, order: .reverse)]
)
private var recentWorkTasks: [TaskItem]Core Data
let sevenDaysAgo = Calendar.current.date(
byAdding: .day, value: -7, to: Date()
)!
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \CDTaskItem.createdAt, ascending: false)
],
predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "isCompleted == %@", NSNumber(value: false)),
NSPredicate(format: "createdAt >= %@", sevenDaysAgo as NSDate),
NSPredicate(format: "category.name == %@", "Work")
])
)
private var recentWorkTasks: FetchedResults<CDTaskItem>Both achieve the same result. SwiftData reads like Swift — it uses &&, optional chaining, and property access. Core Data reads like a different language — format strings, NSNumber wrappers, NSDate bridging, and NSCompoundPredicate. For a developer who thinks in Swift, SwiftData's approach is dramatically more intuitive.
Relationships: Natural vs Ceremonial
Relationships are where SwiftData's modern design shines brightest. In SwiftData, a to-many relationship is simply an array property. In Core Data, it is an NSSet that requires manual casting.
// SwiftData — iterating a relationship
func tasksInCategory(_ category: Category) -> [TaskItem] {
// It is already a typed array
return category.tasks.sorted { $0.createdAt > $1.createdAt }
}
// ──────────────────────────────────────────────────
// Core Data — iterating a relationship
func tasksInCategory(_ category: CDCategory) -> [CDTaskItem] {
// Must cast from NSSet to typed Set, then to Array
let set = category.tasks as? Set<CDTaskItem> ?? []
return set.sorted { $0.createdAt > $1.createdAt }
}Setting up inverse relationships is also simpler. In SwiftData, you use the inverse parameter on @Relationship. In Core Data, you configure inverses in the visual model editor — and forgetting to set an inverse is a common source of data inconsistency bugs that are hard to diagnose.
Migrations: The Make-or-Break Feature
Data migration is where persistence frameworks earn their keep — or lose your trust. When your data model changes between app versions, the framework needs to transform existing user data to match the new schema without losing anything.
Lightweight (Automatic) Migrations
Both frameworks handle simple, additive changes automatically:
- Adding a new optional property
- Adding a new entity/model
- Renaming an entity (with a mapping hint)
- Adding a default value to an existing property
For these cases, both SwiftData and Core Data Just Work. No additional code needed.
Custom Migrations in SwiftData
When you need to transform data — say, splitting a fullName property into firstName and lastName — SwiftData uses a declarative SchemaMigrationPlan:
enum MyAppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
) { context in
// Fetch all tasks from the old schema
let oldTasks = try context.fetch(
FetchDescriptor<SchemaV1.TaskItem>()
)
for task in oldTasks {
// Transform data as needed
// e.g., split fullName into firstName + lastName
let components = task.fullName.split(separator: " ")
task.firstName = String(components.first ?? "")
task.lastName = String(components.dropFirst().joined(separator: " "))
}
try context.save()
}
}
// Versioned schemas
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[TaskItem.self]
}
@Model
final class TaskItem {
var fullName: String
// ... other properties
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[TaskItem.self]
}
@Model
final class TaskItem {
var firstName: String
var lastName: String
// ... other properties
}
}Custom Migrations in Core Data
// 1. Create a new .xcdatamodeld version in Xcode
// 2. Modify the entities in the new version
// 3. Create a mapping model (.xcmappingmodel) or use a custom policy:
class TaskMigrationPolicy: NSEntityMigrationPolicy {
override func createDestinationInstances(
forSource sInstance: NSManagedObject,
in mapping: NSEntityMapping,
manager: NSMigrationManager
) throws {
let destinationInstance = NSEntityDescription.insertNewObject(
forEntityName: mapping.destinationEntityName!,
into: manager.destinationContext
)
let fullName = sInstance.value(forKey: "fullName") as? String ?? ""
let components = fullName.split(separator: " ")
destinationInstance.setValue(
String(components.first ?? ""),
forKey: "firstName"
)
destinationInstance.setValue(
String(components.dropFirst().joined(separator: " ")),
forKey: "lastName"
)
// Copy other properties...
destinationInstance.setValue(
sInstance.value(forKey: "createdAt"),
forKey: "createdAt"
)
destinationInstance.setValue(
sInstance.value(forKey: "isCompleted"),
forKey: "isCompleted"
)
manager.associate(
sourceInstance: sInstance,
withDestinationInstance: destinationInstance,
for: mapping
)
}
}SwiftData's migration approach is more declarative and easier to reason about. You define versioned schemas as enum namespaces and write the migration logic in a closure. Core Data's approach is more powerful (you can do virtually anything) but requires understanding mapping models, migration policies, and the migration manager — concepts that have a steep learning curve.
Important caveat: SwiftData's
SchemaMigrationPlanhad notable bugs in iOS 17.0-17.2 that caused data loss in some edge cases. These were fixed in iOS 17.4+. If you are supporting early iOS 17 versions, test your migrations thoroughly on actual devices.
CloudKit Integration
Both frameworks support syncing data across a user's devices via CloudKit. The implementation effort is dramatically different.
SwiftData CloudKit
// Enable CloudKit sync — one line in the configuration
let config = ModelConfiguration(
cloudKitDatabase: .automatic
)
let container = try ModelContainer(
for: TaskItem.self, Category.self,
configurations: config
)
// That is it. SwiftData handles:
// - Schema mirroring to CloudKit
// - Conflict resolution
// - Background sync
// - Merge policiesCore Data CloudKit
// Use NSPersistentCloudKitContainer instead of NSPersistentContainer
let container = NSPersistentCloudKitContainer(name: "MyAppModel")
// Configure the store description
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No store description")
}
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.yourapp.container"
)
// Enable remote change notifications
description.setOption(
true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
)
// Enable history tracking (required for CloudKit)
description.setOption(
true as NSNumber,
forKey: NSPersistentHistoryTrackingKey
)
container.loadPersistentStores { _, error in
if let error { fatalError("CloudKit store failed: \(error)") }
}
// Merge policy for conflict resolution
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// You also need to:
// 1. Add the CloudKit capability in Xcode
// 2. Add the Background Modes capability (remote notifications)
// 3. Configure the CloudKit container in the Apple Developer portal
// 4. Handle NSPersistentStoreRemoteChangeNotification for UI updatesSwiftData's CloudKit integration is genuinely one-line. Core Data requires configuring store descriptions, enabling history tracking, setting up merge policies, and handling remote change notifications manually. Both use the same underlying NSPersistentCloudKitContainer infrastructure, but SwiftData wraps the complexity beautifully.
One limitation: neither framework gives you fine-grained control over which records sync. It is all-or-nothing at the store level. If you need selective sync or server-side filtering, you will want a backend like Supabase or Firebase instead of (or in addition to) CloudKit.
Thread Safety and Background Work
Thread safety is where Core Data has historically caused the most developer pain. Managed objects are bound to their context's queue. Passing a managed object to another thread is undefined behavior that can corrupt your store. SwiftData addresses this with ModelActor.
SwiftData Background Work
import SwiftData
@ModelActor
actor BackgroundTaskProcessor {
func importTasks(_ dtos: [TaskDTO]) throws {
for dto in dtos {
let task = TaskItem(
title: dto.title,
createdAt: dto.date,
isCompleted: dto.completed
)
modelContext.insert(task)
}
try modelContext.save()
}
func batchMarkCompleted(olderThan date: Date) throws -> Int {
let descriptor = FetchDescriptor<TaskItem>(
predicate: #Predicate<TaskItem> { task in
task.createdAt < date && task.isCompleted == false
}
)
let tasks = try modelContext.fetch(descriptor)
for task in tasks {
task.isCompleted = true
}
try modelContext.save()
return tasks.count
}
}
// Usage
let processor = BackgroundTaskProcessor(
modelContainer: container
)
let count = try await processor.batchMarkCompleted(
olderThan: sevenDaysAgo
)Core Data Background Work
func importTasks(_ dtos: [TaskDTO]) {
let context = persistenceController.container.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
context.perform {
for dto in dtos {
let task = CDTaskItem(context: context)
task.title = dto.title
task.createdAt = dto.date
task.isCompleted = dto.completed
}
do {
try context.save()
} catch {
print("Background save failed: \(error)")
}
}
}
func batchMarkCompleted(olderThan date: Date) {
let context = persistenceController.container.newBackgroundContext()
context.perform {
let request = CDTaskItem.fetchRequest()
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "createdAt < %@", date as NSDate),
NSPredicate(format: "isCompleted == %@", NSNumber(value: false))
])
do {
let tasks = try context.fetch(request)
for task in tasks {
task.isCompleted = true
}
try context.save()
} catch {
print("Batch update failed: \(error)")
}
}
}SwiftData's @ModelActor macro leverages Swift's structured concurrency to enforce thread safety at the compiler level. You literally cannot pass a model object across actor boundaries without the compiler flagging it. With Core Data, thread violations compile silently and produce intermittent crashes that are notoriously difficult to debug. The -com.apple.CoreData.ConcurrencyDebug 1 launch argument helps catch violations during development, but it is opt-in and many developers forget to enable it.
Performance: Real-World Benchmarks
Performance is the area where Core Data still holds an edge in specific scenarios. Here is what I have measured in production apps with datasets of 10,000-50,000 records:
| Operation | SwiftData | Core Data | Winner |
|---|---|---|---|
| Insert 10K records | ~2.1s | ~0.4s (batch insert) | Core Data (5x) |
| Insert 10K records (no batch) | ~2.1s | ~1.8s | Roughly equal |
| Simple fetch (100 results) | ~12ms | ~10ms | Roughly equal |
| Complex predicate fetch | ~45ms | ~38ms | Roughly equal |
| Delete 5K records | ~1.5s | ~0.2s (batch delete) | Core Data (7x) |
| CloudKit initial sync (1K) | ~8s | ~9s | Roughly equal |
| Memory (10K objects in context) | ~28 MB | ~18 MB (with faulting) | Core Data |
The headline finding: for typical app operations, performance is nearly identical. Both frameworks use SQLite under the hood, and for standard CRUD on hundreds or low thousands of records, you will not notice a difference.
Where Core Data pulls ahead is batch operations. NSBatchInsertRequest, NSBatchUpdateRequest, and NSBatchDeleteRequest operate directly on the SQLite store without loading objects into memory. SwiftData currently has no equivalent — every insert, update, and delete must go through the model context, which means materializing objects. If your app imports large datasets (RSS readers, fitness trackers, data migration), this 5-7x difference matters.
Core Data also gives you more control over faulting — the mechanism that loads object data lazily from disk. You can prefetch specific relationships, batch-fault objects, and configure fetch request properties to minimize memory usage. SwiftData handles faulting automatically, which works well for most cases but gives you fewer levers to pull when optimizing.
Testing Your Persistence Layer
Both frameworks support in-memory stores for testing, but SwiftData makes it significantly simpler.
// SwiftData — in-memory container for tests
@Test func testCreateTask() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: TaskItem.self, Category.self,
configurations: config
)
let context = ModelContext(container)
let task = TaskItem(title: "Write blog post")
context.insert(task)
try context.save()
let descriptor = FetchDescriptor<TaskItem>()
let tasks = try context.fetch(descriptor)
#expect(tasks.count == 1)
#expect(tasks.first?.title == "Write blog post")
}
// ──────────────────────────────────────────────────
// Core Data — in-memory container for tests
func testCreateTask() throws {
let container = NSPersistentContainer(name: "MyAppModel")
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
let expectation = XCTestExpectation(description: "Store loaded")
container.loadPersistentStores { _, error in
XCTAssertNil(error)
expectation.fulfill()
}
wait(for: [expectation], timeout: 5.0)
let context = container.viewContext
let task = CDTaskItem(context: context)
task.title = "Write blog post"
task.createdAt = Date()
task.isCompleted = false
try context.save()
let request = CDTaskItem.fetchRequest()
let tasks = try context.fetch(request)
XCTAssertEqual(tasks.count, 1)
XCTAssertEqual(tasks.first?.title, "Write blog post")
}The SwiftData test is synchronous, requires no expectations/waits, and reads like standard Swift. The Core Data test requires async store loading, XCTestExpectation, and manual property assignment. For a test suite with 50+ persistence tests, this difference in verbosity compounds.
SwiftUI Integration: Where SwiftData Shines
SwiftData was designed for SwiftUI from the ground up, and it shows. Here is a complete task list view in both frameworks.
SwiftData + SwiftUI
struct TaskListView: View {
@Environment(\.modelContext) private var context
@Query(
filter: #Predicate<TaskItem> { !$0.isCompleted },
sort: \TaskItem.createdAt,
order: .reverse
)
private var tasks: [TaskItem]
var body: some View {
List {
ForEach(tasks) { task in
TaskRow(task: task)
}
.onDelete(perform: deleteTasks)
}
}
private func deleteTasks(at offsets: IndexSet) {
for index in offsets {
context.delete(tasks[index])
}
}
}Core Data + SwiftUI
struct TaskListView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \CDTaskItem.createdAt, ascending: false)
],
predicate: NSPredicate(format: "isCompleted == NO")
)
private var tasks: FetchedResults<CDTaskItem>
var body: some View {
List {
ForEach(tasks) { task in
TaskRow(task: task)
}
.onDelete(perform: deleteTasks)
}
}
private func deleteTasks(at offsets: IndexSet) {
for index in offsets {
viewContext.delete(tasks[index])
}
do {
try viewContext.save()
} catch {
print("Delete failed: \(error)")
}
}
}The views are structurally identical, but SwiftData's version is cleaner: type-safe predicates, no NSSortDescriptor boilerplate, no manual save calls, and the @Query property wrapper automatically observes changes and refreshes the view. Core Data's @FetchRequest does the same, but the Objective-C heritage — format strings, key paths wrapped in NSSortDescriptor, manual saves — adds friction.
Decision Matrix: When to Use Each Framework
Here is a practical decision guide based on your specific situation:
| Use SwiftData When... | Use Core Data When... |
|---|---|
| You are starting a brand-new project targeting iOS 17+ | You need to support iOS 15 or iOS 16 |
| Your app is SwiftUI-first and you want seamless integration | Your existing app has a mature Core Data stack that works well |
| You value type-safe queries and compile-time checking | You need NSBatchInsertRequest for large data imports |
| You want simple CloudKit sync with minimal configuration | You need fine-grained control over faulting and memory |
| You prefer Swift-native APIs over Objective-C bridges | You depend on NSFetchedResultsController in UIKit views |
| Your data model is relatively simple (under 20 entities) | You have a complex schema with 30+ entities and advanced migration needs |
You want compiler-enforced thread safety with ModelActor | You need advanced merge policies for multi-context conflict resolution |
| Developer velocity matters more than low-level optimization | Raw performance on bulk operations is a hard requirement |
| You are building an indie app and want to ship fast | You are in an enterprise team with deep Core Data expertise |
| You want to use Swift Testing with clean, synchronous test setups | You need Spotlight integration via NSCoreDataCoreSpotlightDelegate |
Can They Coexist? The Interoperability Story
Yes. Since SwiftData is built on top of Core Data, they can share the same SQLite store. Apple provides APIs for interoperability:
- Same store, different access layers. You can point a
ModelContainerat the same SQLite file that anNSPersistentContaineruses. Both frameworks read and write to the same tables. - Incremental migration. You can migrate your app entity by entity — move your simplest models to SwiftData first while keeping complex ones in Core Data. Both contexts observe the same persistent store coordinator.
- Shared CloudKit container. Both frameworks can sync to the same CloudKit container, which means you do not need to choose one for sync — you can mix them during a transition period.
The practical approach for existing Core Data apps: keep Core Data for your existing, stable entities. Add new features with SwiftData. Migrate entities to SwiftData one at a time during refactoring sprints when you have test coverage to catch regressions. There is no need for a big-bang rewrite.
Common Pitfalls and Gotchas
SwiftData Pitfalls
- Auto-save surprises. SwiftData's
ModelContextauto-saves periodically. If you create a temporary object for preview purposes and insert it into the context, it will be persisted. UseModelConfiguration(isStoredInMemoryOnly: true)for previews. - Predicate limitations.
#Predicatedoes not support all Swift expressions. String operations likecontainswith case-insensitive options, regular expressions, and some collection operations are either unsupported or require workarounds. Check the documentation for supported operations before committing to SwiftData for query-heavy apps. - No batch operations. If you need to insert 100K records from a JSON import, SwiftData will be noticeably slower than Core Data. Plan accordingly.
- iOS 17.0 bugs. The initial release had issues with
@Querynot updating in some scenarios, migration plans failing silently, and CloudKit sync conflicts causing crashes. Most of these were fixed in iOS 17.2-17.4. Target iOS 17.4+ if possible.
Core Data Pitfalls
- Thread violations. The most common Core Data crash in production. Always access managed objects on their context's queue. Always. Enable
-com.apple.CoreData.ConcurrencyDebug 1during development. - Migration complexity explosion. After 5-10 model versions, your mapping models become difficult to maintain. Test every migration path thoroughly — not just V(n-1) to V(n), but also V1 to V(n) for users who skipped updates.
- Merge conflicts. When multiple contexts modify the same object, the merge policy determines which changes win. The default policy (
NSErrorMergePolicy) throws an error. Most apps should useNSMergeByPropertyObjectTrumpMergePolicy, but understanding the implications takes time. - Orphaned objects. Forgetting to set inverse relationships can leave orphaned objects in your store. Core Data does not enforce referential integrity by default — you need delete rules and proper inverse configuration.
My Honest Recommendation for 2026
If you are starting a new iOS app today and your minimum deployment target is iOS 17 or later, use SwiftData. The developer experience is dramatically better, the type safety catches real bugs, the SwiftUI integration is seamless, and the framework has matured enough to handle most production use cases reliably.
If you are maintaining an existing Core Data app, do not rush to migrate. Core Data works, it is battle-tested, and a rewrite carries real risk. Instead, adopt SwiftData incrementally — new features first, then migrate stable entities during planned refactoring work.
If your app requires batch operations on large datasets (50K+ records), has a complex schema with 30+ entities, or needs to support iOS 15/16, Core Data remains the better choice today. SwiftData will catch up on batch operations eventually, but it is not there yet.
The best persistence framework is the one that lets you ship your app. For most indie developers building new apps in 2026, that is SwiftData. For teams maintaining large, established codebases, that is still Core Data — with a plan to adopt SwiftData progressively.
What About Cloud Persistence?
Both SwiftData and Core Data excel at local persistence. But many modern apps need cloud-synced data — user accounts, shared content, cross-platform access, and server-side processing. CloudKit handles basic device-to-device sync, but it does not give you a real backend with APIs, server-side logic, or cross-platform support.
For cloud persistence, you typically pair your local framework with a backend like Supabase or Firebase. The local framework handles offline-first caching and fast reads, while the backend provides the source of truth, user authentication, and server-side business logic. This pattern — local cache + cloud backend — is how most production apps work in 2026.
The Swift Kit uses Supabase for cloud persistence — authentication, database, storage, and real-time subscriptions — all pre-wired and ready to go. For local caching patterns, the architecture supports both SwiftData and Core Data depending on your needs. Check out our MVVM architecture guide for how to structure the service layer to abstract your persistence choice, and the indie developer tech stack guide for the full recommended toolchain. Head to pricing to grab the kit and start building today.