SwiftData Fetching Pending Changes

When you fetch data using Core Data it includes pending changes by default. SwiftData, in theory, works the same way let’s see how it works in practise.

Last updated: Mar 7, 2024. Both bugs were fixed in iOS 17.4

Fetching Pending Changes

Here’s a SwiftData fetch request with a predicate that returns all visited country model objects, sorted by name:

let descriptor = FetchDescriptor<Country>(
      predicate: #Predicate { $0.visited },
      sortBy: [SortDescriptor(\.name,
               comparator: .localizedStandard)])
let countries = try context.fetch(descriptor)

The Core Data SQLDebug flag also works for SwiftData so we can see the SELECT statement and result this generates:

CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZAREA, t0.ZCAPITAL,
 t0.ZCONTINENT, t0.ZCURRENCY, t0.ZNAME, t0.ZPOPULATION, t0.ZVISITED,
 FROM ZCOUNTRY t0 WHERE  t0.ZVISITED = ? ORDER BY t0.ZNAME COLLATE
 NSCollateFinderlike 
CoreData: annotation: sql connection fetch time: 0.0004s
CoreData: annotation: total fetch execution time: 0.0005s for 5 rows.

I have five countries in my store that have the visited flag set to true, the SQL query returns five rows which I get back in my countries array, sorted by name:

countries.map { $0.name }
// ["Belgium", "France", "Germany", "Italy", "United Kingdom"]

What happens if I have pending changes? I’ll repeat the above fetch request after setting the visited flag for another country (Spain), but before saving the context. The SQLDebug log still shows 5 rows returned:

CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZAREA, t0.ZCAPITAL, ...
...
CoreData: annotation: total fetch execution time: 0.0005s for 5 rows.

But the result returned to me now contains 6 countries because it includes the unsaved model object for “Spain”:

// ["Belgium", "France", "Germany", "Italy", "Spain", "United Kingdom"]

Note that the results are still returned in sorted order and the pending changes are only included if they match the predicate of the fetch request.

Excluding Pending Changes

If you don’t want the pending changes included in the fetch results you can override the default by setting includePendingChanges to false in the fetch descriptor:

var descriptor = FetchDescriptor<Country>(
    predicate: #Predicate { $0.visited },
    sortBy: [SortDescriptor(\.name,
             comparator: .localizedStandard)])
descriptor.includePendingChanges = false

The fetch request ignored the includePendingChanges flag in early releases of iOS 17. Testing with iOS 17.2, the pending change is always returned in the results (FB13509125). Apple fixed the bug in iOS 17.4.

For comparison, here’s the same fetch request using Core Data:

let request = Country.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "name"
                             ascending: true)]
request.predicate = NSPredicate(format: "%K == true",
                      #keyPath(Country.visited))
request.includesPendingChanges = false
let countries = try context.fetch(request)

FetchLimit

The fetchLimit, as the name suggests, puts an upper bound on the number of results returned. Repeating my previous request with a fetch limit of 1:

var descriptor = FetchDescriptor<Country>(
  predicate: #Predicate { $0.visited },
  sortBy: [SortDescriptor(\.name,
           comparator: .localizedStandard)])
descriptor.fetchLimit = 1
let countries = try context.fetch(descriptor)

When using a SQLite store, the fetchLimit becomes a LIMIT clause on the SELECT statement returning a single row:

CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZAREA, t0.ZCAPITAL,
 t0.ZCONTINENT, t0.ZCURRENCY, t0.ZNAME, t0.ZPOPULATION, t0.ZVISITED,
 FROM ZCOUNTRY t0 WHERE  t0.ZVISITED = ? ORDER BY t0.ZNAME
 COLLATE NSCollateFinderlike  LIMIT 1
CoreData: annotation: sql connection fetch time: 0.0003s
CoreData: annotation: total fetch execution time: 0.0004s for 1 rows.

As before, the includePendingChanges flag determines if the result includes unsaved changes or not. This can be surprising when looking at the SQLDebug logs.

For example, suppose I start with an empty database, add two new items but I don’t yet save the context. Repeating my fetch request with a fetch limit of 1, including pending changes, shows 0 rows returned in the logs:

CoreData: annotation: total fetch execution time: 0.0004s for 0 rows.

My fetch request still returns 1 item but it came from the pending changes rather than the on-disk store.

// ["Belgium"]

Unexpected Behaviour (pre-iOS 17.4)

There was some unexpected behaviour in the way pending changes interacted with a fetch limit before iOS 17.4.

With no pending changes, it works as expected and returns a single row. However, if I have a pending change that matches the predicate:

CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZAREA, t0.ZCAPITAL,
 t0.ZCONTINENT, t0.ZCURRENCY, t0.ZNAME, t0.ZPOPULATION, t0.ZVISITED,
 FROM ZCOUNTRY t0 WHERE  t0.ZVISITED = ? ORDER BY t0.ZNAME
 COLLATE NSCollateFinderlike  LIMIT 2
CoreData: annotation: sql connection fetch time: 0.0002s
CoreData: annotation: total fetch execution time: 0.0003s for 2 rows.

My fetch request still has a fetchLimit of 1 but the SQL SELECT statement shows a LIMIT of 2 and I get back two results:

// ["Belgium", "Spain"]

It seems that SwiftData is including the pending change in the result without taking into account the fetch limit. If I configure the fetch descriptor to exclude pending changes it works as expected:

descriptor.includePendingChanges = false

The LIMIT is still 2 in the SELECT statement but the result only contains a single item, excluding the pending change:

// ["Belgium"]

Apple fixed this bug in iOS 17.4 (FB13509173).