An iOS Engineering Reference

Learning iOS SwiftData

32 Sections Swift / iOS Long-Form

A deep, hands-on guide to SwiftData in Swift on iOS. We’ll work through the framework end to end — the stack (ModelContainer, ModelContext, ModelConfiguration), the @Model macro and what it actually generates, queries with #Predicate and FetchDescriptor, relationships and cascading, the concurrency story via ModelActor, performance tuning, migrations through VersionedSchema, CloudKit sync, and sharing the store with widgets and extensions. Then we’ll cover SwiftUI integration in two complementary ways: the property-wrapper path (@Query, @Environment(\.modelContext), @Bindable) which is fast to build with and idiomatic, and the manual path (repositories, ModelActor-backed services, hand-rolled @Observable stores, AsyncStream change observation) which gives you a testable, decoupled architecture for serious apps.

This guide assumes you know Swift well. It also assumes some Core Data familiarity is helpful but not required — SwiftData is Core Data underneath, but the API surface is different enough that we’ll explain each piece on its own terms while occasionally pointing out where the bridge sits.

SwiftData is a young framework. It was introduced at WWDC 2023, and significant capabilities (custom migration stages, history tracking, more flexible predicates) landed in iOS 17.2, 17.4, and iOS 18. This guide targets iOS 17+ with notes where iOS 18 changes things. Expect Apple to keep evolving the API. Anchoring on the underlying concepts — schemas, contexts, persistent identifiers, model actors — protects you against API churn.

Table of Contents

  1. What SwiftData Is (and Its Relationship to Core Data)
  2. Setting Up: ModelContainer, ModelContext, ModelConfiguration
  3. The @Model Macro and What It Generates
  4. Properties, Attributes, and @Attribute
  5. Relationships: To-One, To-Many, Cascading
  6. Inverse Relationships and Why They Matter
  7. CRUD: Inserting, Reading, Updating, Deleting
  8. Saving and the Autosave Behavior
  9. FetchDescriptor in Detail
  10. #Predicate: Type-Safe Queries
  11. SortDescriptor and Result Limits
  12. Unique Constraints and Identifiers
  13. Transient Properties and Computed Values
  14. ModelContext Concurrency Model
  15. ModelActor and Background Work
  16. Cross-Context Identifiers: PersistentIdentifier
  17. Faulting in SwiftData
  18. Performance: Indexing, Batch Sizes, Prefetching
  19. SwiftData Without SwiftUI: The Pure Data Layer
  20. SwiftUI: @Query and @Environment(.modelContext)
  21. SwiftUI: Dynamic Queries
  22. SwiftUI: Editing Models In-Place with @Bindable
  23. SwiftUI Without Property Wrappers/Macros: The Repository Pattern
  24. SwiftUI Without Property Wrappers/Macros: ModelActor-Based Services
  25. SwiftUI Without Property Wrappers/Macros: AsyncStream-Driven Updates
  26. SwiftUI Without Property Wrappers/Macros: Hand-Rolled @Observable Stores
  27. Migrations: VersionedSchema and SchemaMigrationPlan
  28. Custom Migration Stages
  29. CloudKit Integration
  30. Sharing a Store: Widgets, Extensions, App Groups
  31. Common Gotchas and Anti-Patterns
  32. Where to Go Deeper

1. What SwiftData Is (and Its Relationship to Core Data)

Before writing a line of code, you need a clear mental model of what SwiftData actually is. The marketing answer (“a modern, Swift-native persistence framework”) is true but useless. The technical answer reshapes how you reason about every API in this guide.

SwiftData is Core Data with a new face

SwiftData is built directly on Core Data. Underneath the @Model macro, the ModelContainer, the #Predicate DSL — there’s NSPersistentContainer, NSManagedObjectContext, and NSManagedObject. The store on disk is the same SQLite file format Core Data has used for over a decade. The transaction semantics, the faulting machinery, the change tracking, the merge policies — all inherited.

Why does this matter? Three reasons.

First, the constraints transfer. The “rules” that bite Core Data developers — context concurrency, managed object identity, faulting, save lifecycle — also bite SwiftData developers. If you’ve debugged a Core Data app, you’ve already debugged a SwiftData app.

Second, the performance characteristics transfer. SwiftData is as fast (and as slow) as Core Data. The N+1 query problem still exists. Faulting still surprises you. Large batch operations still need enumerateBatch-style patterns. None of this is fixed by virtue of being “modern.”

Third, the ecosystem transfers. When SwiftData’s documentation is thin (and it often is), Core Data’s documentation answers the question. When you hit a strange error from SwiftData, searching for the same error in Core Data contexts almost always produces the explanation. The Core Data community has 20 years of accumulated knowledge; you inherit it.

What’s different is the API surface. Macros instead of NSManagedObject subclassing. #Predicate instead of NSPredicate string DSLs. ModelActor for concurrency instead of context queues. @Query and @Bindable for SwiftUI. The shapes are new; the engine is not.

What SwiftData adds over Core Data

The new face isn’t just sugar. SwiftData introduces real ergonomic improvements:

  • Compile-time-safe predicates. #Predicate { $0.score > 100 } is checked by the compiler. NSPredicate(format: "score > %@", 100) is checked at runtime, and the runtime errors are infuriating.
  • No .xcdatamodeld file required. Your model is your Swift code. No more clicking through Xcode’s data model editor for trivial changes.
  • Swift-native types in models. URL, UUID, Decimal, custom Codable value types — all just work as model properties, without manual transformers.
  • Macros eliminate boilerplate. @Model generates the NSManagedObject plumbing for you. No more “Class definition” / “Manual none” decisions in the model editor.
  • Better SwiftUI integration. @Query is genuinely nicer than @FetchRequest. Type-safe, declarative, easy to parameterize via subviews.

These are real wins. For new apps targeting iOS 17+, SwiftData is the right starting point unless you have specific reasons to stay on Core Data.

What SwiftData doesn’t add

SwiftData is not a step-change in capabilities. It is not:

  • An async framework by default. The default ModelContext is main-actor-bound, just like Core Data’s viewContext. Async work requires ModelActor, and that has its own learning curve (covered in Section 15).
  • A magic concurrency solution. You still cannot pass model instances across actor boundaries. You still need PersistentIdentifiers (the SwiftData equivalent of NSManagedObjectID).
  • A new database engine. It’s SQLite, same as before. No vector indexes, no full-text search out of the box, no JSON column types beyond what Core Data exposes.
  • Cloud-first. CloudKit sync is available (via ModelConfiguration.cloudKitDatabase), but it’s a wrapper over the same NSPersistentCloudKitContainer you’d use in Core Data, with the same constraints (no unique constraints synced to CloudKit, no required relationships in synced models, optional everything).

Knowing the limits prevents you from writing code that assumes SwiftData is something it isn’t.

When NOT to use SwiftData

A few cases where SwiftData is the wrong tool:

  • You target iOS < 17. SwiftData requires iOS 17. Core Data is your only option below that.
  • You need complex multi-store setups. Core Data’s NSPersistentStoreCoordinator lets you attach multiple stores with different configurations and route entities between them. SwiftData supports multiple configurations, but the tooling is thinner.
  • You have an existing Core Data app with substantial migration history. Migrating to SwiftData is possible (SwiftData can read Core Data stores), but the win is small and the work is real. Stay on Core Data unless you have a reason.
  • You need a Swift-native key-value store, not an object graph. Use UserDefaults for small things, FileManager + Codable for medium things. SwiftData is overkill for storing 50 settings.
  • You need a relational SQL store with hand-tuned queries. Use GRDB. SwiftData (and Core Data) abstract SQL away; if you want SQL, use a SQL-native library.

For most iOS app developers building consumer-facing apps that need persistence, none of these caveats apply. SwiftData is a fine choice.

The mental model to carry

Three sentences to keep in mind throughout this guide:

  1. SwiftData is an object graph manager that happens to persist to SQLite via Core Data. The graph is the primary thing; persistence is one capability.
  2. ModelContext is a scratchpad. You bring objects into it, mutate them, and save. Multiple scratchpads can coexist (one per actor, typically).
  3. PersistentIdentifiers are the only thing safe to pass between actors. Models themselves are not.

If those three feel obvious by the end of this guide, you’ve internalized SwiftData correctly.

What to internalize

SwiftData is Core Data with a Swift-native API. The engine, constraints, and performance characteristics are inherited from Core Data; the macros, predicates, and SwiftUI integrations are new. It’s not async by default. It’s not a new database. Use it when you target iOS 17+ and want the ergonomic wins; stay on Core Data for older targets or complex multi-store setups; use GRDB if you want SQL. Carry the three-sentence mental model — object graph, context as scratchpad, persistent identifiers as the only cross-actor safe type — and you’ll navigate the rest of the framework with much less confusion.


2. Setting Up: ModelContainer, ModelContext, ModelConfiguration

A SwiftData stack has three layers: a ModelContainer that owns the schema and store, a ModelConfiguration that describes the store’s characteristics (file location, CloudKit, in-memory), and ModelContext instances that mediate reads and writes. You’ll set these up in the first 50 lines of nearly every SwiftData app, and getting them right matters because changing them later is harder than getting them right the first time.

The minimal setup

The absolute simplest SwiftData app, suitable for prototypes and tutorials:

import SwiftData
import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Note.self, Folder.self])
    }
}

.modelContainer(for:) does a lot under the hood:

  • Constructs a Schema from the listed model types.
  • Creates a default ModelConfiguration (on-disk SQLite at the standard Application Support path).
  • Builds a ModelContainer.
  • Injects the container into the SwiftUI environment so @Environment(\.modelContext) works in any child view.

For a prototype, this is enough. For real apps, you almost always want explicit control over the configuration.

Explicit container construction

The “real app” pattern looks like this:

import SwiftData

let schema = Schema([Note.self, Folder.self, Tag.self])

let configuration = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    allowsSave: true,
    cloudKitDatabase: .none
)

let container: ModelContainer
do {
    container = try ModelContainer(
        for: schema,
        configurations: [configuration]
    )
} catch {
    fatalError("Could not create ModelContainer: \(error)")
}

Each piece does specific work:

  • Schema is the list of @Model types. The schema determines what tables exist in the SQLite store and what columns they have. Changing it across app versions triggers migration (Section 27).
  • ModelConfiguration describes a single store. An app can have multiple configurations (e.g., one for user data, one for a cached preview store). The store can be in-memory (for tests and previews), on-disk (the default), or CloudKit-backed.
  • ModelContainer is the top-level object. It holds the schema, the configurations, and the underlying NSPersistentContainer. There’s typically one per app, and it lives for the app’s lifetime.

The fatalError on failure is appropriate for app launch — if the container can’t be created (corrupt store, incompatible schema, disk full), the app can’t function. In production code you’d want to handle this with a recovery flow (delete the store and reinitialize, show an error UI) rather than crashing.

Using the container

Once you have a container, you inject it into SwiftUI:

@main
struct MyApp: App {
    let container: ModelContainer = {
        let schema = Schema([Note.self, Folder.self, Tag.self])
        let config = ModelConfiguration(schema: schema)
        return try! ModelContainer(for: schema, configurations: [config])
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

This gives you the same SwiftUI integration as .modelContainer(for:) but with the explicit container you built. The container is a stored property of App, so it’s constructed once at launch and lives for the app’s lifetime.

For non-SwiftUI usage (command-line tools, server-side Swift, AppKit), you skip the SwiftUI bits and pass the container around explicitly:

let container = try ModelContainer(for: schema, configurations: [config])
let context = ModelContext(container)
// use context.insert(...), context.fetch(...), context.save()

ModelContext(container) creates a fresh context bound to the current actor. The mainContext property on the container returns the container’s primary context (always main-actor-bound). For background work you create new contexts on a ModelActor (Section 15).

ModelConfiguration options

ModelConfiguration has several initializers and options worth understanding:

// Default: on-disk SQLite at standard path
ModelConfiguration()

// In-memory only (no persistence — perfect for tests and previews)
ModelConfiguration(isStoredInMemoryOnly: true)

// Custom URL (e.g., a shared App Group container)
ModelConfiguration(url: URL.documentsDirectory.appending(path: "custom.store"))

// CloudKit-backed
ModelConfiguration(
    schema: schema,
    cloudKitDatabase: .private("iCloud.com.example.MyApp")
)

// Read-only (allowsSave: false) — useful for a bundled seed database
ModelConfiguration(
    schema: schema,
    url: Bundle.main.url(forResource: "seed", withExtension: "store")!,
    allowsSave: false
)

// A specific group identifier (for App Groups / shared containers)
ModelConfiguration(
    schema: schema,
    groupContainer: .identifier("group.com.example.shared")
)

A few practical patterns:

Tests use isStoredInMemoryOnly: true so each test runs against a fresh, empty store with no file system side effects.

Previews use the same in-memory option, pre-populated with sample data:

#Preview {
    let container = try! ModelContainer(
        for: Note.self,
        configurations: ModelConfiguration(isStoredInMemoryOnly: true)
    )
    // Insert sample data
    let context = container.mainContext
    context.insert(Note(title: "Sample"))
    return NotesListView()
        .modelContainer(container)
}

Production uses on-disk, default location, optionally with CloudKit.

Bundled seed data uses a read-only store shipped in the app bundle, optionally combined with a writable user store (multiple configurations).

Multiple configurations

An app can have multiple stores. The use case is when you want to separate concerns — for example, a read-only seed store of starter content plus a writable user store:

let seedConfig = ModelConfiguration(
    schema: schema,
    url: Bundle.main.url(forResource: "seed", withExtension: "store")!,
    allowsSave: false
)

let userConfig = ModelConfiguration(
    schema: schema,
    url: URL.documentsDirectory.appending(path: "user.store")
)

let container = try ModelContainer(
    for: schema,
    configurations: [seedConfig, userConfig]
)

Multi-configuration setups are more advanced and have subtle behavior around which configuration a fetched object came from. For most apps, one configuration is enough.

Where the SQLite file lives

The default store location is Library/Application Support/<bundle-id>/default.store. You can print it:

print(container.configurations.first?.url ?? "no URL")

On the simulator, the path looks like:

~/Library/Developer/CoreSimulator/Devices/<device-uuid>/data/Containers/Data/Application/<app-uuid>/Library/Application Support/default.store

As with Core Data, there are typically three files: default.store, default.store-shm, default.store-wal. All three are part of the database. Don’t move or delete them individually.

You can open the .store file in any SQLite browser (DB Browser for SQLite, TablePlus). You’ll see tables with names like ZNOTE, ZFOLDER, with columns prefixed Z. This is the same SQLite-via-Core-Data format that’s been around for a decade. Look but don’t touch.

ModelContext at the surface

ModelContext is the API you’ll use for nearly everything once setup is done. It mediates between you and the store:

let context = ModelContext(container)

let note = Note(title: "First note")
context.insert(note)

try context.save()  // persists to disk

let notes = try context.fetch(FetchDescriptor<Note>())
print(notes.count)

context.delete(note)
try context.save()

mainContext (on the container) is the default context for the main actor. Inside SwiftUI views, you access it via @Environment(\.modelContext):

struct NotesListView: View {
    @Environment(\.modelContext) var context

    var body: some View {
        Button("Add Note") {
            context.insert(Note(title: "New"))
            try? context.save()
        }
    }
}

That context is the container’s main context, automatically injected by the .modelContainer(...) modifier on the parent scene.

Background contexts need different handling — covered in Sections 14 and 15.

The model container is shared; contexts are not

This is a critical distinction that bites people:

  • One ModelContainer per app. Build it once, use it everywhere.
  • Many ModelContexts. The main context for UI work. Separate contexts on background actors for heavy work. Each context is isolated; changes in one don’t appear in another until saves propagate.

Sharing a container across the app means all contexts see the same data eventually. Creating multiple containers (different SQLite files, different schemas) is a separate concern — multi-database setups, which most apps don’t need.

Setup pitfalls

Constructing the container in a view body. Don’t do this:

// BAD
struct MyView: View {
    var body: some View {
        let container = try! ModelContainer(...)  // new container every render!
        // ...
    }
}

Body runs many times. You’d create a new SQLite store each render (or at least a new in-memory store). Construct the container once at app launch.

Mismatched schemas across the app and tests. If your app uses Schema([Note.self, Folder.self]) and your test uses Schema([Note.self]), fetches that join Folder will fail in tests. Centralize the schema in a function or static property.

Force-unwrapping in production. try! ModelContainer(...) is fine in previews and tests. In production, handle the error — the user might recover from a corrupt store if you give them an option to reset.

Using for: (the variadic version) when you should use explicit configurations. .modelContainer(for: [Note.self]) is a convenience; once you need CloudKit, in-memory, or a custom URL, switch to the explicit construction with ModelConfiguration.

Storing models in the model container’s mainContext from background code. The main context is main-actor-bound. Touching it from background code is a runtime error in strict concurrency mode and a subtle bug otherwise. Use a ModelActor (Section 15).

What to internalize

Set up the container once at app launch, hold it for the app’s lifetime, and inject it into SwiftUI via .modelContainer(...). Use ModelConfiguration to be explicit about in-memory vs on-disk, CloudKit, group containers, and read-only seed stores. Reach for the mainContext on the main actor; build separate contexts (typically via ModelActor) for background work. Don’t construct containers in view bodies. Centralize your schema so app code, previews, and tests all see the same shape.


3. The @Model Macro and What It Generates

The @Model macro is SwiftData’s magic. Slap @Model on a class, list some properties, and you get a persistent type — no .xcdatamodeld file, no NSManagedObject subclassing, no boilerplate. But “magic” is the wrong mental model. The macro generates concrete Swift code at compile time. Understanding what it generates is the difference between using SwiftData blindly and using it confidently.

The simplest model

import SwiftData
import Foundation

@Model
final class Note {
    var title: String
    var body: String
    var createdAt: Date

    init(title: String, body: String = "", createdAt: Date = .now) {
        self.title = title
        self.body = body
        self.createdAt = createdAt
    }
}

That’s a complete, persistent SwiftData model. You can insert instances into a context, fetch them, update them, delete them. The framework handles persistence to SQLite, change tracking, and identity transparently.

A few things to notice:

  • It’s a class, not a struct. SwiftData models are reference types. They have identity. Two Note references can point to the same persisted object.
  • It’s final. Subclassing @Model types is supported but rarely useful, and subclassing has performance implications. Mark final unless you have a specific reason.
  • Properties are var, not let. Models are mutable. A let property in a @Model class doesn’t persist meaningfully — you can’t update it after insertion.
  • An initializer is required. Unlike Core Data’s NSManagedObject subclasses, SwiftData models need a regular Swift initializer. The macro doesn’t generate one for you.

What the macro generates

When the compiler expands @Model, the class becomes something like this (simplified, but accurate in spirit):

final class Note: PersistentModel {
    // The hidden backing store
    @Transient private var _$backingData: any BackingData<Note>

    // Conformance to PersistentModel
    static var schemaMetadata: [Schema.PropertyMetadata] { ... }

    // Persistent identifier
    var persistentModelID: PersistentIdentifier { ... }

    // Property getters/setters that read/write the backing data
    var title: String {
        get { getValue(forKey: \.title) }
        set { setValue(forKey: \.title, to: newValue) }
    }

    var body: String { /* same shape */ }
    var createdAt: Date { /* same shape */ }

    // Initializer that sets up backing data and stores the values
    init(title: String, body: String = "", createdAt: Date = .now) {
        self.init(backingData: ...)
        self.title = title
        self.body = body
        self.createdAt = createdAt
    }

    // ... and many more synthesized members
}

The key insight: your properties become computed properties that read and write to a hidden backing store. The backing store is what knows about the database, change tracking, faulting, and so on. Your stored-property-looking declarations are really just declarations of what columns exist and how to access them.

This means a few things that surprise people:

  • You cannot use property observers (willSet, didSet) on @Model properties in the way you might expect. The macro replaces your stored properties with computed ones, and computed properties don’t support observers. If you want to react to changes, use SwiftUI observation (Section 22) or override accessors manually with @Attribute-based customization.
  • @Transient properties (the kind you mark explicitly) are real stored properties. They live alongside the synthesized backing data and are not persisted.
  • @Model types conform to Observable automatically. The macro emits the necessary boilerplate so SwiftUI views can observe property changes. This is why @Bindable works on SwiftData models without ceremony.

The persistent identifier

Every @Model instance has a persistentModelID: PersistentIdentifier property. This is the durable identifier for the object — it’s stable across saves, across launches, and across actor boundaries (it’s Sendable).

let note = Note(title: "Hello")
context.insert(note)
try context.save()

let id = note.persistentModelID
print(id)  // PersistentIdentifier(uri: x-coredata://.../Note/p1)

You’ll use persistentModelID heavily when working across actors (Section 16). The pattern: pass PersistentIdentifiers between actors, then resolve them to model instances inside each actor’s context.

Initialization runs before insertion

A subtle point: when you call Note(title: "Hi"), the initializer runs and creates an uninserted Note instance. It’s not yet part of any context, not yet persisted, not yet known to the schema. Only after context.insert(note) does SwiftData know about it.

let note = Note(title: "Hi")        // not in any context
print(note.persistentModelID)        // valid, but represents an "unsaved" state
context.insert(note)                 // now SwiftData knows about it
try context.save()                   // now it's on disk

The persistentModelID of an uninserted model is technically valid but represents a temporary state. After insertion and save, the identifier stabilizes.

You can construct relationships before insertion:

let folder = Folder(name: "Work")
let note = Note(title: "Meeting notes", folder: folder)
context.insert(note)
// folder is auto-inserted by virtue of being reachable from note
try context.save()

SwiftData walks the object graph from inserted objects and brings in any reachable but uninserted models. This is convenient but can also be confusing — you might insert one thing and find five things saved.

Final, but not really

Best practice is to mark your @Model classes final. The macro generates code that’s optimized for final classes. Non-final @Model classes work but have minor performance overhead and unclear inheritance semantics with SwiftData’s internal subclassing of NSManagedObject.

Inheritance in SwiftData is supported in iOS 18+ via @Model on a base class with subclasses also marked @Model, but the feature is new and has rough edges. Stick with composition (have-a relationships) over inheritance (is-a) unless you have a specific reason.

Manual conformance is not allowed

You cannot write class Note: PersistentModel { ... } manually — the conformance is meant to be generated by the macro. The PersistentModel protocol has internal requirements (the BackingData machinery, schema metadata, identifier resolution) that you cannot reasonably implement by hand.

This is a constraint, not a problem. It means @Model is the single way to define a persistent type in SwiftData. There’s no escape hatch into “raw” persistent objects.

What the model can’t do

Some things are off-limits in @Model classes:

  • No @Published properties. Use @Bindable from SwiftUI to observe instead. The macro handles observation.
  • No willSet/didSet observers on persistent properties. As noted above.
  • No protocol-only properties. You can have a property whose type is any SomeProtocol, but SwiftData has trouble storing it. Stick to concrete types or use Codable-conforming value types.
  • No actor-isolated state on the model itself. Models aren’t actors. Cross-actor safety is handled at the ModelContext level.
  • No async initializers. The initializer must be synchronous.

Common shapes

The shapes you’ll write most often:

// A simple value model
@Model
final class Tag {
    var name: String
    var color: String

    init(name: String, color: String = "#888888") {
        self.name = name
        self.color = color
    }
}

// With a relationship
@Model
final class Note {
    var title: String
    var body: String
    var createdAt: Date

    @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
    var folder: Folder?

    var tags: [Tag]

    init(title: String, body: String = "", folder: Folder? = nil, tags: [Tag] = []) {
        self.title = title
        self.body = body
        self.createdAt = .now
        self.folder = folder
        self.tags = tags
    }
}

// With a unique identifier
@Model
final class User {
    @Attribute(.unique) var email: String
    var displayName: String

    init(email: String, displayName: String) {
        self.email = email
        self.displayName = displayName
    }
}

// With a transient (non-persisted) cached value
@Model
final class Report {
    var title: String
    var rawData: Data

    @Transient
    var cachedDecodedReport: DecodedReport?

    init(title: String, rawData: Data) {
        self.title = title
        self.rawData = rawData
    }
}

These patterns cover 80% of model code you’ll write. The remaining 20% — complex inheritance, custom attribute storage, exotic relationships — we cover in later sections.

Identity and equality

Two @Model instances are equal (==) when they refer to the same persisted object. The macro generates Equatable and Hashable conformance based on persistentModelID. This is usually what you want.

But it has implications:

let note1 = try context.fetch(FetchDescriptor<Note>()).first!
let note2 = try otherContext.fetch(FetchDescriptor<Note>()).first!

// Even if both fetched the same persisted object, they're different instances:
note1 === note2  // false (different instances)
note1.persistentModelID == note2.persistentModelID  // true (same persistent object)
note1 == note2  // true (based on persistentModelID)

Use === to check instance identity, == to check persistent identity. For most app code, == is what you want.

Model pitfalls

Forgetting final. The macro works without it, but you’ll see slightly worse performance and harder-to-debug runtime behavior in edge cases.

Adding willSet/didSet and expecting them to work. They won’t. Use SwiftUI observation or restructure your design.

Using let for properties. They appear to work but don’t persist updates. The macro requires var.

Putting non-Codable reference types as properties. Reference types stored as properties need to be either @Model types (relationships) or Codable value types. Plain class references don’t persist.

Forgetting to call context.insert(). The model exists in memory after init but doesn’t reach the database until you insert and save. A common bug: “why isn’t my fetch returning this thing I just created?” — because you forgot to insert.

Storing computed state in a stored property. If var fullName: String { firstName + " " + lastName } is what you mean, declare it as a computed property, not a var. Otherwise you’re storing a redundant copy in the database.

What to internalize

@Model generates a class whose properties are computed getters and setters over a hidden backing store. Mark your models final. Provide a regular Swift initializer. Use var for persistent properties, mark non-persisted properties @Transient. Models are reference types with identity based on persistentModelID. Models live in memory after init but only persist after context.insert(...) + context.save(). SwiftData walks the graph from inserted objects, so reachable but uninserted models get pulled in. Don’t try to add willSet/didSet; use observation instead.


4. Properties, Attributes, and @Attribute

The properties you declare on an @Model class become columns in a SQLite table. Most of the time the defaults are right, and you simply write var title: String. But several scenarios — uniqueness constraints, custom column names, transformable values, external binary storage — require the @Attribute macro to customize behavior. This section walks through what types are supported, the @Attribute options, and the surprises.

Supported property types

SwiftData supports a generous set of property types out of the box. The complete list:

  • Primitives: Bool, Int, Int8/16/32/64, Float, Double, String.
  • Foundation types: Date, Data, URL, UUID, Decimal.
  • Collections: Array<T>, Set<T>, Dictionary<K, V> where T, K, V are themselves supported.
  • Codable structs and enums. Any Codable value type gets serialized to JSON or a property list under the hood.
  • Optional versions of any of the above.
  • Relationships: other @Model types, optional or in arrays/sets.

A few notable consequences:

  • URL is first-class. No need to store as String and convert. Same with UUID.
  • Decimal for money. Use it for financial values; never Double.
  • Enums work if Codable. Make your enum String, Codable (or Int, Codable) and use it directly as a property type.

Example showing the spread:

import SwiftData
import Foundation

enum Priority: String, Codable {
    case low, medium, high
}

struct Address: Codable {
    var street: String
    var city: String
    var postalCode: String
}

@Model
final class Person {
    var id: UUID
    var fullName: String
    var birthDate: Date
    var heightCm: Double
    var balance: Decimal
    var avatarURL: URL?
    var priority: Priority
    var address: Address  // stored as encoded data
    var aliases: [String]
    var tagsByCategory: [String: [String]]

    init(fullName: String) {
        self.id = UUID()
        self.fullName = fullName
        self.birthDate = .now
        self.heightCm = 0
        self.balance = 0
        self.priority = .medium
        self.address = Address(street: "", city: "", postalCode: "")
        self.aliases = []
        self.tagsByCategory = [:]
    }
}

All of these compile and persist. The Address struct is stored as encoded data in a single column (you can’t query into its fields directly — see “querying through Codable values” below).

The @Attribute macro

@Attribute customizes how a property is persisted:

@Model
final class User {
    @Attribute(.unique) var email: String
    @Attribute(originalName: "display_name") var displayName: String
    @Attribute(.externalStorage) var avatarData: Data
    @Attribute(.preserveValueOnDeletion) var legalName: String

    init(email: String, displayName: String, avatarData: Data = Data(), legalName: String) {
        self.email = email
        self.displayName = displayName
        self.avatarData = avatarData
        self.legalName = legalName
    }
}

The options:

  • .unique — the column has a unique constraint. Insert duplicates and you get an error (well, kind of — see Section 12).
  • .externalStorage — large binary data stored as a file alongside the SQLite store rather than in a SQLite blob column. Recommended for any Data over ~100 KB.
  • .preserveValueOnDeletion — when the row is deleted, the value persists in history tracking. Useful for audit logs (iOS 17.4+).
  • originalName: — the column name in SQLite. Use when renaming a Swift property without forcing a migration.

You can combine: @Attribute(.unique, originalName: "email_address").

Renaming a property without migration

Suppose v1 of your app has:

@Model
final class User {
    var emailAddress: String
}

And v2 wants to rename to:

@Model
final class User {
    @Attribute(originalName: "emailAddress")
    var email: String
}

Without originalName, SwiftData would see “old column emailAddress, new column email” and require a custom migration. With originalName, SwiftData understands the column is the same — just renamed in Swift — and the migration is lightweight (Section 27).

This is one of the most useful refactoring tools SwiftData gives you. Use it whenever you rename a property in code.

External storage for binary blobs

SQLite is fine for medium-sized binary data, but stores big blobs poorly. A 5 MB image in a column slows down every query that doesn’t need it (because the row is large). The fix is external storage:

@Model
final class Photo {
    var title: String
    var capturedAt: Date

    @Attribute(.externalStorage)
    var fullImageData: Data

    var thumbnailData: Data  // small, inline storage is fine

    init(title: String, fullImageData: Data, thumbnailData: Data) {
        self.title = title
        self.capturedAt = .now
        self.fullImageData = fullImageData
        self.thumbnailData = thumbnailData
    }
}

When the Data is small (under a threshold SwiftData chooses internally — historically ~100 KB), it’s stored inline. When it’s large, SwiftData writes it to a file in a directory next to the SQLite store and stores a reference in the column. You don’t manage the file directly; SwiftData handles it transparently.

External storage means:

  • Queries don’t have to scan past blob data.
  • Fetched models don’t materialize the blob until you access the property.
  • Backups (Time Machine, iCloud Drive) handle the files alongside the database.

For images, video, audio, and large documents, .externalStorage is almost always the right choice.

Codable value types as properties

A Codable struct as a model property gets encoded (usually to JSON) and stored as a Data column:

struct UserPreferences: Codable {
    var theme: String
    var fontSize: Int
    var notifications: Bool
}

@Model
final class User {
    var name: String
    var preferences: UserPreferences

    init(name: String) {
        self.name = name
        self.preferences = UserPreferences(theme: "light", fontSize: 14, notifications: true)
    }
}

This works seamlessly. You read and write user.preferences like any other property.

But there’s a catch: you cannot query into the encoded structure. #Predicate<User> { $0.preferences.theme == "dark" } won’t work — the predicate compiler doesn’t know how to peek inside the encoded blob. If you need to query by theme, you should promote it to a top-level property:

@Model
final class User {
    var name: String
    var preferenceTheme: String
    var preferenceFontSize: Int
    var preferenceNotifications: Bool

