Thanks for being a part of WWDC25!

How did we do? We’d love to know your thoughts on this year’s conference. Take the survey here

SwiftData relationshipKeyPathsForPrefetching not working

relationshipKeyPathsForPrefetching in SwiftData does not seem to work here when scrolling down the list. Why?

I would like all categories to be fetched while posts are fetched - not while scrolling down the list.

struct ContentView: View {
    var body: some View {
        QueryList(
            fetchDescriptor: withCategoriesFetchDescriptor
        )
    }
    
    var withCategoriesFetchDescriptor: FetchDescriptor<Post> {
        var fetchDescriptor = FetchDescriptor<Post>()
        fetchDescriptor.relationshipKeyPathsForPrefetching = [\.category]
        return fetchDescriptor
    }
}
struct QueryList: View {        
    @Query
    var posts: [Post]

    init(fetchDescriptor: FetchDescriptor<Post>) {
        _posts = Query(fetchDescriptor)
    }

    var body: some View {
        List(posts) { post in
            VStack {
                Text(post.title)
                Text(post.category?.name ?? "")
                    .font(.footnote)
            }
        }
    }
}

@Model
final class Post {
    var title: String
    var category: Category?
    
    init(title: String) {
        self.title = title
    }
}

@Model final class Category {
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

By using -com.apple.CoreData.SQLDebug 1, I do see that, although the system indeed does prefetching, accessing an attribute of a prefetched relationship still triggers a fetch, which seems to be unnecessary.

I’d suggest that you file a feedback report – If you do so, please share your report ID here.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

FB16366153 (SwiftData relationshipKeyPathsForPrefetching not working)

I also filed one for the same issue: FB16858906

In addition, prefetching related models will also prefetch pks for any one-to-many models in the related model which is unexpected and not desired.

Given:

@Model final class OrderItem {
    var quantity: Int
    var sku: Int
    var order: Order? = nil
     
     init(quantity: Int, sku: Int) {
        self.quantity = quantity
        self.sku = sku
    }
}

@Model final class Order {
    var orderID: Int
    var timestamp: Date = Date()
    var account: Account?
    @Relationship(deleteRule: .cascade, inverse: \OrderItem.order)
    var orderItems: [OrderItem]? = []
    
    init(orderID: Int) { self.orderID = orderID }
}

@Model final class Account {
    var accountID: Int
    @Relationship(deleteRule: .cascade, inverse: \Order.account)    
    var orders: [Order]? = []
    
    init(accountID: Int) { self.accountID = accountID }
}

With some sample data:

let account = Account(accountID: 1)
modelContext.insert(account)

let order = Order(orderID: 100)
modelContext.insert(order)
order.account = account

let orderItem = OrderItem(quantity: 1, sku: 999)
modelContext.insert(orderItem)
orderItem.order = order

Trying to fetch Accounts and pre-fetch related Orders results in 5 queries rather than 2:

var fd = FetchDescriptor<Account>()
fd.relationshipKeyPathsForPrefetching = [\.orders]
let accounts = modelContext.fetch(fd)
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZACCOUNTID FROM ZACCOUNT t0 
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZORDERID, t0.ZTIMESTAMP, t0.ZACCOUNT FROM ZORDER t0 WHERE  t0.ZACCOUNT IN (SELECT * FROM _Z_intarray0)  ORDER BY t0.ZACCOUNT
// CoreData: sql: SELECT 0, t0.Z_PK FROM ZORDERITEM t0 WHERE  t0.ZORDER = ? 

for account in accounts {
    if let orders = account.orders {
        for order in orders {
            let orderID = order.orderID
            // CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZORDERID, t0.ZTIMESTAMP, t0.ZACCOUNT FROM ZORDER t0 WHERE  t0.Z_PK IN  (?)  
            // CoreData: sql: SELECT 0, t0.Z_PK FROM ZORDERITEM t0 WHERE  t0.ZORDER = ? 
        }
    }
}

It looks like I can work around part of the problem by executing the fetch for the related models directly. SwiftData will then properly use them as a cache.

// Fetch accounts
var fd = FetchDescriptor<Account>()
let accounts = modelContext.fetch(fd)
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZACCOUNTID FROM ZACCOUNT t0 
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZORDERID, t0.ZTIMESTAMP, t0.ZACCOUNT, t0.ZORDERITEMS FROM ZORDER t0 WHERE  t0.ZACCOUNT IN (SELECT * FROM _Z_intarray0)  ORDER BY t0.ZACCOUNT

// Fetch related orders for accounts
let accArray = accounts.map { $0.persistentModelID }
var fd2 = FetchDescriptor<Order>()
fd2.predicate = #Predicate {                
    if let account = $0.account {                    
        accArray.contains(account.persistentModelID)
    } else {
        false
    }
}

let orders = try? modelContext.fetch(fd2)
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZORDERID, t0.ZTIMESTAMP, t0.ZACCOUNT, t0.ZORDERITEMS FROM ZORDER t0 WHERE (CASE ((CASE ( t0.ZACCOUNT IS NOT NULL) when 1 then ((CASE ( t0.ZACCOUNT IN (SELECT * FROM _Z_intarray0) ) when 1 then (?) else (?) end)) else (?) end) IS NOT NULL) when 1 then ((CASE ( t0.ZACCOUNT IS NOT NULL) when 1 then ((CASE ( t0.ZACCOUNT IN (SELECT * FROM _Z_intarray1) ) when 1 then (?) else (?) end)) else (?) end)) else (?) end) = ? 
// + 1 query to OrderItem per result if not using an intermediary model

for account in accounts {
    if let orders = account.orders {
        for order in orders {
            // this will use the cached order fetched above so no queries will execute
            let orderID = order.orderID
        }
    }
}

To keep SwiftData from executing a query for each Order to get the PKs for each OrderItem though seems to require replacing the 1-to-many relationship with a 1-to-1 relationship with an intermediary model since SwiftData only pre-fetches on to-many relationships. This isn't particularly ergonomic, but it seems to work.

SwiftData relationshipKeyPathsForPrefetching not working
 
 
Q