SwiftData in production: what the tutorials do not tell you
SwiftData demos look easy. You annotate a class with @Model, drop a @Query into a view, and a live, persisted, animated list appears: no fetch requests, no NSManagedObject, no boilerplate. For a to-do app with forty rows, it really is that good, and you should use it.
Then you ship a point-of-sale app, the cashier scrolls a year of receipts, and the easy version gets expensive. I have watched a checkout screen drop frames during a busy hour because a @Query three views away kept refetching ten thousand transactions on every keystroke. The framework was not broken. We were using the demo-grade pattern at production scale. This is a field guide to the four traps that bite real SwiftData apps, with the code that gets you out of each, written from a POS codebase, not a tutorial.
Why @Model and @Query really are great, to start
Give SwiftData its due. The modelling story is strong: a plain Swift class, one macro, and you have a persisted entity with relationships and CloudKit sync available.
import SwiftData
@Model
final class Transaction {
var total: Decimal
var createdAt: Date
var note: String
init(total: Decimal, createdAt: Date = .now, note: String = "") {
self.total = total
self.createdAt = createdAt
self.note = note
}
}
Inside a view, @Query binds that model to the UI and keeps it live: insert a row anywhere and every @Query watching it animates. For the first screens of any app, this is the right tool and you should not reach for anything heavier. Paul Hudson’s Hacking with Swift material is the fastest on-ramp here, and Apple’s “Meet SwiftData” session covers the model layer well. The traps below are not arguments against SwiftData; they are the things that only show up once the row count and the team both grow.
When every view refetches, the main thread pays
@Query is a SwiftUI view tool, by design: it is a property wrapper that ties fetching to the view’s lifecycle and the environment’s model context. That is exactly what makes it risky when overused. Put a broad @Query in a view that re-evaluates often (a search field, a parent that reshuffles), and you can re-run a large fetch far more often than the data actually changes. The view body becomes a fetch trigger.
The fix is to move the fetch out of the view and into an @Observable model that owns a FetchDescriptor and runs it deliberately. The view observes results; it no longer is the query.
@Observable
final class TransactionListModel {
private(set) var rows: [Transaction] = []
private let context: ModelContext
init(context: ModelContext) {
self.context = context
}
func load(searchText: String) {
var descriptor = FetchDescriptor<Transaction>(
predicate: searchText.isEmpty ? nil : #Predicate {
$0.note.localizedStandardContains(searchText)
},
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 50
rows = (try? context.fetch(descriptor)) ?? []
}
}
Two details that the compiler will not teach you. For string search inside a predicate, use localizedStandardContains; lowercased().contains() does not work in a SwiftData predicate. And never write @Query (or this kind of model) outside a SwiftUI view expecting it to live-update; the model above re-fetches only when you call load, which is the whole point: you decide when.
Ten thousand rows: paginate, do not fetch the world
A year of receipts is not a list you fetch in one go. Loading ten thousand fully-realised model objects to show twenty on screen is the single most common SwiftData performance mistake I see. FetchDescriptor has the two properties you need: fetchLimit and fetchOffset. Page the data.
func page(_ index: Int, size: Int = 50) throws -> [Transaction] {
var descriptor = FetchDescriptor<Transaction>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = size
descriptor.fetchOffset = index * size
// only pull the columns the row actually shows
descriptor.propertiesToFetch = [\.total, \.createdAt]
return try context.fetch(descriptor)
}
Two tuning knobs most tutorials skip. propertiesToFetch tells SwiftData to load only the properties a row renders instead of every column, which matters when a model has heavy fields. And if each row reads a relationship, set relationshipKeyPathsForPrefetching so SwiftData fetches them up front instead of firing a query per row (the classic N+1 problem). If you only need a count for a badge, call context.fetchCount(descriptor) rather than fetching rows and reading .count.
There is also a SwiftUI-side trap that masquerades as a SwiftData one: slapping .id(UUID()) or an unstable .id(...) on a List or ForEach. It throws away identity on every update, so SwiftUI rebuilds the whole list and re-realises rows instead of diffing, which means death by a thousand redraws. Let rows keep stable identity and let the framework diff. As objc.io’s Thinking in SwiftUI (Eidhof & Kugler) stresses, identity is what SwiftUI uses to do the least work; misusing .id() opts you out of that.
On iOS 18 and later, if you filter or sort large tables on the same fields constantly, add an index so the store does not scan every row:
@Model
final class Transaction {
#Index<Transaction>([\.createdAt], [\.createdAt, \.total])
var total: Decimal
var createdAt: Date
// …
}
How do you know any of this helped? You measure. This is the one screenshot in this article a diagram cannot replace: the Time Profiler and SwiftData instruments before and after pagination.
The delete-rule crash nobody warns you about
Relationships are where SwiftData quietly hands you a crash. Model a Transaction with line Items and accept the defaults, and two things go wrong. First, SwiftData often infers the inverse relationship incorrectly, so edits do not propagate the way you expect. Second, and this is the painful one, the default delete rule is .nullify, which sets the child’s reference to its parent to nil when the parent is deleted. If that reference is non-optional, nullify cannot satisfy it and the app crashes, or you are left with orphaned rows that no longer belong to anything.
Be explicit on exactly one side of the relationship: declare the inverse, and choose the delete rule deliberately.
@Model
final class Transaction {
var total: Decimal
var createdAt: Date
// declare the relationship + inverse on ONE side only;
// deleting a transaction deletes its line items
@Relationship(deleteRule: .cascade, inverse: \LineItem.transaction)
var items: [LineItem] = []
init(total: Decimal, createdAt: Date = .now) {
self.total = total
self.createdAt = createdAt
}
}
@Model
final class LineItem {
var name: String
var price: Decimal
var transaction: Transaction? // optional back-reference
init(name: String, price: Decimal) {
self.name = name
self.price = price
}
}
Three rules that save you here. Put @Relationship on one side only; declaring it on both creates a circular reference. Make the back-reference optional so .nullify always has a legal state to fall back to. And pick the delete rule on purpose: .cascade when children cannot exist without the parent (line items, receipt details), .nullify when they can (a category that outlives the products tagged with it). Donny Wals’ writing on SwiftData goes deep on these relationship and concurrency edges, and it is the reference I send teammates to when a delete starts crashing.
While you are at it: do not name any model property description (SwiftData disallows it), and put a migration plan in place even for lightweight changes. The day you rename a property in a shipped app, a VersionedSchema is the difference between a clean upgrade and a corrupted store.
Keep checkout smooth: heavy writes belong on a ModelActor
Importing five thousand products from a supplier file, or reconciling a day of sales, must not happen on the main context; do it there and the checkout UI freezes mid-transaction. But there is a hard concurrency rule: a ModelContext and its model instances must never cross actor boundaries. You cannot fetch on the main actor and hand the objects to a background task. The container and PersistentIdentifier are sendable; the live objects are not.
@ModelActor is built for exactly this. It gives a background actor its own ModelContext tied to the same store, so heavy work runs off the main thread and the two contexts coordinate through the shared container, not by passing objects between threads.
@ModelActor
actor ImportActor {
func importProducts(_ rows: [ProductRow]) throws {
for row in rows {
modelContext.insert(
Product(name: row.name, price: row.price)
)
}
try modelContext.save() // save explicitly; autosave is unpredictable
}
}
// kick it off from the main actor without blocking the UI
let importer = ImportActor(modelContainer: container)
try await importer.importProducts(rows)
The @ModelActor macro synthesises the initializer and the actor’s own context for you. Insert and save inside the actor; when it finishes, tell the UI to refetch its current page. If you must reference an object created in the actor from the main side afterwards, pass its PersistentIdentifier across and re-fetch in the main context, never the object itself. Save explicitly with try modelContext.save(); modern SwiftData autosaves infrequently and unpredictably, so when correctness matters, do not wait for it.
The honest verdict: SwiftData vs Core Data vs GRDB
After shipping with all three, here is the trade as I see it. SwiftData wins on speed-to-ship and how naturally it fits SwiftUI. Core Data wins on maturity and fine-grained control: a decade of tuning knobs, NSFetchedResultsController, well-tested migrations. GRDB wins when you want SQLite with no abstraction in the way: raw SQL, full-text search, exact control over schema and migrations, and predictable performance.
| Attribute | SwiftData | Core Data | GRDB |
|---|---|---|---|
| Setup cost | Lowest: one macro | Higher: model editor, stack | Low, but you write SQL |
| SwiftUI fit | Native (@Query) | Good (@FetchRequest) | Manual / via wrappers |
| Control over queries | Limited predicate subset | High | Total: raw SQL, joins |
| Migrations | Improving; VersionedSchema | Mature, well-understood | Explicit, fully in your hands |
| Maturity | Young (iOS 17+), evolving | Very mature | Mature, widely used |
| Best when… | New SwiftUI app, model fits | Big existing investment | You need SQL-level control |
Which trap are you hitting?
Every trap is a default that is fine for a demo and wrong at scale. Match the pattern to your row count, not to the tutorial.
My default for a new SwiftUI app in 2026 is SwiftData, until a hard requirement (heavy SQL, complex reporting queries, a migration story Core Data already nails) points elsewhere. The mistake is not choosing SwiftData; it is shipping the demo patterns and being surprised when ten thousand rows behave like ten thousand rows.
Knuth’s line gets quoted to justify never optimising: “premature optimization is the root of all evil.” But he was warning against guessing, not against measuring. Ship the simple @Query version, profile it on real data, and graduate exactly the screens that Instruments tells you to. That is not premature. That is the job.
Cited sources