SwiftData regression in iOS 27.2: inverse relationship not set on children when an uninserted model graph is attached to a persisted model

Since iOS 27.2 (24B119), SwiftData no longer sets the inverse (child → parent) relationship for the children of a model that hasn't been inserted yet when that model is attached to an already-saved model. The children are saved with a nil parent, so the parent's to-many relationship reads back empty. This happens in the same ModelContext and also after reopening the store.

The same code works correctly on iOS 27.0 (24A5390f).

@Model final class Ledger {
    @Relationship(deleteRule: .cascade, inverse: \Transaction.ledger)
    var transactions: [Transaction] = []
}

@Model final class Transaction {
    var ledger: Ledger?
    @Relationship(deleteRule: .cascade, inverse: \Item.transaction)
    var items: [Item] = []
    init(items: [Item]) { self.items = items }
}

@Model final class Item {
    var transaction: Transaction?
}

// Inside a @ModelActor; `ledger` is fetched and already saved.
let transaction = Transaction(items: [Item(), Item()])   // not inserted
ledger.transactions.append(transaction)

// iOS 27.0: item.transaction === transaction for every item
// iOS 27.2: item.transaction == nil for every item
try modelContext.save()
// iOS 27.2: transaction.items.count == 0 (expected 2); both items are orphaned rows

The inverse is already nil before save(), so this isn't a save or flush problem. The link just isn't set when the graph is attached.

Affected patterns on 27.2
• persisted.list.append(uninsertedParentWithChildren)
• persisted.list[index] = uninsertedParentWithChildren
• Reassigning persisted.list = [...] when the new models have their own child arrays (grandchildren end up orphaned)
• Models with several to-many arrays of the same child type

Not affected
• A graph that's entirely new, including the root
• To-one relationships set in init
• Models inserted into the context before the relationship is set

Toolchain ruled out. I built the repro on the same iPhone 13 (iOS 27.2) with three toolchains: xcode 26.6, 27.1 and 27.2

Workaround. Insert the children and set the inverse explicitly before saving:

modelContext.insert(transaction)
for item in transaction.items {
    modelContext.insert(item)
    item.transaction = transaction
}
ledger.transactions.append(transaction)
try modelContext.save()

You have to do this at every level, grandchildren included.
Has anyone else seen this on 27.2? And can someone from the SwiftData team confirm whether this is an intended behavior change or a regression?

Feedback: FB25355425 (includes a standalone repro project, reports from iOS 27.0 and 27.2, and a sysdiagnose)

The following code works for me. I am wondering if there is anything done differently on your side.

The code, followed by the result when running on iOS 27.2 (24B119) with Xcode 27.2 beta 2 (27B5028f):

let ledger = Ledger(transactions: [])
modelContext.insert(ledger)
do {
    try modelContext.save()
} catch {
    print("\(error)")
}

let item1 = Item()
let item2 = Item()
let transaction = Transaction(items: [item1, item2])
ledger.transactions.append(transaction)

