---
title: "Core Data Fetch Requests: Predicates, Sort Descriptors, and Batch Updates"
slug: "core-data-fetch-requests"
category: "ios"
tags: ["ios", "core-data", "swift", "fetch-requests", "predicates", "sort-descriptors", "batch-updates", "nsfetchrequest"]
status: "stable"
last_updated: 2026-05-14
summary: "Writing efficient Core Data fetch requests: predicate syntax, sort descriptors, fetch limits, batch fetching, and NSBatchUpdateRequest for bulk writes."
related: ["[[ios/core-data]]", "[[ios/core-data-stack]]", "[[ios/core-data-concurrency]]", "[[ios/core-data-migrations]]", "[[ios/core-data-swiftdata-comparison]]", "[[ios/swiftui]]", "[[coding/swift]]"]
---

## 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 [[ios/core-data-stack]] and [[ios/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.

```swift
// 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.

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

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

```swift
// 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`.

```swift
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 [[ios/swiftui]], prefer `@FetchRequest` for simple cases and an `@Observable` view model wrapping an `NSFetchedResultsController` for multi-section or highly dynamic lists.

## Related

- [[ios/core-data]]
- [[ios/core-data-stack]]
- [[ios/core-data-concurrency]]
- [[ios/core-data-migrations]]
- [[ios/core-data-swiftdata-comparison]]
- [[ios/swiftui]]
- [[coding/swift]]
