---
name: core-data
description: "Create, audit or refine Core Data persistence for apps still on Core Data rather than SwiftData: stack setup, NSManagedObject subclasses, NSFetchedResultsController for list UIs, NSBatchInsertRequest, NSBatchDeleteRequest and NSBatchUpdateRequest bulk work, NSPersistentHistoryChangeRequest history tracking across app and extension targets, NSStagedMigrationManager staged migrations and NSCompositeAttributeDescription composite attributes (iOS 17+), and making Core Data contexts work with Swift Concurrency. Use when working on an existing Core Data store. Not for Core Data and SwiftData coexistence or migration; use the swiftdata skill."
metadata:
  source: multi-agent-pipeline
---

# Core Data

For apps that still persist with Core Data rather than SwiftData. Covers the
stack, queue rules and Swift Concurrency, fetched results controllers, batch
requests, persistent history, staged migration, composite attributes and
testing. Hand-offs: SwiftData coexistence and migration go to `swiftdata`;
iCloud sync details (zones, sharing, `CKError`) go to `cloudkit-sync`; test
style goes to `swift-testing`.

## The stack

`NSPersistentContainer` wraps the model, coordinator and contexts. Create it
once and share it.

```swift
import CoreData

final class LibraryStore: Sendable {
    static let shared = LibraryStore()
    let container: NSPersistentContainer

    private init() {
        container = NSPersistentContainer(name: "Library")
        container.loadPersistentStores { _, error in
            if let error { fatalError("Store failed to load: \(error)") }
        }
        let ui = container.viewContext
        ui.automaticallyMergesChangesFromParent = true
        ui.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump   // NSMergeByPropertyObjectTrumpMergePolicy
    }

    func makeWorker() -> NSManagedObjectContext {
        container.newBackgroundContext()
    }
}
```

`LibraryStore` is checked `Sendable` with no `@unchecked`: its only stored
property is a `let` of `NSPersistentContainer`, which the SDK marks `Sendable`.
Replace `fatalError` with real recovery in shipping code. When the store syncs
through iCloud, create an `NSPersistentCloudKitContainer` instead.

## Queues and threads

Every context belongs to a queue: `viewContext` to the main queue, background
contexts to private queues.

- Touch a context from anywhere other than its own queue only inside
  `perform(_:)` or `performAndWait(_:)`.
- Never hand an `NSManagedObject` to another context or thread. Pass its
  `NSManagedObjectID` and fetch it again on the other side.
- Turn on `automaticallyMergesChangesFromParent` for `viewContext`.

```swift
func markReturned(_ loanID: NSManagedObjectID) async throws {
    let worker = LibraryStore.shared.makeWorker()
    try await worker.perform {
        guard let loan = try worker.existingObject(with: loanID) as? Loan else { return }
        loan.returnedAt = .now
        try worker.save()
    }
}
```

### Swift Concurrency

Since iOS 15 you can `try await context.perform { }`. Do not mark
`NSManagedObject` subclasses `Sendable` or `@unchecked Sendable`; they are not
thread-safe.

```swift
func importBooks(_ rows: [BookRow]) async throws {
    let worker = LibraryStore.shared.makeWorker()
    try await worker.perform {
        for row in rows {
            let book = Book(context: worker)
            book.isbn = row.isbn
            book.title = row.title
        }
        if worker.hasChanges { try worker.save() }
    }
    // viewContext picks this up through automatic merging
}

func archive(_ bookID: NSManagedObjectID) {
    Task.detached {                       // NSManagedObjectID is Sendable
        let worker = LibraryStore.shared.makeWorker()
        try await worker.perform {
            let book = try worker.existingObject(with: bookID) as? Book
            book?.isArchived = true
            try worker.save()
        }
    }
}
```

## `NSFetchedResultsController`

An FRC feeds a table or collection view from a fetch request, tracks changes,
and can cache section data.

```swift
import UIKit

final class ShelfViewController: UICollectionViewController, @preconcurrency NSFetchedResultsControllerDelegate {
    private var results: NSFetchedResultsController<Book>!
    private var dataSource: UICollectionViewDiffableDataSource<String, NSManagedObjectID>!

    override func viewDidLoad() {
        super.viewDidLoad()
        let request = Book.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Book.title, ascending: true)]
        request.fetchBatchSize = 25
        results = NSFetchedResultsController(fetchRequest: request,
                                             managedObjectContext: LibraryStore.shared.container.viewContext,
                                             sectionNameKeyPath: nil,
                                             cacheName: nil)
        results.delegate = self
        try? results.performFetch()
    }

    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                    didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
        let typed = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
        dataSource.apply(typed, animatingDifferences: true)
    }
}
```