print("item1.transaction = \(item1.transaction, default: "nil")")
do {
    try modelContext.save()
} catch {
    print("\(error)")
}
print("transaction.items.count = \(transaction.items.count)")
item1.transaction = MySwiftData.Transaction
transaction.items.count = 2

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks for trying it. Your test differs from mine in two ways that I only mentioned in a code comment (// Inside a @ModelActor; ledger is fetched and already saved), so let me make them explicit:

  1. Background context. The write runs inside a @ModelActor, not on the main context.
  2. The parent is fetched, not reused. The ledger is saved first, then fetched again in the actor's context before the append. In your test it's the instance you just inserted.
  3. On-disk store, checked after reopening. After save() I open the store in a new ModelContainer and count items whose transaction is nil.

Feedback: FB25355425 (includes a standalone repro project, reports from iOS 27.0 and 27.2, and a sysdiagnose)

Would you mind to change the code example above to reflect the difference so I can run it to reproduce the issue you described ? I think that will be easier.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks for testing.
I found the difference: my forum snippet declared the to-many arrays with a default (= []), which our real models don't.

With the default, 27.2 works, matching your result. Without it, the inverse is never set:

@Model final class Ledger {
    @Relationship(deleteRule: .cascade, inverse: \Transaction.ledger)
    var transactions: [Transaction]                 // no = []
    init(transactions: [Transaction]) { self.transactions = transactions }
}

@Model final class Transaction {
    var ledger: Ledger?
    @Relationship(deleteRule: .cascade, inverse: \Item.transaction)
    var items: [Item]                               // no = []
    init(items: [Item]) { self.items = items }
}

@Model final class Item {
    var transaction: Transaction?
    init() {}
} ```
import SwiftData

// A: DTS forum code — to-many arrays default to []
@Model final class LedgerA {
    @Relationship(deleteRule: .cascade, inverse: \TransactionA.ledger) var transactions: [TransactionA] = []
    init() {}
}
@Model final class TransactionA {
    var ledger: LedgerA?
    @Relationship(deleteRule: .cascade, inverse: \ItemA.transaction) var items: [ItemA] = []
    init(items: [ItemA]) { self.items = items }
}
@Model final class ItemA { var transaction: TransactionA?; init() {} }

// B: same, but no `= []` default
@Model final class LedgerB {
    @Relationship(deleteRule: .cascade, inverse: \TransactionB.ledger) var transactions: [TransactionB]
    init() { transactions = [] }
}
@Model final class TransactionB {
    var ledger: LedgerB?
    @Relationship(deleteRule: .cascade, inverse: \ItemB.transaction) var items: [ItemB]
    init(items: [ItemB]) { self.items = items }
}
@Model final class ItemB { var transaction: TransactionB?; init() {} }

// C: optional to-many arrays
@Model final class LedgerC {
    @Relationship(deleteRule: .cascade, inverse: \TransactionC.ledger) var transactions: [TransactionC]?
    init() { transactions = [] }
}
@Model final class TransactionC {
    var ledger: LedgerC?
    @Relationship(deleteRule: .cascade, inverse: \ItemC.transaction) var items: [ItemC]?
    init(items: [ItemC]) { self.items = items }
}
@Model final class ItemC { var transaction: TransactionC?; init() {} }

@MainActor
func runInverseRepro() throws {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)

    // A — default []
    let contextA = ModelContext(try ModelContainer(for: LedgerA.self, configurations: config))
    contextA.insert(LedgerA())
    try contextA.save()
    if let ledger = try contextA.fetch(FetchDescriptor<LedgerA>()).first {
        let items = [ItemA(), ItemA()]
        ledger.transactions.append(TransactionA(items: items))   // transaction not inserted
        print("A (= [])     nil inverses before save:", items.filter { $0.transaction == nil }.count)
    }

    // B — no default
    let contextB = ModelContext(try ModelContainer(for: LedgerB.self, configurations: config))
    contextB.insert(LedgerB())
    try contextB.save()
    if let ledger = try contextB.fetch(FetchDescriptor<LedgerB>()).first {
        let items = [ItemB(), ItemB()]
        ledger.transactions.append(TransactionB(items: items))
        print("B (no [])    nil inverses before save:", items.filter { $0.transaction == nil }.count)
    }

    // C — optional
    let contextC = ModelContext(try ModelContainer(for: LedgerC.self, configurations: config))
    contextC.insert(LedgerC())
    try contextC.save()
    if let ledger = try contextC.fetch(FetchDescriptor<LedgerC>()).first {
        let items = [ItemC(), ItemC()]
        ledger.transactions?.append(TransactionC(items: items))
        print("C (optional) nil inverses before save:", items.filter { $0.transaction == nil }.count)
    }
}

Same code

27.2
A (= [])     nil inverses before save: 0
B (no [])    nil inverses before save: 2
C (optional) nil inverses before save: 0
27.0
A (= [])     nil inverses before save: 0
B (no [])    nil inverses before save: 0
C (optional) nil inverses before save: 0