nanda mochammad
Apple Developer Concepts

SwiftData in production: what the tutorials do not tell you

13 min read
Tagged iOS

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.

The promise

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.

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 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.

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 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.

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 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.

[ 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. 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.

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 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.

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 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.

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. 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:

  • @Query is right for small, always-visible lists. When a screen gets big or re-evaluates often, move the fetch into an @Observable model with a FetchDescriptor.
  • Never fetch the whole table. Use fetchLimit and fetchOffset to page, propertiesToFetch to load less, and keep list identity stable (no .id(UUID())).
  • Declare @Relationship on one side only, keep the back-reference optional, and pick .cascade or .nullify on purpose.
  • Bulk writes go on a @ModelActor with its own context. Pass only a PersistentIdentifier across actors, and call save() 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