Without a diffable data source, read `results.sections?.count`,
`results.sections?[section].numberOfObjects` and `results.object(at:)`.

Rules:

- The fetch request needs at least one sort descriptor.
- Before changing the predicate or sort descriptors, call
  `NSFetchedResultsController.deleteCache(withName:)` or use `cacheName: nil`.
- The snapshot callback (iOS 13+) is preferred over the per-object change callbacks.
- After `reset()` on the context, call `performFetch()` again.

## Batch requests

Batch requests run directly against SQLite. They skip the context, which makes
them fast, but they post no context notifications, so **merge their results
yourself**.

```swift
@Sendable func publish(_ result: Any?, as key: String) {
    let ids = result as? [NSManagedObjectID] ?? []
    let ui = LibraryStore.shared.container.viewContext
    NSManagedObjectContext.mergeChanges(fromRemoteContextSave: [key: ids], into: [ui])
}

let worker = LibraryStore.shared.makeWorker()

// Insert (iOS 13+)
try await worker.perform {
    let insert = NSBatchInsertRequest(entity: Book.entity(), objects: rows.map { ["isbn": $0.isbn, "title": $0.title] })
    insert.resultType = .objectIDs
    let inserted = try worker.execute(insert) as? NSBatchInsertResult
    publish(inserted?.result, as: NSInsertedObjectsKey)
}

// Delete (iOS 9+)
try await worker.perform {
    let stale: NSFetchRequest<NSFetchRequestResult> = Loan.fetchRequest()
    stale.predicate = NSPredicate(format: "returnedAt < %@", cutoff as NSDate)
    let delete = NSBatchDeleteRequest(fetchRequest: stale)
    delete.resultType = .resultTypeObjectIDs
    let removed = try worker.execute(delete) as? NSBatchDeleteResult
    publish(removed?.result, as: NSDeletedObjectsKey)
}

// Update (iOS 8+)
try await worker.perform {
    let update = NSBatchUpdateRequest(entity: Book.entity())
    update.propertiesToUpdate = ["isArchived": true]
    update.resultType = .updatedObjectIDsResultType
    let changed = try worker.execute(update) as? NSBatchUpdateResult
    publish(changed?.result, as: NSUpdatedObjectsKey)
}
```

A batch delete does not honor the **Deny** delete rule and skips validation
beyond the basic delete rules. Check those constraints yourself first.

## Persistent history

History tracking records store-level changes so the app, its extensions and
widgets (separate processes) can see each other's writes, including batch
requests.

### Turn it on

```swift
guard let description = container.persistentStoreDescriptions.first else { return }
for key in [NSPersistentHistoryTrackingKey, NSPersistentStoreRemoteChangeNotificationPostOptionKey] {
    description.setOption(NSNumber(value: true), forKey: key)
}
container.persistentStoreDescriptions = [description]
```

### Consume it

1. Observe `.NSPersistentStoreRemoteChange`, with the container's persistent store coordinator as the object.
2. On a background context, fetch transactions after your last token.
3. Merge each transaction into `viewContext` and move the token forward.
4. Purge history older than the token.

