Overview
Core Data on iOS 18 with Swift 6 and Xcode 16 still ships Apple’s most capable persistence stack: schema migrations, CloudKit sync, fetched results, and background contexts. SwiftData (iOS 17+) is the newer greenfield option. This page covers when to stay on Core Data and how to use it well. For UI rules, see swiftui; for language rules, see swift.
Own one stack via NSPersistentContainer
One container per app. Construct it at the app entry point, inject the viewContext through @Environment(\.managedObjectContext), and treat the container as the single source of truth.
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Model")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.loadPersistentStores { _, error in
if let error { fatalError("Core Data failed to load: \(error)") }
}
}
}Set automaticallyMergesChangesFromParent = true so the view context picks up background-context changes without manual notification handling.
Use viewContext on the main actor; everything else on a private queue
The viewContext is bound to the main thread. Do not run imports, large fetches, or batch updates on it.
func importJSON(_ data: Data) async throws {
try await container.performBackgroundTask { context in
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
let items = try JSONDecoder().decode([ItemDTO].self, from: data)
for dto in items {
let item = Item(context: context)
item.id = dto.id
item.title = dto.title
}
try context.save()
}
}Every read must happen on the context that fetched the object. Pass NSManagedObjectID across contexts, never the object.
Prefer lightweight migrations; reach for mapping models on breaking changes
Set shouldMigrateStoreAutomatically and shouldInferMappingModelAutomatically on the store description. Lightweight migrations cover added attributes, renamed entities, and added relationships.
let description = container.persistentStoreDescriptions.first!
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = trueWhen a change cannot be inferred (splitting an entity, transforming values, merging attributes), add a versioned mapping model and a custom NSEntityMigrationPolicy. Test migrations in CI against a fixture of the previous schema’s store.
Bind simple SwiftUI views with @FetchRequest
For lists driven by a single predicate and sort, use @FetchRequest. It updates automatically when underlying data changes.
struct ItemList: View {
@FetchRequest(
sortDescriptors: [SortDescriptor(\.createdAt, order: .reverse)],
predicate: NSPredicate(format: "isArchived == NO")
) private var items: FetchedResults<Item>
var body: some View {
List(items) { item in
Text(item.title ?? "Untitled")
}
}
}When the predicate or sort needs to react to user input, switch to a view model that owns an NSFetchedResultsController and publishes via @Observable. @FetchRequest’s dynamic API exists but tangles state with views.
Push heavy work to background contexts
Use performBackgroundTask or newBackgroundContext() for imports, batch deletes, and any operation touching more than a handful of objects.
- Set
mergePolicyon the background context; the default throws on conflicts. - Use
NSBatchInsertRequestfor imports over a few thousand rows; it skips the object graph and writes directly to SQLite. - Use
NSBatchDeleteRequestfor purges; pair it with merging the result IDs intoviewContextso SwiftUI updates.
let request = NSBatchDeleteRequest(fetchRequest: Item.fetchRequest())
request.resultType = .resultTypeObjectIDs
let result = try context.execute(request) as? NSBatchDeleteResult
let changes = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [viewContext])Pick SwiftData for greenfield, Core Data for migrations or CloudKit-heavy apps
Use SwiftData when the model is new, the team is on iOS 17+, and the schema fits @Model. The Swift-native API, automatic migrations for additive changes, and @Query integration are worth the trade.
Stay on Core Data when:
- The app already ships a Core Data store; SwiftData migration is not free.
- The schema uses fetched properties, derived attributes, or relationships SwiftData does not model cleanly.
- CloudKit sync needs
NSPersistentCloudKitContainer’s configuration knobs. - Custom
NSEntityMigrationPolicymigrations are on the roadmap.
Both APIs can coexist behind a repository layer; wrap reads and writes in a protocol and swap the backing store without touching views.