Overview

Core Data on iOS 18 with Swift 6 and Xcode 16 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. This page covers when to stay on Core Data and how to use it well. For UI rules, see swiftui; for language rules, see 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.

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.

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.

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.

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.
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.