```swift
final class HistoryReader: NSObject, @unchecked Sendable {
    private let container: NSPersistentContainer
    private let worker: NSManagedObjectContext
    private let tokenKey: String            // one per target: "history.app", "history.widget"
    private var lastToken: NSPersistentHistoryToken?

    init(container: NSPersistentContainer, target: String) {
        self.container = container
        self.worker = container.newBackgroundContext()
        self.tokenKey = "history.\(target)"
        super.init()
        lastToken = Self.loadToken(forKey: tokenKey)
        let center = NotificationCenter.default
        center.addObserver(self, selector: #selector(storeChanged),
                           name: .NSPersistentStoreRemoteChange, object: container.persistentStoreCoordinator)
    }

    @objc private func storeChanged(_ note: Notification) {
        worker.perform { [self] in
            let fetch = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
            let history = try? worker.execute(fetch) as? NSPersistentHistoryResult
            guard let transactions = history?.result as? [NSPersistentHistoryTransaction] else { return }
            let ui = container.viewContext
            for transaction in transactions {
                let changes = transaction.objectIDNotification().userInfo ?? [:]
                NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [ui])
                lastToken = transaction.token
            }
            Self.saveToken(lastToken, forKey: tokenKey)
            if let lastToken {
                _ = try? worker.execute(NSPersistentHistoryChangeRequest.deleteHistory(before: lastToken))
            }
        }
    }

    private static var defaults: UserDefaults { UserDefaults(suiteName: "group.app.library") ?? .standard }

    private static func loadToken(forKey key: String) -> NSPersistentHistoryToken? {
        defaults.data(forKey: key).flatMap {
            try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSPersistentHistoryToken.self, from: $0)
        }
    }

    private static func saveToken(_ token: NSPersistentHistoryToken?, forKey key: String) {
        guard let token else { return }
        let encoded = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
        defaults.set(encoded, forKey: key)
    }
}
```

`@unchecked Sendable` rests on one guarantee: after `init` (which sets
`lastToken` before the observer is registered), `lastToken` is read and written
only inside `worker.perform`, and one context runs its `perform` blocks one at a
time on its private queue. Creating a new background context per notification
would break that, because two notifications could then run on two queues at
once.

The static merge call is safe from the worker queue; calling
`viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())`
inside `viewContext.perform` is the equivalent instance form.

Keep one token per target (for example in the shared `UserDefaults` suite) so
each process resumes where it stopped after relaunch. Purge only up to the
point every reader has consumed.

## Staged migration (iOS 17+)

`NSStagedMigrationManager` applies an ordered list of stages, each either
lightweight or custom.

- `NSLightweightMigrationStage` takes the **version checksums** (`[String]`) of
  compiled model versions, not their names, and has a `label`.
- `NSManagedObjectModelReference(name:in:versionChecksum:)` points to one model version.
- `NSCustomMigrationStage(migratingFrom:to:)` takes a source and a destination
  reference and offers `label` and `willMigrateHandler` (called with the manager and stage).

```swift
let v1 = NSManagedObjectModelReference(name: "Library_v1", in: .main, versionChecksum: "k3Qp...v1")
let v2 = NSManagedObjectModelReference(name: "Library_v2", in: .main, versionChecksum: "R8bz...v2")
let v3Checksum = "Yt5m...v3"

let splitAuthor = NSCustomMigrationStage(migratingFrom: v1, to: v2)
splitAuthor.label = "Split author into first and last name"
splitAuthor.willMigrateHandler = { manager, _ in
    guard let store = manager.container else { return }
    let context = store.newBackgroundContext()
    try context.performAndWait {
        let request = NSFetchRequest<NSManagedObject>(entityName: "Book")
        for book in try context.fetch(request) {
            let full = book.value(forKey: "author") as? String ?? ""
            let parts = full.split(separator: " ", maxSplits: 1).map(String.init)
            book.setValue(parts.first, forKey: "authorFirst")
            book.setValue(parts.count > 1 ? parts[1] : nil, forKey: "authorLast")
        }
        try context.save()
    }
}

let addIndexes = NSLightweightMigrationStage([v3Checksum])
addIndexes.label = "v2 to v3: add indexes"

let manager = NSStagedMigrationManager([splitAuthor, addIndexes])
let description = container.persistentStoreDescriptions.first ?? NSPersistentStoreDescription()
description.setOption(manager, forKey: NSPersistentStoreStagedMigrationManagerOptionKey)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
    if let error { MigrationLog.failed(error) }
}
```

The option must be on the store description **before** `loadPersistentStores`.
Read the checksums from the compiled `.momd` (for example
`NSManagedObjectModel.versionChecksum`), never type a model name in their place.

Older deployment targets fall back to inferred mapping
(`NSInferMappingModelAutomaticallyOption`) or hand-written mapping models.

## Composite attributes (iOS 17+)

`NSCompositeAttributeDescription` groups several sub-attributes into one
logical attribute, for example an address made of street, city and postcode.
Create it in the model editor by choosing the **Composite** type and adding
nested attributes. When the same store is read by SwiftData, a composite maps
to a `Codable` struct.

## Where SwiftData starts

Coexistence and migration work belongs to `swiftdata`. The rules that matter
from this side:

