SwiftData in production: what the tutorials do not tell you
Hi everyone, have you ever shipped a SwiftData app that felt fast in the demo and slow once real data arrived? This article is about what changes when the row count grows.
By the end of this article, you will know the four SwiftData traps that show up at production scale. You will be able to move a fetch out of a view, page a large list, set delete rules that do not crash, and run heavy writes off the main thread. You will also have a plain comparison of SwiftData, Core Data, and GRDB.
SwiftData demos look easy. You mark a class with @Model. You drop a @Query into a view. A live, saved, animated list appears. There are 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 through a year of receipts, and the easy version gets expensive. I have watched a checkout screen drop frames during a busy hour. The cause was a @Query three views away that kept refetching ten thousand transactions on every keystroke. We were using the demo pattern at production scale. This is a field guide to the four traps that bite real SwiftData apps. Each one comes with the code that gets you out. It comes from a POS codebase.
Why @Model and @Query really are great, to start
Give SwiftData its due. The modelling story is strong. You write a plain Swift class and add one macro. You get a saved entity with relationships, and CloudKit sync is 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. Do not reach for anything heavier. Paul Hudson’s Hacking with Swift material is the fastest way in. Apple’s “Meet SwiftData” session covers the model layer well. The traps below 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 what makes it risky when overused. Put a broad @Query in a view that re-evaluates often, like a search field or a parent that reshuffles. Now you 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. Put it in an @Observable model that owns a FetchDescriptor and runs it on purpose. The view observes the results instead of being 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 the compiler will not catch. 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 and expect it to live-update. The model above re-fetches only when you call load. 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 full model objects to show twenty on screen is the 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 more tuning knobs. propertiesToFetch tells SwiftData to load only the properties a row shows, instead of every column. This matters when a model has heavy fields. And if each row reads a relationship, set relationshipKeyPathsForPrefetching. SwiftData then 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). Do not fetch rows and read .count.
There is also a SwiftUI-side trap that looks like a SwiftData one: putting .id(UUID()) or an unstable .id(...) on a List or ForEach. It throws away identity on every update. SwiftUI then rebuilds the whole list and re-creates rows instead of diffing. 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 all the time, add an index. Then 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. The screenshot below shows 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. Two things go wrong. First, SwiftData often guesses the inverse relationship wrong, so edits do not spread the way you expect. Second, the default delete rule is .nullify. It sets the child’s reference to its parent to nil when the parent is deleted. If that reference is non-optional, nullify cannot do that. 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 on purpose.
@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. Use .cascade when children cannot exist without the parent (line items, receipt details). Use .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. 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 does not allow it). And put a migration plan in place even for small 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 this. It gives a background actor its own ModelContext tied to the same store. Heavy work runs off the main thread. 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 writes 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 pass the object itself. Save explicitly with try modelContext.save(). Modern SwiftData autosaves rarely and at unpredictable times, 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 control: a decade of tuning knobs, NSFetchedResultsController, well-tested migrations. GRDB wins when you want SQLite with nothing 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. I only move away when a hard requirement points elsewhere, like heavy SQL, complex reporting queries, or a migration story Core Data already nails. The mistake is shipping the demo patterns and being surprised when ten thousand rows behave like ten thousand rows.
Recap
The goal of this article was to show you the four SwiftData traps that appear at production scale, and how to get out of each one. Here are the main points:
@Queryis right for small, always-visible lists. When a screen gets big or re-evaluates often, move the fetch into an@Observablemodel with aFetchDescriptor.- Never fetch the whole table. Use
fetchLimitandfetchOffsetto page,propertiesToFetchto load less, and keep list identity stable (no.id(UUID())). - Declare
@Relationshipon one side only, keep the back-reference optional, and pick.cascadeor.nullifyon purpose. - Bulk writes go on a
@ModelActorwith its own context. Pass only aPersistentIdentifieracross actors, and callsave()yourself. - SwiftData is a good default for a new SwiftUI app. Core Data fits a big existing investment. GRDB fits when you need SQL-level control.
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. Then fix exactly the screens that Instruments tells you to.
I hope this writing helps you. Thanks for reading, and if you hit a trap I missed, let me know.
Cited sources