    init(name: String) {
        self.name = name
        self.preferenceTheme = "light"
        self.preferenceFontSize = 14
        self.preferenceNotifications = true
    }
}

Less elegant, but queryable. The tradeoff is structural: nested values are convenient until you need to query them, then they become a blocker.

Default values

Defaults work the way you’d expect:

@Model
final class Setting {
    var key: String
    var value: String = ""
    var isEnabled: Bool = true
    var lastModified: Date = .now

    init(key: String) {
        self.key = key
    }
}

The defaults are used when:

  • The initializer doesn’t set the value.
  • A new column is added in a future schema version (the default fills in for existing rows during lightweight migration).

For default-fill during migration, the default must be a compile-time constant or simple expression. Date() won’t work because each row would need a fresh date; use a static stored value or perform a custom migration.

Optional vs. default

A subtle but important distinction:

var middleName: String?       // optional, can be nil
var middleName: String = ""   // non-optional, defaults to empty string

Optional means “this might not exist.” Default-empty means “it always exists but might be the empty string.” The choice affects:

  • Querying: #Predicate<Person> { $0.middleName != nil } only works if optional.
  • CloudKit: CloudKit requires all properties to be optional or have defaults (Section 29).
  • API ergonomics: optionals force callers to handle the nil case.

When in doubt, pick optional. Optionals model “might not exist” cleanly; defaults model “always exists, possibly empty.” Use defaults sparingly for primitives where empty/zero is meaningful.

Enumerations

Enums work great as properties, as long as they’re Codable:

enum Priority: String, Codable {
    case low, medium, high
}

@Model
final class Task {
    var name: String
    var priority: Priority

