
Core Data
- 1.6k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
core-data is an agent skill for build and review core data persistence with batch inserts and fetched results controllers.
About
The core-data skill is designed for build and review Core Data persistence with batch inserts and fetched results controllers. Core Data Build and maintain data persistence using Core Data for apps that have not adopted SwiftData. Covers stack setup, concurrency, batch operations, NSFetchedResultsController, persistent history tracking, staged migration, and testing. Invoke when the user works with NSManagedObject, batch inserts, or NSFetchedResultsController.
- Concurrency and Threading.
- NSFetchedResultsController.
- Batch Operations.
- Persistent History Tracking.
- Staged Migration.
Core Data by the numbers
- 1,597 all-time installs (skills.sh)
- +109 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #150 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
core-data capabilities & compatibility
- Capabilities
- concurrency and threading · nsfetchedresultscontroller · batch operations · persistent history tracking
What core-data says it does
Build, review, or improve Core Data persistence in apps that have not adopted SwiftData. Use when working with NSManagedObject subclasses, NSFetchedResultsController for list-drive
Build, review, or improve Core Data persistence in apps that have not adopted SwiftData. Use when working with NSManagedObject subclasses, NSFetchedResultsContr
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill core-dataAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.6k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do I build and review core data persistence with batch inserts and fetched results controllers?
Build and review Core Data persistence with batch inserts and fetched results controllers.
Who is it for?
iOS developers maintaining Core Data stacks before SwiftData migration.
Skip if: Skip for SwiftData-only greenfield apps without Core Data legacy.
When should I use this skill?
User works with NSManagedObject, batch inserts, or NSFetchedResultsController.
What you get
Completed core-data workflow with documented commands, files, and expected deliverables.
- Migration review notes
- Corrected Swift migration code
- Store description wiring guidance
By the numbers
- Includes eval scenarios targeting iOS 26 NSStagedMigrationManager and NSLightweightMigrationStage wiring
Files
Core Data
Build and maintain data persistence using Core Data for apps that have not adopted SwiftData. Covers stack setup, concurrency, batch operations, NSFetchedResultsController, persistent history tracking, staged migration, and testing.
Contents
- Stack Setup
- Concurrency and Threading
- NSFetchedResultsController
- Batch Operations
- Persistent History Tracking
- Staged Migration
- Composite Attributes
- SwiftData Boundary
- Testing
- Common Mistakes
- Review Checklist
- References
Stack Setup
NSPersistentContainer encapsulates the Core Data stack.
Docs: NSPersistentContainer
import CoreData
final class CoreDataStack: @unchecked Sendable {
static let shared = CoreDataStack()
let container: NSPersistentContainer
private init() {
container = NSPersistentContainer(name: "MyAppModel")
container.loadPersistentStores { _, error in
if let error { fatalError("Core Data store failed: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
var viewContext: NSManagedObjectContext { container.viewContext }
func newBackgroundContext() -> NSManagedObjectContext {
container.newBackgroundContext()
}
}For CloudKit sync, use NSPersistentCloudKitContainer instead.
Concurrency and Threading
Core Data contexts are bound to queues. The viewContext is on the main queue; background contexts operate on private queues.
Docs: NSManagedObjectContext
Rules:
- Always use
perform(_:)orperformAndWait(_:)when accessing a context
off its own queue.
- Never pass
NSManagedObjectinstances across context or thread boundaries.
Pass NSManagedObjectID instead and re-fetch.
- Set
automaticallyMergesChangesFromParent = trueon theviewContext.
// Writing on a background context
func updateTrip(id: NSManagedObjectID, newName: String) async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
guard let trip = try context.existingObject(with: id) as? CDTrip else {
throw PersistenceError.notFound
}
trip.name = newName
try context.save()
}
}Swift Concurrency Integration
NSManagedObjectContext.perform(_:) has an async throws overload (iOS 15+). Avoid marking NSManagedObject subclasses as Sendable.
func importItems(_ records: [ItemRecord]) async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
for record in records {
let item = CDItem(context: context)
item.id = record.id
item.title = record.title
}
try context.save()
}
// After save completes, viewContext auto-merges if configured
}Do not use `@unchecked Sendable` on managed objects. If you need cross-boundary communication, pass the objectID (which is Sendable) and re-fetch:
let objectID = trip.objectID // Sendable
Task.detached {
let bgContext = CoreDataStack.shared.newBackgroundContext()
try await bgContext.perform {
let trip = try bgContext.existingObject(with: objectID) as! CDTrip
trip.isFavorite = true
try bgContext.save()
}
}NSFetchedResultsController
Efficiently drives UITableView / UICollectionView from a Core Data fetch request, with built-in change tracking and optional caching.
Docs: NSFetchedResultsController
import CoreData
import UIKit
class TripsViewController: UITableViewController, NSFetchedResultsControllerDelegate {
private lazy var fetchedResultsController: NSFetchedResultsController<CDTrip> = {
let request: NSFetchRequest<CDTrip> = CDTrip.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \CDTrip.startDate, ascending: false)
]
request.fetchBatchSize = 20
let controller = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: CoreDataStack.shared.viewContext,
sectionNameKeyPath: nil,
cacheName: "TripsCache"
)
controller.delegate = self
return controller
}()
override func viewDidLoad() {
super.viewDidLoad()
try? fetchedResultsController.performFetch()
}
// MARK: - UITableViewDataSource
override func numberOfSections(in tableView: UITableView) -> Int {
fetchedResultsController.sections?.count ?? 0
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
fetchedResultsController.sections?[section].numberOfObjects ?? 0
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "TripCell", for: indexPath)
let trip = fetchedResultsController.object(at: indexPath)
cell.textLabel?.text = trip.name
return cell
}
// MARK: - NSFetchedResultsControllerDelegate (diffable)
func controller(
_ controller: NSFetchedResultsController<any NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
) {
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
dataSource.apply(snapshot, animatingDifferences: true)
}
}Key points:
- The fetch request must have at least one sort descriptor.
- Call
deleteCache(withName:)before changing the fetch request predicate or
sort descriptors, or set cacheName to nil.
- The diffable snapshot delegate method (
didChangeContentWith:) is available
iOS 13+ and is preferred over the older per-change callbacks.
- After a context
reset(), callperformFetch()again.
Batch Operations
Batch operations execute at the SQL level, bypassing the managed object context. They are fast but don't trigger context notifications automatically.
NSBatchInsertRequest (iOS 13+)
Docs: NSBatchInsertRequest
func batchImport(_ records: [[String: Any]]) async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
let request = NSBatchInsertRequest(
entity: CDTrip.entity(),
objects: records
)
request.resultType = .objectIDs
let result = try context.execute(request) as? NSBatchInsertResult
if let ids = result?.result as? [NSManagedObjectID] {
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: [NSInsertedObjectsKey: ids],
into: [CoreDataStack.shared.viewContext]
)
}
}
}NSBatchDeleteRequest (iOS 9+)
Docs: NSBatchDeleteRequest
func deleteOldTrips(before cutoff: Date) async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = CDTrip.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "endDate < %@", cutoff as NSDate)
let request = NSBatchDeleteRequest(fetchRequest: fetchRequest)
request.resultType = .resultTypeObjectIDs
let result = try context.execute(request) as? NSBatchDeleteResult
if let ids = result?.result as? [NSManagedObjectID] {
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: [NSDeletedObjectsKey: ids],
into: [CoreDataStack.shared.viewContext]
)
}
}
}NSBatchUpdateRequest (iOS 8+)
func markAllTripsAsNotFavorite() async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
let request = NSBatchUpdateRequest(entity: CDTrip.entity())
request.propertiesToUpdate = ["isFavorite": false]
request.resultType = .updatedObjectIDsResultType
let result = try context.execute(request) as? NSBatchUpdateResult
if let ids = result?.result as? [NSManagedObjectID] {
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: [NSUpdatedObjectsKey: ids],
into: [CoreDataStack.shared.viewContext]
)
}
}
}Always merge changes back into relevant contexts after batch operations. Batch delete does not enforce the Deny delete rule.
Persistent History Tracking
Track store-level changes across targets (app, extensions, widgets) and processes.
Docs: NSPersistentHistoryChangeRequest
Enable History Tracking
let description = NSPersistentStoreDescription()
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.persistentStoreDescriptions = [description]Observe, Fetch, Merge, and Purge
// 1. Observe remote change notifications
NotificationCenter.default.addObserver(
self, selector: #selector(storeRemoteChange(_:)),
name: .NSPersistentStoreRemoteChange, object: container.persistentStoreCoordinator
)
// 2. Fetch history since last token
@objc func storeRemoteChange(_ notification: Notification) {
let context = container.newBackgroundContext()
context.perform {
let request = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastToken)
if let result = try? context.execute(request) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction] {
// 3. Merge into viewContext
for transaction in transactions {
self.container.viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
self.lastToken = transaction.token
}
}
// 4. Purge old history
let purgeRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: self.lastToken)
try? context.execute(purgeRequest)
}
}Store lastToken in UserDefaults (per target) so history is processed correctly across launches.
Staged Migration
NSStagedMigrationManager (iOS 17+) sequences schema migrations through ordered stages, each lightweight or custom.
Docs: NSStagedMigrationManager
import CoreData
// Define migration stages
// Use version checksums from the compiled model versions, not model names.
let checksumV1 = "<ModelV1 version checksum>"
let checksumV2 = "<ModelV2 version checksum>"
let checksumV3 = "<ModelV3 version checksum>"
let stage1to2 = NSLightweightMigrationStage([checksumV1, checksumV2])
stage1to2.label = "Add isFavorite property"
let modelV2 = NSManagedObjectModelReference(
name: "ModelV2",
in: Bundle.main,
versionChecksum: checksumV2
)
let modelV3 = NSManagedObjectModelReference(
name: "ModelV3",
in: Bundle.main,
versionChecksum: checksumV3
)
let stage2to3 = NSCustomMigrationStage(
migratingFrom: modelV2,
to: modelV3
)
stage2to3.label = "Split name into firstName/lastName"
stage2to3.willMigrateHandler = { migrationManager, currentStage in
guard let container = migrationManager.container else { return }
let context = container.newBackgroundContext()
try context.performAndWait {
// Transform data between schema versions
let request = NSFetchRequest<NSManagedObject>(entityName: "Person")
let people = try context.fetch(request)
for person in people {
let fullName = person.value(forKey: "name") as? String ?? ""
let parts = fullName.split(separator: " ", maxSplits: 1)
person.setValue(String(parts.first ?? ""), forKey: "firstName")
person.setValue(parts.count > 1 ? String(parts.last!) : "", forKey: "lastName")
}
try context.save()
}
}
// Apply to the persistent store
let manager = NSStagedMigrationManager([stage1to2, stage2to3])
let description = NSPersistentStoreDescription()
description.setOption(manager,
forKey: NSPersistentStoreStagedMigrationManagerOptionKey)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error { fatalError("Migration failed: \(error)") }
}For apps targeting below iOS 17, use lightweight migration (NSInferMappingModelAutomaticallyOption) or mapping models.
NSLightweightMigrationStage takes version checksums ([String]), not human-readable model names.
Composite Attributes
iOS 17+ supports composite attributes: groups of sub-attributes on an entity that act as a single logical unit. Define them in the model editor by adding a Composite type attribute and nesting sub-attributes beneath it.
Docs: NSCompositeAttributeDescription
Composite attributes map to Codable structs in SwiftData coexistence scenarios.
SwiftData Boundary
Use the swiftdata skill for Core Data + SwiftData coexistence or migration implementation. Before handing off, preserve these Core Data boundaries:
- SwiftData must point at the existing persistent store URL when it is meant to
share or migrate Core Data data.
- Shared persisted data must keep entity names, property names, types, and
schema compatible across the Core Data model and SwiftData @Model classes.
- Map renamed persisted properties with SwiftData
@Attribute(originalName:).
Testing
In-Memory Store for Tests
import CoreData
import Testing
struct CoreDataTests {
func makeTestContainer() throws -> NSPersistentContainer {
let container = NSPersistentContainer(name: "MyAppModel")
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
var loadError: Error?
container.loadPersistentStores { _, error in loadError = error }
if let loadError { throw loadError }
return container
}
@Test func createAndFetchTrip() throws {
let container = try makeTestContainer()
let context = container.viewContext
let trip = CDTrip(context: context)
trip.name = "Test Trip"
trip.startDate = .now
try context.save()
let request: NSFetchRequest<CDTrip> = CDTrip.fetchRequest()
let trips = try context.fetch(request)
#expect(trips.count == 1)
#expect(trips.first?.name == "Test Trip")
}
}Tips:
- Share the
NSManagedObjectModelinstance across tests to avoid "duplicate
entity" warnings.
- Use a single shared model loaded once:
private let sharedModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "MyAppModel", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
func makeTestContainer() throws -> NSPersistentContainer {
let container = NSPersistentContainer(name: "MyAppModel",
managedObjectModel: sharedModel)
// ... configure in-memory store
}Common Mistakes
| Mistake | Fix |
|---|---|
Passing NSManagedObject across threads | Pass objectID and re-fetch in the target context |
| Forgetting to merge batch operation results | Call mergeChanges(fromRemoteContextSave:into:) |
Calling save() without checking hasChanges | Guard with context.hasChanges first |
Using deprecated init(concurrencyType:) confinement type | Use .privateQueueConcurrencyType or .mainQueueConcurrencyType |
Not setting mergePolicy on viewContext | Set NSMergeByPropertyObjectTrumpMergePolicy to avoid conflict crashes |
Modifying fetch request on live NSFetchedResultsController without deleting cache | Call deleteCache(withName:) first or use cacheName: nil |
| Batch delete ignoring Deny delete rule | Batch delete bypasses delete rules; validate manually |
Marking NSManagedObject as @unchecked Sendable | Do not. Pass objectID instead |
| Pointing SwiftData at a fresh store during coexistence | Use the existing store URL and compatible schema when SwiftData should share or migrate Core Data data |
Review Checklist
- [ ]
NSPersistentContaineris initialized once and shared - [ ]
viewContextused only on main queue; background contexts for writes - [ ]
perform(_:)orperformAndWait(_:)wraps all off-queue context access - [ ]
automaticallyMergesChangesFromParentset onviewContext - [ ]
mergePolicyset onviewContextto prevent conflict crashes - [ ] Batch operation results merged into relevant contexts
- [ ]
NSFetchedResultsControllerfetch requests have sort descriptors - [ ] Persistent history tracking enabled for multi-target apps
- [ ] Core Data + SwiftData handoff preserves store URL, schema compatibility, entity/property names, and rename mappings
- [ ] Tests use in-memory stores with shared
NSManagedObjectModel - [ ] No
NSManagedObjectinstances cross thread boundaries
References
- Apple docs: Core Data | NSPersistentContainer | NSFetchedResultsController | NSStagedMigrationManager
{
"skill_name": "core-data",
"evals": [
{
"id": 1,
"prompt": "Review this Core Data staged migration plan for an iOS 26 app: the team creates NSLightweightMigrationStage([\"ModelV1\", \"ModelV2\"]), constructs NSManagedObjectModelReference(named: \"ModelV2\", in: .main), and expects NSStagedMigrationManager to run without wiring it into the persistent store description. Give concise corrected guidance with Swift where useful.",
"expected_output": "A review that corrects staged migration checksum usage, NSManagedObjectModelReference initializer requirements, and store-description wiring.",
"files": [],
"assertions": [
"States that NSLightweightMigrationStage takes version checksums, not human-readable model names.",
"Uses NSManagedObjectModelReference(name:in:versionChecksum:) or an equivalent initializer with a versionChecksum.",
"Includes source and destination model references for an NSCustomMigrationStage.",
"Shows or describes setting NSPersistentStoreStagedMigrationManagerOptionKey before loading the persistent stores."
]
},
{
"id": 2,
"prompt": "Review this Core Data multi-target sync plan: a widget and app share a SQLite store, batch inserts and deletes run on background contexts, the viewContext never merges SQL-level changes, no persistent history token is stored, and objects are passed directly to detached tasks. Explain what to fix.",
"expected_output": "A review that fixes batch-operation merging, persistent history tracking, token storage, and object/thread boundaries.",
"files": [],
"assertions": [
"Explains that batch inserts, updates, and deletes bypass normal managed-object context change tracking and must be merged or consumed through persistent history.",
"Recommends enabling NSPersistentHistoryTrackingKey for multi-target or multi-process stores.",
"Recommends observing NSPersistentStoreRemoteChange notifications when remote-change delivery is needed.",
"States that each target or consumer should persist its own persistent history token.",
"States that NSManagedObject instances must not cross context or thread boundaries; pass NSManagedObjectID and re-fetch.",
"Mentions that batch delete bypasses validation beyond basic delete rules and is incompatible with the Deny delete rule."
]
},
{
"id": 3,
"prompt": "A team asks for a Core Data plan, but the real request is to run Core Data and SwiftData side by side against the same existing SQLite store and then migrate new screens to @Model. How should the agent route the work and what high-level boundary guidance should it give?",
"expected_output": "A boundary answer that identifies Core Data + SwiftData coexistence/migration as SwiftData-owned while giving only high-level handoff guidance.",
"files": [],
"assertions": [
"States that Core Data + SwiftData coexistence or migration should use the swiftdata skill.",
"Avoids turning the Core Data skill into a full SwiftData migration guide.",
"Gives high-level boundary guidance such as matching store URL, entity names, property names, and schema.",
"Mentions using @Attribute(originalName:) or equivalent rename mapping for renamed properties.",
"Preserves standalone Core Data guidance for apps that have not adopted SwiftData."
]
}
]
}
Related skills
How it compares
Pick core-data over generic database skills when debugging Apple Core Data migration stages and store descriptions in Swift iOS projects.
FAQ
What does core-data do?
Build and review Core Data persistence with batch inserts and fetched results controllers.
When should I use core-data?
User works with NSManagedObject, batch inserts, or NSFetchedResultsController.
Is core-data safe to install?
Review the Security Audits panel on this page before installing in production.