Overview

NSFetchRequest is the primary read API for Core Data. Writing it correctly determines whether a list loads in 10 ms or 500 ms. Poor predicates, missing indexes, and fetches on the main thread are the three most common sources of Core Data performance problems. NSBatchUpdateRequest and NSBatchDeleteRequest bypass the object graph for bulk writes, offering orders-of-magnitude improvements over object-by-object loops. For the context and stack that runs these requests, see core-data-stack and core-data-concurrency.

Index attributes you filter or sort on frequently

Core Data maps to SQLite. An unindexed attribute in a predicate produces a full table scan. Open the data model editor, select the attribute, and enable “Indexed” for any attribute that appears in a frequent NSPredicate.

Commonly indexed attributes: createdAt, id, status, userId, any foreign-key equivalent. Do not index attributes that are rarely queried; indexes consume write time and disk space.

Write predicates with typed key paths, not raw strings

String key paths bypass the compiler. Use #keyPath for compile-time-checked attribute names.

// Fragile: typo in "isArchived" silently returns all results
NSPredicate(format: "isArchiived == NO")
 
// Safer: use compile-time key paths
let predicate = NSPredicate(
  format: "%K == %@ AND %K == NO",
  #keyPath(Item.userId), userId,
  #keyPath(Item.isArchived)
)

#keyPath produces a compile-time-checked string. For iOS 15+, NSPredicate with key path expressions from \Item.userId is available in limited contexts; adopt it as the API matures.

Set fetch limits and batch sizes for large result sets

Fetching 10,000 objects into memory at once is rarely correct. Set fetchLimit when you need only the top N results. Set fetchBatchSize to load objects in pages, keeping memory flat as the user scrolls.

let request = NSFetchRequest<Item>(entityName: "Item")
request.predicate = NSPredicate(format: "%K == NO", #keyPath(Item.isArchived))
request.sortDescriptors = [NSSortDescriptor(key: #keyPath(Item.createdAt), ascending: false)]
request.fetchLimit = 50
request.fetchBatchSize = 20

fetchBatchSize is only useful when the results drive a UITableView or NSFetchedResultsController; @FetchRequest in SwiftUI does not benefit from it.

Use @FetchRequest in SwiftUI for reactive list views

@FetchRequest subscribes to the context and re-renders the view when matching objects change. It is the right tool for views that display a filtered, sorted list of objects.

struct ActiveItemList: View {
  @FetchRequest(
    sortDescriptors: [SortDescriptor(\Item.createdAt, order: .reverse)],
    predicate: NSPredicate(format: "%K == NO", #keyPath(Item.isArchived)),
    animation: .default
  ) private var items: FetchedResults<Item>
 
  var body: some View {
    List(items) { item in
      ItemRow(item: item)
    }
  }
}

When the predicate depends on a runtime value (user search term, filter selection), use a view model that owns an NSFetchedResultsController and publishes via @Observable.

Use NSBatchUpdateRequest and NSBatchDeleteRequest for bulk writes

Object-by-object updates and deletes for large sets are slow because each operation runs through the full change tracking pipeline. Batch operations write directly to SQLite.

// Batch update: mark all items as archived
let batchUpdate = NSBatchUpdateRequest(entityName: "Item")
batchUpdate.predicate = NSPredicate(format: "%K < %@", #keyPath(Item.createdAt), cutoffDate as CVarArg)
batchUpdate.propertiesToUpdate = [#keyPath(Item.isArchived): true]
batchUpdate.resultType = .updatedObjectIDsResultType
let result = try context.execute(batchUpdate) as? NSBatchUpdateResult
let ids = result?.result as? [NSManagedObjectID] ?? []
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: [NSUpdatedObjectsKey: ids], into: [viewContext])

Always merge batch operation results back into the view context. Batch operations bypass the context’s change notifications; without the merge, the UI does not reflect the change.

Use NSFetchedResultsController for sections and live updates in UIKit

NSFetchedResultsController is the bridge between Core Data and UITableView/UICollectionView. It batches change notifications into insertions, deletions, and moves that map directly to performBatchUpdates.

lazy var frc: NSFetchedResultsController<Item> = {
  let request = Item.fetchRequest()
  request.sortDescriptors = [
    NSSortDescriptor(key: #keyPath(Item.section), ascending: true),
    NSSortDescriptor(key: #keyPath(Item.createdAt), ascending: false)
  ]
  request.fetchBatchSize = 20
  return NSFetchedResultsController(
    fetchRequest: request,
    managedObjectContext: viewContext,
    sectionNameKeyPath: #keyPath(Item.section),
    cacheName: nil
  )
}()

In swiftui, prefer @FetchRequest for simple cases and an @Observable view model wrapping an NSFetchedResultsController for multi-section or highly dynamic lists.