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. PassNSManagedObjectIDinstead 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 setcacheNametonil. - 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.
For destructive or retryable batch work, use a proof loop: preflight the predicate and expected count, execute with an object-ID result type, merge IDs into live contexts, refetch, and assert the postcondition. On failure, restore a pristine fixture or prove the operation is idempotent before retrying; never blindly rerun a partially completed batch.
Persistent History Tracking
Track store-level changes across targets (app, extensions, widgets) and processes. The core workflow is:
Docs: NSPersistentHistoryChangeRequest
- Enable persistent history and remote-change notifications before loading the store.
- Observe changes and fetch transactions after the target's durable token.
- Merge transaction notifications into live contexts, then persist the new token.
- Purge only history that every relevant consumer has processed.
Load persistent-history.md when implementing the store options, observer, token persistence, merge loop, or purge policy.
Staged Migration
NSStagedMigrationManager (iOS 17+) sequences schema migrations through
ordered lightweight or custom stages. Stage inputs use compiled model-version
checksums, not model names. Apps supporting systems below iOS 17 need the
lightweight migration or mapping-model path.
Docs: NSStagedMigrationManager
Load staged-migration.md when building the ordered stages, model references, custom handler, and persistent-store option.
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
@Modelclasses. - 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