    init(name: String, priority: Priority = .medium) {
        self.name = name
        self.priority = priority
    }
}

You can query enum values:

let highPriority = try context.fetch(
    FetchDescriptor<Task>(predicate: #Predicate { $0.priority == .high })
)

The raw value is stored (in this case, the string “high”). When you read the property, SwiftData decodes back to the enum.

Caveats:

  • Changing an enum case’s raw value is a breaking change. Existing rows have the old raw value; new code expects the new raw value. Avoid renaming case raw values.
  • Adding new cases is fine. Existing rows have old raw values; new code can match against new cases without breaking.
  • Removing cases is dangerous. If a row has the removed case’s raw value, decoding fails when you read it. Migrate first.

Default initializers for migration safety

When you add a new property in v2 of your model, existing rows from v1 don’t have that value. SwiftData uses the default to fill in:

// v1
@Model
final class Note {
    var title: String
    init(title: String) { self.title = title }
}

// v2 — added isPinned
@Model
final class Note {
    var title: String
    var isPinned: Bool = false  // default lets existing rows fill in

    init(title: String) {
        self.title = title
        self.isPinned = false
    }
}

Without the default, the migration fails (or requires custom handling). Always provide defaults for new properties when shipping schema changes.

Attribute pitfalls

Forgetting .externalStorage for image data. Storing many 2 MB images inline grows the SQLite file rapidly and slows down queries. Use external storage.

Using Double for currency. Floating-point arithmetic is not exact. Decimal is. Always use Decimal for money, percentages that need precision, and other “exact” rational numbers.

Storing JSON in a String and parsing on read. If the JSON is Codable-decodable, declare a Codable struct and let SwiftData store it. No manual parsing.

Trying to query into a Codable value type. If you need to query by a field, promote that field to a top-level property.

Renaming a property without @Attribute(originalName:). This forces a custom migration. Use originalName to rename in code without migrating data.

Using .unique on CloudKit-synced models. Unique constraints don’t sync to CloudKit. Section 12 covers the workarounds.

Mismatched optional vs. default across model versions. Changing var x: String? to var x: String = "" (or vice versa) across versions can break migrations. Plan property nullability carefully.

What to internalize

Most Swift types just work as @Model properties — primitives, Date, Data, URL, UUID, Decimal, Codable structs, arrays, sets, dictionaries. Use @Attribute(.unique) for unique constraints, @Attribute(.externalStorage) for large binary blobs, @Attribute(originalName:) for renaming properties safely across versions. Codable struct properties are convenient but un-queryable — promote fields to top-level when you need to filter on them. Provide defaults for new properties in v2+ of your schema so existing rows can fill in during migration. Pick optional vs default deliberately based on whether “might not exist” or “always exists, possibly empty” is the semantic.


5. Relationships: To-One, To-Many, Cascading

Relationships are where SwiftData’s object-graph nature shows. A Note belongs to a Folder; a Folder has many Notes. You declare these as ordinary Swift references and arrays, decorate with @Relationship for cascade and inverse hints, and SwiftData handles the database side. This section covers all the shapes of relationships and how to think about them.

To-one relationships

A to-one relationship is a single reference:

@Model
final class Note {
    var title: String
    var folder: Folder?  // to-one (optional means it might not have a folder)

    init(title: String, folder: Folder? = nil) {
        self.title = title
        self.folder = folder
    }
}

@Model
final class Folder {
    var name: String

    init(name: String) { self.name = name }
}

note.folder = someFolder assigns the relationship. note.folder = nil unsets it. SwiftData persists this as a foreign-key-style relationship in SQLite.

A to-one relationship can also be non-optional:

@Model
final class Comment {
    var body: String
    var post: Post  // required — every comment belongs to a post

    init(body: String, post: Post) {
        self.body = body
        self.post = post
    }
}

Required relationships are stronger guarantees: a Comment cannot exist without a Post. But: required relationships interact poorly with CloudKit (which requires everything to be optional). If you might sync, use optional.

To-many relationships

A to-many relationship is a collection:

@Model
final class Folder {
    var name: String
    var notes: [Note] = []  // to-many

    init(name: String) { self.name = name }
}

folder.notes is an array. You can iterate it, add to it (folder.notes.append(note)), remove from it (folder.notes.remove(at: 0)), or replace it entirely.

You can also use Set for to-many:

var notes: Set<Note> = []

The difference: Array preserves order, Set doesn’t. For ordered relationships (like notes in display order), use Array. For unordered set membership (like tags), use Set.

Declaring both sides

Most relationships are bidirectional: a folder has notes; each note has a folder. You declare both:

@Model
final class Folder {
    var name: String
    var notes: [Note] = []

    init(name: String) { self.name = name }
}

@Model
final class Note {
    var title: String
    var folder: Folder?

    init(title: String, folder: Folder? = nil) {
        self.title = title
        self.folder = folder
    }
}

When you write note.folder = someFolder, SwiftData automatically updates someFolder.notes to include the note. When you write folder.notes.append(note), SwiftData updates note.folder to folder. The graph stays consistent.

This automatic maintenance is one of SwiftData’s most useful features. But it depends on SwiftData knowing that folder.notes and note.folder are the same relationship from two sides — which means you need to specify the inverse (Section 6).

The @Relationship macro

@Relationship customizes relationship behavior:

@Model
final class Folder {
    var name: String

    @Relationship(deleteRule: .cascade, inverse: \Note.folder)
    var notes: [Note] = []

    init(name: String) { self.name = name }
}

@Model
final class Note {
    var title: String
    var folder: Folder?

    init(title: String, folder: Folder? = nil) {
        self.title = title
        self.folder = folder
    }
}

Two options used:

  • deleteRule: .cascade — when the folder is deleted, all its notes are also deleted. (Other rules: .nullify, .deny, .noAction — covered below.)
  • inverse: \Note.folder — points to the other side of the relationship. SwiftData uses this to keep both sides in sync.

You typically only put @Relationship on one side (whichever side you want to declare the rules on). The other side picks up the relationship implicitly.

Delete rules

A delete rule controls what happens to related objects when you delete an object.

@Relationship(deleteRule: .cascade)
var notes: [Note] = []

The rules:

  • .cascade — delete the related objects too. Deleting a folder deletes all its notes. Use this for owned children that don’t make sense without the parent.
  • .nullify — set the related side to nil. Deleting a folder leaves notes in place but with note.folder == nil. The default for optional relationships.
  • .deny — refuse the deletion if there are related objects. The save fails. Use for parents that should be explicitly emptied first.
  • .noAction — leave the related objects in inconsistent state (with dangling references). Almost never what you want; the database doesn’t enforce it.

Practical guidance:

  • Cascade for owned children (a Post’s Comments, a Document’s Pages, an Album’s Photos).
  • Nullify for soft associations (a Note’s Folder — the note can outlive the folder).
  • Deny for protected parents (a User account whose deletion requires explicit cleanup first).
  • NoAction is almost a bug; avoid.

Many-to-many relationships

A many-to-many relationship has collections on both sides:

@Model
final class Note {
    var title: String

    @Relationship(inverse: \Tag.notes)
    var tags: [Tag] = []

    init(title: String) { self.title = title }
}

@Model
final class Tag {
    var name: String
    var notes: [Note] = []

    init(name: String) { self.name = name }
}

A note has many tags; a tag has many notes. SwiftData manages a join table behind the scenes (you don’t see it in your Swift code, but it’s there in SQLite).

Many-to-many delete rules need care:

  • note.tags has rule .nullify (default) — deleting a note removes it from each tag’s notes array.
  • tag.notes has rule .nullify — deleting a tag removes it from each note’s tags array.

If you want to prevent deleting a tag while notes still reference it, set .deny:

@Relationship(deleteRule: .deny, inverse: \Note.tags)
var notes: [Note] = []

Now try context.delete(tag); try context.save() fails if the tag still has notes.

Reading and writing collections

Operating on a to-many collection is just Swift:

folder.notes.append(note)
folder.notes.remove(at: 0)
folder.notes.removeAll { $0.isPinned == false }

let pinned = folder.notes.filter(\.isPinned)
let count = folder.notes.count

Under the hood, SwiftData materializes the collection on access. Each .notes access on the same folder returns the same logical collection (with the same elements, in the same order if ordered).

For large collections, accessing all elements can be expensive (faulting in many objects). When you only need to count or check existence, use specific patterns:

// Just want the count? Don't load everything.
let count = folder.notes.count  // SwiftData can optimize this

// Filtering can be more efficient via a fetch:
let urgent = try context.fetch(
    FetchDescriptor<Note>(
        predicate: #Predicate { $0.folder == folder && $0.priority == .high }
    )
)

For very large to-many collections, prefer fetching via predicates over traversing the relationship — covered in performance Section 18.

Inserting via relationship

When you assign a model to a relationship, it gets inserted into the context implicitly (if it wasn’t already):

let folder = Folder(name: "Work")
context.insert(folder)

let note = Note(title: "Hi")
folder.notes.append(note)
// note is now in the context, no explicit context.insert(note) needed

try context.save()

This is convenient but can be a footgun. If you build a big graph of unsaved models and assign them all into relationships, they’ll all be inserted on save. Sometimes that’s what you want; sometimes you wanted to discard some of them.

Relationship pitfalls

Forgetting inverses. SwiftData can work without explicit inverses, but the behavior is murky (which side “owns” the relationship?). Always specify inverses (Section 6).

Wrong delete rule. Cascading deletes a critical relationship and you lose data you wanted to keep. Or nullify leaves orphaned children. Choose deliberately for each relationship.

Using Array<T> when order doesn’t matter. Order requires SwiftData to store sequence information, which has overhead. Use Set<T> for unordered to-many.

Inserting models, then assigning them — getting them inserted twice. SwiftData de-duplicates, but it’s still wasteful. Assign first, let the implicit insertion happen, or insert explicitly without assigning. Don’t do both.

Cyclic graphs with cascade rules. A .cascade on both sides of a cycle can lead to infinite deletion attempts. SwiftData generally handles this correctly, but the semantics are fragile; prefer one direction of cascade.

Many-to-many with massive sets. A user with 100,000 tag associations is a query problem. Consider whether you really need many-to-many or whether the data shape should be different.

What to internalize

To-one is var x: Other? (or non-optional). To-many is var xs: [Other] = [] (or Set<Other>). Use @Relationship to set delete rules and specify inverses. .cascade for owned children, .nullify for soft associations, .deny for protected parents. Assign models to relationships and they get auto-inserted on save. SwiftData keeps both sides of bidirectional relationships in sync — but only if you tell it about the inverse. For large collections, prefer fetching by predicate over traversing the relationship.


6. Inverse Relationships and Why They Matter

We touched on inverses in Section 5. They’re important enough — and confusing enough — to deserve their own section. An inverse is the “other side” of a bidirectional relationship. Getting them right means SwiftData keeps your object graph consistent automatically. Getting them wrong (or omitting them) means you’ll see subtle bugs where deletes don’t propagate, where folders show notes that have moved away, where the database drifts out of sync with what your code expects.

What an inverse is

Take this pair:

@Model
final class Folder {
    var name: String
    var notes: [Note] = []

    init(name: String) { self.name = name }
}

@Model
final class Note {
    var title: String
    var folder: Folder?

    init(title: String) { self.title = title }
}

folder.notes and note.folder describe the same relationship from two sides. They’re inverses of each other.

The bidirectional consistency you want:

  • If note.folder = folder1, then folder1.notes should contain note.
  • If you assign folder2.notes = [note], then note.folder should become folder2.
  • If you delete a folder with cascading, its notes should also be deleted.
  • If you delete a note, it should disappear from its folder’s notes array.

This consistency only happens if SwiftData knows the two properties are inverses of each other.

Declaring inverses explicitly

@Model
final class Folder {
    var name: String

    @Relationship(deleteRule: .cascade, inverse: \Note.folder)
    var notes: [Note] = []

    init(name: String) { self.name = name }
}

@Model
final class Note {
    var title: String
    var folder: Folder?

    init(title: String) { self.title = title }
}

The inverse: \Note.folder on Folder.notes tells SwiftData: “the other side of notes is Note.folder.” Now the graph stays consistent automatically.

Best practice: declare the inverse on exactly one side. SwiftData figures out the other side from the keypath. By convention, put it on the to-many side (the side that has the cascade rule), but either works.

What happens without an explicit inverse

In iOS 17 early versions, SwiftData would try to infer the inverse from the schema (matching types and field shapes). This often worked but was fragile — rename a property, refactor a type, and the inference might fail silently. You’d find your folders and notes drifting out of sync.

In iOS 17.4 and later, the inference is more reliable, but explicit inverse: is still strongly recommended. Three reasons:

  1. It documents intent. Anyone reading the model knows the relationship is bidirectional.
  2. It’s robust against refactors. Renames update via Xcode’s refactoring; inference doesn’t.
  3. It’s required for some edge cases. Multiple relationships between the same two types (e.g., a Person with both manager: Person? and directReports: [Person]) need explicit inverses to disambiguate.

Multiple relationships between the same types

Self-referencing or two-relationship cases need explicit inverses to disambiguate:

@Model
final class Person {
    var name: String

    @Relationship(inverse: \Person.directReports)
    var manager: Person?

    @Relationship(inverse: \Person.manager)
    var directReports: [Person] = []

    init(name: String) { self.name = name }
}

Without the explicit inverses, SwiftData can’t tell which property is the inverse of which. It might pair manager with itself (nonsense) or with directReports (correct) — leaving you with a 50/50 chance of bugs.

Another example: a Friend relationship that’s many-to-many on the same type:

@Model
final class Person {
    var name: String

    @Relationship(inverse: \Person.friends)
    var friends: [Person] = []

    init(name: String) { self.name = name }
}

The inverse points back to itself (the relationship is symmetric). Friend lists stay synchronized: personA.friends.append(personB) causes personB.friends to include personA.

How automatic synchronization works

When SwiftData knows about the inverse, every relationship mutation triggers updates on both sides:

let folder = Folder(name: "Work")
let note = Note(title: "Hi")
context.insert(folder)
context.insert(note)

note.folder = folder
// SwiftData now also updates folder.notes to include note.
// You can read folder.notes and see [note] without any extra steps.

print(folder.notes.count)  // 1

This isn’t done by your code calling folder.notes.append. It’s done by SwiftData internally, between when the assignment happens and the next time you read folder.notes. The mechanism is the same one Core Data has used for years: the framework intercepts mutations via the synthesized accessors and updates the inverse.

The consistency is “logically immediate” — by the next read, both sides agree. But if you assign and then immediately read, there’s no race; SwiftData enforces ordering.

Deletion and inverses

Inverses make delete rules work correctly:

context.delete(folder)
// With deleteRule: .cascade, all of folder.notes are also deleted.
// With deleteRule: .nullify, each note has note.folder set to nil.

try context.save()

Without the inverse declared, SwiftData might not know that deleting the folder should affect its notes. The cascade rule still exists in the schema, but the engine needs to know which property to traverse to find the affected children.

In practice: declare inverses, and delete rules work as expected. Skip inverses, and you might see deletions that don’t propagate.

When inverses can be omitted

You can omit inverse: if:

  • The relationship is purely one-way. (Rare; most relationships are bidirectional in practice.)
  • The inference is unambiguous and you trust SwiftData to figure it out. (Risky.)

A “purely one-way” relationship is something like an audit log:

@Model
final class AuditLog {
    var timestamp: Date
    var user: User  // the user this log is about

    init(timestamp: Date, user: User) {
        self.timestamp = timestamp
        self.user = user
    }
}

@Model
final class User {
    var name: String
    // No reverse relationship to audit logs — they're just "about" the user

    init(name: String) { self.name = name }
}

If the User doesn’t need to access “all my audit logs,” there’s no reverse property and no inverse to declare. This is fine. But many apps grow into needing the reverse, at which point you add it and declare the inverse.

Inverse pitfalls

Forgetting inverses when adding a second relationship. Your code compiles, the app runs, but folder.notes and note.folder drift out of sync over time. Test by mutating one side and reading the other.

Inverses pointing to the wrong property. A typo in the keypath that compiles (because the wrong property exists with the right type) but doesn’t represent the actual inverse. Rare but possible; verify by reading both sides after a mutation.

Declaring inverses on both sides. Pick one side. Declaring on both is redundant and can sometimes confuse the macro expansion.

Changing the inverse after data exists. If you ship v1 with Folder.notes paired with Note.folder, and v2 changes the pairing, existing data is in the old format. Migration may not be trivial.

Self-relationships without explicit inverses. Always declare inverses for self-referencing relationships. The cost is one annotation; the benefit is unambiguous semantics.

What to internalize

A relationship has two sides; the inverse is the other side. Declare inverses explicitly with @Relationship(inverse: \OtherType.property) — typically on the to-many side, by convention. Inverses let SwiftData keep both sides in sync automatically, make delete rules work correctly, and disambiguate cases with multiple relationships between the same types. Self-referencing relationships (a Person with manager and directReports) require explicit inverses. The cost is one annotation; the cost of skipping is data drift.


7. CRUD: Inserting, Reading, Updating, Deleting

You now know how to define models. The next layer is CRUD — Create, Read, Update, Delete — the four core operations every persistent data system supports. SwiftData’s API is mostly intuitive, but each operation has subtleties worth understanding.

Insert

Creating a new model and persisting it is two steps:

let note = Note(title: "First note")
context.insert(note)
try context.save()

That’s it. init creates the in-memory instance. insert adds it to the context’s tracking. save writes to SQLite.

Between init and insert, the model is “dangling” — it exists in memory but isn’t part of any context. You can still set properties, but it won’t be persisted unless you insert it (or assign it to a relationship of an already-inserted model, which triggers implicit insertion).

Between insert and save, the model is in the context as a pending insert. It’s not yet on disk. If you call context.rollback() before saving, the insert is discarded.

After save, the model has a stable persistentModelID and lives in the SQLite store.

Implicit inserts via relationships

As mentioned in Section 5, assigning an uninserted model to a relationship of an inserted model causes the uninserted one to get inserted on save:

let folder = Folder(name: "Work")
context.insert(folder)

let note = Note(title: "Hi")
folder.notes.append(note)
// note is now reachable from folder, so it'll be saved too

try context.save()
// Both folder and note are persisted

This is the convenient case. The hazardous case: an entire graph of unrelated unsaved models that suddenly all get saved because one of them is inserted:

let user = User(name: "Alice")
let log1 = AuditLog(user: user, action: "login")
let log2 = AuditLog(user: user, action: "view")

context.insert(user)
// log1 and log2 reference user, so they're reachable from user's inverse
// (if you've declared it). They get saved too.

try context.save()

If the inverse User.auditLogs exists, both logs are saved. If not, they’re orphaned in memory. Either way, the behavior is determined by graph reachability.

Batch insert via context

For inserting many models, you can use a loop:

for title in titles {
    let note = Note(title: title)
    context.insert(note)
}
try context.save()

For very large batches (thousands of models), this gets slow because the context accumulates pending changes. Patterns for large batches are covered in Section 18, but the short version: save every N inserts (e.g., every 100), and call context.reset() periodically to discard the working set.

Read

The simplest read is a fetch of all models of a type:

let allNotes = try context.fetch(FetchDescriptor<Note>())

FetchDescriptor<Note>() is the “everything” query. It returns all Note instances in the context’s store. We’ll cover all the options for FetchDescriptor in Section 9.

A common variant: fetch with a predicate.

let predicate = #Predicate<Note> { $0.isPinned }
let pinned = try context.fetch(FetchDescriptor<Note>(predicate: predicate))

#Predicate is the compile-time-safe DSL for filtering (Section 10).

Fetch by ID

If you have a PersistentIdentifier, you can resolve it to a model:

if let note = context.model(for: noteID) as? Note {
    // got it
}

context.model(for:) returns (any PersistentModel)? — you cast to your concrete type. This is the standard pattern for resolving IDs passed across actor boundaries (Section 16).

Note: model(for:) doesn’t issue a fetch if the model is already loaded in the context. It returns the existing instance if present. This is how multiple calls return the same instance for the same ID (object identity preserved within a context).

Update

Updating is just assigning to properties:

note.title = "New title"
note.body = "Updated body"
try context.save()

There’s no explicit “update” call. SwiftData tracks changes via the synthesized property setters and writes them on save.

You can mutate relationships the same way:

note.folder = otherFolder
note.tags.append(newTag)
try context.save()

Mutations are batched until save(). Until then, the in-memory model has the new values but disk doesn’t.

Delete

Deleting a single model:

context.delete(note)
try context.save()

After delete, the model still exists as a reference in your code, but accessing its properties may throw (it’s a “deleted” object). Don’t keep references to deleted models.

For bulk deletes, the pattern is to fetch and loop, or use a batch delete (covered in Section 18 — significantly faster for large deletes):

let oldNotes = try context.fetch(
    FetchDescriptor<Note>(predicate: #Predicate { $0.createdAt < cutoffDate })
)
for note in oldNotes {
    context.delete(note)
}
try context.save()

Or with the batch API in iOS 18:

try context.delete(model: Note.self, where: #Predicate { $0.createdAt < cutoffDate })
try context.save()

The batch API is faster for large counts because it doesn’t materialize each model.

Save errors

context.save() throws. The errors are usually one of:

  • Validation failures — a model property failed validation (e.g., a non-optional was nil).
  • Constraint violations — a unique constraint was violated (Section 12).
  • Merge conflicts — two contexts modified the same object differently (rare in single-context apps).
  • I/O failures — disk full, permissions, hardware error. Rare but possible.

A typical error handler:

do {
    try context.save()
} catch {
    print("Save failed: \(error)")
    // Optionally roll back:
    context.rollback()
    // Or show an error to the user
}

context.rollback() undoes all pending changes since the last save. After rolling back, the context returns to the state of the last successful save.

For most apps, save failures should be loud — they indicate something is wrong. Don’t swallow them silently.

Transactional saves

If you want a series of operations to either all succeed or all fail:

do {
    let note = Note(title: "Hi")
    context.insert(note)

    let log = AuditLog(action: "created note")
    context.insert(log)

    try context.save()  // both succeed, or both fail
} catch {
    context.rollback()
    // both are rolled back
}

Save is atomic at the SQLite level. Either all pending changes commit, or none do. This is foundational for data integrity.

Has changes / unsaved changes

You can check if the context has unsaved changes:

if context.hasChanges {
    try context.save()
}

Useful when deciding whether to prompt “discard changes?” on dismiss.

You can also inspect what’s pending:

let inserted = context.insertedModelsArray  // [PersistentModel]
let updated = context.changedModelsArray
let deleted = context.deletedModelsArray

For debugging or for selective saves (rare).

CRUD pitfalls

Forgetting to save. Insertions, updates, and deletes don’t persist until save(). Many “why doesn’t my data show up” bugs are missing saves.

Saving in a tight loop without batching. Save flushes the entire transaction; doing it on each item in a 10,000-item loop is slow. Save every N (typically 100-1000).

Catching save errors and continuing. A save error usually means data is in a bad state. Continuing as if nothing happened leads to compounded errors. Roll back, surface to the user, or abort.

Keeping references to deleted models. After context.delete(note) and save(), the note variable is a stale reference. Don’t access it.

Inserting a model into multiple contexts. A model belongs to one context. Inserting it into another moves it; using it from the original may fail. This is rare in single-context apps but a common source of bugs in multi-actor apps. Use PersistentIdentifier to pass references, then resolve in each context (Section 16).

Confusion about implicit inserts. A model assigned to an inserted model’s relationship gets inserted itself. If you don’t want this, don’t assign — keep them separate until you decide.

What to internalize

context.insert(model) adds a model to a context. try context.save() persists pending changes. Property assignments are tracked automatically; no explicit update call. context.delete(model) marks for deletion. Saves are atomic — all pending changes commit together or not at all. Save errors should be loud; roll back or surface. Implicit inserts via relationships are convenient but can be surprising. For large bulk operations, save every N items or use batch APIs (Section 18).


8. Saving and the Autosave Behavior

The save() call writes pending changes to SQLite. But SwiftData has an autosave feature that calls save on a timer, automatically. Understanding when this fires (and when it doesn’t) shapes how you build reliable apps. This section is the saving lifecycle in detail.

What save does

A simplified view of context.save():

  1. Validate all pending changes (insertions, updates, deletes).
  2. Begin a SQLite transaction.
  3. Write all pending changes as SQL statements.
  4. Commit the transaction (or roll back on failure).
  5. Notify observers (SwiftUI @Query, change listeners).

If validation fails (e.g., a unique constraint violation), the save throws and no SQL is executed. Existing data is unchanged.

After a successful save:

  • All inserted models have stable persistentModelIDs.
  • The context’s hasChanges is false.
  • Other contexts watching the store will eventually see the changes (immediate for the main context, on next refresh for actor-bound contexts).

Autosave

ModelContext has an autosaveEnabled property. The default is true.

context.autosaveEnabled = true   // default
context.autosaveEnabled = false  // disable

When autosave is enabled, the context periodically calls save() itself — typically on UI runloop events, after a debounce period, when the app moves to background, and so on. The exact triggers are internal and not documented in detail, but the behavior is: changes get persisted “soon” without you doing anything.

For SwiftUI apps using .modelContainer(...), autosave is enabled on the main context. You can mutate models in views and they’ll save automatically.

struct NoteEditor: View {
    @Bindable var note: Note

    var body: some View {
        TextField("Title", text: $note.title)
        // Mutations to note.title autosave; no explicit save needed
    }
}

This is incredibly convenient for prototypes. Each keystroke updates the model, autosave persists. But it has implications.

Why autosave isn’t always your friend

Autosave is great for low-stakes UI editing. It’s risky for:

  • Batch operations. You insert 1,000 models in a loop. Autosave might fire halfway, saving an incomplete state. If your code expects all-or-nothing, this breaks.
  • Multi-step transactions. You insert a User and an AuditLog together. Autosave might save the User without the log, leaving inconsistent state.
  • Operations that are expected to be atomic. Banking-style “subtract from one account, add to another” — autosave between the two leaves money missing.

For these, disable autosave and call save() explicitly:

let context = ModelContext(container)
context.autosaveEnabled = false

// Do work
try context.save()  // explicit, atomic

A common pattern: use the main context (with autosave on) for UI work, and create a dedicated context with autosave off for batch jobs.

When you should always save explicitly

Even with autosave on, explicit saves are valuable in certain moments:

  • App moves to background. You want the latest state on disk. Call save() in scene phase change.
  • User dismisses a view that owns unsaved state. Save on dismiss to be sure.
  • After a critical operation. A payment, a booking, a purchase — call save immediately, don’t trust autosave to get to it before a crash.
  • Before performing a synchronization step. You want everything written before the next iCloud sync, before a network upload, etc.

The pattern:

@Environment(\.scenePhase) var scenePhase
@Environment(\.modelContext) var context

var body: some View {
    NavigationStack {
        // ...
    }
    .onChange(of: scenePhase) { _, newPhase in
        if newPhase == .background {
            try? context.save()
        }
    }
}

This catches the “user backgrounded the app” case where autosave might not have fired yet.

Save propagation

When the main context saves, changes propagate immediately to anything observing the main context (SwiftUI @Query, your @Bindable views).

When a background context saves, changes are written to SQLite, but other contexts don’t see them automatically until they refresh:

// Background context inserts and saves
await backgroundContext.perform {
    backgroundContext.insert(Note(title: "From background"))
    try backgroundContext.save()
}

// Main context — does it see the new note?
let notes = try mainContext.fetch(FetchDescriptor<Note>())
// Yes, it sees the new note (mainContext re-reads from SQLite)

Fetches always go to the store. So after a background save, the next fetch on the main context sees the new data. But: if you had a model instance loaded in the main context that was modified by the background context, the main context’s instance might be stale until you refresh it. Section 16 covers this.

Refresh and refault

Sometimes you want to force the context to re-read a model from disk (because you know another context changed it):

context.rollback()  // discards changes, refaults everything
// OR
context.refresh(note, mergeChanges: false)  // refault just this one

In iOS 18, there’s a more nuanced refresh API. For most apps, you don’t need it — the framework handles refresh automatically when fetches happen.

History tracking

iOS 17.4 added persistent history tracking to SwiftData. This lets you query a log of changes made by any context (including from background actors, app extensions, widgets):

@Environment(\.modelContext) var context

func recentChanges() {
    let descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
    do {
        let transactions = try context.fetchHistory(descriptor)
        for transaction in transactions {
            print(transaction)
        }
    } catch {
        print("History fetch failed: \(error)")
    }
}

History tracking is most useful for:

  • Apps with extensions/widgets. The main app can see changes made by extensions and refresh UI accordingly.
  • Sync/conflict resolution. Know what changed since the last sync.
  • Audit/debugging. Inspect what’s been modified recently.

Enable in your ModelConfiguration:

let config = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: .none
)

History tracking is enabled by default in iOS 17.4+. You can disable via private APIs in edge cases, but most apps want it on.

Save errors and recovery

A failed save leaves the context with pending changes. You have options:

do {
    try context.save()
} catch let error as SwiftDataError {
    switch error {
    case .uniqueConstraintViolation:
        // Show user that the value is duplicate
        showDuplicateAlert()
        context.rollback()
    default:
        context.rollback()
        showGenericError()
    }
} catch {
    // Some other error
    context.rollback()
    showGenericError()
}

(The exact error types and matchable cases depend on iOS version; this is illustrative.)

Common recovery patterns:

  • Show error, roll back, let user retry. For most user-facing errors.
  • Show error, keep pending changes, let user fix. Rare but useful for validation issues the user can correct.
  • Silently retry with backoff. For transient I/O errors. Use sparingly.
  • Crash and let the system restart. Only for completely fatal cases (database is corrupt).

Autosave pitfalls

Relying on autosave for critical operations. Always explicit-save right after payment, booking, account changes. Don’t trust autosave to get there before a crash.

Batch operations on an autosaving context. Autosave can fire mid-batch and leave inconsistent state. Use a dedicated context with autosave off.

Multiple contexts both autosaving the same data. Race conditions. Each context should own a specific scope of data, or use a single context with explicit saves.

Forgetting to save on background. Autosave may not run when the app is backgrounded if not enough time has passed. Save explicitly on scene phase change.

Not handling save errors. Letting saves fail silently means data loss. Always catch and at least log.

Save inside a tight loop. for i in 1...10000 { insert; save } is slow. Batch with periodic saves.

What to internalize

save() writes pending changes atomically. Autosave fires periodically for the main context, convenient for UI work but dangerous for batch operations and multi-step transactions. Disable autosave for batch jobs and dedicated background contexts. Save explicitly at critical moments: app backgrounding, after important operations, before sync. Saves propagate immediately to observers of the same context; other contexts see changes on their next fetch or refresh. History tracking (iOS 17.4+) lets you query what’s changed recently. Failed saves leave pending changes; roll back to discard or fix and retry.


9. FetchDescriptor in Detail

FetchDescriptor<T> is the query type in SwiftData. It bundles a predicate, sort descriptors, a fetch limit, and other options into a single value that you pass to context.fetch(...). This section covers every option and the patterns for using them well.

The shape of FetchDescriptor

let descriptor = FetchDescriptor<Note>(
    predicate: #Predicate<Note> { $0.isPinned },
    sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 50
descriptor.fetchOffset = 0
descriptor.includePendingChanges = true
descriptor.propertiesToFetch = [\.title, \.createdAt]
descriptor.relationshipKeyPathsForPrefetching = [\.folder]

let results = try context.fetch(descriptor)

All options have sensible defaults. The simplest fetch is FetchDescriptor<Note>() — no predicate, default sort, no limit.

Predicate

The predicate filters results:

let predicate = #Predicate<Note> {
    $0.isPinned && $0.createdAt > sevenDaysAgo
}
let descriptor = FetchDescriptor<Note>(predicate: predicate)

#Predicate is the compile-safe DSL. We cover it in depth in Section 10. For now: it works on top-level properties, supports comparisons, boolean operators, string operations, and relationship traversal.

Sort descriptors

sortBy: orders results:

let descriptor = FetchDescriptor<Note>(
    sortBy: [
        SortDescriptor(\.isPinned, order: .reverse),  // pinned first
        SortDescriptor(\.createdAt, order: .reverse)  // then newest first
    ]
)

Multiple sort descriptors are applied in order. Section 11 covers sort descriptors in detail.

Fetch limit

fetchLimit caps the number of results. The default is no limit (return everything matching).

var descriptor = FetchDescriptor<Note>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
descriptor.fetchLimit = 10  // top 10 newest

let recent = try context.fetch(descriptor)

Always set a fetch limit when:

  • You’re showing a list with a known maximum (e.g., “10 most recent”).
  • You’re querying for a single result with .first (set limit to 1).
  • The query could match millions of rows and you only need some.

Unbounded fetches against large tables are a common performance bug.

Fetch offset

fetchOffset skips the first N results. Combined with fetchLimit, you get pagination:

var descriptor = FetchDescriptor<Note>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
descriptor.fetchLimit = 20
descriptor.fetchOffset = 40  // page 3 (skip first 40, take next 20)

let page = try context.fetch(descriptor)

Pagination has caveats:

  • Stable sort required. If items shift between fetches (e.g., new ones inserted), offsets become unreliable.
  • Performance degrades with offset. SQLite still scans the skipped rows. Deep pagination (offset 10,000) is slow.
  • Cursor-based pagination is better for large datasets. Use a createdAt < lastSeenDate predicate instead of offset.

For modest paging (offsets up to a few hundred), fetchOffset is fine.

Property fetching (partial fetches)

By default, fetching a model loads all its properties. For large models with many fields you don’t need, this is wasteful. propertiesToFetch restricts to specific properties:

var descriptor = FetchDescriptor<Note>()
descriptor.propertiesToFetch = [\.title, \.createdAt]

let lightweightNotes = try context.fetch(descriptor)
// Each note is a fault for everything except title and createdAt

The returned objects are “partial” — they have the listed properties materialized, and other properties get faulted in when first accessed. If you only access the listed properties, you save memory and disk I/O.

Use sparingly:

  • For lists showing only a few fields of each item (title + date).
  • For “count” or “summary” use cases.
  • Don’t use when you’ll likely access more properties later (faulting will defeat the savings).

Relationship prefetching

By default, accessing a relationship triggers a fault (a query to load related objects). For lists where you’ll access each item’s relationship, this causes N+1 queries — one for the list, one per item.

relationshipKeyPathsForPrefetching solves this:

var descriptor = FetchDescriptor<Note>()
descriptor.relationshipKeyPathsForPrefetching = [\.folder, \.tags]

let notes = try context.fetch(descriptor)
for note in notes {
    print(note.folder?.name ?? "no folder")  // no additional query — folder already loaded
    print(note.tags.count)  // no additional query
}

Prefetching issues a single batched query to load all the related objects, then they’re already in the context when you access them.

When to prefetch:

  • Lists that display relationship data (a notes list showing folder names).
  • Loops that traverse relationships.
  • Anywhere you’d see N+1 query patterns.

When not to prefetch:

  • Single-object reads where you might access one relationship.
  • Lists where the relationship is rarely accessed (don’t load what you don’t use).

Section 18 covers performance patterns including prefetching in more depth.

Include pending changes

includePendingChanges (default true) controls whether unsaved inserts/updates are reflected in fetch results.

context.insert(Note(title: "Just inserted"))

var descriptor = FetchDescriptor<Note>()
descriptor.includePendingChanges = true  // default

let notes = try context.fetch(descriptor)
// "Just inserted" is included even though we haven't saved

Set to false if you specifically want the “saved state” view of the data. Rare.

Fetching a single item

Convention for fetching one item (or first match):

var descriptor = FetchDescriptor<Note>(predicate: #Predicate { $0.id == targetID })
descriptor.fetchLimit = 1

let note = try context.fetch(descriptor).first

Setting fetchLimit = 1 is faster than fetching all matches and taking .first from a longer list.

Fetching counts only

When you only need a count, use the dedicated API:

let count = try context.fetchCount(FetchDescriptor<Note>(predicate: #Predicate { $0.isPinned }))

fetchCount runs a SELECT COUNT(*) against SQLite without materializing any models. Much faster than fetch(...).count for large datasets.

Fetching identifiers only

Sometimes you want the identifiers of matching rows without the data:

let ids = try context.fetchIdentifiers(FetchDescriptor<Note>(predicate: ...))
// Returns [PersistentIdentifier]

Useful for cross-actor work (you can pass identifiers across actor boundaries cheaply, then resolve them on the receiving side) or for storing lightweight references.

Caching fetched data

FetchDescriptor doesn’t cache. Each call to context.fetch(descriptor) re-queries SQLite. But: if a fetched model is already loaded in the context, accessing it doesn’t reload — the context returns the existing instance.

This means: repeated fetches with the same descriptor have a “warm” effect — the underlying objects are already in memory, even though the fetch re-runs the query. You’re paying for the query but not for the object materialization.

For truly cached results, store the returned array yourself:

// In a view model
private var cachedNotes: [Note] = []

func refresh() throws {
    cachedNotes = try context.fetch(FetchDescriptor<Note>())
}

But cache invalidation is your problem. For SwiftUI, @Query does this automatically (Section 20).

Building descriptors programmatically

You can construct descriptors based on runtime state:

func makeDescriptor(showingPinned: Bool, search: String) -> FetchDescriptor<Note> {
    let predicate: Predicate<Note>
    if showingPinned, !search.isEmpty {
        predicate = #Predicate { $0.isPinned && $0.title.contains(search) }
    } else if showingPinned {
        predicate = #Predicate { $0.isPinned }
    } else if !search.isEmpty {
        predicate = #Predicate { $0.title.contains(search) }
    } else {
        predicate = #Predicate { _ in true }  // match all
    }

    return FetchDescriptor<Note>(predicate: predicate, sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
}

Predicates can be conditionally chosen but not easily composed at runtime (Section 10 covers limitations).

FetchDescriptor pitfalls

No fetch limit on potentially large queries. Loading a million rows kills your app. Set a limit, or paginate.

Forgetting to prefetch relationships in lists. N+1 queries silently slow down lists. Profile with the SQL debug flag to see if you’re issuing too many queries.

Loading too many properties. If a model has a big Data field and you don’t need it for the list, use propertiesToFetch to skip it.

Pagination with unstable sort. If you paginate by sort key + offset, and items shift, offsets are wrong. Use a stable sort (include the model ID as a tiebreaker) or cursor-based pagination.

Using fetch.count instead of fetchCount. Materializes all models just to count them. Use the dedicated API.

Repeated identical fetches in a loop. Each is a SQL query. Cache the result or use @Query in SwiftUI.

What to internalize

FetchDescriptor<T> bundles a predicate, sort, fetch limit, offset, property selection, and prefetch hints. Always set a fetch limit on queries that could return many rows. Use fetchCount for counts (cheap), fetchIdentifiers for IDs only. Prefetch relationships when you’ll traverse them in a loop to avoid N+1 queries. Use propertiesToFetch for partial fetches when working with subsets of large models. Pagination via fetchOffset works for modest sizes; for large datasets, use cursor-based predicates instead.


10. #Predicate: Type-Safe Queries

#Predicate is the macro-based query DSL in SwiftData. It replaces NSPredicate (which used a string-based format) with a Swift closure that’s checked at compile time. The result is queries that look like Swift, fail at compile time when wrong, and refactor like Swift.

But: #Predicate is not a generalized closure. It’s a DSL with restrictions. You can’t call arbitrary Swift functions inside one. You can’t use if let. You can’t capture local computed values that don’t translate to SQL. Understanding the boundaries is essential.

The basic shape

let predicate = #Predicate<Note> { note in
    note.isPinned == true
}

let pinned = try context.fetch(FetchDescriptor<Note>(predicate: predicate))

The macro generates a value of type Predicate<Note> that SwiftData translates into a SQL WHERE clause. You write Swift; SwiftData generates SQL.

Comparison operators

All standard comparisons work on supported types:

#Predicate<Note> { $0.createdAt > Date.distantPast }
#Predicate<Note> { $0.title == "Hello" }
#Predicate<Note> { $0.score >= 100 }
#Predicate<Note> { $0.body != "" }
#Predicate<Note> { $0.priority < .high }  // Comparable enums work

Boolean composition

#Predicate<Note> { $0.isPinned && $0.createdAt > someDate }
#Predicate<Note> { $0.isPinned || $0.isFavorited }
#Predicate<Note> { !$0.isDeleted }

You can nest as deeply as needed:

#Predicate<Note> {
    ($0.isPinned || $0.isFavorited) &&
    !$0.isDeleted &&
    $0.score > 50
}

The compiler-translated SQL has corresponding AND / OR / NOT operators.

String operations

SwiftData supports several string operations in predicates:

#Predicate<Note> { $0.title.contains("urgent") }
#Predicate<Note> { $0.title.starts(with: "Note") }
#Predicate<Note> { $0.title.localizedStandardContains(searchText) }

contains is case-sensitive substring match. For case-insensitive search, use localizedStandardContains (which also handles diacritics).

You can also use regular expressions in iOS 17.4+:

let regex = try Regex("note-\\d+")
#Predicate<Note> { $0.title.contains(regex) }

Optional handling

Optionals work, but you handle nil explicitly:

#Predicate<Note> { $0.folder != nil }
#Predicate<Note> { $0.folder == nil }
#Predicate<Note> { $0.folder?.name == "Work" }

The chained optional ?. works for navigating into the related model.

You cannot use if let or guard let inside #Predicate. The DSL has no statement support; it’s expression-only.

Relationship traversal

You can navigate to-one relationships in predicates:

#Predicate<Note> { $0.folder?.name == "Work" }
#Predicate<Note> { $0.folder?.isArchived == false }

For to-many relationships, you can use contains(where:):

#Predicate<Note> { note in
    note.tags.contains { tag in tag.name == "urgent" }
}

This generates SQL with a JOIN to find notes whose tag set includes the matching tag.

You can also check the count of a relationship:

#Predicate<Folder> { $0.notes.count > 5 }
#Predicate<Folder> { $0.notes.isEmpty }

Date arithmetic

Date comparisons work directly:

let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 3600)
#Predicate<Note> { $0.createdAt > oneWeekAgo }

You compute date values outside the predicate (in regular Swift), and capture them. The DSL doesn’t support computing dates inline — Date.now.addingTimeInterval(-7 * 24 * 3600) inside the predicate often doesn’t compile or doesn’t work as expected.

The pattern:

func recentNotes(since: Date) throws -> [Note] {
    let predicate = #Predicate<Note> { $0.createdAt > since }
    return try context.fetch(FetchDescriptor<Note>(predicate: predicate))
}

// Call with a precomputed date
let cutoff = Date().addingTimeInterval(-7 * 24 * 3600)
let notes = try recentNotes(since: cutoff)

Capturing variables

You can capture local values:

let searchText = "urgent"
let predicate = #Predicate<Note> { $0.title.contains(searchText) }

The captured value is bound at predicate creation time. If searchText changes later, the predicate uses the old value.

What you cannot capture: arbitrary computed properties or function results that the predicate doesn’t know how to translate to SQL:

let computed = someComplicatedComputation()  // returns Int

let predicate = #Predicate<Note> { $0.score > computed }  // OK, computed is Int

But:

let predicate = #Predicate<Note> { $0.title == myFancyFormatter(name) }  // NOT OK
// Calling functions inside the predicate is not supported

The rule of thumb: predicates can call methods on the model and standard operators on supported types, but cannot call arbitrary Swift functions.

What predicates can NOT do

The DSL has clear limits:

  • No if let or guard let. Use != nil and ?. for optionals.
  • No switch. Use || for or-chains.
  • No arbitrary function calls. Operators and a small set of methods only.
  • No closures over models. You can use contains(where:) on relationships, but the inner closure has the same DSL restrictions.
  • No iteration. No for, no while, no forEach. Use contains(where:) for to-many filtering.
  • No mutation. Predicates are read-only.
  • No string interpolation. \(searchText) inside a string literal doesn’t translate. Capture values into properties or use string operations.
  • No Codable value type traversal. As mentioned in Section 4, predicates can’t peek into Codable struct properties.

When you need any of these, do the filtering in Swift after fetching. Or restructure the data to make the query expressible (promote a Codable field to a top-level property).

Conditional predicates

You can build predicates conditionally:

func searchPredicate(text: String) -> Predicate<Note> {
    if text.isEmpty {
        return #Predicate<Note> { _ in true }
    } else {
        return #Predicate<Note> { $0.title.contains(text) }
    }
}

This is the standard pattern for optional filters. The _ in true predicate matches everything; combining it with other filters via && is a no-op.

For more complex composition (combining several optional filters), you typically build the predicate inline in a function:

func notesPredicate(
    pinned: Bool?,
    folder: Folder?,
    search: String?
) -> Predicate<Note> {
    switch (pinned, folder, search) {
    case (true, let f?, let s?):
        return #Predicate { $0.isPinned && $0.folder == f && $0.title.contains(s) }
    case (true, let f?, nil):
        return #Predicate { $0.isPinned && $0.folder == f }
    // ... etc.
    case (nil, nil, nil):
        return #Predicate { _ in true }
    default:
        // Build the rest of the cases
        return #Predicate { _ in true }
    }
}

Verbose. The DSL doesn’t currently support composable predicate builders the way some ORMs do. For complex query building, you may need to maintain a small switch like this — or live with the verbosity.

Predicate pitfalls

Capturing values that change. A predicate captures values at creation. If searchText is a @State, you need a fresh predicate each time it changes. SwiftUI’s @Query(filter:) rebuilds the predicate when its parameters change — manual fetches need manual rebuild.

Trying to call methods on captured types. #Predicate { $0.title == userSettings.preferredTitle() } may not compile or may produce a malformed predicate. Capture the result of the method call, not the call itself.

Forgetting case sensitivity. contains is case-sensitive. For user-facing search, use localizedStandardContains.

Optionals confusion. #Predicate<Note> { $0.folder.name == "Work" } doesn’t compile if folder is optional. Use $0.folder?.name == "Work".

contains(where:) on huge to-many. Joins through relationships can be slow on large collections. Consider whether the data should be denormalized (e.g., a hasUrgentTag: Bool cached on Note).

Using print or other side effects. Predicates are pure expressions. Side effects don’t make sense and aren’t supported.

What to internalize

#Predicate is a compile-time-safe DSL for filtering. It supports comparison, boolean composition, string operations (including localized variants), optional handling with ?., relationship traversal, and contains(where:) on to-many. It does NOT support if let, switch, arbitrary function calls, iteration, or peeking into Codable structs. Capture values from outside; results of complex Swift work go in via captured variables. For conditional filters, build the predicate per-case. When the DSL isn’t expressive enough, filter in Swift after fetching, or restructure the model.


11. SortDescriptor and Result Limits

Once you’ve filtered to the rows you want, sorting and limiting shape the result. SortDescriptor is the type-safe sort ordering; fetchLimit and fetchOffset control pagination. Together with predicates, they make up the three knobs you’ll turn on every meaningful fetch.

SortDescriptor basics

SortDescriptor describes a sort by a key path:

SortDescriptor(\Note.createdAt, order: .reverse)
SortDescriptor(\Note.title, order: .forward)
SortDescriptor(\Note.score)  // .forward by default

.forward is ascending (oldest first, A→Z, lowest score first). .reverse is descending. Pick based on display order.

You pass an array of sort descriptors to the fetch descriptor:

let descriptor = FetchDescriptor<Note>(
    sortBy: [
        SortDescriptor(\.isPinned, order: .reverse),  // pinned first (true > false in .reverse)
        SortDescriptor(\.createdAt, order: .reverse)  // then newest first
    ]
)

Multiple descriptors are applied in order. Within rows that tie on the first criterion, the second applies. Within rows that tie on the second, the third. And so on.

String comparison

String sort uses Unicode codepoint order by default. For user-facing display, you want locale-aware comparison:

SortDescriptor(\Note.title, comparator: .localizedStandard)

The comparator: parameter accepts:

  • .lexical (default) — codepoint comparison.
  • .localized — locale-aware, case-sensitive.
  • .localizedStandard — locale-aware, case-insensitive, handles diacritics — the right choice for user-visible lists.
SortDescriptor(\Note.title, comparator: .localizedStandard, order: .forward)

Sorting by relationship properties

You can sort by a property of a to-one relationship:

SortDescriptor(\Note.folder?.name, order: .forward)

Notes with folder == nil will be at one end (depending on the order). The SQL becomes a JOIN with ORDER BY folder.name.

You cannot directly sort by a to-many property (what would that mean — sort by the array?). If you need to sort by something derived from a to-many (like “folders with the most notes first”), you’d typically denormalize that derived value onto the model, or sort in Swift after fetching.

Sorting nullable properties

Nil values sort either first or last depending on the order. In .forward, nil typically sorts first (it’s “less than” any value); in .reverse, nil sorts last. The exact behavior depends on the comparator. For deterministic placement, you can add a secondary sort to disambiguate:

[
    SortDescriptor(\.completedAt, order: .reverse),  // most recently completed first
    SortDescriptor(\.createdAt, order: .reverse)  // then by creation date for nil completedAt
]

Stable sorts

If two items have identical sort keys, the order between them isn’t guaranteed unless you add a tiebreaker. For pagination especially, you want a stable order:

[
    SortDescriptor(\.score, order: .reverse),
    SortDescriptor(\.id, order: .forward)  // stable tiebreaker
]

Using \.id (a UUID or numeric identifier) as the final sort guarantees stable ordering. Without it, you could see the same row appear on two different pages of paginated results.

Fetch limit

fetchLimit is set on the descriptor:

var descriptor = FetchDescriptor<Note>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
descriptor.fetchLimit = 50

let recent = try context.fetch(descriptor)

This translates to SQL’s LIMIT 50. The database stops scanning once it has 50 matches (given the sort order). For “top N” queries, this is much more efficient than fetching all and slicing.

Fetch offset

fetchOffset skips the first N matches. Combined with limit, you get pagination:

descriptor.fetchLimit = 20
descriptor.fetchOffset = 0   // page 1
// or
descriptor.fetchOffset = 20  // page 2
// or
descriptor.fetchOffset = 40  // page 3

The cost: SQLite still has to find the offset, which means scanning N rows before returning. For small offsets, this is fine. For deep pagination (offset 10,000+), it’s slow because the database scans-and-discards 10,000 rows.

Cursor-based pagination

For large datasets, cursor-based pagination is faster:

// First page: most recent 20
var descriptor = FetchDescriptor<Note>(
    sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 20
let firstPage = try context.fetch(descriptor)

// Next page: 20 older than the last item on the first page
if let lastItem = firstPage.last {
    let cutoff = lastItem.createdAt
    descriptor = FetchDescriptor<Note>(
        predicate: #Predicate { $0.createdAt < cutoff },
        sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
    )
    descriptor.fetchLimit = 20
    let nextPage = try context.fetch(descriptor)
}

This uses the sort key as a cursor. SQLite can use an index on createdAt to jump directly to the cutoff position, rather than scanning.

For stable cursors with ties, include a secondary key (like the model ID) in the cursor:

let cutoffDate = lastItem.createdAt
let cutoffID = lastItem.persistentModelID

let predicate = #Predicate<Note> {
    $0.createdAt < cutoffDate ||
    ($0.createdAt == cutoffDate && $0.persistentModelID < cutoffID)
}

This pattern handles the edge case where multiple items share the same createdAt.

Pagination pitfalls

Offset-based pagination with unstable order. Items shift between pages; you see duplicates or miss items. Use cursor-based or include a stable tiebreaker.

Deep offsets. Offset 10,000 is slow because of the scan. Use cursor pagination.

No limit on a “show more” query. If the user scrolls fast, you might fetch the entire dataset by accident. Always have a sensible page size.

Forgetting that pagination requires the same sort across pages. Page 1 sorted by date; page 2 sorted by title — the merged result is meaningless. Keep the sort consistent.

Mixing pending changes with pagination. A model that’s inserted in the middle of a paginated traversal causes pagination drift. For long traversals, either save and refresh between pages, or use a context with includePendingChanges = false.

Sort + limit + offset together

The full shape:

var descriptor = FetchDescriptor<Note>(
    predicate: #Predicate { $0.isArchived == false },
    sortBy: [
        SortDescriptor(\.isPinned, order: .reverse),
        SortDescriptor(\.createdAt, order: .reverse),
        SortDescriptor(\.persistentModelID, order: .forward)  // stable tiebreaker
    ]
)
descriptor.fetchLimit = 20
descriptor.fetchOffset = page * 20

let results = try context.fetch(descriptor)

This is a typical “paged, sorted, filtered list” descriptor. The predicate narrows; the sort orders; the limit caps; the offset moves through pages.

What to internalize

SortDescriptor(\.property, order:) is the type-safe sort. Use .localizedStandard comparator for user-facing string sorting. Multiple descriptors apply in order; include a stable tiebreaker (like the model ID) when pagination is involved. fetchLimit caps results — set it on any query that could return many rows. fetchOffset paginates but degrades with depth; for large datasets, use cursor-based pagination via a predicate on the sort key. Sort by to-one relationship properties works via \.folder?.name; sort by to-many requires denormalization or post-fetch sorting in Swift.


12. Unique Constraints and Identifiers

Some properties must be unique across all rows. A user’s email. A document’s slug. A product’s SKU. SwiftData supports unique constraints via @Attribute(.unique), but there are subtleties — especially around CloudKit compatibility and how violations behave at runtime. This section covers what works, what doesn’t, and the patterns to use instead.

Declaring uniqueness

@Model
final class User {
    @Attribute(.unique) var email: String
    var displayName: String

    init(email: String, displayName: String) {
        self.email = email
        self.displayName = displayName
    }
}

The @Attribute(.unique) annotation adds a SQL UNIQUE constraint to the column. The database refuses to insert duplicates.

How violations behave

Insert a duplicate, and the save fails:

context.insert(User(email: "alice@example.com", displayName: "Alice"))
try context.save()  // succeeds

context.insert(User(email: "alice@example.com", displayName: "Alice's Twin"))
try context.save()  // throws

The thrown error is a SwiftDataError (or sometimes an underlying NSError for Core Data compatibility). You’d catch it and handle:

do {
    context.insert(newUser)
    try context.save()
} catch {
    print("Save failed, likely duplicate email: \(error)")
    context.rollback()
}

The rollback discards the failed insert.

Upsert: insert or update

A common pattern: you have a value with a unique key, and you want to insert it if new or update if it exists. SwiftData doesn’t have a built-in upsert, so you fetch first:

func upsertUser(email: String, displayName: String) throws {
    var descriptor = FetchDescriptor<User>(predicate: #Predicate { $0.email == email })
    descriptor.fetchLimit = 1

    if let existing = try context.fetch(descriptor).first {
        existing.displayName = displayName
    } else {
        context.insert(User(email: email, displayName: displayName))
    }
    try context.save()
}

This is the standard pattern. The fetch+insert flow handles new and existing cases.

For high-throughput cases (batch importing thousands of rows), the fetch-per-row gets slow. You can:

  • Fetch all existing matches up front in a single query, build a dictionary keyed by email, then iterate the new data updating or inserting.
  • Use a batch insert API (iOS 18, covered in Section 18) with conflict handling.

Composite uniqueness

A composite unique constraint (e.g., “an email is unique within a tenant”) isn’t directly supported by @Attribute(.unique). The annotation applies to a single column.

Workarounds:

  • Compute a composite key as a single property. Store "\(tenantID):\(email)" in a uniqueKey field, marked unique. Recompute on every change.
  • Enforce in code, not in the schema. Do the uniqueness check yourself (like the upsert pattern) and don’t rely on the database constraint. Race conditions if multiple actors might insert simultaneously.
  • Use Core Data directly (escape hatch). SwiftData is built on Core Data; if you really need composite unique constraints, drop down to the Core Data API. Rare.

For most apps, the computed composite key is the right pattern:

@Model
final class Membership {
    @Attribute(.unique) var compositeKey: String  // "tenantID:userID"
    var tenantID: UUID
    var userID: UUID
    var role: String

    init(tenantID: UUID, userID: UUID, role: String) {
        self.tenantID = tenantID
        self.userID = userID
        self.role = role
        self.compositeKey = "\(tenantID.uuidString):\(userID.uuidString)"
    }
}

Make sure to update compositeKey if the underlying tenantID or userID ever changes (typically they wouldn’t).

Unique constraints and CloudKit

Unique constraints do not work with CloudKit-synced models. CloudKit’s data model doesn’t enforce uniqueness; it eventually-consistent and could let duplicates through. If you try to set @Attribute(.unique) on a model in a CloudKit-backed configuration, SwiftData either ignores the annotation or refuses to set up the configuration (the exact behavior has varied across iOS versions).

If your model needs to sync via CloudKit:

  • Drop the @Attribute(.unique) annotation.
  • Use UUID identifiers everywhere (which are unique by nature with negligible collision probability).
  • Handle conflicts in code (detect duplicates after sync, merge or delete).

This is one of the most common surprises when adding CloudKit sync to an existing app. Plan for it.

UUIDs as identifiers

Using a UUID as a property is the most common pattern for stable, unique identifiers:

@Model
final class Document {
    @Attribute(.unique) var id: UUID  // unique constraint locally
    var title: String

    init(id: UUID = UUID(), title: String) {
        self.id = id
        self.title = title
    }
}

UUIDs are 128 bits — collision probability is astronomically low. Even without an explicit unique constraint, treating UUID as the de facto identifier is safe.

For CloudKit:

  • Drop the @Attribute(.unique) annotation.
  • Trust UUID collision probability.
  • Or handle the (extremely rare) collision case as a recoverable error.

Persistent identifier vs custom identifier

@Model types have a persistentModelID: PersistentIdentifier automatically. Why use a custom UUID too?

Because persistentModelID:

  • Is opaque (you can’t read or write its contents).
  • Is not portable across stores (a model from another device’s CloudKit has a different persistentModelID even if it’s “the same” record).
  • Has internal representation that may change across iOS versions.

A custom UUID:

  • Is portable.
  • Can be encoded in URLs, JSON, deep links.
  • Stays stable across stores and devices.

Both are useful. Use persistentModelID for cross-actor passing within a single store. Use a custom UUID for external references (URLs, JSON payloads, cross-device sync).

Querying by unique identifier

A common pattern:

func findUser(byEmail email: String) throws -> User? {
    var descriptor = FetchDescriptor<User>(predicate: #Predicate { $0.email == email })
    descriptor.fetchLimit = 1
    return try context.fetch(descriptor).first
}

func findDocument(byID id: UUID) throws -> Document? {
    var descriptor = FetchDescriptor<Document>(predicate: #Predicate { $0.id == id })
    descriptor.fetchLimit = 1
    return try context.fetch(descriptor).first
}

With an index (which @Attribute(.unique) creates automatically), these lookups are fast even with millions of rows.

Unique constraint pitfalls

Forgetting that @Attribute(.unique) doesn’t work with CloudKit. You add the annotation, ship the app, enable CloudKit — and either the app crashes or duplicates start appearing. Test the CloudKit configuration path early.

Race conditions on insert. Two actors check “does this email exist?” simultaneously, both see no, both insert. The second save fails. Handle this gracefully (catch the constraint violation and refetch).

Composite uniqueness assumed. “Email is unique per tenant” requires a composite key column. The .unique annotation on email alone won’t enforce this.

Changing a unique property after insertion. If you change user.email after insertion, the constraint is enforced on save (the new value must be unique). Be prepared for this case.

Migrating to add .unique on a non-empty column. The migration may fail if existing rows have duplicates. Clean up duplicates first, then add the constraint.

Trusting that nil values count as unique. A nullable column with .unique: SQLite treats nil as distinct from other nils, so multiple rows with nil are allowed. If you need “either set and unique, or null,” this is fine. If you need “exactly one nil,” you’d need application-level logic.

What to internalize

@Attribute(.unique) adds a SQL unique constraint, enforced at save time with errors on duplicates. Use it for naturally-unique single columns (email, slug, UUID). For composite uniqueness, compute and store a composite key string and mark that unique. Unique constraints DO NOT WORK with CloudKit-synced models — use UUID identifiers and handle conflicts in code instead. UUIDs are practical de facto unique identifiers due to collision improbability. persistentModelID is for within-store, within-actor passing; UUIDs are for external references.


13. Transient Properties and Computed Values

Not every property of a model is meant to live on disk. Sometimes you want cached derivatives (a pre-rendered Markdown HTML), session-only state (a download progress indicator), or computed views over persisted data (a full name from first + last). SwiftData supports these via @Transient and ordinary Swift computed properties. This section covers when to use each.

Computed properties

The simplest form: a property derived from other persisted properties:

@Model
final class Person {
    var firstName: String
    var lastName: String

    var fullName: String {
        "\(firstName) \(lastName)"
    }

    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}

fullName is a Swift computed property — no @Model involvement. It runs whenever you read it. No storage, no faulting, no migration.

When to use computed properties:

  • Cheap to compute (string concatenation, simple arithmetic).
  • Derived from other persistent properties (so always in sync).
  • Not queryable. You cannot #Predicate<Person> { $0.fullName == "..." } because fullName doesn’t exist in SQLite.

If you need to query by a derived value, you have two options: promote it to a stored property (and keep it in sync manually), or filter in Swift after fetching.

@Transient properties

@Transient marks a property as non-persistent. The property still exists on the in-memory model instance but isn’t saved to disk:

@Model
final class Report {
    var title: String
    var rawMarkdown: String

    @Transient
    var renderedHTML: String?

    init(title: String, rawMarkdown: String) {
        self.title = title
        self.rawMarkdown = rawMarkdown
    }
}

renderedHTML lives only in memory. When you fetch a Report from disk, it’s nil. You’d set it after some expensive HTML rendering, and it stays set as long as the model is alive in memory.

Use cases for @Transient:

  • Cached computed values that are expensive to compute. Markdown→HTML rendering, image processing results, parsed JSON, decrypted data.
  • Session-only state. UI flags like “is currently being edited,” loading indicators, animation states.
  • Pre-loaded fault-friendly snapshots. Things you want loaded eagerly but don’t want to persist.
  • External references. A pointer to a non-@Model object you want to associate with this instance for the session.

Important caveats:

  • @Transient properties must have a default value or be optional. They get reset to that default when the model is re-faulted or rehydrated from disk. You can’t preserve transient state across “model goes out of scope and comes back.”
  • They don’t participate in observation by default. SwiftUI doesn’t watch them. If you need view updates when a transient changes, you need to model it differently (often by keeping the transient state on a separate observable object).
  • They’re not thread-safe. Like all model properties, they’re accessed on whatever actor owns the context.

Transient default values

A transient property needs a default the framework can use when materializing the model:

@Model
final class Document {
    var title: String
    var content: String

    @Transient
    var isEditing: Bool = false  // default; reset when refaulted

    @Transient
    var draft: String?  // optional; nil when refaulted

    init(title: String, content: String) {
        self.title = title
        self.content = content
    }
}

Both forms work. The transient gets the default (or nil) every time the model is materialized.

Cached computed values

A practical pattern: lazy initialization of an expensive derivative.

@Model
final class Report {
    var rawData: Data

    @Transient
    private var _decodedReport: DecodedReport?

    var decodedReport: DecodedReport {
        if let cached = _decodedReport {
            return cached
        }
        let decoded = decode(rawData)
        _decodedReport = decoded
        return decoded
    }

    init(rawData: Data) {
        self.rawData = rawData
    }
}

decodedReport is computed but caches the result in a transient property. First access pays the decoding cost; subsequent accesses are free.

Two things to know:

  • Cache invalidation. If rawData changes, _decodedReport should be set to nil. You’d need to make rawData a private setter and have a method that updates both.
  • Memory pressure. The cache lives as long as the model. For many models, this adds up. Consider an external cache (separate from the model) with eviction.

When to promote a computed value to a stored property

Sometimes the right answer is to make a derived value persistent — denormalization. The trade-off is consistency (you have to maintain it) vs query performance (it’s now indexable and predicate-able).

@Model
final class Folder {
    var name: String
    var notes: [Note] = []

    // Denormalized count — must be kept in sync
    var noteCount: Int = 0

    init(name: String) { self.name = name }
}

Now you can #Predicate<Folder> { $0.noteCount > 10 }, which is a fast indexed lookup. But: every time a note is added or removed from the folder, you have to update noteCount. This means writing extra code in your addNote / removeNote methods, and getting it wrong leads to drift.

Denormalize when:

  • The derived value is used in queries (predicates, sorts).
  • The cost of maintaining it is low (mutations are infrequent).
  • Or the cost of computing it each time is high (large to-many that you’d have to count repeatedly).

Don’t denormalize when:

  • The value is only read for display, not querying.
  • Mutations are frequent and the bookkeeping gets complex.
  • The derived value depends on data outside this model’s control.

Transient properties and observability

By default, @Transient properties don’t participate in Observable change notifications. That means a SwiftUI view bound to a transient property won’t re-render when the transient changes.

If you need observability, you typically have one of two patterns:

Pattern A: keep the state outside the model. Use a separate @Observable view model that holds the UI state (isEditing, draft, etc.), referencing the model by its identifier or via injection. This is the cleaner pattern.

Pattern B: observe via SwiftUI mechanisms. Use @State for view-local UI flags, @Bindable for direct edits to persistent properties. Don’t try to bridge transients into the view system.

A pseudo-example of pattern A:

@Observable
@MainActor
final class EditorViewModel {
    let documentID: PersistentIdentifier
    var draft: String = ""
    var isEditing: Bool = false

    init(documentID: PersistentIdentifier) {
        self.documentID = documentID
    }
}

struct EditorView: View {
    @State private var viewModel: EditorViewModel

    init(documentID: PersistentIdentifier) {
        _viewModel = State(wrappedValue: EditorViewModel(documentID: documentID))
    }

    var body: some View {
        @Bindable var bindable = viewModel
        TextField("Draft", text: $bindable.draft)
    }
}

The view model is @Observable, so its properties drive SwiftUI updates. The persisted model has no transient UI state.

Transient pitfalls

Expecting transients to survive a fetch round trip. Fetch a model, set a transient, save → the transient is gone the next time you fetch (the new instance is freshly faulted). This is by design.

Storing reference types in transients without handling identity. If the transient is a class reference, the model holds a strong reference to it. Be careful about retain cycles.

Using transients for cross-cutting state. UI flags and editing state usually belong on a view model, not on the persistent model.

Forgetting to invalidate cached transients on source changes. If rawData changes, the cached decoded version is stale. Reset on mutation.

Mutating transients across actors. A transient is still accessed on the model’s actor. From another actor, you’d violate isolation.

What to internalize

Computed properties are derived values that don’t persist — cheap, always in sync, not queryable. @Transient properties exist in memory but don’t persist; useful for caches, session state, eager derivatives that survive within a single load. They get reset to defaults on refault. Transients don’t participate in SwiftUI observation by default — keep UI state on view models. Promote derived values to stored properties when you need to query or sort by them, accepting the maintenance cost. Don’t try to use transients for UI state; that’s view-model territory.


14. ModelContext Concurrency Model

SwiftData’s concurrency story is the place where most apps go wrong. The framework’s rules are inherited from Core Data, but the new Swift concurrency model and macros add layers. This section explains the rules, why they exist, and the patterns that follow from them.

The shortest summary: models belong to a context; contexts belong to an actor; passing a model to a different actor is a bug.

Models are not Sendable

@Model types are reference types. They’re not annotated Sendable, and you cannot make them Sendable. Why? Because:

  • Models hold a reference to their context.
  • Contexts are tied to an actor (typically the main actor).
  • Mutating a model is the same as mutating the context’s state.
  • Mutation across actors is the definition of a data race.

So you cannot pass a model across an actor boundary. If you try:

let note = mainContext.fetch(...).first!
Task.detached {
    note.title = "from background"  // ❌ runtime error or compile error in strict mode
}

The compiler in strict concurrency mode rejects this. Without strict checking, you might get away with it in tests and see crashes in production.

The rule: don’t share models across actor boundaries. Instead, pass PersistentIdentifiers and resolve them in each context.

The main context

container.mainContext is the context for the main actor. It’s the context SwiftUI views use via @Environment(\.modelContext). It’s the context you’d use for:

  • Anything driven by user interaction (taps, drags, text edits).
  • Anything that displays data (fetches feeding views).
  • Lightweight CRUD (a button that creates a new note).

It is not the context for:

  • Importing 10,000 rows from a JSON file.
  • Running a long-running background sync.
  • Processing data in a way that would block the main queue noticeably.

Using the main context for heavy work locks up the UI. Use a ModelActor instead.

Why context, actor, and queue are intertwined

In Core Data, an NSManagedObjectContext has a concurrencyType: main queue or private queue. Operations on the context must happen on the right queue. SwiftData inherits this but layers Swift concurrency on top:

  • The main context’s queue is the main actor’s queue.
  • A ModelActor’s context runs on that actor’s queue.
  • Each context is single-threaded from its perspective; concurrent access from outside the right actor is a bug.

The Swift type system enforces this via @MainActor and custom actor types. When the rules feel “obvious,” it’s because Swift’s strict concurrency checks were designed to make them so.

Reading models from the right actor

You read models within the actor that owns the context:

@MainActor
class NotesViewModel {
    let context: ModelContext  // the mainContext

    func loadNotes() throws -> [Note] {
        try context.fetch(FetchDescriptor<Note>())
    }
}

All reads happen on the main actor. The returned [Note] lives on the main actor too. If you pass it to a background actor, you’ve violated the rule.

What you can pass: identifiers, primitives, snapshots:

// Bad
let notes = try await mainViewModel.loadNotes()
let titles = await backgroundActor.processNotes(notes)  // ❌ notes crossed actor

// Good
let ids = await mainViewModel.loadNoteIDs()  // returns [PersistentIdentifier]
let titles = await backgroundActor.processNotes(ids)  // ids are Sendable

PersistentIdentifier is Sendable. You can pass it freely. On the receiving side, resolve it to a model via the receiving context.

Snapshot pattern

For some cases, you want to “freeze” the model’s state into a value type and pass that across actors:

struct NoteSnapshot: Sendable {
    let id: PersistentIdentifier
    let title: String
    let body: String
    let createdAt: Date
}

extension Note {
    var snapshot: NoteSnapshot {
        NoteSnapshot(
            id: persistentModelID,
            title: title,
            body: body,
            createdAt: createdAt
        )
    }
}

NoteSnapshot is a struct, Sendable, safe to pass anywhere. You read from the model on the main actor, take a snapshot, pass the snapshot to a background actor, do work, pass results back.

This pattern is essential for complex multi-actor flows. Don’t share models; share value-type snapshots.

Saving and propagation

Each context owns its pending changes. When a context saves, it writes to the shared SQLite store. Other contexts (on other actors) see the changes on their next fetch — or never, if they hold stale cached models.

This means:

  • The main context can save data inserted by a ModelActor only after the actor saves its own context.
  • After a background save, a fetch on the main context reflects the new data.
  • But: if the main context already had a model loaded, it might still see the old version until you refresh.

The framework largely handles refresh automatically when you save. But you should know the mechanism so unexpected behavior makes sense.

Autosave and concurrency

The main context typically has autosave enabled. A ModelActor’s context can have autosave enabled or disabled depending on what the actor is doing — for batch jobs, disable.

A typical setup:

@ModelActor
actor ImportActor {
    func importBatch(items: [ImportItem]) throws {
        modelContext.autosaveEnabled = false  // batch control

        for item in items {
            modelContext.insert(Note(title: item.title))
        }
        try modelContext.save()  // single atomic save
    }
}

(The @ModelActor macro is covered in Section 15.)

Lifecycle: actor disposal

When a ModelActor instance is deallocated, its context is too. Pending changes are lost. So:

  • Save before the actor goes out of scope.
  • Hold actor references (don’t create-and-discard).
  • For app-lifecycle background workers, hold the actor on a long-lived object.

Concurrency pitfalls

Crossing actor boundaries with model references. The compile-time error is your friend; don’t bypass it with @unchecked Sendable.

Confusion about which context returned which model. A model has identity tied to its context. Always know which context you’re in.

Reading a model on actor A while another actor B saves the same model. The actor A version may be stale. Refresh or refault if you need the latest.

Long-running work on the main context. Locks up the UI. Use a ModelActor.

Capturing models in Task.detached blocks. Detached tasks have no actor; capturing a model is undefined. Either dispatch back to the model’s actor, or pass identifiers.

Forgetting to save the actor’s context. Changes made on a ModelActor aren’t visible to other contexts until the actor’s context is saved.

What to internalize

Models belong to contexts; contexts belong to actors; models don’t cross actor boundaries — PersistentIdentifiers and snapshots do. The main context is for UI work; ModelActors are for background work. Strict Swift concurrency enforces these rules at compile time. After a background save, fetches on the main context see new data; existing in-memory model instances may need refresh. Disable autosave on batch jobs for atomicity. Cross-actor concurrency in SwiftData is the same model Core Data has had for years — Swift’s type system just makes the boundaries explicit.


15. ModelActor and Background Work

The @ModelActor macro generates an actor type with a ModelContext already wired up. It’s the canonical pattern for background work in SwiftData. This section covers how to define one, how to use it, what it generates, and patterns for serious background work.

Defining a ModelActor

import SwiftData

@ModelActor
actor DataActor {
    func importNotes(_ payloads: [NotePayload]) throws {
        for payload in payloads {
            let note = Note(title: payload.title, body: payload.body)
            modelContext.insert(note)
        }
        try modelContext.save()
    }
}

The @ModelActor macro:

  • Makes the type conform to ModelActor (a protocol).
  • Adds an init(modelContainer: ModelContainer) initializer.
  • Adds a modelExecutor: ModelExecutor property.
  • Adds a modelContext: ModelContext property.

You write the business logic. The actor isolation, context binding, and lifecycle are generated.

Constructing and using

let actor = DataActor(modelContainer: container)
try await actor.importNotes(payloads)

The actor has its own private ModelContext, isolated from the main context. Mutations and saves on the actor are independent. After save, other contexts (including main) see the data on their next fetch.

Actors are reference types. Hold an actor reference for the lifetime of the work — don’t create a new one per call (it’s expensive to set up the context).

Returning data from an actor

You cannot return models from the actor (they’d cross the actor boundary). Return Sendable values:

@ModelActor
actor DataActor {
    func noteCount() throws -> Int {
        try modelContext.fetchCount(FetchDescriptor<Note>())
    }

    func noteTitles() throws -> [String] {
        try modelContext.fetch(FetchDescriptor<Note>()).map(\.title)
    }

    func noteIDs() throws -> [PersistentIdentifier] {
        try modelContext.fetchIdentifiers(FetchDescriptor<Note>())
    }
}

These all return Sendable types. Caller can await them safely.

Passing identifiers around

The canonical cross-actor flow:

// Main actor fetches IDs to display
@MainActor
class NotesViewModel {
    @Observable
    var noteIDs: [PersistentIdentifier] = []
    let dataActor: DataActor
    let container: ModelContainer

    func refresh() async throws {
        noteIDs = try await dataActor.noteIDs()
    }

    // For displaying: resolve to model via the main context
    func note(for id: PersistentIdentifier) -> Note? {
        container.mainContext.model(for: id) as? Note
    }
}

The actor fetches IDs and returns them. The main actor displays via lookup. Each side stays in its own context.

For bulk display, you could do the lookup once for all IDs and cache the resulting models in main-context memory:

@MainActor
class NotesViewModel {
    var notes: [Note] = []

    func refresh() async throws {
        let ids = try await dataActor.noteIDs()
        let mainContext = container.mainContext
        notes = ids.compactMap { id in mainContext.model(for: id) as? Note }
    }
}

This pattern is sometimes called “thin client” — the heavy work is on the actor; the main thread just resolves identifiers for display.

Inserting and updating from an actor

A typical write flow:

@ModelActor
actor DataActor {
    func createNote(title: String, body: String) throws -> PersistentIdentifier {
        let note = Note(title: title, body: body)
        modelContext.insert(note)
        try modelContext.save()
        return note.persistentModelID
    }

    func updateNote(_ id: PersistentIdentifier, title: String, body: String) throws {
        guard let note = modelContext.model(for: id) as? Note else {
            throw DataError.notFound
        }
        note.title = title
        note.body = body
        try modelContext.save()
    }

    func deleteNote(_ id: PersistentIdentifier) throws {
        guard let note = modelContext.model(for: id) as? Note else { return }
        modelContext.delete(note)
        try modelContext.save()
    }
}

Every method:

  1. Resolves any incoming IDs via modelContext.model(for: id).
  2. Does the work (insert, mutate, delete).
  3. Saves.
  4. Returns IDs or values, not models.

This is the standard CRUD shape for actor-based work.

Bulk operations

For importing large amounts of data, batch and save periodically:

@ModelActor
actor ImportActor {
    func importLarge(_ payloads: [NotePayload]) throws {
        modelContext.autosaveEnabled = false

        let batchSize = 500
        for (index, payload) in payloads.enumerated() {
            modelContext.insert(Note(title: payload.title, body: payload.body))

            if index % batchSize == batchSize - 1 {
                try modelContext.save()
                modelContext.reset()  // discard in-memory state to free memory
            }
        }
        try modelContext.save()
    }
}

context.reset() clears the in-memory tracked state, freeing memory. Without it, the context’s memory grows linearly with the number of inserts.

For very large imports (100,000+ rows), consider:

  • iOS 18’s batch insert APIs (Section 18).
  • Splitting into multiple actor instances, each handling a subset.
  • Using a bare Core Data batch insert request (escape hatch).

Coordinating multiple actors

Multiple actors can read and write the same store. SQLite handles the locking. But coordination is your job:

let importActor = ImportActor(modelContainer: container)
let exportActor = ExportActor(modelContainer: container)

async let importTask = importActor.importBatch(items)
async let exportTask = exportActor.exportToFile()

try await importTask
try await exportTask

Concurrent actors are fine as long as they don’t depend on each other’s intermediate state. If they do, sequence them with await.

Long-running actors

For app-lifecycle background workers, hold the actor on a long-lived object:

@MainActor
class AppCoordinator {
    let container: ModelContainer
    let importActor: ImportActor
    let syncActor: SyncActor

    init(container: ModelContainer) {
        self.container = container
        self.importActor = ImportActor(modelContainer: container)
        self.syncActor = SyncActor(modelContainer: container)
    }
}

These actors are created once at app launch and live for the app’s lifetime. You call methods on them from anywhere on the main actor.

Streaming updates from an actor

For long-running work that should report progress, use AsyncStream:

@ModelActor
actor ImportActor {
    func importStreamingProgress(_ payloads: [NotePayload]) -> AsyncStream<Int> {
        AsyncStream { continuation in
            Task {
                for (index, payload) in payloads.enumerated() {
                    modelContext.insert(Note(title: payload.title, body: payload.body))

                    if index % 100 == 99 {
                        try? modelContext.save()
                        continuation.yield(index + 1)
                    }
                }
                try? modelContext.save()
                continuation.yield(payloads.count)
                continuation.finish()
            }
        }
    }
}

// Caller:
for await count in actor.importStreamingProgress(payloads) {
    print("Imported \(count)")
}

Progress streaming is essential for UX during long operations.

Testing ModelActors

Testing in-memory:

import Testing

@Test func testImportActor() async throws {
    let schema = Schema([Note.self])
    let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
    let container = try ModelContainer(for: schema, configurations: [config])

    let actor = ImportActor(modelContainer: container)
    try await actor.importBatch(items: testPayloads)

    let count = try await actor.noteCount()
    #expect(count == testPayloads.count)
}

In-memory containers are fast and isolated. Each test gets a fresh state.

ModelActor pitfalls

Creating actors per call. Each init(modelContainer:) sets up a fresh context, which is expensive. Hold actor references.

Returning models from actor methods. Compile error or runtime crash. Return IDs or snapshots.

Forgetting to save. Mutations on the actor’s context don’t persist until save. A common bug.

Mixing main-context and actor-context models. A model from the actor’s context is not the same object as the same-ID model from main context. Don’t try to compare instances across contexts; compare IDs.

Not handling actor isolation correctly in Swift 6. Strict concurrency catches more, but legacy code may have silently incorrect patterns. Audit when adopting.

Long-running synchronous work inside an actor method. Even on a background actor, blocking the actor blocks other calls to it. Yield periodically with await Task.yield() if processing large batches.

What to internalize

@ModelActor generates an actor with a private ModelContext. Use it for background work — heavy imports, syncing, processing. Pass PersistentIdentifiers into actor methods; resolve them via modelContext.model(for:). Return identifiers or Sendable values, not models. Hold actor references for the app’s lifetime; don’t create per-call. Disable autosave on batch operations. Use context.reset() periodically during very large imports to free memory. Test with in-memory containers for fast isolation. The compile-time isolation enforced by Swift is your safety net — don’t @unchecked Sendable your way past it.


16. Cross-Context Identifiers: PersistentIdentifier

PersistentIdentifier is SwiftData’s Sendable reference type for persisted models. It’s the same role NSManagedObjectID plays in Core Data. Master this type and cross-actor work becomes routine; misunderstand it and you’ll fight the framework forever.

What PersistentIdentifier is

Every @Model instance has a persistentModelID: PersistentIdentifier property. The identifier is:

  • Sendable. Pass it across actor boundaries freely.
  • Hashable, Equatable. Use as dictionary key, in sets, compare directly.
  • Codable. Encode for storage, network transport, deep links.
  • Stable. Same value across saves, across launches, across actors.
  • Opaque. You can’t inspect its contents meaningfully.
let note = Note(title: "Hi")
context.insert(note)
try context.save()

let id = note.persistentModelID
print(id)  // Opaque description

The identifier is internally a URI-style reference into the SQLite store, but you don’t need to understand the internal format. Treat it as an opaque token.

The temporary vs permanent state

Before a model is inserted and saved, its persistentModelID is in a “temporary” state. Once saved, it transitions to “permanent.”

let note = Note(title: "Hi")
let beforeInsert = note.persistentModelID  // temporary

context.insert(note)
let afterInsert = note.persistentModelID  // still temporary

try context.save()
let afterSave = note.persistentModelID  // permanent

Within a single session, the values might compare equal across these states (SwiftData often maintains continuity). But:

  • The temporary ID is not stable across launches.
  • Persisting a temporary ID to disk or transmitting it makes no sense.

For external references (URLs, JSON, deep links), always wait until after save before capturing the identifier.

Resolving an ID to a model

Every context can resolve an identifier to a model instance:

guard let note = context.model(for: id) as? Note else {
    // Not found — might be deleted, or never existed
    return
}

The cast is necessary because model(for:) returns (any PersistentModel)?. You know the type because you stored it, or because you typed-routed the call.

model(for:) is fast:

  • If the model is already loaded in the context, it returns the existing instance (preserving object identity).
  • If not loaded, it issues a fault — the model is materialized when you first access a property.

This means resolution is “free” if the model is already loaded, and roughly as expensive as a fetch if not.

Passing IDs across actors

The canonical pattern:

// Main actor: have a note instance, want to do work on it in the background
@MainActor
class NotesViewModel {
    let dataActor: DataActor

    func makeImportant(_ note: Note) async throws {
        let id = note.persistentModelID
        try await dataActor.markAsImportant(id)
    }
}

@ModelActor
actor DataActor {
    func markAsImportant(_ id: PersistentIdentifier) throws {
        guard let note = modelContext.model(for: id) as? Note else { return }
        note.isImportant = true
        try modelContext.save()
    }
}

The main actor doesn’t pass the model — it passes the ID. The background actor resolves the ID to its own context’s instance, mutates, saves. The main actor’s instance updates on the next fetch or via change observation.

Persisting IDs

PersistentIdentifier is Codable, so you can persist it. The most common case: a deep link that opens a specific item:

struct DeepLink: Codable {
    let target: PersistentIdentifier
}

// Encode for a URL
let link = DeepLink(target: note.persistentModelID)
let encoded = try JSONEncoder().encode(link)
let urlString = encoded.base64EncodedString()

// Decode and resolve later
let decoded = try JSONDecoder().decode(DeepLink.self, from: encodedData)
let note = context.model(for: decoded.target) as? Note

Caveat: persistent identifiers are tied to the store. A PersistentIdentifier from one device’s store doesn’t correspond to anything in another device’s store, even for the “same” record synced via CloudKit. For cross-device references, use a stable UUID property in your model.

Identifiers and CloudKit

When CloudKit syncs a model to another device, the persistentModelID on the receiving device is different from the source. CloudKit uses its own record IDs internally and synthesizes new persistentModelIDs on each device.

For cross-device-stable references, use a UUID field on your model:

@Model
final class Document {
    var id: UUID
    var title: String

    init(id: UUID = UUID(), title: String) {
        self.id = id
        self.title = title
    }
}

// Use `id` for cross-device references; use `persistentModelID` for within-device passing.

Comparing IDs

Equality:

let id1 = note1.persistentModelID
let id2 = note2.persistentModelID
print(id1 == id2)  // true if same persisted object

You can use IDs in sets, dictionaries, comparisons:

var seen: Set<PersistentIdentifier> = []
for note in notes {
    seen.insert(note.persistentModelID)
}

But: identifiers are opaque. You can’t extract anything meaningful from them (no embedded entity name, no integer index — at least not via the public API).

When the model isn’t found

context.model(for: id) returns nil if:

  • The ID is temporary (model was never saved).
  • The model was deleted.
  • The ID is from a different store (e.g., a stale ID from before a store reset).

Always handle this case:

guard let note = context.model(for: id) as? Note else {
    // Show "this note no longer exists" or similar
    return
}

Performance considerations

model(for:) is fast if the model is already loaded. If not, the framework either:

  • Faults the model (cheap, no full fetch — just sets up the placeholder).
  • Or fully fetches if the property is accessed immediately.

For looking up many IDs in a loop, prefer batch operations:

// Instead of:
for id in ids {
    if let note = context.model(for: id) as? Note {
        // ...
    }
}

// Consider:
let descriptor = FetchDescriptor<Note>(
    predicate: #Predicate { ids.contains($0.persistentModelID) }
)
let notes = try context.fetch(descriptor)

The batch fetch is one query; the per-ID lookup might be many.

Identifier pitfalls

Persisting temporary IDs. They’re not stable. Save before capturing.

Comparing IDs across stores. A PersistentIdentifier from store A is not equal to a PersistentIdentifier from store B, even for “the same” CloudKit-synced record. Use a stable UUID for cross-store comparison.

Casting model(for:) result to the wrong type. The cast returns nil if the type doesn’t match. Be sure the ID is for the expected entity.

Assuming model(for:) always succeeds. Models can be deleted. Always handle nil.

Stale IDs from a deleted store. If you reset the store (e.g., user logs out, wipes data), old IDs are invalid. Treat them as opaque and stale-tolerant.

Using ID equality to check “are these the same instance?” Same ID means same persisted object, but the in-memory instance might be different across contexts. Use === for instance identity, == (which uses ID) for persistent identity.

What to internalize

PersistentIdentifier is the Sendable, Hashable, Codable reference to a persisted model. It’s stable across saves and launches. Pass IDs across actors; never pass model instances. Resolve IDs via context.model(for: id). Wait until after save before capturing IDs for external use (URLs, deep links, network). For cross-device references, use a UUID property — PersistentIdentifiers are store-local. Handle nil returns from model(for:) — IDs can become stale. Batch lookups via predicates over persistentModelID for performance.


17. Faulting in SwiftData

Faulting is the lazy materialization mechanism SwiftData (and Core Data) use to keep memory in check. Fetched models aren’t fully loaded into memory by default — they’re “faults,” lightweight placeholders that materialize their data when first accessed. Understanding faulting prevents memory blowups, debugger confusion, and unexpected query patterns.

What a fault is

When you fetch a model, the returned object is initially a fault: a wrapper that knows the object’s ID but doesn’t have its property values loaded. The first time you access a property, SwiftData fires the fault — it issues a SQL query to load the values from the database.

let notes = try context.fetch(FetchDescriptor<Note>())
// notes is [Note]. Each Note is a fault.

let firstTitle = notes.first?.title
// Accessing .title fires the fault for that one note: a SQL query loads all its properties.

After firing, the model is “materialized” — properties are in memory, subsequent accesses are free.

This is a SwiftData inheritance from Core Data. It’s the same mechanism, with the same characteristics.

Why faulting exists

For a query that returns 10,000 rows, you don’t want to load all 10,000 rows’ data into memory. Faulting lets the result set be cheap; only the rows you actually inspect get materialized.

The trade-off: each individual fault firing is a small query. If you access properties on every row in a 10,000-element result, you’ve issued 10,001 queries (the original fetch plus one per fault). This is the N+1 query pattern.

Detecting and avoiding N+1

The classic N+1 scenario:

let folders = try context.fetch(FetchDescriptor<Folder>())  // 1 query
for folder in folders {
    print("\(folder.name): \(folder.notes.count) notes")  // 1 query per folder (faulting notes)
}
// Total: N+1 queries

The fix: prefetch the relationship.

var descriptor = FetchDescriptor<Folder>()
descriptor.relationshipKeyPathsForPrefetching = [\.notes]

let folders = try context.fetch(descriptor)  // 1 query for folders, 1 batched query for all notes
for folder in folders {
    print("\(folder.name): \(folder.notes.count) notes")  // 0 additional queries
}
// Total: 2 queries

Prefetching tells SwiftData to batch-load the relationships when fetching the parents.

To-many faulting

Each to-many relationship is itself a fault. When you access folder.notes, you may trigger a separate query to load the note IDs (and faults for each note).

let folder = folders.first!
let notesArray = folder.notes  // fires the to-many fault: query for all related note IDs
// Each note is still a fault until you access its properties.

For iterating just to count, use .count on the relationship — SwiftData can optimize this to a SELECT COUNT(*) without materializing the related objects.

let count = folder.notes.count  // optimized — no full materialization

But accessing individual properties on each one:

for note in folder.notes {
    print(note.title)  // each access fires that note's fault
}

Each note.title access is a query. Use relationshipKeyPathsForPrefetching to avoid this.

Forcing materialization

Sometimes you want to fully load models eagerly:

context.refresh(note, mergeChanges: false)

This is rarely needed. Prefer prefetching at fetch time.

For a list of “all properties at once,” accessing one property typically materializes the whole row (the fault loads all attributes in one query, even if you only asked for one). So note.title brings in note.body, note.createdAt, etc.

Faulting and printing

A confusing experience for newcomers: printing a model often shows “fault” placeholders rather than values:

print(notes.first!)
// Optional(<Note: 0x...; data: <fault>>)

This is because the description method doesn’t fire faults. Access a property first:

let note = notes.first!
_ = note.title  // fire the fault
print(note)
// Optional(<Note; id=...; title="Hello"; body="..."; ...>)

For debugging, print(note.title) works fine — it fires the fault for title and prints the value.

Faulting and memory pressure

A fetched-and-fully-materialized set of 100,000 models uses substantial memory. Even as faults, they have some overhead, but materialized objects can multiply.

For very large traversals where you can’t filter at the database level, materialization aware patterns help:

let descriptor = FetchDescriptor<Note>()
let notes = try context.fetch(descriptor)

for chunk in notes.chunks(of: 1000) {
    for note in chunk {
        process(note.title)  // fires the fault
    }
    context.reset()  // discards materialized state and faults
}

context.reset() clears the in-memory tracked state. The model instances in your loop are now invalid; the next loop iteration’s chunk would re-fetch faults.

(Note: Array.chunks(of:) is from swift-algorithms — use whatever chunking utility you have.)

Faulted objects after deletion

If you have a fault that hasn’t been fired, and the object is deleted by another context, the next fault firing fails:

let notes = try context.fetch(FetchDescriptor<Note>())  // includes some that another actor might delete
// ... time passes; another actor deletes some ...
print(notes.first!.title)  // might throw or return empty

In practice, you handle this by either:

  • Re-fetching when you need fresh data.
  • Catching errors from accessors.

Faulting and SwiftUI

SwiftUI’s @Query handles faulting automatically. The view body iterates over query results; each access fires faults as needed. For lists with thousands of items, SwiftUI’s lazy rendering combined with faulting means only visible cells materialize their data.

For best performance:

  • Use @Query(filter:sort:) to narrow results.
  • Use propertiesToFetch only when most properties are unused.
  • Use relationshipKeyPathsForPrefetching when row UIs display relationship data.

The default behavior is reasonable for most lists. Optimize when you measure a problem.

Faulting pitfalls

N+1 queries. The most common faulting bug. Always prefetch relationships you’ll access in a loop.

Faulting in tight loops. Even if it’s the same column, faulting once per row is slow. Either accept the cost (small numbers) or batch via prefetching / batch fetches.

Memory growth from large in-memory result sets. Periodically context.reset() if processing many rows.

Printing a fault and being confused. Access a property first or accept the placeholder text in logs.

Holding fault references after context resets. Invalid; re-fetch instead.

Refreshing too aggressively. context.refresh for every model in a list is wasteful. SwiftData refaults on demand.

What to internalize

Fetched models start as faults — placeholders that materialize on first property access. Accessing each row’s properties in a loop creates N+1 query patterns; prefetch relationships to batch-load. Use propertiesToFetch for sparse property reads. Use context.reset() periodically in large traversals to free memory. SwiftUI handles faulting transparently for @Query-backed lists. Don’t fight the system — accept faults as the default; optimize specific cases when profiling shows them.


18. Performance: Indexing, Batch Sizes, Prefetching

Performance in SwiftData breaks down into a few familiar concerns: how SQL is generated, what indexes exist, how big the working set is, how relationships are loaded, and how saves are batched. This section is the practical optimization guide.

Profile first

Before optimizing, measure. SwiftData uses Core Data underneath, which means you can enable SQL debug logging:

In your scheme’s Run → Arguments → Arguments Passed On Launch, add:

-com.apple.CoreData.SQLDebug 1

(Set to 3 or 4 for more detail.)

Now every SQL query SwiftData runs prints to the Xcode console. You’ll see:

CoreData: sql: SELECT ZNOTE.Z_PK, ZNOTE.ZTITLE, ... FROM ZNOTE WHERE ZNOTE.ZISPINNED = ? ORDER BY ZNOTE.ZCREATEDAT DESC LIMIT 50
CoreData: annotation: sql connection fetch time: 0.0023s
CoreData: annotation: total fetch execution time: 0.0034s for 50 rows.

This is the actual SQL being executed. You can see counts, durations, and patterns. If you see 1,000 queries scrolling by during a list scroll, you have an N+1 problem.

Indexing

SQLite indexes accelerate queries that filter, sort, or join on a column. Without an index, the database scans every row. With one, it jumps directly to the matching range.

SwiftData creates indexes automatically for:

  • Primary keys (every entity has one).
  • Unique columns (@Attribute(.unique)).
  • Relationship foreign keys.

For other columns you query or sort frequently, declare an index explicitly. As of iOS 17 the public API was limited; iOS 18 added #Index for declarative indexes:

@Model
final class Note {
    #Index<Note>([\.createdAt], [\.folder], [\.title, \.createdAt])

    var title: String
    var createdAt: Date
    var folder: Folder?

    init(title: String) {
        self.title = title
        self.createdAt = .now
    }
}

The #Index<Note>(...) macro takes one or more KeyPath arrays. Each array is a separate index. Composite indexes (multiple key paths in one array) accelerate queries that filter on the leading columns.

When to add an index:

  • A column appears in many WHERE clauses.
  • A column is used in ORDER BY for large tables.
  • A relationship key is used for sorting (the FK index helps).

Don’t index every column. Indexes have write overhead — each insert/update has to update every index. Two or three thoughtful indexes is usually right; ten is usually wrong.

Fetch size and pagination

Always limit fetches that could return many rows:

var descriptor = FetchDescriptor<Note>()
descriptor.fetchLimit = 50  // never load more than 50 in this fetch

For lists displaying tens of items: a limit of “page size + small buffer.” For lists displaying potentially thousands: paginate via cursor (Section 11).

For “all rows for this folder” where the folder might have 10 notes or 10,000: still set a limit. If the limit is too high, you’ve gone over budget; bail out with an error or paginate.

Prefetching relationships

We covered this in Section 17. The summary:

descriptor.relationshipKeyPathsForPrefetching = [\.folder, \.tags]

Any relationship you’ll touch in a loop must be prefetched. Profile to confirm — the SQL debug output will show whether you got 2 queries (good) or 1+N (bad).

Partial fetches

Use propertiesToFetch when you only need a few columns from a model with many:

descriptor.propertiesToFetch = [\.title, \.createdAt]

For a list view that displays only title and date, this avoids loading the full body of each note. Useful for models with big text fields, JSON blobs, or large transient computations.

But: a partial fetch still returns model instances. Accessing a non-fetched property fires a fault for the rest. If you’ll soon access more than a couple of properties, partial fetching’s benefit evaporates.

Batch inserts

For inserting many rows quickly, the loop-and-save-periodically pattern works:

context.autosaveEnabled = false
let batchSize = 500
for (i, item) in items.enumerated() {
    context.insert(Note(title: item.title))
    if i.isMultiple(of: batchSize) {
        try context.save()
        context.reset()
    }
}
try context.save()

context.reset() clears the in-memory tracking. Memory stays bounded.

For very large imports (100,000+), use Core Data’s batch insert request (escape hatch via the underlying NSPersistentContainer):

let descriptor = NSBatchInsertRequest(entityName: "Note", objects: payloads)
descriptor.resultType = .objectIDs

let result = try persistentContainer.viewContext.execute(descriptor) as? NSBatchInsertResult

Batch inserts skip change tracking, validation, and most of the overhead. They’re orders of magnitude faster than insert-and-save loops for millions of rows.

(This drops below the SwiftData surface — be aware of what you’re giving up: no change notifications, no SwiftData object materialization, no automatic relationship maintenance.)

Batch updates and deletes

iOS 18 added batch APIs to SwiftData itself:

// Batch update
try modelContext.update(
    model: Note.self,
    where: #Predicate { $0.isArchived == false && $0.createdAt < cutoff },
    setting: \.isArchived,
    to: true
)
try modelContext.save()

// Batch delete
try modelContext.delete(
    model: Note.self,
    where: #Predicate { $0.isExpired }
)
try modelContext.save()

These don’t materialize models. Much faster for large operations.

Caveats:

  • Notifications may not fire for affected models — observers see the data “blink” into new state on next fetch.
  • Cascade rules don’t fire for batch deletes.
  • Tests that observe individual model changes may need adjustment.

For “delete all old notes” or “mark all as read” operations, batch APIs are huge wins.

Reducing memory pressure

Symptoms: your app grows in memory over time, eventually OOM-killed.

Common causes:

  • A context that has accumulated many loaded models. Use context.reset() periodically.
  • Held references in a long-lived view model that prevent deallocation. Audit.
  • Large Data properties stored inline. Use @Attribute(.externalStorage).
  • Cached transient computations that accumulate. Set caps.

For long-running background tasks, periodic context.reset() keeps memory flat.

Save batching

A common antipattern: saving after every small change in a tight loop.

// BAD
for note in notes {
    note.isProcessed = true
    try context.save()  // save per iteration
}

Each save commits a SQLite transaction — overhead per save. Even small saves have ~milliseconds of cost. 10,000 of them is 10+ seconds.

// GOOD
for note in notes {
    note.isProcessed = true
}
try context.save()  // one save at the end

If you need progress reporting, save less frequently (every 100, every 1000):

for (i, note) in notes.enumerated() {
    note.isProcessed = true
    if i.isMultiple(of: 100) {
        try context.save()
        progress.completed = i + 1
    }
}
try context.save()

Compound queries

A query that touches multiple tables (joins) is more expensive than a query on one. SwiftData hides joins, but they’re there in the SQL.

Examples that join:

  • #Predicate<Note> { $0.folder?.name == "Work" } joins Note and Folder.
  • #Predicate<Folder> { $0.notes.contains { $0.isPinned } } joins Folder and Note.

These are fine in moderation; for hot-path queries, consider denormalizing (storing folderName on Note so no join is needed).

Avoiding the SQL coordinator bottleneck

SQLite uses a single writer at a time per database. Many actors can read concurrently, but writes serialize. If you have multiple writing actors, they queue. For high write throughput:

  • Batch writes (don’t save per item).
  • Group related writes into one actor.
  • Consider whether SwiftData / SQLite is the right tool for your use case (a write-heavy event log might be better served by an append-only file).

Performance pitfalls

Loading a million rows. Never. Limit, paginate, or use batch APIs.

Loops without prefetching. Always profile with SQL debug; if you see N+1, prefetch.

Saving per iteration. Save in batches.

No indexes on hot query columns. Add #Index for queries that show up in profiles.

Storing large blobs inline. Use .externalStorage for anything over ~100KB.

Caching everything in memory forever. context.reset() periodically; audit cache sizes.

Optimizing without measuring. Spending hours on a query that runs once per minute. Profile first; optimize the slow ones.

What to internalize

Profile with SQL debug logging before optimizing. Add indexes for hot query columns via #Index. Always set fetchLimit on potentially-large queries. Prefetch relationships you’ll access in loops. Use propertiesToFetch for sparse reads of wide models. Use batch insert/update/delete APIs for large operations (iOS 18+) or drop to Core Data batch requests for extreme cases. Save in batches; never per-iteration. Use context.reset() to bound memory in long-running operations. Use @Attribute(.externalStorage) for large binary data. Audit your model hot paths after the app is in production — the right optimizations come from real usage data.


19. SwiftData Without SwiftUI: The Pure Data Layer

Before getting into SwiftUI integrations, it’s worth knowing how to use SwiftData without any SwiftUI involvement. This matters for several reasons: command-line tools, server-side Swift, business-logic layers in larger apps, and — most importantly — testable architectures where the data layer doesn’t know about views. A clean data layer is also more portable: if you later swap to UIKit, AppKit, or a different UI framework, the data layer doesn’t change.

The plain stack

A SwiftData stack with no view layer:

import SwiftData
import Foundation

let schema = Schema([Note.self, Folder.self, Tag.self])
let configuration = ModelConfiguration(schema: schema)
let container = try ModelContainer(for: schema, configurations: [configuration])

let context = ModelContext(container)

// Use it
context.insert(Note(title: "Hello"))
try context.save()

let notes = try context.fetch(FetchDescriptor<Note>())
print(notes.map(\.title))

That’s a complete working SwiftData setup. No SwiftUI, no environment, no property wrappers. Just types, methods, and explicit calls.

Why think about it this way

There’s a temptation, especially with @Query, to treat SwiftData and SwiftUI as inseparable. They aren’t. SwiftData has its own object model and API. SwiftUI provides ergonomic integrations on top.

Building your data layer pure means:

  • Testable in isolation. No view hierarchy, no environment, no app target — just types and assertions.
  • Reusable across UI frameworks. Today SwiftUI, tomorrow UIKit, the day after a SwiftUI rewrite. The data layer doesn’t change.
  • Easier to reason about. No magic. You see the explicit context, the explicit fetch, the explicit save.
  • Better mental model for SwiftUI integration. When you do use @Query, you understand what it’s doing because you know what a manual fetch looks like.

A data access layer pattern

A pattern that works well: a repository or service object that hides the SwiftData details:

protocol NoteRepository {
    func allNotes() throws -> [Note]
    func notes(matching query: String) throws -> [Note]
    func insert(_ note: Note) throws
    func delete(_ note: Note) throws
}

final class SwiftDataNoteRepository: NoteRepository {
    let context: ModelContext

    init(context: ModelContext) {
        self.context = context
    }

    func allNotes() throws -> [Note] {
        try context.fetch(FetchDescriptor<Note>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]))
    }

    func notes(matching query: String) throws -> [Note] {
        let predicate = #Predicate<Note> { $0.title.localizedStandardContains(query) }
        return try context.fetch(FetchDescriptor<Note>(predicate: predicate))
    }

    func insert(_ note: Note) throws {
        context.insert(note)
        try context.save()
    }

    func delete(_ note: Note) throws {
        context.delete(note)
        try context.save()
    }
}

The repository is a thin layer. View models (or view controllers) consume it. Tests use a mock implementation.

The repository pattern is what we’ll build out properly in Section 23.

Async wrappers

Most SwiftData operations are synchronous. For business logic, you often want async signatures (so you can switch to background work later without changing call sites):

actor AsyncNoteService {
    let context: ModelContext  // a context isolated to this actor

    init(container: ModelContainer) {
        self.context = ModelContext(container)
    }

    func allNotes() async throws -> [NoteSnapshot] {
        try context.fetch(FetchDescriptor<Note>()).map(\.snapshot)
    }

    func insert(title: String) async throws -> PersistentIdentifier {
        let note = Note(title: title)
        context.insert(note)
        try context.save()
        return note.persistentModelID
    }
}

Note: returning NoteSnapshot (a Sendable struct), not Note. The actor’s context is isolated; models can’t cross out.

For an app that wants to start synchronous and migrate to async, declaring async signatures from the start saves rework later.

Unit testing the data layer

A pure data layer is trivial to test:

import Testing
import SwiftData

@Test func testRepositoryInsert() throws {
    let schema = Schema([Note.self])
    let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
    let container = try ModelContainer(for: schema, configurations: [config])
    let context = ModelContext(container)
    let repo = SwiftDataNoteRepository(context: context)

    let note = Note(title: "Test")
    try repo.insert(note)

    let all = try repo.allNotes()
    #expect(all.count == 1)
    #expect(all.first?.title == "Test")
}

In-memory configuration means no disk I/O, fast tests, clean state per test. You can run hundreds of such tests in seconds.

For testing async actors, the test methods become async:

@Test func testAsyncInsert() async throws {
    let container = try testContainer()
    let service = AsyncNoteService(container: container)

    let id = try await service.insert(title: "Test")
    let notes = try await service.allNotes()

    #expect(notes.contains { $0.id == id })
}

Server-side Swift

SwiftData works on server-side Swift (Linux, with appropriate Foundation builds). The patterns are the same:

  • Build the container at startup.
  • Create a context per request (or use an actor-pooled approach).
  • Save explicitly.
  • Return Sendable types from handlers.

A simple Vapor-style handler:

func handleListNotes(request: Request) async throws -> [NoteSnapshot] {
    let container = container  // shared container
    let context = ModelContext(container)
    let notes = try context.fetch(FetchDescriptor<Note>())
    return notes.map(\.snapshot)
}

Each request makes its own context, fetches, returns snapshots. The container is shared. No SwiftUI involved.

This isn’t a common production pattern (server-side Swift apps typically prefer GRDB or PostgreSQL), but it’s possible and the data layer story is straightforward.

Command-line tools

A CLI tool that exports notes to a JSON file:

@main
struct ExportNotes {
    static func main() throws {
        let schema = Schema([Note.self])
        let url = URL.documentsDirectory.appending(path: "notes.store")
        let config = ModelConfiguration(schema: schema, url: url)
        let container = try ModelContainer(for: schema, configurations: [config])

        let context = ModelContext(container)
        let notes = try context.fetch(FetchDescriptor<Note>())

        let snapshots = notes.map(\.snapshot)
        let json = try JSONEncoder().encode(snapshots)
        try json.write(to: URL(filePath: "notes-export.json"))

        print("Exported \(snapshots.count) notes")
    }
}

Run from the command line. No UI involved. Just data movement.

Importing data without SwiftUI

A similar tool for import:

@main
struct ImportNotes {
    static func main() throws {
        let json = try Data(contentsOf: URL(filePath: "notes-import.json"))
        let snapshots = try JSONDecoder().decode([NoteSnapshot].self, from: json)

        let schema = Schema([Note.self])
        let url = URL.documentsDirectory.appending(path: "notes.store")
        let config = ModelConfiguration(schema: schema, url: url)
        let container = try ModelContainer(for: schema, configurations: [config])
        let context = ModelContext(container)

        for snapshot in snapshots {
            let note = Note(title: snapshot.title, body: snapshot.body)
            context.insert(note)
        }
        try context.save()

        print("Imported \(snapshots.count) notes")
    }
}

This is just SwiftData CRUD. No views, no environment.

Pure data layer pitfalls

Constructing contexts in deeply nested code. If every method creates a fresh context, you’ve broken the “one container, few long-lived contexts” model. Inject contexts; don’t construct them ad hoc.

Returning models from actor boundaries. Even in a pure data layer, actor isolation rules apply. Return snapshots.

Synchronous APIs that block the main thread. Wrapping in async signatures from the start lets you migrate to background actors later without breaking call sites.

Mocking via concrete classes. Use protocols (like NoteRepository) for testability. Concrete types make mocks awkward.

Tightly coupling business logic to the model. Business logic that knows about @Model types is hard to test. Convert to snapshots or domain types at the layer boundary.

What to internalize

SwiftData is fully usable without SwiftUI. The clean shape: a long-lived ModelContainer, a small number of ModelContexts (one per actor), and explicit method calls. A repository or service layer hides SwiftData specifics from business logic and views. Test in-memory and isolated. Return snapshots from actor methods; never models. Building the data layer pure first, then integrating SwiftUI, leads to better architectures than starting with @Query and reverse-engineering the data flow.


20. SwiftUI: @Query and @Environment(.modelContext)

SwiftUI integration is where SwiftData becomes magical for app development. @Query declares a fetch in a view, and SwiftUI keeps it up to date as the underlying data changes. @Environment(\.modelContext) gives you the container’s main context for inserts and saves. Together, they let you build a CRUD UI in a few lines.

But: this magic has trade-offs. Tight coupling to the view. Hard-to-test code. Refresh behavior you can’t easily customize. This section covers the property wrappers’ mechanics; later sections (23-26) cover the manual paths for when you need them.

Setting up the environment

The container injection happens at the scene level:

import SwiftUI
import SwiftData

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Note.self, Folder.self])
    }
}

