core-data

Build, review, or improve Core Data persistence in apps that have not adopted SwiftData. Use when working with NSManagedObject subclasses,…

INSTALLATION
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill core-data
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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](#stack-setup)
  • [Concurrency and Threading](#concurrency-and-threading)
  • [NSFetchedResultsController](#nsfetchedresultscontroller)
  • [Batch Operations](#batch-operations)
  • [Persistent History Tracking](#persistent-history-tracking)
  • [Staged Migration](#staged-migration)
  • [Composite Attributes](#composite-attributes)
  • [Testing](#testing)
  • [Common Mistakes](#common-mistakes)
  • [Review Checklist](#review-checklist)
  • [References](#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(_:) or performAndWait(_:) when accessing a context

off its own queue.

  • Never pass NSManagedObject instances across context or thread boundaries.

Pass NSManagedObjectID instead and re-fetch.

  • Set automaticallyMergesChangesFromParent = true on the viewContext.
// 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(), call performFetch() 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 stage1to2 = NSLightweightMigrationStage([checksumV1, checksumV2])

stage1to2.label = "Add isFavorite property"

let stage2to3 = NSCustomMigrationStage(

    migratingFrom: NSManagedObjectModelReference(

        named: "ModelV2", in: Bundle.main),

    to: NSManagedObjectModelReference(

        named: "ModelV3", in: Bundle.main)

)

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.

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 NSManagedObjectModel instance 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

Review Checklist

  • NSPersistentContainer is initialized once and shared
  • viewContext used only on main queue; background contexts for writes
  • perform(_:) or performAndWait(_:) wraps all off-queue context access
  • automaticallyMergesChangesFromParent set on viewContext
  • mergePolicy set on viewContext to prevent conflict crashes
  • Batch operation results merged into relevant contexts
  • NSFetchedResultsController fetch requests have sort descriptors
  • Persistent history tracking enabled for multi-target apps
  • Tests use in-memory stores with shared NSManagedObjectModel
  • No NSManagedObject instances cross thread boundaries

References

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card