$npx -y skills add dpearson2699/swift-ios-skills --skill core-dataBuild, review, or improve Core Data persistence in apps that have not adopted SwiftData. Use when working with NSManagedObject subclasses, NSFetchedResultsController for list-driven UI, NSBatchInsertRequest / NSBatchDeleteRequest / NSBatchUpdateRequest for bulk operations, NSPers
| 1 | # Core Data |
| 2 | |
| 3 | Build and maintain data persistence using Core Data for apps that have not |
| 4 | adopted SwiftData. Covers stack setup, concurrency, batch operations, |
| 5 | NSFetchedResultsController, persistent history tracking, staged migration, |
| 6 | and testing. |
| 7 | |
| 8 | ## Contents |
| 9 | |
| 10 | - [Stack Setup](#stack-setup) |
| 11 | - [Concurrency and Threading](#concurrency-and-threading) |
| 12 | - [NSFetchedResultsController](#nsfetchedresultscontroller) |
| 13 | - [Batch Operations](#batch-operations) |
| 14 | - [Persistent History Tracking](#persistent-history-tracking) |
| 15 | - [Staged Migration](#staged-migration) |
| 16 | - [Composite Attributes](#composite-attributes) |
| 17 | - [SwiftData Boundary](#swiftdata-boundary) |
| 18 | - [Testing](#testing) |
| 19 | - [Common Mistakes](#common-mistakes) |
| 20 | - [Review Checklist](#review-checklist) |
| 21 | - [References](#references) |
| 22 | |
| 23 | ## Stack Setup |
| 24 | |
| 25 | `NSPersistentContainer` encapsulates the Core Data stack. |
| 26 | |
| 27 | Docs: [NSPersistentContainer](https://sosumi.ai/documentation/coredata/nspersistentcontainer) |
| 28 | |
| 29 | ```swift |
| 30 | import CoreData |
| 31 | |
| 32 | final class CoreDataStack: @unchecked Sendable { |
| 33 | static let shared = CoreDataStack() |
| 34 | |
| 35 | let container: NSPersistentContainer |
| 36 | |
| 37 | private init() { |
| 38 | container = NSPersistentContainer(name: "MyAppModel") |
| 39 | container.loadPersistentStores { _, error in |
| 40 | if let error { fatalError("Core Data store failed: \(error)") } |
| 41 | } |
| 42 | container.viewContext.automaticallyMergesChangesFromParent = true |
| 43 | container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy |
| 44 | } |
| 45 | |
| 46 | var viewContext: NSManagedObjectContext { container.viewContext } |
| 47 | |
| 48 | func newBackgroundContext() -> NSManagedObjectContext { |
| 49 | container.newBackgroundContext() |
| 50 | } |
| 51 | } |
| 52 | ``` |
| 53 | |
| 54 | For CloudKit sync, use `NSPersistentCloudKitContainer` instead. |
| 55 | |
| 56 | ## Concurrency and Threading |
| 57 | |
| 58 | Core Data contexts are bound to queues. The `viewContext` is on the main queue; |
| 59 | background contexts operate on private queues. |
| 60 | |
| 61 | Docs: [NSManagedObjectContext](https://sosumi.ai/documentation/coredata/nsmanagedobjectcontext) |
| 62 | |
| 63 | **Rules:** |
| 64 | - Always use `perform(_:)` or `performAndWait(_:)` when accessing a context |
| 65 | off its own queue. |
| 66 | - Never pass `NSManagedObject` instances across context or thread boundaries. |
| 67 | Pass `NSManagedObjectID` instead and re-fetch. |
| 68 | - Set `automaticallyMergesChangesFromParent = true` on the `viewContext`. |
| 69 | |
| 70 | ```swift |
| 71 | // Writing on a background context |
| 72 | func updateTrip(id: NSManagedObjectID, newName: String) async throws { |
| 73 | let context = CoreDataStack.shared.newBackgroundContext() |
| 74 | try await context.perform { |
| 75 | guard let trip = try context.existingObject(with: id) as? CDTrip else { |
| 76 | throw PersistenceError.notFound |
| 77 | } |
| 78 | trip.name = newName |
| 79 | try context.save() |
| 80 | } |
| 81 | } |
| 82 | ``` |
| 83 | |
| 84 | ### Swift Concurrency Integration |
| 85 | |
| 86 | `NSManagedObjectContext.perform(_:)` has an `async throws` overload |
| 87 | (iOS 15+). Avoid marking `NSManagedObject` subclasses as `Sendable`. |
| 88 | |
| 89 | ```swift |
| 90 | func importItems(_ records: [ItemRecord]) async throws { |
| 91 | let context = CoreDataStack.shared.newBackgroundContext() |
| 92 | try await context.perform { |
| 93 | for record in records { |
| 94 | let item = CDItem(context: context) |
| 95 | item.id = record.id |
| 96 | item.title = record.title |
| 97 | } |
| 98 | try context.save() |
| 99 | } |
| 100 | // After save completes, viewContext auto-merges if configured |
| 101 | } |
| 102 | ``` |
| 103 | |
| 104 | **Do not use `@unchecked Sendable` on managed objects.** If you need |
| 105 | cross-boundary communication, pass the `objectID` (which is `Sendable`) |
| 106 | and re-fetch: |
| 107 | |
| 108 | ```swift |
| 109 | let objectID = trip.objectID // Sendable |
| 110 | Task.detached { |
| 111 | let bgContext = CoreDataStack.shared.newBackgroundContext() |
| 112 | try await bgContext.perform { |
| 113 | let trip = try bgContext.existingObject(with: objectID) as! CDTrip |
| 114 | trip.isFavorite = true |
| 115 | try bgContext.save() |
| 116 | } |
| 117 | } |
| 118 | ``` |
| 119 | |
| 120 | ## NSFetchedResultsController |
| 121 | |
| 122 | Efficiently drives `UITableView` / `UICollectionView` from a Core Data fetch |
| 123 | request, with built-in change tracking and optional caching. |
| 124 | |
| 125 | Docs: [NSFetchedResultsController](https://sosumi.ai/documentation/coredata/nsfetchedresultscontroller) |
| 126 | |
| 127 | ```swift |
| 128 | import CoreData |
| 129 | import UIKit |
| 130 | |
| 131 | class TripsViewController: UITableViewController, NSFetchedResultsControllerDelegate { |
| 132 | |
| 133 | private lazy var fetchedResultsController: NSFetchedResultsController<CDTrip> = { |
| 134 | let request: NSFetchRequest<CDTrip> = C |