- Point SwiftData at the **existing** store URL when it shares or takes over Core Data data.
- The `@Model` classes must line up with the Core Data model: same entity and
  property names, matching types, a compatible schema.
- Map renamed persisted properties with `@Attribute(originalName:)`.

## Testing with an in-memory store

```swift
import Testing
import CoreData

enum TestModel {
    nonisolated(unsafe) static let shared: NSManagedObjectModel = {   // loaded once, never mutated
        let compiled = Bundle(for: LibraryStore.self).url(forResource: "Library", withExtension: "momd")
        return compiled.flatMap(NSManagedObjectModel.init(contentsOf:)) ?? NSManagedObjectModel()
    }()
}

func makeMemoryContainer() throws -> NSPersistentContainer {
    let container = NSPersistentContainer(name: "Library", managedObjectModel: TestModel.shared)
    let memory = NSPersistentStoreDescription()
    memory.type = NSInMemoryStoreType
    container.persistentStoreDescriptions = [memory]
    var failure: Error?
    container.loadPersistentStores { _, problem in failure = problem }
    if let failure { throw failure }
    return container
}

@Test func savingABookPersistsIt() throws {
    let context = try makeMemoryContainer().viewContext
    let book = Book(context: context)
    book.title = "Dune"
    try context.save()

    let fetched = try context.fetch(Book.fetchRequest())
    #expect(fetched.count == 1)
    #expect(fetched.first?.title == "Dune")
}
```

Load the model once and pass the same `NSManagedObjectModel` to every test
container; loading it per test produces "multiple entity descriptions claim"
duplicate-entity warnings.

## Common mistakes

| Mistake | Fix |
|---|---|
| Handing an `NSManagedObject` to another thread | Send `objectID` and fetch again |
| Batch results never merged | `NSManagedObjectContext.mergeChanges(fromRemoteContextSave:into:)` |
| `save()` with nothing to save | Check `context.hasChanges` first |
| The deprecated confinement type passed to `init(concurrencyType:)` | Pick `.privateQueueConcurrencyType` or `.mainQueueConcurrencyType` |
| No `mergePolicy` on `viewContext` | `NSMergeByPropertyObjectTrumpMergePolicy`, avoiding merge-conflict failures |
| Editing a live FRC request without clearing its cache | `deleteCache(withName:)` or `cacheName: nil` |
| Relying on the Deny rule with batch delete | Batch delete ignores it; validate manually |
| `@unchecked Sendable` on managed objects | Remove it; pass `objectID` |
| SwiftData opening a fresh, empty file while sharing data with Core Data | Reuse the current store URL and keep the schema compatible |

## Review checklist

- [ ] One `NSPersistentContainer`, created once and shared
- [ ] `viewContext` used only on the main queue; writes on background contexts
- [ ] Every off-queue access wrapped in `perform` or `performAndWait`
- [ ] `automaticallyMergesChangesFromParent` on `viewContext`
- [ ] `mergePolicy` on `viewContext`
- [ ] Batch request results merged
- [ ] FRC fetch requests sorted
- [ ] Persistent history enabled when several targets share the store
- [ ] SwiftData hand-off keeps the store URL, schema, entity and property names, and rename mappings
- [ ] Test containers are in-memory and share a single loaded model
- [ ] No `NSManagedObject` crosses a thread boundary

## References

- [Core Data](https://developer.apple.com/documentation/coredata)
- [NSPersistentContainer](https://developer.apple.com/documentation/coredata/nspersistentcontainer)
- [NSManagedObjectContext](https://developer.apple.com/documentation/coredata/nsmanagedobjectcontext)
- [NSFetchedResultsController](https://developer.apple.com/documentation/coredata/nsfetchedresultscontroller)
- [NSBatchInsertRequest](https://developer.apple.com/documentation/coredata/nsbatchinsertrequest)
- [NSBatchDeleteRequest](https://developer.apple.com/documentation/coredata/nsbatchdeleterequest)
- [NSPersistentHistoryChangeRequest](https://developer.apple.com/documentation/coredata/nspersistenthistorychangerequest)
- [NSStagedMigrationManager](https://developer.apple.com/documentation/coredata/nsstagedmigrationmanager)
- [NSCompositeAttributeDescription](https://developer.apple.com/documentation/coredata/nscompositeattributedescription)