.modelContainer(for:) constructs the container and inserts it (and its mainContext) into the SwiftUI environment. Child views can pull either out:

@Environment(\.modelContext) var context

context is the main context — main-actor-bound, autosave-enabled by default.

You can also inject an explicit container you constructed yourself:

let container = makeContainer()
WindowGroup {
    ContentView()
}
.modelContainer(container)

@Query in a view

struct NotesListView: View {
    @Query var notes: [Note]

    var body: some View {
        List(notes) { note in
            Text(note.title)
        }
    }
}

@Query declares a fetch. The view re-renders when the underlying data changes (insert, update, delete). No explicit refresh, no observation setup.

By default, @Query fetches all instances of the type, sorted by persistentModelID. You almost always want to specify sorting:

@Query(sort: \Note.createdAt, order: .reverse) var notes: [Note]

You can also filter:

@Query(filter: #Predicate<Note> { $0.isPinned }) var pinned: [Note]

Or both:

@Query(filter: #Predicate<Note> { $0.isPinned },
       sort: \Note.createdAt, order: .reverse)
var pinnedRecent: [Note]

The signature shape: @Query(filter:sort:order:animation:) with many variants.

Multiple sort descriptors

For more complex sorts, use a [SortDescriptor<Note>]:

@Query(filter: #Predicate<Note> { !$0.isDeleted },
       sort: [
           SortDescriptor(\.isPinned, order: .reverse),
           SortDescriptor(\.createdAt, order: .reverse)
       ])
var notes: [Note]

This is the same SortDescriptor type used in FetchDescriptor — same options, same composition rules.

Inserts and deletes from views

To insert, use the context from the environment:

struct NotesListView: View {
    @Query(sort: \Note.createdAt, order: .reverse) var notes: [Note]
    @Environment(\.modelContext) var context

    var body: some View {
        List {
            ForEach(notes) { note in
                Text(note.title)
            }
            .onDelete { indices in
                for index in indices {
                    context.delete(notes[index])
                }
            }
        }
        .toolbar {
            Button("Add") {
                context.insert(Note(title: "New note"))
            }
        }
    }
}

Because autosave is enabled by default on the main context, you don’t need to call context.save() — it happens automatically. (For critical operations, save explicitly anyway, as discussed in Section 8.)

The inserted note appears in the list immediately. The deleted note disappears immediately. @Query observes the context and refreshes the view.

Animation

@Query accepts an animation parameter:

@Query(sort: \Note.createdAt, order: .reverse, animation: .default) var notes: [Note]

When the data changes and the view re-renders, the changes are animated. New rows slide in, deletions slide out. The default animation is usually right; you can pass .spring(), .easeInOut, etc.

Working with single items

For a single item view (e.g., a detail screen), you’d typically pass the model as a parameter:

struct NoteDetail: View {
    let note: Note  // not @Query — just a parameter

    var body: some View {
        Text(note.title)
        Text(note.body)
    }
}

// Caller:
NavigationLink(value: note) {
    Text(note.title)
}
.navigationDestination(for: Note.self) { note in
    NoteDetail(note: note)
}

You can also use @Bindable for in-place editing (Section 22):

struct NoteEditor: View {
    @Bindable var note: Note

    var body: some View {
        TextField("Title", text: $note.title)
        TextEditor(text: $note.body)
    }
}

Initializing @Query at runtime

For queries whose predicate or sort depends on view parameters, you initialize @Query in init:

struct FilteredNotesList: View {
    @Query var notes: [Note]

    init(folder: Folder) {
        let folderID = folder.persistentModelID
        let predicate = #Predicate<Note> { $0.folder?.persistentModelID == folderID }
        _notes = Query(filter: predicate, sort: [SortDescriptor(\.createdAt, order: .reverse)])
    }

    var body: some View {
        List(notes) { note in
            Text(note.title)
        }
    }
}

Note the _notes = Query(...) syntax. _notes is the underlying property wrapper storage; assigning a new Query value in init parameterizes the fetch.

Caveat: this initializer runs each time the view is instantiated. If the parent passes a new folder, a new view is constructed with a new query. Existing views don’t update their query unless re-instantiated (covered in Section 21 for dynamic queries).

Performance characteristics

@Query does what you’d expect:

  • Fetches the data once on view appearance.
  • Observes the context for changes that affect the predicate or sort.
  • Re-fetches and re-renders on relevant changes.

It does not:

  • Paginate. The fetch loads everything matching.
  • Use propertiesToFetch for partial loads.
  • Prefetch relationships (you’d need a custom approach).

For lists with thousands of items, you’d want to either limit via predicate (e.g., date cutoff), pre-compute pagination, or drop to manual fetching.

Empty states and loading

@Query results are synchronously available. There’s no “loading” state because the fetch is fast (the actual data is materialized as faults, hydrated on access).

For empty states:

var body: some View {
    if notes.isEmpty {
        ContentUnavailableView("No notes yet", systemImage: "doc.text")
    } else {
        List(notes) { note in Text(note.title) }
    }
}

If the fetch could be slow (very large data, complex predicates), you might want to render a placeholder during the first appearance. But typically @Query’s synchronous shape is fine.

Refresh behavior

When does @Query re-fetch?

  • When the context detects a change to a model matching the predicate.
  • When the context detects an insert that could match the predicate.
  • When the context detects a delete of a model that matched.
  • After a save from a background context (eventually).

The refresh is generally fast — @Query keeps the previous results and applies a diff. For large result sets, the diff can still be expensive. Predicates that match many rows have correspondingly bigger refresh cost.

@Query pitfalls

No fetch limit. @Query loads everything matching. For potentially large datasets, paginate or filter via predicate (date cutoff, etc.).

Re-querying on every keystroke. If you build a search predicate from @State text, every keystroke creates a new query. Debounce or batch the search state.

Trying to use computed predicates with captured non-stable values. A predicate captures values at creation. If those values change, you need a new @Query initialization. Covered in Section 21.

Reading @Query results in non-render code. The fetch is intentionally tied to view rendering. If you need data outside a view, use manual context.fetch(...).

Forgetting to save (when autosave is off). If you’ve disabled autosave, mutations through @Bindable properties don’t persist. Save explicitly.

Performance with large datasets. Tens of thousands of rows + a list view + @Query = slow. Paginate or filter early.

What to internalize

@Query declares a fetch tied to a view. SwiftUI re-renders on changes. @Environment(\.modelContext) is the main context, autosaving by default. Inserts and deletes through that context propagate to all @Query-driven views instantly. For parameterized queries, initialize @Query in init. @Query doesn’t paginate or prefetch — for large datasets, filter at the predicate level. Save explicitly for critical operations, even with autosave on. @Query is great for prototype-to-production CRUD; for more nuanced data flows, the manual patterns in later sections give you more control.


21. SwiftUI: Dynamic Queries

@Query becomes more powerful — and more complicated — when its parameters need to change at runtime. The user types in a search box; the predicate should incorporate that text. The user picks a sort order; the descriptor should switch. The view should keep its identity (no jarring resets) as parameters change.

This section covers the patterns for dynamic queries.

The wrong approach: state inside the @Query view

A natural first attempt:

struct NotesList: View {
    @State var searchText = ""
    @Query var notes: [Note]  // can't reference searchText here at compile time

    var body: some View {
        TextField("Search", text: $searchText)
        List(notes) { note in Text(note.title) }
    }
}

This doesn’t work for filtering by searchText. The @Query predicate is fixed at view construction.

Pattern 1: Lift state to parent, parameterize child

The simplest pattern: keep the search state in the parent, parameterize the query in the child:

struct ParentView: View {
    @State var searchText = ""

    var body: some View {
        VStack {
            TextField("Search", text: $searchText)
            FilteredNotesList(searchText: searchText)
        }
    }
}

struct FilteredNotesList: View {
    @Query var notes: [Note]

    init(searchText: String) {
        let predicate: Predicate<Note>
        if searchText.isEmpty {
            predicate = #Predicate<Note> { _ in true }
        } else {
            predicate = #Predicate<Note> { $0.title.localizedStandardContains(searchText) }
        }
        _notes = Query(filter: predicate, sort: [SortDescriptor(\.createdAt, order: .reverse)])
    }

    var body: some View {
        List(notes) { note in
            Text(note.title)
        }
    }
}

Each time searchText changes, the parent rebuilds with the new text. The child’s init runs with the new value, creating a new @Query filter.

This works, but every parameter change reconstructs the child view. For complex views, this can cause animations to reset, scroll position to lost, focus to disappear.

Pattern 2: Use a view that preserves identity

To preserve view identity across parameter changes, you can use .id() modifier carefully — but more commonly, the parameter-as-state pattern is preferred:

struct NotesList: View {
    @State var searchText = ""
    @State var sortOrder = SortOrder.reverse

    var body: some View {
        VStack {
            TextField("Search", text: $searchText)
            Picker("Sort", selection: $sortOrder) {
                Text("Newest first").tag(SortOrder.reverse)
                Text("Oldest first").tag(SortOrder.forward)
            }

            FilteredNotesList(searchText: searchText, sortOrder: sortOrder)
        }
    }
}

struct FilteredNotesList: View {
    @Query var notes: [Note]

    init(searchText: String, sortOrder: SortOrder) {
        let predicate: Predicate<Note>
        if searchText.isEmpty {
            predicate = #Predicate<Note> { _ in true }
        } else {
            predicate = #Predicate<Note> { $0.title.localizedStandardContains(searchText) }
        }
        _notes = Query(filter: predicate, sort: [SortDescriptor(\.createdAt, order: sortOrder)])
    }

    var body: some View {
        List(notes) { note in
            Text(note.title)
        }
    }
}

The view’s identity is determined by its position in the parent’s view tree (not its parameters). So changing parameters re-runs init but doesn’t replace the view — animations, scroll position, focus all preserve.

If searchText changes on every keystroke, you re-query on every keystroke. For large datasets, this is slow. Debounce:

struct NotesList: View {
    @State var rawSearch = ""
    @State var debouncedSearch = ""

    var body: some View {
        VStack {
            TextField("Search", text: $rawSearch)
            FilteredNotesList(searchText: debouncedSearch)
        }
        .onChange(of: rawSearch) { _, new in
            Task {
                try? await Task.sleep(for: .milliseconds(300))
                if rawSearch == new {  // not changed during sleep
                    debouncedSearch = new
                }
            }
        }
    }
}

debouncedSearch only updates after the user pauses typing for 300ms. The query only re-runs when the debounced value changes.

For a polished debounce, use Observable view models or a custom modifier — the basic idea is the same.

Pattern 4: Using @Query.Animation for smooth transitions

@Query(filter: ..., sort: ..., animation: .default) var notes: [Note]

When the query result changes (because the predicate filtered differently), the list animates the additions and removals. This makes dynamic-filter UIs feel polished.

For more nuanced animations (e.g., a quick filter vs. a slower paginate), you can wrap the changes in withAnimation blocks at the call site.

Pattern 5: Sort order selection

Sort orders are easy to make dynamic:

enum NoteSort: String, CaseIterable {
    case dateDescending = "Newest first"
    case dateAscending = "Oldest first"
    case titleAZ = "Title A-Z"
    case titleZA = "Title Z-A"

    var sortDescriptor: SortDescriptor<Note> {
        switch self {
        case .dateDescending: return SortDescriptor(\.createdAt, order: .reverse)
        case .dateAscending: return SortDescriptor(\.createdAt, order: .forward)
        case .titleAZ: return SortDescriptor(\.title, comparator: .localizedStandard)
        case .titleZA: return SortDescriptor(\.title, comparator: .localizedStandard, order: .reverse)
        }
    }
}

struct NotesList: View {
    @State var sort = NoteSort.dateDescending

    var body: some View {
        VStack {
            Picker("Sort", selection: $sort) {
                ForEach(NoteSort.allCases, id: \.self) { Text($0.rawValue).tag($0) }
            }
            FilteredNotesList(sortDescriptor: sort.sortDescriptor)
        }
    }
}

struct FilteredNotesList: View {
    @Query var notes: [Note]

    init(sortDescriptor: SortDescriptor<Note>) {
        _notes = Query(sort: [sortDescriptor])
    }

    var body: some View {
        List(notes) { note in Text(note.title) }
    }
}

The enum encapsulates the sort options; the picker drives state; the child view reconfigures its query.

Pattern 6: Multiple filters via composition

For multiple optional filters, build the predicate inline:

struct FilteredNotesList: View {
    @Query var notes: [Note]

    init(
        searchText: String,
        folder: Folder?,
        showingPinnedOnly: Bool
    ) {
        let folderID = folder?.persistentModelID
        let predicate = #Predicate<Note> { note in
            (searchText.isEmpty || note.title.localizedStandardContains(searchText)) &&
            (folderID == nil || note.folder?.persistentModelID == folderID) &&
            (!showingPinnedOnly || note.isPinned)
        }
        _notes = Query(filter: predicate, sort: [SortDescriptor(\.createdAt, order: .reverse)])
    }

    var body: some View {
        List(notes) { note in Text(note.title) }
    }
}

