nanda mochammad
Apple Developer Concepts

SwiftData in production: what the tutorials do not tell you

12 min read
Tagged iOS

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.

The promise

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.

Trap 1: @Query everywhere

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.

Diagram: two ways a SwiftUI view reads SwiftData. The @Query path binds the view directly to the store and refetches on every view update; the @Observable path puts a manual FetchDescriptor behind a model the view observes, so fetching happens only when you ask. SWIFTDATA ModelContext 10k rows PATH A: @QUERY View @Query var rows refetch on every view update PATH B: @OBSERVABLE View observes model @Observable model FetchDescriptor fetch only when you ask · limit + offset
Two ways a view reads SwiftData. @Query binds the view straight to the store and refetches as the view updates: fine when small, costly at 10k rows. The @Observable path puts a FetchDescriptor behind a model the view observes, so a fetch happens only when you ask for one.
@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.

Trap 2: large lists

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.

[ Screenshot: Instruments before/after, main-thread spike from a full 10k fetch vs a flat trace fetching one 50-row page ]
Instruments (Time Profiler / SwiftData template) captured on the receipts screen before and after pagination. The 'before' trace spikes on the main thread as 10k objects are realised; the 'after' trace stays flat fetching one 50-row page.
Trap 3: relationships and deletes

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.

Trap 4: background work

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.

Diagram: the main actor's ModelContext keeps the checkout UI responsive while a ModelActor runs on a background thread with its own ModelContext to do a bulk import, then notifies the UI to refetch. The two contexts talk through the shared store, never by passing models across threads. MAIN ACTOR · UI Checkout view mainContext · stays at 60-120 fps BACKGROUND · @ModelActor ImportActor own ModelContext · 5k-row import Shared store ModelContainer · on disk done → refetch contexts share the store, never pass models across threads (move PersistentIdentifier instead)
The main actor's context keeps the checkout UI responsive while a @ModelActor runs a bulk import on a background thread with its own context. They meet at the shared store; when the import finishes it signals the UI to refetch. Models never cross the boundary; only the sendable PersistentIdentifier does.
@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.

AttributeSwiftDataCore DataGRDB
Setup costLowest: one macroHigher: model editor, stackLow, but you write SQL
SwiftUI fitNative (@Query)Good (@FetchRequest)Manual / via wrappers
Control over queriesLimited predicate subsetHighTotal: raw SQL, joins
MigrationsImproving; VersionedSchemaMature, well-understoodExplicit, fully in your hands
MaturityYoung (iOS 17+), evolvingVery matureMature, widely used
Best when…New SwiftUI app, model fitsBig existing investmentYou need SQL-level control
Decision tree: pick GRDB when you need full SQL control or migrations across many app versions; keep Core Data when you already have a large investment in it or need NSFetchedResultsController and fine-grained tuning; reach for SwiftData for new SwiftUI apps where its model fits. New persistence layer? start here Need raw SQL, joins, precise migrations? Heavy Core Data app already? New SwiftUI app, model fits? GRDB SQLite, full control Stay on Core Data mature, tunable SwiftData fast to ship, modern
A decision tree for a new persistence layer. The discriminators are how much SQL-level control you need and whether you already carry a Core Data investment, not which framework is newest.

Which trap are you hitting?

Lists stutter on inputrefetching too often→ Move fetch to @Observable
Slow with many rowsfetching the world→ fetchLimit + fetchOffset
Crash on deletebad delete rule→ Explicit @Relationship + optional
UI freezes on importheavy work on main→ @ModelActor background context

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