How a SwiftData Insert-Order Bug Silently Dropped Relationships in TechSheet
A user of TechSheet reported that custom maintenance schedules they added in the app were “basically not persisting.” They’d open Add Schedule, pick Custom, type a name and an interval, tap Add, the sheet would dismiss — and the schedule wasn’t in the list. Sometimes it appeared briefly, sometimes not at all.
The fix was one line per call site. Finding it took me a while because Apple’s own documentation contradicts itself about what the right pattern is.
The symptom
TechSheet’s schedule list reads through a SwiftData inverse relationship:
private var activeSchedules: [MaintenanceSchedule] {
(vehicle.maintenanceSchedules ?? [])
.filter { !$0.isDeleted }
.sorted { $0.displayTypeName < $1.displayTypeName }
}
Inverse means the schedule has a vehicle: Vehicle? property, and Vehicle declares the inverse:
@Relationship(deleteRule: .cascade, inverse: \MaintenanceSchedule.vehicle)
public var maintenanceSchedules: [MaintenanceSchedule]? = []
When a user added a custom schedule, the code that ran was the canonical pattern you’ll see in tutorials and Apple’s own sample projects:
let schedule = MaintenanceSchedule(vehicle: vehicle, maintenanceType: .custom)
schedule.intervalMiles = miles
schedule.customTypeName = "Differential Service"
modelContext.insert(schedule)
try modelContext.save()
That looks correct. The init sets self.vehicle = vehicle. The schedule is inserted into the context. The context is saved. Done.
Except vehicle.maintenanceSchedules doesn’t include the new schedule. The row exists in the SwiftData store — I confirmed this with a FetchDescriptor<MaintenanceSchedule> query — but the inverse array on the parent is empty. The view, which reads through the inverse, shows nothing.
Why the symptom was specific to schedules
TechSheet has the same shape of code for maintenance records, documents, and schedules. Why did only schedules look broken?
The answer is how each view loads its data:
MaintenanceHistoryViewuses@Query<MaintenanceRecord>directly — it fetches records regardless of who their parent is, so an orphaned record still shows up.MaintenanceScheduleViewreadsvehicle.maintenanceSchedules— the inverse. If the inverse isn’t populated, the schedule is invisible.
So records were silently broken too, but the symptom was hidden because the history view didn’t depend on the relationship. Computed properties like vehicle.totalMaintenanceCost were undercounting — the kind of bug you might never notice unless a user complained about a specific number being off.
The fix
For every call site that creates a child model with a parent reference, swap the order:
// Before — what most tutorials and Apple's sample code show:
let schedule = MaintenanceSchedule(vehicle: vehicle, maintenanceType: .custom)
modelContext.insert(schedule)
// After — the working pattern:
let schedule = MaintenanceSchedule(maintenanceType: .custom)
modelContext.insert(schedule)
schedule.vehicle = vehicle
That’s it. Insert first, then assign the relationship.
I had nine call sites across TechSheet that did this — Add Schedule, the “Create Schedule?” prompt after logging a service, the Log Service record itself, Add Document, Scan Receipt, the default-schedules factory used by Add Vehicle and Onboarding, the backup-restore importer, and the vehicle transfer importer. All of them used the old pattern. All of them were silently affected to some degree.
What Apple’s documentation actually says
This is where it gets uncomfortable. Apple’s own sources don’t agree.
The ModelContext reference page says you shouldn’t need to insert children at all:
“If your app’s schema describes relationships between models, you don’t need to manually insert each model into the context when you first create them. Instead, create the graph of related models and insert only the graph’s root model into the context. The context recognizes the hierarchy and automatically handles the insertion of the related models. The same behavior applies even if the graph contains both new and existing models.”
If that worked, my original code should have worked. It didn’t.
The “Adding and editing persistent data in your app” sample-code doc teaches the bug-prone pattern directly:
“When adding a new animal, the save function creates a new Animal instance, initializing it with the name and diet from the state variables. Then it sets the category and inserts the animal into the model context by calling the model context insert(_:) method.”
That’s animal.category = category followed by ctx.insert(animal) — set the relationship, then insert. The same pattern that broke TechSheet.
Apple Developer Forums thread 763830 is where the truth comes out. A developer hit the exact symptom — inserting Student objects with a non-optional School relationship, finding that school.students.count stayed at 0. A DTS engineer responded, acknowledged it as a known issue, and said the framework “can be more intuitive” here. The recommended workaround, which the original poster confirmed works:
context.insert(school)
context.insert(student1)
context.insert(student2)
// AFTER both are in the context:
student1.school = school
Insert first, then assign. Which is exactly the fix I shipped.
It’s not a CloudKit-only bug
I initially assumed this was specific to CloudKit sync. TechSheet uses cloudKitDatabase: .automatic, and my mental model was that the CloudKit round-trip was dropping the inverse. That was wrong.
The DTS-confirmed bug reproduces on a local-only SwiftData container too. CloudKit just makes the consequences more obvious because the inverse never makes it across devices, and you get user reports faster. But the underlying issue is in how SwiftData processes inverse relationships set during object initialization, before insertion into the context.
The fatbobman article on SwiftData relationships documents the cardinality rules: when both ends of a relationship are optional, SwiftData should auto-set the inverse. When either end is non-optional, you have to declare it explicitly. TechSheet’s models have both ends optional — Vehicle.maintenanceSchedules: [MaintenanceSchedule]? = [] and MaintenanceSchedule.vehicle: Vehicle? — so per the documented rules the inverse should propagate automatically. It doesn’t, reliably, when you set the relationship before insert.
How I caught it
The user described the bug clearly enough that I could form a hypothesis fast: schedule saved, but inverse not populated. From there it was code review.
The audit was simple — grep for every modelContext.insert( call in the project, then check whether a relationship had been set on the inserted model before that line. I found nine production sites and one factory (DefaultSchedules.createDefaults). The factory got a new context-aware overload that inserts each schedule before wiring the vehicle; the old in-memory overload stayed for TestDataGenerator, which builds the whole graph in memory and inserts as one transaction — a separate pattern that isn’t affected.
The fix landed as a single commit, all unit tests passed, and the change is purely call-site discipline — no schema changes, no migrations.
What this means for your apps
If you’re shipping SwiftData with relationships, audit your code for this pattern:
let child = ChildModel(parent: parent, ...)
context.insert(child)
That’s the footgun. Either of the following is safer:
// Option 1: insert first, then assign
let child = ChildModel(...)
context.insert(child)
child.parent = parent
// Option 2: insert both as a graph (Apple's documented preference)
let parent = ParentModel(...)
let child = ChildModel(parent: parent, ...)
parent.children = [child]
context.insert(parent)
Option 1 is what Apple DTS recommends for the bug. Option 2 is what Apple’s ModelContext reference page implies should work — and in some configurations does — but I’d trust Option 1 in production until Apple ships a fix.
The bigger lesson for me, after three years of SwiftData in production: trust the symptoms users report, not the documented behavior. SwiftData’s high-level API hides a lot of nuance, and the gap between “what the docs say” and “what the framework does” is real. The CloudKit layer makes it harder to debug, but the underlying tradeoffs are still worth it for solo-developer infrastructure.
The takeaway
If your SwiftData app reads data through inverse relationships, and you find yourself wondering why an object you just saved “isn’t showing up,” check the order in which you insert it versus when you set its relationship. The pattern that breaks is in Apple’s own sample code. The pattern that works is in a forum thread.
Insert first. Assign after. Ship.