The predicate combines all filters with logical AND. Each filter is conditionally a no-op (e.g., searchText.isEmpty short-circuits to true).

You capture local values (folderID, showingPinnedOnly) at predicate construction. The predicate is recreated each init, which happens each time the parent passes new parameters.

Dynamic queries in practice

A real app’s main list view often combines all these patterns. A typical shape:

struct NotesListView: View {
    @State var search = ""
    @State var sort = NoteSort.dateDescending
    @State var selectedFolder: Folder?

    var body: some View {
        VStack {
            HStack {
                TextField("Search", text: $search)
                Picker("Sort", selection: $sort) { /* ... */ }
            }
            FolderPicker(selected: $selectedFolder)
            FilteredNotesList(
                searchText: search,
                folder: selectedFolder,
                sortDescriptor: sort.sortDescriptor
            )
        }
    }
}

The parent owns the filter state. The child re-queries based on parameters. This composes well.

Dynamic query pitfalls

Building complex predicates each keystroke. Predicate construction has overhead. Combined with re-fetching, fast typing can lag. Debounce.

Forgetting the conditional no-op pattern. If you write searchText.isEmpty || ..., the predicate is true || ..., which matches everything. Without that, when searchText is empty, you’d match nothing (because note.title.contains("") may or may not match, depending on implementation).

