---
title: "Core Data Best Practices"
slug: "core-data"
category: "ios"
tags: ["ios", "core-data", "swift"]
status: "stable"
last_updated: 2026-08-14
summary: "Core Data stack setup, context concurrency, lightweight migrations, SwiftUI integration, and when to pick SwiftData instead."
related: ["[[ios/swiftui]]", "[[ios/mapkit]]", "[[coding/swift]]"]
---

## Overview

Core Data on iOS 26 with Swift 6.2+ and Xcode 26 still ships Apple's most capable persistence stack: schema migrations, CloudKit sync, fetched results, and background contexts. SwiftData (iOS 17+) is the newer greenfield option, and its WWDC 2026 additions (sectioned `@Query` fetches, `@Attribute(.codable)`, `ResultsObserver`/`HistoryObserver`) close some of the old gaps; see [[ios/core-data-swiftdata-comparison]] for what still tips the decision toward Core Data. This page covers when to stay on Core Data and how to use it well. For UI rules, see [[ios/swiftui]]; for language rules, see [[coding/swift]].

## Own one stack via `NSPersistentContainer`

One container per app. Construct it at the app entry point, inject the `viewContext` through `@Environment(\.managedObjectContext)`, and treat the container as the single source of truth.

```swift
final class PersistenceController {
  static let shared = PersistenceController()
  let container: NSPersistentContainer

  init(inMemory: Bool = false) {
    container = NSPersistentContainer(name: "Model")
    if inMemory {
      container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
    }
    container.viewContext.automaticallyMergesChangesFromParent = true
    container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    container.loadPersistentStores { _, error in
      if let error { fatalError("Core Data failed to load: \(error)") }
    }
  }
}
```

Set `automaticallyMergesChangesFromParent = true` so the view context picks up background-context changes without manual notification handling.

## Use `viewContext` on the main actor; everything else on a private queue

The `viewContext` is bound to the main thread. Do not run imports, large fetches, or batch updates on it.

```swift
func importJSON(_ data: Data) async throws {
  try await container.performBackgroundTask { context in
    context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    let items = try JSONDecoder().decode([ItemDTO].self, from: data)
    for dto in items {
      let item = Item(context: context)
      item.id = dto.id
      item.title = dto.title
    }
    try context.save()
  }
}
```

Every read must happen on the context that fetched the object. Pass `NSManagedObjectID` across contexts, never the object.

## Prefer lightweight migrations; reach for mapping models on breaking changes

Set `shouldMigrateStoreAutomatically` and `shouldInferMappingModelAutomatically` on the store description. Lightweight migrations cover added attributes, renamed entities, and added relationships.

```swift
let description = container.persistentStoreDescriptions.first!
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
```

When a change cannot be inferred (splitting an entity, transforming values, merging attributes), add a versioned mapping model and a custom `NSEntityMigrationPolicy`. Test migrations in CI against a fixture of the previous schema's store.

## Bind simple SwiftUI views with `@FetchRequest`

For lists driven by a single predicate and sort, use `@FetchRequest`. It updates automatically when underlying data changes.

```swift
struct ItemList: View {
  @FetchRequest(
    sortDescriptors: [SortDescriptor(\.createdAt, order: .reverse)],
    predicate: NSPredicate(format: "isArchived == NO")
  ) private var items: FetchedResults<Item>

  var body: some View {
    List(items) { item in
      Text(item.title ?? "Untitled")
    }
  }
}
```

When the predicate or sort needs to react to user input, switch to a view model that owns an `NSFetchedResultsController` and publishes via `@Observable`. `@FetchRequest`'s dynamic API exists but tangles state with views.

## Push heavy work to background contexts

Use `performBackgroundTask` or `newBackgroundContext()` for imports, batch deletes, and any operation touching more than a handful of objects.

- Set `mergePolicy` on the background context; the default throws on conflicts.
- Use `NSBatchInsertRequest` for imports over a few thousand rows; it skips the object graph and writes directly to SQLite.
- Use `NSBatchDeleteRequest` for purges; pair it with merging the result IDs into `viewContext` so SwiftUI updates.

```swift
let request = NSBatchDeleteRequest(fetchRequest: Item.fetchRequest())
request.resultType = .resultTypeObjectIDs
let result = try context.execute(request) as? NSBatchDeleteResult
let changes = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [viewContext])
```

## Pick SwiftData for greenfield, Core Data for migrations or CloudKit-heavy apps

Use SwiftData when the model is new, the team is on iOS 17+, and the schema fits `@Model`. The Swift-native API, automatic migrations for additive changes, and `@Query` integration are worth the trade.

Stay on Core Data when:

- The app already ships a Core Data store; SwiftData migration is not free.
- The schema uses fetched properties, derived attributes, or relationships SwiftData does not model cleanly.
- CloudKit sync needs `NSPersistentCloudKitContainer`'s configuration knobs.
- Custom `NSEntityMigrationPolicy` migrations are on the roadmap.

Both APIs can coexist behind a repository layer; wrap reads and writes in a protocol and swap the backing store without touching views.

## Related

- [[ios/swiftui]]
- [[ios/mapkit]]
- [[coding/swift]]
