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.

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 should be able to 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

Unfortunately, I don’t seem to be able to get that to work using iOS 17.2 (FB13509125). The pending change is always returned in the results.

For comparison, the Core Data fetch request works as expected, only returning results from the store:

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 way pending changes interacts with a fetch limit also seems a little odd with SwiftData. 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:

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.

With no pending changes, this 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 has 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. But this time if I configure the fetch descriptor to exclude pending changes it seems to work 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"]

I’m not sure how likely this is to cause a problem in practise but it’s certainly unexpected (FB13509173).