Capturing values that change. A captured searchText in a closure is the value at capture time. If the captured value is meant to update dynamically, you need a new predicate each time.

Resetting view state when parameters change. Even with the parameter-as-state pattern, certain things (selection bindings, scroll positions) may reset if the SwiftUI view identity changes. Test with realistic interactions.

Pagination with dynamic predicates. Combining dynamic filters with cursor pagination is complex. The cursor is invalid if the filter changes. Plan for invalidation.

Trying to use @Query for paginated data. @Query is all-or-nothing. For paginated lists, you typically need manual fetches with a @State array.

What to internalize

For dynamic queries, lift state to the parent and parameterize the child via init. Use the parameter-as-state pattern (separate child view that takes filter parameters) to keep @Query working while letting filters change. Build predicates with conditional no-op clauses (searchText.isEmpty || ...) so empty filters match everything. Debounce search-triggered queries. Sort orders are easy via enums that map to SortDescriptor. For very dynamic or paginated UIs, drop to manual fetches (Section 23).


22. SwiftUI: Editing Models In-Place with @Bindable

@Bindable is the property wrapper that lets a view edit a model’s properties through two-way bindings. Combined with @Query for reading and @Environment(\.modelContext) for context access, @Bindable completes the trio of SwiftData property wrappers you’ll use in most apps.

This section covers @Bindable’s mechanics, the common patterns, and the failure modes.

The basic pattern

struct NoteEditor: View {
    @Bindable var note: Note

    var body: some View {
        Form {
            TextField("Title", text: $note.title)
            TextEditor(text: $note.body)
            Toggle("Pinned", isOn: $note.isPinned)
        }
    }
}

$note.title is a Binding<String> to the note’s title property. Edits in the TextField mutate the model directly. Because autosave is on for the main context, changes persist automatically.

Why @Bindable exists

@Model-generated types are Observable. In other contexts you’d use @Bindable (or @Environment for observable objects) to bridge from observable types to SwiftUI bindings:

@Bindable var someObservable: SomeObservableObject
TextField("Name", text: $someObservable.name)

For SwiftData, the same applies. @Bindable var note: Note lets you make bindings from properties.

Passing models to editors

The typical flow: a list view passes a model to a detail/editor view:

struct NotesListView: View {
    @Query var notes: [Note]

    var body: some View {
        NavigationStack {
            List(notes) { note in
                NavigationLink(note.title) {
                    NoteEditor(note: note)
                }
            }
        }
    }
}

struct NoteEditor: View {
    @Bindable var note: Note

    var body: some View {
        Form {
            TextField("Title", text: $note.title)
            TextEditor(text: $note.body)
        }
    }
}

The note is passed by reference. Edits in the editor modify the actual model, which propagates back to the list view (which re-renders to show the updated title).

Local @Bindable shadows

When you receive a model as a regular parameter (not as @Bindable) and need to make bindings, use a local @Bindable shadow:

struct NoteSection: View {
    let note: Note  // not @Bindable

    var body: some View {
        @Bindable var bindable = note  // local shadow
        Form {
            TextField("Title", text: $bindable.title)
        }
    }
}

The @Bindable keyword can be used inside a body (as a local variable). This is a SwiftUI pattern that bridges non-@Bindable parameters into binding territory.

You’d use this when:

  • The caller doesn’t want to commit to passing as @Bindable.
  • You only need bindings in a portion of the view.
  • You’re working with a model from a ForEach that doesn’t take @Bindable directly.

@Bindable and read-only views

If a view only reads from a model, you don’t need @Bindable:

struct NotePreview: View {
    let note: Note

    var body: some View {
        VStack {
            Text(note.title).font(.headline)
            Text(note.body).font(.body)
        }
    }
}

@Bindable is for views that edit. Read-only views just take a model parameter.

But: even read-only views need to re-render when the model changes. The Observable conformance handles this automatically — accessing a property in body registers an observation; mutations trigger re-renders. No @Bindable needed.

Editing relationships

You can edit a to-one relationship through @Bindable:

struct NoteEditor: View {
    @Bindable var note: Note
    @Query var folders: [Folder]

    var body: some View {
        Form {
            TextField("Title", text: $note.title)
            Picker("Folder", selection: $note.folder) {
                Text("None").tag(nil as Folder?)
                ForEach(folders) { folder in
                    Text(folder.name).tag(folder as Folder?)
                }
            }
        }
    }
}

The picker’s selection binds to note.folder. Selecting a folder mutates the model. Because of the relationship’s inverse, the chosen folder’s notes array also updates.

For to-many relationships, you typically don’t bind directly to the array. Instead, mutate via methods:

struct NoteTagsView: View {
    @Bindable var note: Note
    @Query var allTags: [Tag]

    var body: some View {
        ForEach(allTags) { tag in
            HStack {
                Text(tag.name)
                Spacer()
                Image(systemName: note.tags.contains(tag) ? "checkmark.circle.fill" : "circle")
            }
            .onTapGesture {
                if let idx = note.tags.firstIndex(of: tag) {
                    note.tags.remove(at: idx)
                } else {
                    note.tags.append(tag)
                }
            }
        }
    }
}

The tap gesture mutates note.tags. The view re-renders because the underlying observation tracks the array.

Save timing

With autosave on (the default for the main context), changes propagate to disk on autosave cycles. For most UI editing, this is fine.

For more careful save control:

struct NoteEditor: View {
    @Bindable var note: Note
    @Environment(\.modelContext) var context
    @Environment(\.dismiss) var dismiss

    var body: some View {
        Form {
            TextField("Title", text: $note.title)
        }
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                Button("Save") {
                    try? context.save()
                    dismiss()
                }
            }
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel") {
                    context.rollback()  // discard changes
                    dismiss()
                }
            }
        }
    }
}

The Save button forces save; Cancel rolls back. This requires autosave to be off (or accepting that autosave might have committed before you click Cancel).

Validation

Validation is your responsibility. You can:

  • Disable the Save button when validation fails.
  • Show inline error messages.
  • Prevent save via early return.
struct NoteEditor: View {
    @Bindable var note: Note

    var isValid: Bool {
        !note.title.isEmpty
    }

    var body: some View {
        Form {
            TextField("Title", text: $note.title)
        }
        .toolbar {
            Button("Done") {
                // saved automatically via autosave
            }
            .disabled(!isValid)
        }
    }
}

The model is mutated as the user types. If autosave is on, invalid state is technically persisted (e.g., empty title). For strict validation, you’d want autosave off and explicit save with a validation gate.

Creating new models with @Bindable

A common pattern: a “create new note” sheet that builds up a new model:

struct NewNoteSheet: View {
    @Environment(\.modelContext) var context
    @Environment(\.dismiss) var dismiss
    @State private var newNote = Note(title: "", body: "")

    var body: some View {
        @Bindable var bindable = newNote
        Form {
            TextField("Title", text: $bindable.title)
            TextEditor(text: $bindable.body)
        }
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                Button("Create") {
                    context.insert(newNote)
                    try? context.save()
                    dismiss()
                }
                .disabled(newNote.title.isEmpty)
            }
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel") { dismiss() }
            }
        }
    }
}

The newNote is created in @State — it’s a new instance, not yet inserted into any context. The local @Bindable shadow gives bindings. On confirm, the note is inserted and saved.

If the user cancels, the note is never inserted; it gets deallocated when the sheet dismisses.

Concurrent editing

If the same model is shown in two views simultaneously (a list + a detail), edits in one propagate to the other:

// List view shows note titles
// Detail view edits note title
// Title updates in the list automatically

This is Observable’s behavior: any view rendering a property re-renders when that property mutates.

For most cases, this is desired. The exceptional case: two detail editors open simultaneously on the same model. Saves and rollbacks affect both. Avoid this case (one editor per model), or use copy-on-edit (with a draft).

Draft/staging pattern

For “edit then save or cancel” flows where mutations shouldn’t persist until confirmed:

struct NoteEditor: View {
    let originalNote: Note
    @Environment(\.modelContext) var context
    @Environment(\.dismiss) var dismiss

    @State private var draftTitle: String
    @State private var draftBody: String

    init(note: Note) {
        self.originalNote = note
        _draftTitle = State(initialValue: note.title)
        _draftBody = State(initialValue: note.body)
    }

    var body: some View {
        Form {
            TextField("Title", text: $draftTitle)
            TextEditor(text: $draftBody)
        }
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                Button("Save") {
                    originalNote.title = draftTitle
                    originalNote.body = draftBody
                    try? context.save()
                    dismiss()
                }
            }
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel") {
                    dismiss()  // discard local state, original unchanged
                }
            }
        }
    }
}

The draft state lives in @State. Mutations only touch the model on Save. The original model is unaffected by typing.

This pattern is verbose but bulletproof. Use it for edit flows with explicit save/cancel semantics.

@Bindable pitfalls

Forgetting the local shadow. let note: Note in a parameter doesn’t support $note.property. Use @Bindable var note: Note for parameters that need bindings, or @Bindable var local = note for local shadowing.

Autosave committing invalid state. While typing, the model has the partial value. Autosave persists it. If your model has validation, partial state may violate it. Either disable autosave during editing, or use the draft pattern.

Mutating models in detached tasks. @Bindable is main-actor-bound. Don’t pass the binding into a Task.detached; you’ll cross actor boundaries.

Editing models from multiple views simultaneously. Race conditions on rapid input. Use one editor per model.

Forgetting to save on critical operations. Autosave is best-effort. Save explicitly before navigation, before backgrounding, before sync.

Confusing Bindable with Observable. @Bindable is for creating bindings ($model.prop). Read-only access doesn’t need it; just take a let note: Note parameter.

What to internalize

@Bindable lets you create $model.property bindings for two-way editing in SwiftUI. Use it as a property wrapper on parameters, or as a local @Bindable var bindable = note shadow inside body when needed. Read-only views don’t need @Bindable — the model’s Observable conformance handles re-renders automatically. With autosave on, edits persist as you type; for explicit save/cancel, disable autosave and call save()/rollback() from buttons. Use the draft pattern (separate @State for in-progress values) for edit flows that need to discard incomplete work. Validate via disabled buttons and inline feedback; consider that partial mutations may violate constraints during typing.


23. SwiftUI Without Property Wrappers/Macros: The Repository Pattern

@Query and @Bindable are convenient, but they tightly couple your views to SwiftData. For testable, layered architectures, you separate the data layer from the view layer using a repository pattern. The view doesn’t know about ModelContext or @Model; it talks to a protocol that exposes the operations the view needs.

This section is the first of four covering manual SwiftUI integration patterns. We’ll build up: repositories here, ModelActor services in Section 24, AsyncStream-driven updates in Section 25, and hand-rolled @Observable stores in Section 26.

Why a repository

A @Query-based view is:

  • Hard to test (you’d need a SwiftData container in unit tests).
  • Hard to swap (replacing SwiftData with another store requires rewriting views).
  • Hard to reason about (the data fetch is mixed with view layout).

A repository-based view:

  • Takes a protocol parameter for data access.
  • Can be tested with a mock repository.
  • Is agnostic about the storage layer.
  • Cleanly separates “what does this view need” from “how is it stored.”

Defining the protocol

protocol NoteRepository {
    func allNotes() throws -> [Note]
    func notes(matching: String) throws -> [Note]
    func insert(title: String, body: String) throws -> PersistentIdentifier
    func update(_ id: PersistentIdentifier, title: String, body: String) throws
    func delete(_ id: PersistentIdentifier) throws
}

The protocol describes the operations. Return types are either model instances (for views that consume them) or Sendable types like PersistentIdentifier.

SwiftData implementation

@MainActor
final class SwiftDataNoteRepository: NoteRepository {
    let context: ModelContext

    init(context: ModelContext) {
        self.context = context
    }

    func allNotes() throws -> [Note] {
        try context.fetch(FetchDescriptor<Note>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]))
    }

    func notes(matching: String) throws -> [Note] {
        let predicate = #Predicate<Note> { $0.title.localizedStandardContains(matching) }
        return try context.fetch(FetchDescriptor<Note>(predicate: predicate))
    }

    func insert(title: String, body: String) throws -> PersistentIdentifier {
        let note = Note(title: title, body: body)
        context.insert(note)
        try context.save()
        return note.persistentModelID
    }

    func update(_ id: PersistentIdentifier, title: String, body: String) throws {
        guard let note = context.model(for: id) as? Note else { return }
        note.title = title
        note.body = body
        try context.save()
    }

    func delete(_ id: PersistentIdentifier) throws {
        guard let note = context.model(for: id) as? Note else { return }
        context.delete(note)
        try context.save()
    }
}

@MainActor because the repository works with the main context. Methods are sync — the operations are cheap enough at this layer.

Mock implementation for tests

final class MockNoteRepository: NoteRepository {
    var notes: [PersistentIdentifier: NoteData] = [:]

    struct NoteData {
        var title: String
        var body: String
    }

    func allNotes() throws -> [Note] {
        // For testing, you'd return mock Note instances or use a struct-based DTO
        // (depends on what your view actually needs)
        []
    }

    func notes(matching: String) throws -> [Note] { [] }

    func insert(title: String, body: String) throws -> PersistentIdentifier {
        let id = PersistentIdentifier()  // mock; in practice you'd track IDs differently
        notes[id] = NoteData(title: title, body: body)
        return id
    }

    func update(_ id: PersistentIdentifier, title: String, body: String) throws {
        notes[id] = NoteData(title: title, body: body)
    }

    func delete(_ id: PersistentIdentifier) throws {
        notes.removeValue(forKey: id)
    }
}

Mock implementations are testable without any SwiftData container.

In practice, mocking Note (an @Model class) is awkward — you can’t instantiate PersistentIdentifier arbitrarily. A common refactor: have the repository return data transfer objects (DTOs) instead of Note:

struct NoteDTO: Identifiable, Sendable {
    let id: PersistentIdentifier
    var title: String
    var body: String
    var createdAt: Date
}

protocol NoteRepository {
    func allNotes() throws -> [NoteDTO]
    func notes(matching: String) throws -> [NoteDTO]
    func insert(title: String, body: String) throws -> PersistentIdentifier
    func update(_ id: PersistentIdentifier, title: String, body: String) throws
    func delete(_ id: PersistentIdentifier) throws
}

@MainActor
final class SwiftDataNoteRepository: NoteRepository {
    let context: ModelContext

    init(context: ModelContext) { self.context = context }

    func allNotes() throws -> [NoteDTO] {
        let notes = try context.fetch(FetchDescriptor<Note>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]))
        return notes.map { NoteDTO(id: $0.persistentModelID, title: $0.title, body: $0.body, createdAt: $0.createdAt) }
    }

    // ... rest similar
}

DTOs make the view-data boundary completely framework-agnostic. The view sees NoteDTO structs, not Note models. Mocking and testing become trivial.

Using a repository in a view model

A @Observable view model holds the repository and exposes data:

@MainActor
@Observable
final class NotesViewModel {
    private let repository: NoteRepository
    var notes: [NoteDTO] = []

    init(repository: NoteRepository) {
        self.repository = repository
    }

    func refresh() {
        do {
            notes = try repository.allNotes()
        } catch {
            print("Refresh failed: \(error)")
        }
    }

    func addNote(title: String, body: String) {
        do {
            _ = try repository.insert(title: title, body: body)
            refresh()
        } catch {
            print("Add failed: \(error)")
        }
    }

    func deleteNote(id: PersistentIdentifier) {
        do {
            try repository.delete(id)
            refresh()
        } catch {
            print("Delete failed: \(error)")
        }
    }
}

The view model is the surface the view talks to. The view sees properties like notes and methods like addNote.

Using the view model in a view

struct NotesListView: View {
    @State private var viewModel: NotesViewModel

    init(viewModel: NotesViewModel) {
        _viewModel = State(wrappedValue: viewModel)
    }

    var body: some View {
        List(viewModel.notes) { note in
            Text(note.title)
        }
        .task {
            viewModel.refresh()
        }
        .toolbar {
            Button("Add") {
                viewModel.addNote(title: "New", body: "")
            }
        }
    }
}

The view is now:

  • Independent of SwiftData (talks to NotesViewModel).
  • Independent of repositories (gets the view model passed in).
  • Testable (instantiate with a mock view model, write snapshot tests or interaction tests).

Injecting at the app level

@main
struct MyApp: App {
    let container: ModelContainer = makeContainer()

    var body: some Scene {
        WindowGroup {
            let repository = SwiftDataNoteRepository(context: container.mainContext)
            let viewModel = NotesViewModel(repository: repository)
            NotesListView(viewModel: viewModel)
        }
    }
}

The app composes the dependency graph at the scene level. This pattern is sometimes called the “composition root.”

Refresh strategies

The simplest refresh: call viewModel.refresh() on view appear:

.task { viewModel.refresh() }

But this doesn’t detect changes made elsewhere. A more reactive approach: observe context save notifications and refresh:

@MainActor
@Observable
final class NotesViewModel {
    // ... 
    private var observers: Set<AnyCancellable> = []

    func startObserving(context: ModelContext) {
        NotificationCenter.default
            .publisher(for: ModelContext.didSave, object: context)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.refresh()
            }
            .store(in: &observers)
    }
}

This is closer to what @Query does internally — observe the context, re-fetch on save.

Or use the AsyncStream pattern (Section 25) for a more modern flavor.

Repository for editing

The same pattern for edits — pass the repository to a detail view model:

@MainActor
@Observable
final class NoteEditorViewModel {
    private let repository: NoteRepository
    private let noteID: PersistentIdentifier

    var title: String = ""
    var body: String = ""

    init(repository: NoteRepository, noteID: PersistentIdentifier) {
        self.repository = repository
        self.noteID = noteID
    }

    func load() throws {
        // Need to add a fetchByID method to the protocol
        let note = try repository.note(id: noteID)
        title = note.title
        body = note.body
    }

    func save() throws {
        try repository.update(noteID, title: title, body: body)
    }
}

The editor view binds to the view model’s properties:

struct NoteEditor: View {
    @State private var viewModel: NoteEditorViewModel

    var body: some View {
        @Bindable var bindable = viewModel  // bind to view model, not model
        Form {
            TextField("Title", text: $bindable.title)
            TextEditor(text: $bindable.body)
        }
        .toolbar {
            Button("Save") {
                try? viewModel.save()
            }
        }
    }
}

The view is now completely SwiftData-agnostic.

Repository pattern pitfalls

Returning models instead of DTOs. Convenient but couples the view to @Model types and makes mocking awkward. DTOs are worth the conversion cost.

Repositories that grow into god objects. Split by feature or entity: NoteRepository, FolderRepository, etc. Don’t put everything in one.

Calling repository methods in view body. The repository should be called from view models or actions. Body should be a function of state.

No refresh strategy. Without observation or explicit refresh on action, the view goes stale. Pick a strategy.

Catching errors and ignoring. “Show user-friendly error” is your responsibility at the view model layer. Logging-and-continue isn’t enough for production.

What to internalize

The repository pattern decouples views from SwiftData. Define a protocol that exposes operations. Implement against SwiftData; mock against in-memory data for tests. Have repositories return DTOs (Sendable structs) rather than @Model instances for full decoupling. Compose at the app level: container → repository → view model → view. Refresh on view appear, or observe context saves for reactive updates. The cost is more code; the benefit is a testable, swappable, layered architecture.


24. SwiftUI Without Property Wrappers/Macros: ModelActor-Based Services

The repository pattern works for synchronous operations on the main context. For background work — heavy imports, syncing, processing — you want an actor-based service. A ModelActor-backed service has its own context, runs operations off the main thread, and returns results as Sendable values.

This section builds on Section 15’s ModelActor material and Section 23’s repository pattern, combining them into a serious background-work architecture.

The shape of an actor-based service

import SwiftData

@ModelActor
actor NoteService {

    func allNotes() throws -> [NoteDTO] {
        let notes = try modelContext.fetch(FetchDescriptor<Note>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]))
        return notes.map(\.dto)
    }

    func search(_ query: String) throws -> [NoteDTO] {
        let predicate = #Predicate<Note> { $0.title.localizedStandardContains(query) }
        let notes = try modelContext.fetch(FetchDescriptor<Note>(predicate: predicate))
        return notes.map(\.dto)
    }

    func insert(title: String, body: String) throws -> PersistentIdentifier {
        let note = Note(title: title, body: body)
        modelContext.insert(note)
        try modelContext.save()
        return note.persistentModelID
    }

    func update(_ id: PersistentIdentifier, title: String, body: String) throws {
        guard let note = modelContext.model(for: id) as? Note else { throw NoteError.notFound }
        note.title = title
        note.body = body
        try modelContext.save()
    }

    func delete(_ id: PersistentIdentifier) throws {
        guard let note = modelContext.model(for: id) as? Note else { return }
        modelContext.delete(note)
        try modelContext.save()
    }

    func importBatch(_ payloads: [NotePayload]) async throws {
        modelContext.autosaveEnabled = false
        for (i, payload) in payloads.enumerated() {
            modelContext.insert(Note(title: payload.title, body: payload.body))
            if i.isMultiple(of: 500) {
                try modelContext.save()
            }
        }
        try modelContext.save()
    }

    enum NoteError: Error { case notFound }
}

extension Note {
    var dto: NoteDTO {
        NoteDTO(id: persistentModelID, title: title, body: body, createdAt: createdAt)
    }
}

Key shapes:

  • @ModelActor macro generates the actor with a private modelContext.
  • All methods accept and return Sendable types (primitives, IDs, DTOs).
  • Heavy operations (importBatch) have specialized handling (autosave off, batched saves).
  • The actor never returns Note instances — only DTOs and IDs.

Using the service from view models

Now the view model is on the main actor, calling into the service:

@MainActor
@Observable
final class NotesViewModel {
    private let service: NoteService
    var notes: [NoteDTO] = []
    var isLoading = false

    init(service: NoteService) {
        self.service = service
    }

    func refresh() async {
        isLoading = true
        defer { isLoading = false }
        do {
            notes = try await service.allNotes()
        } catch {
            print("Refresh failed: \(error)")
        }
    }

    func addNote(title: String, body: String) async {
        do {
            _ = try await service.insert(title: title, body: body)
            await refresh()
        } catch {
            print("Add failed: \(error)")
        }
    }

    func deleteNote(id: PersistentIdentifier) async {
        do {
            try await service.delete(id)
            await refresh()
        } catch {
            print("Delete failed: \(error)")
        }
    }
}

The view model is @MainActor, but its methods are async — they await the service’s actor-isolated methods. The view model’s properties (notes, isLoading) drive UI updates.

Using the view model in a view

struct NotesListView: View {
    @State private var viewModel: NotesViewModel

    init(viewModel: NotesViewModel) {
        _viewModel = State(wrappedValue: viewModel)
    }

    var body: some View {
        Group {
            if viewModel.isLoading {
                ProgressView()
            } else {
                List(viewModel.notes) { note in
                    Text(note.title)
                }
            }
        }
        .task { await viewModel.refresh() }
        .toolbar {
            Button("Add") {
                Task { await viewModel.addNote(title: "New", body: "") }
            }
        }
    }
}

The view triggers async actions via Task { ... } or .task modifiers. Loading states are visible.

This architecture has clear separation:

  • View = pure UI, takes state from view model.
  • View model = main-actor state holder, talks to service.
  • Service = background actor that owns its context.
  • Model = persistent objects, never leave the service’s actor.

Composing at the app level

@main
struct MyApp: App {
    let container: ModelContainer = makeContainer()
    let noteService: NoteService

    init() {
        self.noteService = NoteService(modelContainer: container)
    }

    var body: some Scene {
        WindowGroup {
            let viewModel = NotesViewModel(service: noteService)
            NotesListView(viewModel: viewModel)
        }
    }
}

The service is created once at app launch. It lives for the app’s lifetime. The view model wraps it for the specific view.

Multiple services

You can have multiple actors for different concerns:

let noteService = NoteService(modelContainer: container)
let importService = ImportService(modelContainer: container)
let syncService = SyncService(modelContainer: container)

Each is an independent actor. They share the container (and thus the SQLite store). They have independent contexts (so their pending changes are independent until save).

If multiple services modify the same data, save and refresh strategies matter. A typical pattern:

  • One service writes (e.g., ImportService adds new notes).
  • Another service reads (e.g., NoteService returns lists).
  • After import saves, refresh the read service’s view (by re-fetching).

For more reactive coordination, use the AsyncStream pattern (Section 25).

Long-running operations with progress

Some operations take a while. Surface progress:

@ModelActor
actor ImportService {

    func importLarge(_ payloads: [NotePayload]) -> AsyncStream<ImportProgress> {
        AsyncStream { continuation in
            Task {
                modelContext.autosaveEnabled = false
                let total = payloads.count

                for (i, payload) in payloads.enumerated() {
                    modelContext.insert(Note(title: payload.title, body: payload.body))

                    if i.isMultiple(of: 100) {
                        try? modelContext.save()
                        continuation.yield(ImportProgress(completed: i + 1, total: total))
                    }
                }

                try? modelContext.save()
                continuation.yield(ImportProgress(completed: total, total: total))
                continuation.finish()
            }
        }
    }
}

struct ImportProgress: Sendable {
    let completed: Int
    let total: Int
}

The view model consumes the stream:

@MainActor
@Observable
final class ImportViewModel {
    private let service: ImportService
    var completed = 0
    var total = 0

    init(service: ImportService) { self.service = service }

    func startImport(_ payloads: [NotePayload]) async {
        for await progress in service.importLarge(payloads) {
            completed = progress.completed
            total = progress.total
        }
    }
}

The UI shows a progress bar that updates as the import advances. Streaming progress is essential UX for long operations.

Testing actor-based services

In-memory containers for tests:

@Test func testImportService() async throws {
    let schema = Schema([Note.self])
    let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
    let container = try ModelContainer(for: schema, configurations: [config])

    let service = NoteService(modelContainer: container)
    let id = try await service.insert(title: "Test", body: "")

    let notes = try await service.allNotes()
    #expect(notes.count == 1)
    #expect(notes.first?.id == id)
}

Each test gets a fresh in-memory container. Tests run fast and don’t interfere with each other.

Coordinating refresh

When a service modifies data, view models need to refresh. Options:

Option A: Manual refresh after every write.

func addNote(...) async {
    _ = try await service.insert(...)
    await refresh()
}

Simple, predictable. Doesn’t catch updates from other view models or services.

Option B: Observe context save notifications.

init(service: NoteService, container: ModelContainer) {
    self.service = service
    NotificationCenter.default
        .publisher(for: ModelContext.didSave, object: container.mainContext)
        .sink { [weak self] _ in
            Task { await self?.refresh() }
        }
        .store(in: &cancellables)
}

But the save is on the actor’s context, not the main context. You’d need cross-context notification — which SwiftData doesn’t expose nicely.

Option C: AsyncStream from the service.

See Section 25.

For most apps, Option A is fine: views know when they cause changes, and they refresh after.

ModelActor service pitfalls

Returning Note instances from service methods. They cross the actor boundary. Use DTOs.

Creating services per call. Heavy initialization. Hold service references.

Holding multiple long-lived services that don’t communicate. Different services may operate on the same data without coordination. Plan refresh strategy.

Service methods that block too long. Even on a background actor, blocking the actor blocks all callers. Yield (await Task.yield()) periodically in long loops.

Mixing await and synchronous UI patterns. A Task { await ... } from a button action is fine; reading service state synchronously in body isn’t possible (the await needs an async context). Use view model properties for state, methods for actions.

Forgetting that services don’t see each other’s pending changes. Service A inserts and hasn’t saved. Service B fetches — doesn’t see the insert. Save before expecting visibility from another context.

What to internalize

ModelActor-based services run on background actors with their own contexts. They expose async methods returning Sendable values (DTOs, IDs, primitives). Main-actor view models call into services via await, expose @Observable properties to views. Compose at app level: container → service → view model → view. For long operations, expose AsyncStream for progress. Refresh after writes you cause; use AsyncStream (Section 25) for reactive cross-component updates. The architecture is more code than @Query, but the boundaries are explicit, testable, and scale to complex apps.


25. SwiftUI Without Property Wrappers/Macros: AsyncStream-Driven Updates

In the manual architecture, after a background actor saves, view models need to know — either through manual refresh or some kind of notification. AsyncStream is a clean Swift-concurrency-native way to push change notifications from the data layer to view models. This section covers patterns for building a reactive data layer without @Query’s magic.

Why AsyncStream

The alternatives to AsyncStream:

  • NotificationCenter. Works but is dynamic, untyped, and outside Swift concurrency.
  • Combine Publishers. Older approach, predates strict concurrency, awkward to integrate with actors.
  • Manual refresh. Each caller refreshes after writes. Doesn’t propagate changes from other origins.
  • Polling. Workable but inefficient.

AsyncStream fits Swift concurrency natively:

  • Sendable by design.
  • Easy to compose with actor methods.
  • for await in view models is clean.
  • Cancellation is automatic when the consumer goes away.

A change-stream from the data layer

Define a type for changes:

enum NoteChange: Sendable {
    case inserted(PersistentIdentifier)
    case updated(PersistentIdentifier)
    case deleted(PersistentIdentifier)
    case batchChanged  // any large change you don't itemize
}

The data layer exposes a stream:

@ModelActor
actor NoteService {
    private let changeStream: AsyncStream<NoteChange>
    private let changeContinuation: AsyncStream<NoteChange>.Continuation

    init(modelContainer: ModelContainer) {
        var continuationRef: AsyncStream<NoteChange>.Continuation!
        self.changeStream = AsyncStream { continuation in
            continuationRef = continuation
        }
        self.changeContinuation = continuationRef

        // @ModelActor macro generated init still needs to run; in practice
        // you'd structure this differently, see notes below
    }

    var changes: AsyncStream<NoteChange> { changeStream }

    func insert(title: String, body: String) throws -> PersistentIdentifier {
        let note = Note(title: title, body: body)
        modelContext.insert(note)
        try modelContext.save()
        changeContinuation.yield(.inserted(note.persistentModelID))
        return note.persistentModelID
    }

    func update(_ id: PersistentIdentifier, title: String, body: String) throws {
        guard let note = modelContext.model(for: id) as? Note else { return }
        note.title = title
        note.body = body
        try modelContext.save()
        changeContinuation.yield(.updated(id))
    }

    func delete(_ id: PersistentIdentifier) throws {
        guard let note = modelContext.model(for: id) as? Note else { return }
        modelContext.delete(note)
        try modelContext.save()
        changeContinuation.yield(.deleted(id))
    }
}

(The interaction between @ModelActor macro’s generated initializer and the stream setup is tricky. In practice, you’d implement ModelActor manually for cases that need this, or use a separate broadcast object. The above is illustrative.)

Each mutation yields a change to the stream. Multiple consumers can subscribe — each gets its own iteration.

Caveat: a basic AsyncStream is single-consumer. For multi-consumer broadcasts, you’d wrap with a broadcasting helper. The AsyncSequence family is rich but the standard library’s AsyncStream is single-consumer.

Multi-consumer broadcast

A simple broadcaster:

actor ChangeBroadcaster<T: Sendable> {
    private var continuations: [UUID: AsyncStream<T>.Continuation] = [:]

    func subscribe() -> AsyncStream<T> {
        let id = UUID()
        return AsyncStream { continuation in
            Task { [weak self] in
                await self?.add(id, continuation)
            }
            continuation.onTermination = { [weak self] _ in
                Task { await self?.remove(id) }
            }
        }
    }

    private func add(_ id: UUID, _ continuation: AsyncStream<T>.Continuation) {
        continuations[id] = continuation
    }

    private func remove(_ id: UUID) {
        continuations.removeValue(forKey: id)
    }

    func send(_ event: T) {
        for continuation in continuations.values {
            continuation.yield(event)
        }
    }
}

Each subscriber gets a fresh stream. Adding/removing handled per subscription. The service composes its mutations with broadcaster sends:

@ModelActor
actor NoteService {
    let broadcaster = ChangeBroadcaster<NoteChange>()

    var changes: AsyncStream<NoteChange> {
        get async {
            await broadcaster.subscribe()
        }
    }

    func insert(...) async throws -> PersistentIdentifier {
        // ... do work ...
        await broadcaster.send(.inserted(id))
        return id
    }
}

View model consuming changes

The view model spins up a task to consume the stream:

@MainActor
@Observable
final class NotesViewModel {
    private let service: NoteService
    var notes: [NoteDTO] = []
    private var changeTask: Task<Void, Never>?

    init(service: NoteService) {
        self.service = service
    }

    func startObserving() async {
        await refresh()

        changeTask = Task { [weak self] in
            guard let stream = await self?.service.changes else { return }
            for await change in stream {
                guard let self else { return }
                await self.handleChange(change)
            }
        }
    }

    func stopObserving() {
        changeTask?.cancel()
    }

    private func handleChange(_ change: NoteChange) async {
        // Simple strategy: any change triggers a refresh
        await refresh()

        // Or be smarter: handle specific cases
        // case .inserted(let id): await loadNote(id)
        // case .deleted(let id): notes.removeAll { $0.id == id }
        // ... etc.
    }

    func refresh() async {
        do {
            notes = try await service.allNotes()
        } catch {
            print("Refresh failed: \(error)")
        }
    }
}

The view calls startObserving on appear and stopObserving on disappear:

struct NotesListView: View {
    @State private var viewModel: NotesViewModel

    var body: some View {
        List(viewModel.notes) { note in
            Text(note.title)
        }
        .task {
            await viewModel.startObserving()
        }
        // .task automatically cancels when the view goes away
    }
}

.task cancels the inner work when the view disappears, which cancels the consuming task, which terminates the stream subscription, which the broadcaster cleans up.

Granular handling

For better performance, handle specific changes rather than refreshing everything:

private func handleChange(_ change: NoteChange) async {
    switch change {
    case .inserted(let id):
        do {
            let note = try await service.note(id: id)
            notes.insert(note, at: 0)  // insert at the top
        } catch {
            await refresh()  // fallback
        }
    case .updated(let id):
        do {
            let note = try await service.note(id: id)
            if let idx = notes.firstIndex(where: { $0.id == id }) {
                notes[idx] = note
            }
        } catch {
            await refresh()
        }
    case .deleted(let id):
        notes.removeAll { $0.id == id }
    case .batchChanged:
        await refresh()
    }
}

This avoids re-fetching the whole list for every change. For active editing UIs, granular updates feel snappier.

Stream-driven SwiftUI lists

Pulling it together:

@MainActor
@Observable
final class NotesViewModel {
    private let service: NoteService
    var notes: [NoteDTO] = []
    private var changeTask: Task<Void, Never>?

    init(service: NoteService) {
        self.service = service
    }

    func start() async {
        await refresh()
        changeTask = Task {
            for await change in await service.changes {
                await handleChange(change)
            }
        }
    }

    deinit {
        changeTask?.cancel()
    }

    func add(title: String) async {
        try? await service.insert(title: title, body: "")
        // Stream will notify; handleChange will append
    }

    func delete(at offsets: IndexSet) async {
        for offset in offsets {
            try? await service.delete(notes[offset].id)
        }
    }

    private func handleChange(_ change: NoteChange) async {
        switch change {
        case .inserted(let id):
            if let note = try? await service.note(id: id) {
                notes.insert(note, at: 0)
            }
        case .deleted(let id):
            notes.removeAll { $0.id == id }
        case .updated(let id):
            if let updated = try? await service.note(id: id),
               let idx = notes.firstIndex(where: { $0.id == id }) {
                notes[idx] = updated
            }
        case .batchChanged:
            await refresh()
        }
    }

    private func refresh() async {
        notes = (try? await service.allNotes()) ?? []
    }
}

The view is unchanged from the simple case:

struct NotesListView: View {
    @State private var viewModel: NotesViewModel

    var body: some View {
        List {
            ForEach(viewModel.notes) { note in Text(note.title) }
                .onDelete { offsets in
                    Task { await viewModel.delete(at: offsets) }
                }
        }
        .task { await viewModel.start() }
        .toolbar {
            Button("Add") {
                Task { await viewModel.add(title: "New") }
            }
        }
    }
}

Cross-feature streams

For app-wide change events that span features:

@MainActor
final class AppChangeCenter {
    static let shared = AppChangeCenter()

    let noteChanges = ChangeBroadcaster<NoteChange>()
    let folderChanges = ChangeBroadcaster<FolderChange>()
    let tagChanges = ChangeBroadcaster<TagChange>()

    private init() {}
}

Services emit changes here; any view model in the app can subscribe. This is the SwiftUI-with-actors equivalent of a global event bus.

Saving state across launches

If you cache view-model state to disk (for fast startup), persist DTOs only — never Note instances or PersistentIdentifiers. On startup, render from cache, refresh in background, switch to live data when ready. AsyncStreams help here: the cache provides the initial state; the stream provides updates as soon as the service is up.

Pitfalls

Single-consumer AsyncStream subscribed by multiple consumers. The first consumer gets events; others see nothing. Use a broadcaster.

Memory leaks from never-cancelled stream tasks. Use .task (which auto-cancels) or explicitly cancel in deinit.

Out-of-order updates. A change event arrives before its preceding event was processed. Make handlers idempotent (e.g., delete-on-missing is a no-op).

Stream backpressure. If the producer emits faster than the consumer processes, events queue. For low-rate change events, this is fine; for high-rate, consider buffering policies.

Coupling all view models to the global change center. Direct, explicit dependencies via constructors are clearer than reaching into shared state.

Forgetting that the stream is the only source of truth. If a write happens but you forget to yield, view models miss the change. Audit every write path.

What to internalize

AsyncStream provides a Swift-native reactive update mechanism. Services emit change events after writes; view models consume via for await. Use a broadcaster wrapper for multi-consumer streams. Handle changes granularly for performance (insert single rows, remove single rows) with full refresh as a fallback. .task modifier auto-cancels subscriptions. AsyncStream isn’t free — you need to plumb events through writes — but it gives you reactive UI updates without @Query’s coupling to SwiftData. For complex apps with multiple writers, the consistency this brings is worth the plumbing.


26. SwiftUI Without Property Wrappers/Macros: Hand-Rolled @Observable Stores

The final manual pattern: building view-model “stores” that hide the data layer entirely. The view talks to a store as a high-level state container. The store talks to services or repositories. The view knows nothing about SwiftData, AsyncStreams, or actors.

This pattern is verbose but produces the cleanest architecture. It also makes SwiftUI views the most testable.

What an @Observable store is

An @Observable class that:

  • Holds the view’s complete state.
  • Exposes methods for actions.
  • Drives all the view’s behavior.
  • Hides the implementation (data layer, networking, business logic).

The view becomes a “dumb” projection of the store’s state.

import SwiftData
import Observation

@MainActor
@Observable
final class NotesStore {
    @ObservationIgnored
    private let service: NoteService

    var notes: [NoteDTO] = []
    var searchText: String = ""
    var isLoading: Bool = false
    var error: String?

    @ObservationIgnored
    private var observerTask: Task<Void, Never>?

    init(service: NoteService) {
        self.service = service
    }

    var filteredNotes: [NoteDTO] {
        if searchText.isEmpty { return notes }
        return notes.filter { $0.title.localizedStandardContains(searchText) }
    }

    func start() async {
        isLoading = true
        defer { isLoading = false }

        do {
            notes = try await service.allNotes()
        } catch {
            self.error = "\(error)"
        }

        observerTask = Task { [weak self] in
            guard let stream = await self?.service.changes else { return }
            for await change in stream {
                await self?.handleChange(change)
            }
        }
    }

    func stop() {
        observerTask?.cancel()
        observerTask = nil
    }

    func add(title: String, body: String = "") async {
        do {
            _ = try await service.insert(title: title, body: body)
        } catch {
            self.error = "Failed to add: \(error)"
        }
    }

    func update(id: PersistentIdentifier, title: String, body: String) async {
        do {
            try await service.update(id, title: title, body: body)
        } catch {
            self.error = "Failed to update: \(error)"
        }
    }

    func delete(id: PersistentIdentifier) async {
        do {
            try await service.delete(id)
        } catch {
            self.error = "Failed to delete: \(error)"
        }
    }

    private func handleChange(_ change: NoteChange) async {
        // refresh on any change (simple), or handle granularly
        do {
            notes = try await service.allNotes()
        } catch {
            self.error = "\(error)"
        }
    }
}

The store:

  • Is @Observable and @MainActor.
  • Uses @ObservationIgnored for private state that shouldn’t trigger view updates (the service reference, the task handle).
  • Has both raw state (notes, searchText) and computed state (filteredNotes).
  • Exposes async action methods.
  • Owns the AsyncStream subscription via start() / stop().

The view becomes thin

struct NotesListView: View {
    @State private var store: NotesStore

    init(store: NotesStore) {
        _store = State(wrappedValue: store)
    }

    var body: some View {
        @Bindable var bindableStore = store
        NavigationStack {
            VStack {
                TextField("Search", text: $bindableStore.searchText)

                if store.isLoading {
                    ProgressView()
                } else if let error = store.error {
                    Text(error).foregroundStyle(.red)
                } else {
                    List {
                        ForEach(store.filteredNotes) { note in
                            NavigationLink(value: note.id) {
                                Text(note.title)
                            }
                        }
                        .onDelete { offsets in
                            Task {
                                for offset in offsets {
                                    await store.delete(id: store.filteredNotes[offset].id)
                                }
                            }
                        }
                    }
                }
            }
            .toolbar {
                Button("Add") {
                    Task { await store.add(title: "New note") }
                }
            }
            .navigationDestination(for: PersistentIdentifier.self) { id in
                NoteEditorView(store: NoteEditorStore(service: store.serviceReference, id: id))
            }
        }
        .task { await store.start() }
    }
}

The view is mostly layout. No SwiftData imports needed in the view file. State and actions all go through the store.

Editor store

For a detail/editor view, a dedicated store:

@MainActor
@Observable
final class NoteEditorStore {
    @ObservationIgnored
    private let service: NoteService
    @ObservationIgnored
    private let id: PersistentIdentifier

    var title: String = ""
    var body: String = ""
    var isLoaded: Bool = false
    var error: String?

    init(service: NoteService, id: PersistentIdentifier) {
        self.service = service
        self.id = id
    }

    func load() async {
        do {
            let note = try await service.note(id: id)
            title = note.title
            body = note.body
            isLoaded = true
        } catch {
            self.error = "\(error)"
        }
    }

    func save() async {
        do {
            try await service.update(id, title: title, body: body)
        } catch {
            self.error = "\(error)"
        }
    }
}

The editor view:

struct NoteEditorView: View {
    @State private var store: NoteEditorStore

    init(store: NoteEditorStore) {
        _store = State(wrappedValue: store)
    }

    var body: some View {
        Form {
            if store.isLoaded {
                @Bindable var bindable = store
                TextField("Title", text: $bindable.title)
                TextEditor(text: $bindable.body)
            } else {
                ProgressView()
            }
        }
        .toolbar {
            Button("Save") {
                Task { await store.save() }
            }
        }
        .task { await store.load() }
    }
}

Composition: stores all the way down

Each view has a store. Stores compose:

@MainActor
@Observable
final class AppStore {
    @ObservationIgnored
    let noteService: NoteService
    @ObservationIgnored
    let folderService: FolderService

    init(container: ModelContainer) {
        self.noteService = NoteService(modelContainer: container)
        self.folderService = FolderService(modelContainer: container)
    }

    func makeNotesStore() -> NotesStore {
        NotesStore(service: noteService)
    }

    func makeEditorStore(for id: PersistentIdentifier) -> NoteEditorStore {
        NoteEditorStore(service: noteService, id: id)
    }
}

The AppStore is the composition root. It owns services and produces stores for views.

In your app entry point:

@main
struct MyApp: App {
    @State private var appStore: AppStore

    init() {
        let container = makeContainer()
        _appStore = State(wrappedValue: AppStore(container: container))
    }

    var body: some Scene {
        WindowGroup {
            NotesListView(store: appStore.makeNotesStore())
        }
        .environment(appStore)  // available throughout the view tree if needed
    }
}

Testing the store

@MainActor
@Test func testNotesStore() async throws {
    let container = try testContainer()
    let service = NoteService(modelContainer: container)
    let store = NotesStore(service: service)

    await store.start()
    #expect(store.notes.isEmpty)

    await store.add(title: "Test")
    // Wait for change notification to propagate
    try await Task.sleep(for: .milliseconds(50))
    #expect(store.notes.count == 1)

    await store.delete(id: store.notes[0].id)
    try await Task.sleep(for: .milliseconds(50))
    #expect(store.notes.isEmpty)
}

The store is fully testable. The view, separately, is testable via snapshot tests or by directly instantiating with a test store.

You can also test stores without any SwiftData by injecting a mock service:

@MainActor
@Test func testNotesStoreWithMock() async {
    let mockService = MockNoteService(initialNotes: [...])
    let store = NotesStore(service: mockService)

    await store.start()
    #expect(store.notes.count == 3)
}

But this requires the service to be protocol-based; you’d refactor NoteService to a protocol with a SwiftData implementation and a mock implementation.

Store granularity

Store size is a design choice:

  • One store per screen. Simple. Each store knows its screen’s data needs.
  • One store per feature. Shared between related screens. More coordination.
  • One global app store. Single source of truth. Hard to scale.

Most apps land between “screen” and “feature.” A NotesStore for the notes list and editor; a FolderStore for folder management; an AccountStore for sign-in.

Pitfalls

Stores that grow into god objects. Split by concern. If a store has 30 methods, it’s too big.

Forgetting @ObservationIgnored on non-state references. Without it, accessing store.service in a view triggers observation, which causes unnecessary re-renders.

Tasks that outlive the store. Cancel in stop() or deinit. Memory leaks are easy here.

Tight coupling between stores. If NotesStore directly imports FolderStore, you’ve got a graph that’s hard to test. Inject dependencies through the constructor; share data via streams, not direct references.

View state mixing with data state. “Is the sheet open” is view state — use @State in the view. “What’s in the sheet” is data state — that’s the store’s. Don’t put sheet-open in the store.

Recreating stores on every view init. If a view’s init always creates a new store, state is lost on each instantiation. Use @State for view-owned stores, or pass them in.

What to internalize

A hand-rolled @Observable store is the cleanest separation between view and data. The store holds state, exposes actions, hides the implementation. The view is a thin projection. Use @ObservationIgnored for non-state references. Subscribe to data-layer streams in start(), unsubscribe in stop(). Compose with an app-level store that owns services. Tests are clean because the store can be exercised directly. This pattern is verbose — each screen has its own store — but it scales to complex apps without the architecture rotting. Choose based on app size: simple apps can use @Query happily; medium apps benefit from view models with repositories; large apps with multiple writers and complex flows reward the store pattern’s discipline.


27. Migrations: VersionedSchema and SchemaMigrationPlan

Once your app ships, schema changes happen. Add a property, remove one, rename a model, restructure relationships. SwiftData’s migration system uses VersionedSchema to define each schema version and SchemaMigrationPlan to describe how to move between them. The system handles lightweight cases automatically and lets you write custom stages for the hard ones.

When you need migration

Any change to a @Model type that affects the schema needs migration handling. This includes:

  • Adding a property (lightweight — uses defaults).
  • Removing a property (lightweight — data is dropped).
  • Renaming a property (via @Attribute(originalName:)).
  • Adding or removing relationships.
  • Adding @Attribute(.unique) to an existing column.
  • Restructuring (e.g., splitting one model into two).
  • Adding or removing models entirely.

Without migration, the app fails to launch (container creation throws) when an old store is opened against a new schema.

VersionedSchema

A VersionedSchema wraps a specific schema version:

import SwiftData

enum NoteSchemaV1: VersionedSchema {
    static var versionIdentifier: Schema.Version { Schema.Version(1, 0, 0) }

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self]
    }

    @Model
    final class Note {
        var title: String
        var body: String
        var createdAt: Date

        init(title: String, body: String, createdAt: Date = .now) {
            self.title = title
            self.body = body
            self.createdAt = createdAt
        }
    }

    @Model
    final class Folder {
        var name: String
        @Relationship(deleteRule: .nullify, inverse: \Note.folder)
        var notes: [Note] = []

        init(name: String) { self.name = name }
    }
}

extension NoteSchemaV1.Note {
    @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
    var folder: NoteSchemaV1.Folder?
}

Wait — that’s already weird. The model definitions are inside the VersionedSchema enum. The reason: each schema version has its own model types, and they need to coexist for migration to inspect both old and new shapes.

In practice, you’d structure it differently for the “current” version: the app uses the types from the latest schema, but the migration path can reference older versions.

A second version

After shipping v1, you decide to add an isPinned: Bool to Note:

enum NoteSchemaV2: VersionedSchema {
    static var versionIdentifier: Schema.Version { Schema.Version(2, 0, 0) }

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self]
    }

    @Model
    final class Note {
        var title: String
        var body: String
        var createdAt: Date
        var isPinned: Bool = false  // new in v2

        init(title: String, body: String, createdAt: Date = .now, isPinned: Bool = false) {
            self.title = title
            self.body = body
            self.createdAt = createdAt
            self.isPinned = isPinned
        }
    }

    @Model
    final class Folder {
        var name: String
        @Relationship(deleteRule: .nullify, inverse: \Note.folder)
        var notes: [Note] = []

        init(name: String) { self.name = name }
    }
}

The new property has a default (false), so existing rows fill in automatically — this is a lightweight migration.

SchemaMigrationPlan

The migration plan ties versions together:

enum NoteMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [NoteSchemaV1.self, NoteSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: NoteSchemaV1.self,
        toVersion: NoteSchemaV2.self
    )
}

MigrationStage.lightweight means “just adopt the new schema, fill defaults, drop unused columns.” No custom code.

Using the plan in the container

let container = try ModelContainer(
    for: NoteSchemaV2.Note.self, NoteSchemaV2.Folder.self,
    migrationPlan: NoteMigrationPlan.self,
    configurations: [ModelConfiguration()]
)

On launch:

  • If the store is already v2, no migration runs.
  • If it’s v1, the plan’s stages execute in order.
  • After successful migration, the store is now v2.

Multiple versions

Over time, you accumulate versions:

enum NoteSchemaV3: VersionedSchema { /* adds tags */ }
enum NoteSchemaV4: VersionedSchema { /* splits Tag from Note */ }

enum NoteMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [NoteSchemaV1.self, NoteSchemaV2.self, NoteSchemaV3.self, NoteSchemaV4.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2, migrateV2toV3, migrateV3toV4]
    }
}

The plan handles linear sequences. A user upgrading from v1 to v4 runs all three stages. A user starting fresh at v4 runs none.

Renaming properties (still lightweight)

@Model
final class Note {
    @Attribute(originalName: "title")
    var heading: String

    var body: String
}

With originalName, SwiftData knows “the column in SQLite is still title, but the Swift property is renamed to heading.” This is lightweight — no data migration, just a metadata update.

Lightweight vs custom

Lightweight migration handles:

  • Adding/removing/renaming properties (with originalName for renames).
  • Adding/removing relationships.
  • Adding/removing entire models.
  • Changing property defaults.

Custom migration is needed for:

  • Splitting one model into two.
  • Merging two models into one.
  • Transforming data formats (e.g., converting a string to a structured type).
  • Computing derived values from existing data.
  • Cleaning up duplicates before adding a unique constraint.

Section 28 covers custom migration stages.

Schema versioning conventions

You’ll keep adding versions over time. A reasonable structure:

  • Keep each schema’s models in a dedicated file (NoteSchemaV1.swift, NoteSchemaV2.swift, etc.).
  • The current app code uses typealiases to the latest schema’s types:

swift typealias Note = NoteSchemaCurrent.Note typealias Folder = NoteSchemaCurrent.Folder enum NoteSchemaCurrent { typealias Note = NoteSchemaV4.Note; ... }

  • The container uses the current version’s types.
  • Old versions stay around just for the migration plan.

This keeps app code clean (use plain Note, Folder) while preserving the version history.

Testing migrations

Migration testing is critical. The pattern:

@Test func testMigrationV1toV2() throws {
    // 1. Create a v1 store with sample data
    let url = URL.temporaryDirectory.appending(path: "test.store")

    let v1Config = ModelConfiguration(schema: Schema(versionedSchema: NoteSchemaV1.self), url: url)
    let v1Container = try ModelContainer(for: NoteSchemaV1.Note.self, NoteSchemaV1.Folder.self,
                                          configurations: [v1Config])
    let v1Context = ModelContext(v1Container)
    v1Context.insert(NoteSchemaV1.Note(title: "Test", body: "Body"))
    try v1Context.save()

    // 2. Tear down v1 container
    // (Container deallocs; store remains)

    // 3. Open with v2 schema and migration plan
    let v2Container = try ModelContainer(
        for: NoteSchemaV2.Note.self, NoteSchemaV2.Folder.self,
        migrationPlan: NoteMigrationPlan.self,
        configurations: [ModelConfiguration(schema: Schema(versionedSchema: NoteSchemaV2.self), url: url)]
    )
    let v2Context = ModelContext(v2Container)

    // 4. Verify migration worked
    let notes = try v2Context.fetch(FetchDescriptor<NoteSchemaV2.Note>())
    #expect(notes.count == 1)
    #expect(notes.first?.title == "Test")
    #expect(notes.first?.isPinned == false)  // default value

    // 5. Clean up
    try FileManager.default.removeItem(at: url)
}

Each migration gets its own test. Sample data feeds in, migration runs, assertions verify the result.

Add migration tests to CI. A failing migration is a shipped data-loss bug.

Migration pitfalls

Skipping versions in the plan. A user on v1 needs v1→v2 and v2→v3, not just v1→v3. Include every stage.

No defaults for new non-optional properties. Lightweight migration fails because existing rows have no value. Provide defaults.

Renames without originalName. Lightweight breaks; the migration sees “old column removed, new column added” and drops the data.

Forgetting to ship a custom migration when needed. Splitting a model can’t be lightweight; the data needs transformation. Write the custom stage.

Testing only the latest migration. Users on much older versions may still be upgrading. Test each historical migration.

Mutating schema files after release. The “v1 schema” must match what was actually shipped in v1. Don’t edit historic schema files; create new versions instead.

Throwing the schema enum away after release. Keep all historical versions; the migration plan needs them.

What to internalize

VersionedSchema wraps a specific schema version with model types. SchemaMigrationPlan lists versions and migration stages. Lightweight migrations handle most cases automatically (defaults, renames via originalName, additions/removals). Custom stages handle data transformations. Test every migration with a v(N-1) store and assertions on the v(N) result. Keep historical schemas; never edit them retroactively. The discipline pays off — a botched migration on a shipped app is among the worst data bugs you can ship.


28. Custom Migration Stages

Lightweight migrations handle adding/removing/renaming. They can’t handle data transformations — splitting one model into two, combining fields, normalizing values, deduplicating before a unique constraint, computing new derived fields. For these, you write a custom migration stage.

This section covers the patterns for custom migrations, with realistic examples.

When you need custom migration

Common scenarios:

  • Splitting a model. v1 had Person with homeAddress and workAddress as flat strings. v2 has Person + Address (separate model) with relationships.
  • Combining models. v1 had EmailAddress and PhoneNumber as separate models. v2 has a single Contact with both.
  • Transforming values. v1 stored country names as "USA". v2 stores ISO codes ("US"). Migration converts each row.
  • Deduplicating before adding a unique constraint. v1 had unconstrained email addresses with duplicates. v2 wants @Attribute(.unique) email. Migration must dedupe first.
  • Computing derived data. v1 stored full names. v2 has separate firstName and lastName. Migration parses each row.

For all of these, lightweight migration would lose data, fail constraints, or leave the schema in an inconsistent state. Custom migration runs your code in the middle.

MigrationStage.custom

The custom variant takes pre- and post-handlers:

static let migrateV2toV3 = MigrationStage.custom(
    fromVersion: NoteSchemaV2.self,
    toVersion: NoteSchemaV3.self,
    willMigrate: { context in
        // Run before migration. Existing schema (v2) is in effect.
        // Common use: clean up data that would fail v3's constraints.
    },
    didMigrate: { context in
        // Run after migration. New schema (v3) is in effect.
        // Common use: populate new properties from existing data.
    }
)

willMigrate runs against the old schema. didMigrate runs against the new schema. Between them, the lightweight portion of the migration happens (schema swap).

Example: deduplicating before unique constraint

V1 had emails that weren’t unique. V2 wants @Attribute(.unique) email. Without deduplication, the migration fails at the constraint addition.

static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: NoteSchemaV1.self,
    toVersion: NoteSchemaV2.self,
    willMigrate: { context in
        // Find duplicates in v1 schema
        let users = try context.fetch(FetchDescriptor<NoteSchemaV1.User>())
        var byEmail: [String: [NoteSchemaV1.User]] = [:]
        for user in users {
            byEmail[user.email, default: []].append(user)
        }

        // Keep one per duplicate group; delete the rest
        for (_, group) in byEmail where group.count > 1 {
            // Keep the most recent (most data)
            let toKeep = group.max(by: { $0.lastActiveAt < $1.lastActiveAt })!
            for user in group where user !== toKeep {
                context.delete(user)
            }
        }
        try context.save()
    },
    didMigrate: { _ in
        // No post-work needed; schema swap handles the rest
    }
)

The willMigrate block reads v1 data, identifies and removes duplicates, saves. Then the schema swaps to v2 (with the unique constraint). The result is clean.

Example: transforming country codes

V1 stored country names ("United States"). V2 stores ISO codes ("US"). The schema looks the same (var country: String), but the values need transformation.

static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: NoteSchemaV1.self,
    toVersion: NoteSchemaV2.self,
    willMigrate: { context in
        let conversions: [String: String] = [
            "United States": "US",
            "Canada": "CA",
            "United Kingdom": "GB",
            "Mexico": "MX",
            // ...
        ]

        let users = try context.fetch(FetchDescriptor<NoteSchemaV1.User>())
        for user in users {
            if let code = conversions[user.country] {
                user.country = code
            }
        }
        try context.save()
    },
    didMigrate: { _ in }
)

The transformation happens in the v1 schema’s willMigrate since the column type is unchanged.

Example: splitting a model

V1 had Person with embedded address fields. V2 introduces a separate Address model.

enum SchemaV1: VersionedSchema {
    @Model
    final class Person {
        var name: String
        var addressStreet: String
        var addressCity: String
        var addressCountry: String

        init(name: String, addressStreet: String, addressCity: String, addressCountry: String) {
            // ...
        }
    }
}

enum SchemaV2: VersionedSchema {
    @Model
    final class Person {
        var name: String
        var address: Address?

        init(name: String) { self.name = name }
    }

    @Model
    final class Address {
        var street: String
        var city: String
        var country: String

        init(street: String, city: String, country: String) {
            self.street = street
            self.city = city
            self.country = country
        }
    }
}

The migration creates Address records from each Person’s embedded fields:

static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        // Run in v1 schema. We can read the embedded fields but can't yet
        // create v2's Address. We capture data in temporary storage:
        // (depends on your specific approach; one option below)
    },
    didMigrate: { context in
        // Run in v2 schema. The address fields are gone from Person.
        // If we captured data in willMigrate, restore here.
    }
)

The issue: in willMigrate, the v2 Address doesn’t exist yet. In didMigrate, the v1 fields are gone. You can’t read v1 data and write v2 data in one go.

The workaround: use lightweight migration not removing the old fields, then a second migration that creates the new structure and removes the old fields.

Or: snapshot the data in willMigrate to a temporary file or memory, then read it back in didMigrate and create the new records.

Or: keep the schema “additive” temporarily — both old fields and new Address model exist in v1.5, then v2 removes the old fields after the data is in the new model.

These patterns are app-specific. The point is: splitting a model is tricky and may require multiple steps.

Example: parsing names

V1 stored fullName. V2 separates into firstName, lastName. New properties have defaults; migration populates them.

enum SchemaV2: VersionedSchema {
    @Model
    final class Person {
        var fullName: String  // keep for now; remove in v3
        var firstName: String = ""
        var lastName: String = ""

        // ...
    }
}

static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { _ in },
    didMigrate: { context in
        let persons = try context.fetch(FetchDescriptor<SchemaV2.Person>())
        for person in persons {
            let parts = person.fullName.components(separatedBy: " ")
            person.firstName = parts.first ?? ""
            person.lastName = parts.dropFirst().joined(separator: " ")
        }
        try context.save()
    }
)

In didMigrate, you have access to both fullName (still present in v2) and the new firstName/lastName. You compute the new values from the old.

In a future v3, you might remove fullName (lightweight). The progression: v1 (full only) → v2 (both) → v3 (parts only). This “two-step” approach handles non-trivial transformations.

Working with large datasets

Migrations against large stores can be slow. A willMigrate that iterates a million rows takes minutes. The app waits at launch.

Strategies:

  • Batch the work. Save every N rows; reset the context to free memory.
  • Don’t run on every launch. If migration succeeded once, it doesn’t run again.
  • Move slow migrations to background. For non-critical schema changes, defer the data transformation to after launch (in a background actor). The schema migration still has to happen at launch, but data backfill can wait.
willMigrate: { context in
    context.autosaveEnabled = false
    let batchSize = 500

    let users = try context.fetch(FetchDescriptor<NoteSchemaV1.User>())
    for (i, user) in users.enumerated() {
        // Transform user
        user.email = user.email.lowercased()

        if i.isMultiple(of: batchSize) {
            try context.save()
            context.reset()
        }
    }
    try context.save()
}

Error handling

If willMigrate or didMigrate throws, the migration fails. The store stays in the old state. The app’s container creation throws.

You probably can’t recover well from migration failure at runtime. The pragmatic options:

  • Crash with a useful message. “Migration failed; try reinstalling.”
  • Offer to reset the store. Delete the SQLite file and start fresh (data loss, but app works).
  • Fall back to old version. Only viable if your old code is still in the app, which is unusual.

For most apps, migrations should be tested extensively in CI so failures don’t ship. If they do, accept that recovery is limited.

Sequencing custom and lightweight stages

A migration plan can mix stage types:

static var stages: [MigrationStage] {
    [
        .lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self),
        .custom(fromVersion: SchemaV2.self, toVersion: SchemaV3.self,
                willMigrate: { /* ... */ },
                didMigrate: { /* ... */ }),
        .lightweight(fromVersion: SchemaV3.self, toVersion: SchemaV4.self)
    ]
}

A user on v1 runs all three stages. A user on v3 runs the last one. SwiftData finds the right starting point.

Testing custom migrations

The same testing pattern as Section 27, with assertions on the transformed data:

@Test func testCustomMigration() throws {
    let url = URL.temporaryDirectory.appending(path: "test.store")

    // Build v1 with data that needs transformation
    let v1Container = try ModelContainer(/* v1 schema */, configurations: [ModelConfiguration(url: url)])
    let v1Context = ModelContext(v1Container)
    v1Context.insert(NoteSchemaV1.Person(fullName: "Alice Smith"))
    v1Context.insert(NoteSchemaV1.Person(fullName: "Bob"))
    try v1Context.save()

    // Migrate to v2
    let v2Container = try ModelContainer(
        /* v2 schema */,
        migrationPlan: SchemaMigrationPlan.self,
        configurations: [ModelConfiguration(url: url)]
    )
    let v2Context = ModelContext(v2Container)

    let persons = try v2Context.fetch(FetchDescriptor<NoteSchemaV2.Person>())
    let alice = persons.first { $0.firstName == "Alice" }
    let bob = persons.first { $0.firstName == "Bob" }

    #expect(alice?.lastName == "Smith")
    #expect(bob?.lastName == "")

    try? FileManager.default.removeItem(at: url)
}

Test edge cases: empty values, missing names, malformed data. The migration code runs once per shipped version; bugs there are very public.

Custom migration pitfalls

Trying to access v2 types in willMigrate. Not yet possible; schema is still v1.

Trying to access v1 types in didMigrate. They’re gone; only v2 is reachable.

Throwing during migration. Leaves the store unmigrated; app fails to launch.

Slow migrations blocking launch. Multi-second migrations are noticeable; minute-long migrations look like a hang. Move what you can to background.

Not testing the migration with realistic data. Empty store always migrates fine; the bugs are in the data.

Forgetting that the migration runs once per user, but is shipped to many users. A bug that affects only 1% of users is still affecting many people. Test for breadth.

What to internalize

MigrationStage.custom lets you run code before and after a schema swap. willMigrate operates on the old schema; didMigrate operates on the new. Use custom migrations for: deduplication, value transformations, model splits/merges, derived field computation. Stage transitions can be lightweight (most cases) or custom (where lightweight isn’t enough). Multi-step transformations may need intermediate schema versions. Test with realistic data; test edge cases. Migration failure has limited recovery options at runtime — invest in CI testing.


29. CloudKit Integration

CloudKit-backed SwiftData syncs your app’s data across the user’s devices via their iCloud account. The integration is built on NSPersistentCloudKitContainer (which itself wraps Core Data + CloudKit), so the same constraints and behaviors apply. This section covers setup, the schema constraints CloudKit imposes, and the patterns for shipping a syncing app.

Setting it up

In the project capabilities:

  1. Enable iCloud capability.
  2. Check CloudKit.
  3. Add a CloudKit container (typically iCloud.com.yourcompany.yourapp).

In code:

let config = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: .private("iCloud.com.yourcompany.yourapp")
)

let container = try ModelContainer(for: schema, configurations: [config])

.private(...) means the user’s private CloudKit database (visible only to them, syncing across their devices). .shared(...) is for shared records (collaboration). .none (the default) is no sync.

On the user’s first launch with sync enabled, SwiftData:

  • Creates a CloudKit zone for the app.
  • Uploads existing local data to CloudKit.
  • Pulls down data from other devices (eventually).
  • Maintains sync going forward.

Sync is asynchronous and “eventually consistent.” Changes made on one device appear on others after some delay.

Schema constraints CloudKit imposes

CloudKit-synced models must follow specific rules:

  • All properties must be optional or have defaults. Required (non-optional, no-default) properties are not allowed. CloudKit may receive partial records and needs to fill in.
  • No @Attribute(.unique) constraints. CloudKit’s distributed nature can’t enforce uniqueness server-side. Use UUIDs and handle conflicts in code.
  • Relationships must be optional. A required relationship (var folder: Folder non-optional) is disallowed.
  • No @Relationship(deleteRule: .deny). Deny rules don’t translate to CloudKit semantics.

If your schema violates these, you’ll see errors at container creation:

NSPersistentCloudKitContainer failed to validate configuration:
Required attribute 'title' on entity 'Note' has no default value

Fix by:

  • Marking properties optional: var title: String?
  • Adding defaults: var title: String = ""
  • Removing .unique: drop the annotation.
  • Removing .deny: change to .nullify or handle deletion in code.

Designing for sync from the start

If you might use CloudKit, design your models with these constraints from day one. It’s much easier than retrofitting:

@Model
final class Note {
    var title: String = ""
    var body: String = ""
    var createdAt: Date = .now
    var isPinned: Bool = false

    @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
    var folder: Folder?

    init(title: String, body: String = "") {
        self.title = title
        self.body = body
    }
}

Everything has a default. All relationships are optional. No uniqueness constraints. This model can sync without changes.

UUID-based identifiers

Since you can’t use @Attribute(.unique), use UUIDs:

@Model
final class Document {
    var id: UUID = UUID()
    var title: String = ""

    init(id: UUID = UUID(), title: String) {
        self.id = id
        self.title = title
    }
}

UUIDs are unique with astronomical probability. They serve as your de facto identifier across devices.

For deduplication (the rare collision case, or accidentally synced duplicates), add a fetch-by-UUID method that warns or merges if multiple rows have the same UUID.

Save propagation timing

After a save, CloudKit uploads in the background. The upload isn’t immediate; CloudKit batches and schedules. Typical latency: seconds for small changes, longer for large batches.

Other devices receive updates via push notifications (silent pushes) when CloudKit has changes to deliver. The OS wakes the app (briefly, or when launched) and SwiftData merges the changes into the local store.

You don’t need to do anything to handle the incoming changes. SwiftData’s main context (and any active @Query) updates automatically when the new data merges in.

Conflict handling

When two devices edit the same record between syncs, CloudKit detects the conflict. Default behavior: “last write wins” — the most recent change overrides.

This is usually fine for “human pace” data (one user editing on one device at a time). For collaborative or high-frequency cases, you may want more nuanced merging:

  • Field-level merging. If user A edits title and user B edits body, merge both changes. Default last-write-wins would lose one.
  • Custom conflict resolution. Detect conflicts in code, prompt the user, or apply business logic.

SwiftData’s exposed APIs for conflict resolution are limited. For sophisticated merging, you may need to drop to NSPersistentCloudKitContainer directly.

Sync status and progress

NSPersistentCloudKitContainer (under the hood) exposes events for sync status. SwiftData partly surfaces these via:

container.persistentStoreCoordinator  // for advanced access

In practice, surfacing sync status to users is tricky. Common approaches:

  • Just trust it. Sync happens; users don’t see it. This works for most data.
  • Show last-sync time. Reset when sync completes. Users can see “up to date as of 2 minutes ago.”
  • Show errors via banner. “Sync failed — please check your iCloud settings.”

For most apps, no status UI is needed.

Sharing data

.private is for the user’s own data. .shared is for records the user shares with others (collaboration). The CloudKit + SwiftData integration for sharing is more complex:

  • Users invite collaborators via CKShare.
  • Shared records appear on each collaborator’s device.
  • Each collaborator can edit; changes sync to all.

Setting this up requires:

  • Enabling shared CloudKit database in capabilities.
  • Implementing CKShare UI (using UICloudSharingController).
  • Adapting your model to handle shared records.

The full sharing setup is beyond this section’s scope; see Apple’s CloudKit sharing documentation when you need it.

Local-first vs sync-first

A design question: does the app work offline, syncing when possible? Or does it require sync to be operational?

For most consumer apps: local-first. The user can use the app offline; sync happens transparently when online. SwiftData’s CloudKit integration supports this by default — local saves work, sync queues, when online sync resolves.

For collaboration-heavy apps: sync-first. Some features (e.g., real-time collaboration) require sync. You’d handle the offline case by disabling those features.

CloudKit pitfalls

Required properties. Forgetting that all properties must be optional or default. Test container creation with CloudKit enabled early.

.unique constraints. Won’t work. Use UUIDs and code-level deduplication.

Expecting immediate sync. Sync is eventual. Don’t tie UI to “data appears on other device within seconds.” It can be minutes.

Large external storage. Files referenced by @Attribute(.externalStorage) upload to CloudKit too — they count against the user’s iCloud storage quota. Be conscientious.

Bundle ID changes after release. If the app’s bundle ID changes (developer renames it), CloudKit container IDs change. Existing user data may not migrate. Plan carefully.

Sandbox / entitlement issues. A common bug: container creation fails because the entitlements file doesn’t include CloudKit or the container ID is wrong. Debug via the device’s console.

Quota errors. Users with full iCloud storage can’t sync. Detect and inform.

Schema changes affecting CloudKit. Migrating a model that’s synced is more complex than migrating a local one. Test carefully.

Testing CloudKit. No reliable simulator support — test on real devices with real iCloud accounts. Set up multiple test accounts for multi-device testing.

What to internalize

CloudKit sync via cloudKitDatabase: .private(...) is the simplest way to add cross-device sync. All properties must be optional or have defaults; no unique constraints; relationships optional; no deny delete rules. Use UUIDs as your de facto identifiers. Sync is eventual — don’t expect immediate cross-device visibility. Default conflict resolution is last-write-wins; complex merging requires dropping below the SwiftData layer. Test on real devices with real iCloud accounts. For sharing, expect to add more work (CKShare, custom UI). Design your schema for CloudKit constraints from the start if you might ever sync.


30. Sharing a Store: Widgets, Extensions, App Groups

Many iOS apps have companion targets: widgets, share extensions, intents, watchOS apps. These run as separate processes but often need access to the same data. To share a SwiftData store across them, you use App Groups — a shared container directory accessible by multiple bundles.

App Groups setup

In Xcode:

  1. Open your project’s main target.
  2. Capabilities → App Groups.
  3. Add a group identifier (e.g., group.com.yourcompany.yourapp).
  4. Repeat for each target that needs access (widget, share extension, etc.).

The group identifier must match across targets.

Loading the store from an App Group

Specify the group container in ModelConfiguration:

let config = ModelConfiguration(
    schema: schema,
    groupContainer: .identifier("group.com.yourcompany.yourapp")
)

let container = try ModelContainer(for: schema, configurations: [config])

This places the SQLite store inside the app group’s shared container directory. Any target with the same group identifier can access it.

Same code in app and widget

Both the app and the widget need the same model definitions. The cleanest approach: share the model code via a Swift package or a shared framework.

// Shared package: MyAppData
@Model
final class Note {
    var title: String = ""
    var body: String = ""
    // ...
}

// In the app:
import MyAppData
let container = makeContainer(group: "group.com.yourcompany.yourapp")
let context = ModelContext(container)
// use it normally

// In the widget:
import MyAppData
let container = makeContainer(group: "group.com.yourcompany.yourapp")
let context = ModelContext(container)
let notes = try context.fetch(FetchDescriptor<Note>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]))
// display in widget UI

The widget reads the store, displays data. Changes the main app makes appear in the widget on its next refresh.

Widget refresh timing

Widgets don’t continuously observe. They have a TimelineProvider that decides when to refresh:

struct NoteWidgetProvider: TimelineProvider {
    func getTimeline(in context: Context, completion: @escaping (Timeline<NoteEntry>) -> Void) {
        do {
            let modelContainer = makeContainer(group: "group.com.yourcompany.yourapp")
            let modelContext = ModelContext(modelContainer)
            let notes = try modelContext.fetch(FetchDescriptor<Note>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]))

            let entry = NoteEntry(date: .now, notes: Array(notes.prefix(3)))
            let timeline = Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(900)))  // refresh in 15min
            completion(timeline)
        } catch {
            completion(Timeline(entries: [], policy: .never))
        }
    }
    // ... rest of TimelineProvider
}

The widget reads the store on each timeline regeneration. To force refresh after a main-app change:

// In the main app, after a write
WidgetCenter.shared.reloadAllTimelines()

This nudges WidgetKit to call your timeline provider again.

Share extension reading the store

A share extension typically accepts data from another app (a URL, a text snippet) and saves to your app’s store. It runs as a separate process:

class ShareViewController: SLComposeServiceViewController {
    override func didSelectPost() {
        Task {
            do {
                let container = makeContainer(group: "group.com.yourcompany.yourapp")
                let context = ModelContext(container)

                // Get the shared content
                let content = self.contentText ?? ""
                let note = Note(title: "From share", body: content)
                context.insert(note)
                try context.save()

                self.extensionContext?.completeRequest(returningItems: nil)
            } catch {
                self.extensionContext?.cancelRequest(withError: error)
            }
        }
    }
}

The share extension creates its own container and context (pointing at the same store), inserts, saves. The main app sees the new data on its next fetch or via change observation.

Intents and Siri shortcuts

App Intents (the modern intents framework) run in your app’s process or a separate intents extension. Either way, you might want the data store accessible. The pattern is the same: shared container, configured with the app group.

struct AddNoteIntent: AppIntent {
    static var title: LocalizedStringResource = "Add a Note"

    @Parameter(title: "Title")
    var title: String

    func perform() async throws -> some IntentResult {
        let container = makeContainer(group: "group.com.yourcompany.yourapp")
        let context = ModelContext(container)

        context.insert(Note(title: title))
        try context.save()

        return .result()
    }
}

Coordinating writes from multiple processes

Multiple processes writing to the same SQLite store works (SQLite handles file locking), but consistency between processes is your concern:

  • Process A writes. Save commits to SQLite.
  • Process B reads. Its context may have cached models from before A’s write. A fetch will read the new data; existing instances may be stale.

For widgets, this is usually fine — widgets re-read on each timeline refresh. For long-lived processes (the main app vs. an intent that ran in the background), you may need to refresh or rely on change observation.

History tracking (iOS 17.4+) helps:

let descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
let transactions = try context.fetchHistory(descriptor)
// inspect transactions to see what was modified by other processes

This is useful for the main app to detect changes made by extensions and refresh accordingly.

File location

The shared store file lives in the app group’s container, typically:

~/Library/Group Containers/group.com.yourcompany.yourapp/Library/Application Support/default.store

On the simulator, the path is under the device’s group containers directory. You can inspect with a SQLite browser.

Migration across targets

If the main app migrates the schema, the widget and extensions might still be running with old code (until the user updates the app, which updates all bundles simultaneously). In practice, on iOS, all targets in the same app bundle update together. But:

  • A widget might be invoked just after the main app updates, before the widget process has been refreshed with new code. You may see brief inconsistencies.
  • Extensions can sometimes lag — running in background, they might use cached code.

The migration plan is applied by whichever process first opens the store post-update. Make sure that process can handle the migration (sufficient memory, time). For very heavy migrations, ensure the main app triggers them, not a brief-lived widget.

Pitfalls

Forgetting to update App Group on all targets. Widget can’t open the store; you get errors at container creation.

Mismatched schemas between targets. The widget links against a different version of the model code than the app. Use a shared package/framework.

Widget not reloading after main app changes. Call WidgetCenter.shared.reloadAllTimelines().

Multiple processes writing without coordination. Conflicts are rare (one user, one device) but possible. Use history tracking to detect changes made by other processes.

Group container path issues during development. Group container directory is shared in production but may have quirks in development builds. Test on real devices.

Sandboxing issues with CloudKit + App Groups. Some combinations require additional entitlements. The error messages aren’t always clear.

What to internalize

App Groups enable shared SwiftData stores across your app, widgets, and extensions. Configure with groupContainer: .identifier(...) and use the same identifier in each target’s entitlements. Share model code via a Swift package or framework. Each process opens its own container and context. After main-app writes, force widget refresh with WidgetCenter.shared.reloadAllTimelines(). Use history tracking to detect cross-process changes. Test with real devices — group container behavior can vary in development. Plan migrations carefully when multiple targets share a store.


31. Common Gotchas and Anti-Patterns

This section is a catalog of the recurring traps in SwiftData. Some have been mentioned throughout; others are new. Use this as a reference when something isn’t working as expected.

Models accessed from wrong actor

The number one source of bugs in SwiftData apps. A Note from the main context, used from a Task.detached. Sometimes crashes. Sometimes silently corrupts data. Sometimes works in debug and fails in release.

Diagnose: the compiler in strict concurrency mode will catch many. Crashes mentioning “context” or “thread” in the stack are usually this.

Fix: pass PersistentIdentifier across actors; resolve in each context.

Saving in tight loops

for note in notes {
    note.isProcessed = true
    try context.save()  // ❌ save per iteration
}

Each save commits a SQLite transaction. 10,000 of them is 10+ seconds.

Fix: save once at the end, or batch with periodic saves.

Forgetting context.insert()

let note = Note(title: "Hi")
// no insert
try context.save()  // doesn't save the note

A common confusion: init doesn’t insert.

Fix: always call context.insert(note) before save().

Implicit insert surprises

let folder = Folder(name: "Work")  // not inserted
let note = Note(title: "Hi", folder: folder)
context.insert(note)
try context.save()  // folder is also saved because it's reachable

If you didn’t want the folder saved (e.g., it was a template), this is a bug.

Fix: don’t assign uninserted models to relationships if you don’t want them persisted, or insert them explicitly with intent.

Forgetting final on @Model classes

@Model class Note {  // missing 'final'
    var title: String
}

Works, but has minor perf overhead and unclear semantics. The macro generates better code for final classes.

Fix: always mark @Model classes final.

Using let for persistent properties

@Model final class Note {
    let title: String  // ❌
}

Compiles, but the property doesn’t behave as expected. The macro generates the property as computed (over backing data); let doesn’t make sense in that context.

Fix: use var.

Property observers on persistent properties

@Model final class Note {
    var title: String {
        didSet { print("changed") }  // ❌ won't fire
    }
}

The macro replaces the stored property with computed accessors; didSet on computed properties doesn’t exist as you’d expect.

Fix: observe via SwiftUI (onChange), or restructure.

Querying into Codable structs

struct Address: Codable { var city: String }

@Model final class User {
    var address: Address
}

let predicate = #Predicate<User> { $0.address.city == "Vancouver" }  // ❌ won't work

The Codable struct is stored as encoded data. Predicates can’t peek inside.

Fix: promote the field to a top-level property.

Forgetting @Attribute(originalName:) when renaming

V1: var emailAddress: String. V2: rename to var email: String. Without @Attribute(originalName: "emailAddress"), the migration sees a removed property and a new property — and drops the data.

Fix: always use @Attribute(originalName:) for property renames across versions.

Unique constraints with CloudKit

@Model final class User {
    @Attribute(.unique) var email: String  // ❌ doesn't work with CloudKit
}

let config = ModelConfiguration(cloudKitDatabase: .private("..."))

CloudKit can’t enforce uniqueness. Either the configuration fails or the constraint is silently ignored.

Fix: for CloudKit-synced models, drop .unique and use UUIDs for identity.

Required relationships with CloudKit

@Model final class Comment {
    var post: Post  // ❌ required relationship
}

CloudKit requires all relationships to be optional. The container creation fails.

Fix: make the relationship optional, or remove CloudKit sync.

Inverse forgotten

@Model final class Folder {
    var notes: [Note] = []  // ❌ no inverse specified
}

@Model final class Note {
    var folder: Folder?  // ❌ no inverse specified
}

May work via inference, may drift over time. The graph stays consistent only if SwiftData knows the inverse pairing.

Fix: always specify @Relationship(inverse:) on one side.

Cascading deletes you didn’t expect

@Relationship(deleteRule: .cascade)
var notes: [Note]

Delete the folder, lose all notes. If you wanted to preserve notes when the folder goes away, this is a bug.

Fix: use .nullify for soft associations.

Memory growth from accumulated context

A long-running process inserts and modifies many models. The context’s in-memory tracking grows. Memory pressure leads to OOM kills.

Fix: context.reset() periodically in long-running operations.

Fetching without limits

let all = try context.fetch(FetchDescriptor<Note>())

If there are 10 million notes, this loads them all. Memory and time explode.

Fix: set fetchLimit on any potentially-large query.

N+1 query patterns

Looping through results and accessing a relationship without prefetching:

for folder in folders {
    print(folder.notes.count)  // each access is a SQL query
}

Fix: descriptor.relationshipKeyPathsForPrefetching = [\.notes] before fetching.

Modifying models from a non-main view body

struct NoteView: View {
    let note: Note
    var body: some View {
        Text(note.title)
            .onAppear {
                note.title = "auto-updated"  // ⚠️ mutating in onAppear
            }
    }
}

Mutations in onAppear are technically allowed on main actor, but doing this often (and combined with autosave) can cause infinite re-render loops.

Fix: mutate in response to user actions; if onAppear initialization is needed, gate it with if !alreadyInitialized.

Autosave saving partial state

User is mid-typing in a TextField. The model has the partial value. Autosave fires. Crashes or backgrounding lose the rest of the typing.

For most use cases, this is fine — the model is consistent at every keystroke. For cases where partial state is invalid (e.g., a URL property where the typed string is "https://" mid-typing), the state in disk is invalid.

Fix: for validation-sensitive properties, use draft state in the view; commit to the model only on validation pass.

Holding references to deleted models

context.delete(note)
try context.save()
// later:
print(note.title)  // 💥 may crash or return empty

Deleted models are stale. Don’t access them.

Fix: clear local references after delete; refetch fresh data if needed.

Container per request

func handleRequest() {
    let container = try! ModelContainer(...)  // ❌ new container per call
    // ...
}

Each container does setup. Per-request is enormous overhead.

Fix: one container per app, held in a long-lived object.

Trying to make models Sendable

extension Note: @unchecked Sendable {}  // ❌ lies

You’re telling the compiler “this is fine to pass across actors.” It’s not. Don’t.

Fix: pass PersistentIdentifier instead.

Custom migration accessing wrong schema

willMigrate: { context in
    let items = try context.fetch(FetchDescriptor<SchemaV2.Item>())  // ❌ V2 not yet active
}

willMigrate runs in v1’s schema. V2 types aren’t materialized yet.

Fix: use v1 types in willMigrate, v2 types in didMigrate.

Migrations that take minutes at launch

A custom migration iterating millions of rows takes time. Users see the launch screen for ages.

Fix: batch the work, defer non-critical transformations to background, or split into multiple smaller migration stages.

Tests that don’t reset state

@Test func testInsert() throws {
    context.insert(Note(...))  // uses a shared container
    // didn't reset; next test sees this data
}

Fix: in-memory containers per test (isStoredInMemoryOnly: true) for clean isolation.

Capturing models in closures that outlive their context

Task.detached {
    // captures `note` from main actor
    note.title = "..."  // ❌ wrong actor
}

Fix: capture the ID; resolve in the new actor.

Excessive use of @Query for lists with many filters

@Query re-fetches when its parameters change. Many parameters means many re-fetches.

Fix: for complex filtering, drop to manual fetch with a view model that batches re-queries.

Ignoring save errors

try? context.save()  // ❌ silent failure

A failed save loses data. The user thinks their work is saved.

Fix: handle errors, at least logging them. For user actions, surface the failure.

Composite delete rules with relationships

@Relationship(deleteRule: .cascade)
var posts: [Post]

// In Post:
@Relationship(deleteRule: .cascade)
var comments: [Comment]

Delete user → cascade to posts → cascade to comments. Deep chains can be slow or recursive in surprising ways.

Fix: test deep cascades with realistic data. Consider whether cascade is what you want or if soft-delete is more appropriate.

Forgetting that model(for:) can return nil

let note = context.model(for: id) as! Note  // ❌ force unwrap

If the model is deleted between when you got the ID and when you resolve, you crash.

Fix: use guard let, handle the nil case.

Multiple writes to the same model from concurrent actors

// Actor A
let note = modelContext.model(for: id) as! Note
note.title = "from A"
try modelContext.save()

// Actor B (in parallel)
let note = modelContext.model(for: id) as! Note
note.title = "from B"
try modelContext.save()

Race condition. Last-write-wins, which may not be what you want.

Fix: serialize writes through a single actor, or implement merge conflict resolution.

What to internalize

Most SwiftData bugs fall into a handful of categories: actor-isolation violations, missing inverses, forgetting saves, wrong delete rules, N+1 queries, and migration mishaps. The patterns to defend against them are: pass PersistentIdentifiers across actors, specify inverses, save deliberately, choose delete rules with intent, prefetch relationships, and test migrations with realistic data. When something isn’t working, check this list — odds are the bug is here.


32. Where to Go Deeper

You’ve now covered SwiftData’s surface area — models and relationships, the context lifecycle, predicates, sorts and limits, concurrency, performance, migrations, CloudKit, and the full range of SwiftUI integration patterns from @Query to hand-rolled @Observable stores. This last section is a map of where to keep learning.

Apple’s documentation

The official SwiftData documentation lives at developer.apple.com/documentation/swiftdata. It’s reference-style, sometimes thin on the “why” — but it’s authoritative on API shape. Browse it after reading this guide; you’ll fill in the corners.

WWDC sessions are the best free SwiftData content:

  • WWDC23: “Meet SwiftData” — the introduction. Watch this for the foundational concepts.
  • WWDC23: “Model your schema with SwiftData” — schema design and migrations.
  • WWDC23: “Migrate to SwiftData” — from Core Data.
  • WWDC24: “What’s new in SwiftData” — iOS 18 additions: history tracking, batch operations, indexes.
  • WWDC24: “Track model changes with SwiftData history” — history tracking in depth.

These sessions are 15-30 minutes each, well-produced, and demonstrate features visually.

Core Data documentation

Since SwiftData is built on Core Data, Apple’s Core Data documentation is often more detailed for specific topics. When SwiftData docs are thin, search for the Core Data equivalent:

  • Faulting: Core Data’s documentation explains the faulting model thoroughly.
  • Performance: Core Data’s “Improving Performance” guide applies almost verbatim.
  • Migrations: Core Data has decades of accumulated migration practice. The patterns are well-documented.
  • CloudKit integration: the NSPersistentCloudKitContainer documentation is more detailed than SwiftData’s equivalent.

You won’t need to write Core Data code, but reading the docs gives you the underlying mental model.

Community resources

  • The Swift forums. The SwiftData section sees Apple engineers participating. Search before posting; many gotchas have been discussed.
  • GitHub issues in popular open-source apps using SwiftData. Real bugs, real fixes.
  • Hacking with Swift has a series of SwiftData tutorials covering individual topics in depth.
  • Stack Overflow for specific issues. Tag SwiftData (and core-data when relevant).

The community is smaller than for older frameworks. You’ll often need to combine multiple sources or experiment to confirm behavior.

Tools

  • SQLite browsers (DB Browser for SQLite, TablePlus). Open your .store file to inspect what’s actually in SQLite. Useful for understanding what SwiftData generates.
  • Xcode’s Memory Graph Debugger. Catches model instance leaks, lingering contexts.
  • Instruments → Time Profiler. When migrations or fetches are slow, profile to see where time is spent.
  • Instruments → Allocations. When memory grows unexpectedly.
  • -com.apple.CoreData.SQLDebug 1 launch argument. See every SQL query SwiftData makes. Essential for diagnosing performance.

Topics to explore beyond this guide

  • The NSPersistentCloudKitContainer escape hatch. When SwiftData’s CloudKit story is insufficient (conflict resolution, sharing, schema changes), drop to Core Data + CloudKit directly. The integration with SwiftData is possible but advanced.
  • History tracking in depth. iOS 17.4+ supports rich change history. Useful for sync, audit, undo. The HistoryDescriptor API is worth a separate dive.
  • Batch operations in iOS 18. context.update(model:where:setting:to:) and context.delete(model:where:). Much faster than per-row.
  • #Index macro. Declarative indexing for hot queries. iOS 18+.
  • Server-side Swift with SwiftData. Vapor + SwiftData is possible. The pattern is unusual but works.
  • Custom property storage. Beyond .externalStorage, advanced patterns for custom serialization.
  • Multiple stores with cross-store queries. Advanced configurations with multiple ModelConfigurations.
  • Live activities and widgets with shared stores. Coordinating UI across processes.

Building production apps

Reading documentation only takes you so far. The real learning happens when you ship.

Some practices that help:

  • Start with one feature. Don’t migrate your entire data layer to SwiftData at once. Pick a contained feature and use it there. Learn the rough edges before committing.
  • Test migrations from day one. Every schema version gets a test that loads v(N-1) data and asserts v(N) behavior. Add to CI.
  • Profile your hot paths. The default behavior is usually right, but for screens that feel slow, profile. The fix is usually obvious once you see the SQL.
  • Adopt strict concurrency early. The compile errors are unpleasant, but they catch real bugs. Resist @unchecked Sendable.
  • Audit your save sites. Every place you call save() should have intentional behavior — explicit save for critical operations, autosave for casual editing.
  • Plan for sync from the start. Even if you don’t sync today, designing models with CloudKit constraints (defaults, optionals, UUIDs) keeps the door open.

Architecture as a long game

SwiftData makes simple things very simple. @Query in a SwiftUI view, autosave on the main context — you can build a working app in an evening.

The harder questions come when:

  • The app grows past a single screen.
  • Multiple features need to coordinate.
  • You want to test without standing up containers.
  • The data is shared across processes.
  • Sync introduces conflict scenarios.

The manual patterns in Sections 23-26 (repositories, ModelActor services, AsyncStream-driven updates, hand-rolled @Observable stores) become more valuable as the app grows. There’s no “right” answer for every app — the right answer depends on size, team, longevity, and constraints. But knowing the patterns means you can choose based on actual tradeoffs rather than rebuilding everything later when @Query runs out of expressive power.

A closing thought

SwiftData is a young framework. The mental models are still being shaped — by Apple engineers, by community feedback, by what works and what breaks at scale. If you’re shipping SwiftData apps today, you’re partly an early adopter. Some things will change; some current best practices will become “the old way.” That’s the cost of new tools.

The benefit is that the framework is being actively improved. The iOS 17.4 history tracking, the iOS 18 batch operations and indexes, the iOS 18 inheritance support — these all came from real user feedback and developer pain points.

When you hit a wall, file feedback. Apple engineers do read it. When you find a pattern that works well, share it (blog posts, conference talks, open-source examples). The framework gets better with each iteration partly because the community pushes on it.

Treat the concurrency rules carefully. Use ModelActor for any non-MainActor work. Pass PersistentIdentifiers across actor boundaries; never models. Migrations are doable but need testing — invest in snapshot tests in CI before shipping.

You’ll write code that works. Then you’ll find an edge case. You’ll dig into the framework — possibly into the Core Data parts that show through — and understand it a bit better. Repeat. Eventually SwiftData becomes a tool you reach for confidently for any object-graph persistence problem on Apple platforms.

Good luck, and have fun.

End of Document