---
name: swiftdata
description: "SwiftData persistence for Apple apps: @Model classes with @Attribute, @Relationship, @Transient, #Unique and #Index; @Query, #Predicate, FetchDescriptor and SortDescriptor; ModelContainer and ModelContext setup in SwiftUI; @ModelActor background work; VersionedSchema and SchemaMigrationPlan migrations; CloudKit sync through ModelConfiguration; Core Data coexistence or migration to SwiftData. Use when implementing, reviewing or improving SwiftData persistence. Not for a standalone Core Data stack tutorial."
metadata:
  source: multi-agent-pipeline
---

# SwiftData

Baseline: apps targeting iOS 26 and later, built with Swift 6.3. Features that
arrived earlier are marked with their first OS so an older deployment target
can still use this skill.

SwiftData stores Swift classes directly. You describe the schema with macros on
the class, a `ModelContainer` owns the store, a `ModelContext` tracks edits, and
SwiftUI reads through `@Query`. Most bugs come from three places: predicates the
store cannot translate, model objects crossing actors, and schema changes
shipped without a migration plan.

## Contents

- [Defining models](#defining-models)
- [Container setup](#container-setup)
- [CloudKit sync](#cloudkit-sync)
- [Create, read, update, delete](#create-read-update-delete)
- [@Query in views](#query-in-views)
- [Writing predicates](#writing-predicates)
- [FetchDescriptor](#fetchdescriptor)
- [Schema versions and migration](#schema-versions-and-migration)
- [Background work with @ModelActor](#background-work-with-modelactor)
- [SwiftUI wiring and previews](#swiftui-wiring-and-previews)
- [Mistakes to catch](#mistakes-to-catch)
- [Review checklist](#review-checklist)
- [References](#references)

## Defining models

`@Model` only applies to classes. It adds conformance to `PersistentModel`
(which refines `Observable`, `Hashable` and `Identifiable`), so a model needs
reference semantics and a designated initializer. Model instances are not
`Sendable`: to hand a model to another actor, pass its `PersistentIdentifier`
(which is `Sendable`) and fetch it again from that actor's context.

```swift
import SwiftData
import Foundation

@Model
class Plant {
    var commonName: String
    var acquiredOn: Date
    var needsRepotting: Bool = false
    @Attribute(.externalStorage) var photo: Data?
    @Relationship(deleteRule: .cascade, inverse: \CareEvent.plant)
    var careEvents: [CareEvent] = []
    var bed: GardenBed?
    @Transient var isHighlighted: Bool = false

    init(commonName: String, acquiredOn: Date = .now) {
        self.commonName = commonName
        self.acquiredOn = acquiredOn
    }
}

@Model
final class GardenBed {
    var label: String
    @Relationship(deleteRule: .nullify, inverse: \Plant.bed)
    var plants: [Plant] = []
    init(label: String) { self.label = label }
}

@Model
final class CareEvent {
    var kind: String
    var loggedAt: Date
    var plant: Plant?

    init(kind: String, loggedAt: Date) {
        self.kind = kind
        self.loggedAt = loggedAt
    }
}
```

Rules that follow from the macro:

- A `@Transient` property is never stored, so it needs a default value.
- `@Attribute` options: `.externalStorage` (large blobs beside the store),
  `.unique`, `.spotlight`, `.allowsCloudEncryption`, `.ephemeral`,
  `.transformable(by:)`, and `.preserveValueOnDeletion` (iOS 18+, keeps the
  value in history tombstones).
- Renaming a stored property without losing data:
  `@Attribute(originalName: "old_name") var newName: String`.
- `@Relationship(deleteRule:)` accepts `.cascade`, `.nullify` (the default),
  `.deny` and `.noAction`. Give `inverse:` explicitly; inferred inverses are
  where cascade and nullify stop behaving predictably.
- A one-way relationship (iOS 18+) is declared with `inverse: nil`.
- Compound uniqueness (iOS 18+) lives in the class body:
  `#Unique<Plant>([\.commonName, \.acquiredOn])`.
- A `@Model` class can subclass another `@Model` class (iOS 26+):

```swift
@available(iOS 26, *)
@Model
final class Succulent: Plant {
    var waterEveryDays: Int = 14

    init(commonName: String, waterEveryDays: Int) {
        self.waterEveryDays = waterEveryDays
        super.init(commonName: commonName)
    }
}
```

The base class is declared without `final` so it can be subclassed.

Storable property types: `Bool`, every `Int` and `UInt` width, `Float`,
`Double`, `String`, `Date`, `Data`, `URL`, `UUID`, `Decimal`, arrays,
dictionaries and sets of storable values, `Codable` enums, `Codable` structs and
other compatible `Codable` value types, plus relationships to other `@Model`
classes. Codable value storage is not an iOS 18 feature; it has worked since
SwiftData first shipped. See
[references/swiftdata-advanced.md](references/swiftdata-advanced.md#codable-structs-as-composite-attributes)
for how structs are laid out.

Indexes (`#Index`, iOS 18+) are covered in
[references/indexing.md](references/indexing.md).

## Container setup

```swift
// Every model type the app stores, listed once.
let simple = try ModelContainer(for: Plant.self, CareEvent.self, GardenBed.self)

// Named store, shared with extensions, synced to a private CloudKit database.
let config = ModelConfiguration(
    "Garden",
    isStoredInMemoryOnly: false,
    groupContainer: .identifier("group.example.garden"),
    cloudKitDatabase: .private("iCloud.example.garden")
)
let configured = try ModelContainer(for: Plant.self, configurations: config)

// Versioned schema with a migration plan.
let migrating = try ModelContainer(
    for: GardenSchemaV3.Plant.self,
    migrationPlan: GardenMigrationPlan.self
)

// Tests and previews: nothing touches disk.
let scratch = try ModelContainer(
    for: Plant.self,
    configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
```

## CloudKit sync

Setting `cloudKitDatabase:` on a configuration turns on automatic sync, but the
project entitlements decide whether sync actually runs. For any CloudKit setup
or schema review, give a Capabilities verdict first and the schema findings
after it.

Capabilities:

- The target has the iCloud capability with CloudKit checked and the intended
  container selected.
- Background Modes has Remote notifications enabled.
- Without both, sync is not fully configured even though the code sets
  `cloudKitDatabase`.

Schema rules for a synced store:

- No `@Attribute(.unique)` and no `#Unique`; CloudKit cannot enforce them.
- Relationships are optional, with explicit inverses where needed, and never use
  `.deny`.
- Large `Data` uses `@Attribute(.externalStorage)`.
- Do not make every scalar optional for CloudKit's sake. A required scalar is
  fine when its initializer, a default value or a migration always provides a
  valid value.

Rollout: initialize the development schema only from a non-production build,
inspect it in CloudKit Dashboard, deploy it to production before release, and
from then on make production schema changes additive only.

## Create, read, update, delete

```swift
// Create
let fern = Plant(commonName: "Boston fern")
context.insert(fern)
try context.save()            // or let autosave handle it

// Read
let overdue = try context.fetch(FetchDescriptor<Plant>(
    predicate: #Predicate { $0.needsRepotting },
    sortBy: [SortDescriptor(\.commonName)]
))

// Update: assign; autosave or an explicit save persists it
fern.needsRepotting = true

// Delete one, or many by predicate
context.delete(fern)
try context.delete(model: CareEvent.self, where: #Predicate { $0.kind == "mist" })

// Group changes so they commit together or not at all
try context.transaction {
    context.insert(Plant(commonName: "Pothos"))
    context.insert(Plant(commonName: "Monstera"))
}
```

## @Query in views

```swift
struct RepotList: View {
    @Query(filter: #Predicate<Plant> { $0.needsRepotting },
           sort: \.acquiredOn, order: .reverse)
    private var plants: [Plant]

    var body: some View {
        List(plants) { Text($0.commonName) }
    }
}
```

When the filter depends on input, build the query in `init`:

```swift
struct PlantSearchResults: View {
    @Query private var plants: [Plant]

    init(term: String) {
        _plants = Query(
            filter: #Predicate<Plant> {
                term.isEmpty || $0.commonName.localizedStandardContains(term)
            },
            sort: \.commonName
        )
    }

    var body: some View { List(plants) { Text($0.commonName) } }
}
```

A static `FetchDescriptor` works too, for example to cap the count:

```swift
struct NewestPlants: View {
    static var newestFive: FetchDescriptor<Plant> {
        var d = FetchDescriptor<Plant>(sortBy: [SortDescriptor(\.acquiredOn, order: .reverse)])
        d.fetchLimit = 5
        return d
    }
    @Query(NewestPlants.newestFive) private var plants: [Plant]
    var body: some View { List(plants) { Text($0.commonName) } }
}
```

All `@Query` initializers, error reporting, sectioning and dynamic sorting:
[references/swiftdata-queries.md](references/swiftdata-queries.md).

## Writing predicates

```swift
let term = "fern"
let cutoff = Calendar.current.date(byAdding: .month, value: -6, to: .now) ?? .distantPast

let byName = #Predicate<Plant> { $0.commonName.localizedStandardContains(term) }
let recent = #Predicate<Plant> { $0.acquiredOn > cutoff }
let combined = #Predicate<Plant> { $0.needsRepotting && $0.commonName != "" }
let inBed = #Predicate<Plant> { $0.bed?.label != nil }
let misted = #Predicate<Plant> { plant in
    plant.careEvents.contains { $0.kind == "mist" }
}
```

Supported: `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `!`, `contains()`,
`allSatisfy()`, `filter()`, `starts(with:)`, `localizedStandardContains()`,
`caseInsensitiveCompare()`, arithmetic, conditional (ternary) expressions,
optional chaining and binding, `??`, and type casts.

Keep out of predicates: loops, local declarations, mutation and any method not
on that list. Such code often compiles and then fails at runtime. The crash
table and a debugging routine are in
[references/predicate-pitfalls.md](references/predicate-pitfalls.md).

## FetchDescriptor

```swift
var descriptor = FetchDescriptor<Plant>(sortBy: [SortDescriptor(\.commonName)])
descriptor.fetchLimit = 50
descriptor.fetchOffset = 100
descriptor.includePendingChanges = true          // the default
descriptor.propertiesToFetch = [\.commonName]
descriptor.relationshipKeyPathsForPrefetching = [\.careEvents]

let page = try context.fetch(descriptor)
let total = try context.fetchCount(descriptor)
let ids = try context.fetchIdentifiers(descriptor)

let sweep = FetchDescriptor<Plant>()
try context.enumerate(sweep, batchSize: 1_000) { plant in
    plant.isHighlighted = false
}
```

## Schema versions and migration

Each shipped schema is an enum that conforms to `VersionedSchema` and nests its
own model classes. The plan lists them oldest first and the stages between them.

```swift
enum GardenSchemaV1: VersionedSchema {
    static let versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Plant.self] }

    @Model final class Plant {
        var name: String
        init(name: String) { self.name = name }
    }
}

enum GardenSchemaV2: VersionedSchema {
    static let versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [Plant.self] }

    @Model final class Plant {
        @Attribute(originalName: "name") var commonName: String
        var sunlight: String = "partial"
        init(commonName: String) { self.commonName = commonName }
    }
}

enum GardenSchemaV3: VersionedSchema {
    static let versionIdentifier = Schema.Version(3, 0, 0)
    static var models: [any PersistentModel.Type] { [Plant.self] }

    @Model final class Plant {
        var commonName: String
        var sunlight: String = "partial"
        var sunlightHours: Int = 4
        init(commonName: String) { self.commonName = commonName }
    }
}

enum GardenMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [GardenSchemaV1.self, GardenSchemaV2.self, GardenSchemaV3.self]
    }
    static var stages: [MigrationStage] {
        [
            .lightweight(fromVersion: GardenSchemaV1.self, toVersion: GardenSchemaV2.self),
            .custom(
                fromVersion: GardenSchemaV2.self, toVersion: GardenSchemaV3.self,
                willMigrate: nil,
                didMigrate: { context in
                    let plants = try context.fetch(FetchDescriptor<GardenSchemaV3.Plant>())
                    for plant in plants {
                        plant.sunlightHours = plant.sunlight == "full" ? 8 : 4
                    }
                    try context.save()
                }
            )
        ]
    }
}
```

A lightweight stage covers: new optional or defaulted properties, renames
through `originalName`, removed properties, and new model types. Anything that
has to compute values goes in a custom stage.

## Background work with @ModelActor

```swift
@ModelActor
actor CareImporter {
    func markWatered(_ ids: [PersistentIdentifier]) throws {
        for id in ids {
            guard let plant = self[id, as: Plant.self] else { continue }
            plant.careEvents.append(CareEvent(kind: "water", loggedAt: .now))
        }
        try modelContext.save()
    }
}

let importer = CareImporter(modelContainer: container)
try await importer.markWatered(plants.map(\.persistentModelID))
```

- The macro gives the actor its own `modelContext`. Autosave does not cover it,
  so every method that writes ends with `try modelContext.save()`.
- `ModelContainer` is `Sendable`. `ModelContext` is not and stays on the actor
  that made it.
- Only `PersistentIdentifier` values (from `persistentModelID`) cross actor
  boundaries. Model instances never do.

## SwiftUI wiring and previews

```swift
@main
struct GardenApp: App {
    var body: some Scene {
        WindowGroup { PlantListView() }
            .modelContainer(for: [Plant.self, CareEvent.self, GardenBed.self])
    }
}

struct AddPlantButton: View {
    @Environment(\.modelContext) private var context
    var body: some View {
        Button("Add") { context.insert(Plant(commonName: "New plant")) }
    }
}

@MainActor
func makePreviewContainer() -> ModelContainer {
    do {
        let container = try ModelContainer(
            for: Plant.self,
            configurations: ModelConfiguration(isStoredInMemoryOnly: true)
        )
        container.mainContext.insert(Plant(commonName: "Aloe"))
        return container
    } catch {
        fatalError("Preview container failed: \(error)")
    }
}

#Preview {
    PlantListView().modelContainer(makePreviewContainer())
}
```

The views that read models get them from the main context: `@Query` and
`@Environment(\.modelContext)` both run on the main actor. Work done in a
`@ModelActor` hands results back as identifiers, and the view resolves them on
the main context. Shared preview containers and `PreviewModifier` traits are in
[references/swiftdata-advanced.md](references/swiftdata-advanced.md#previews-with-in-memory-stores).

## Mistakes to catch

| Wrong | Right |
|---|---|
| `@Model struct` | `@Model` needs a class (reference semantics) |
| `@Transient var x: Int` | `@Transient var x: Int = 0` |
| No `.modelContainer` above the view | `@Query` returns nothing; attach it at the scene or root |
| Passing a model to another actor | Pass `persistentModelID` and resolve on the other side |
| Using a context inside `Task.detached` | Use a `@ModelActor` actor |
| `DispatchQueue.global()` with a new `ModelContext` | Use a `@ModelActor` actor |
| `$0.name.uppercased() == ...` in a predicate | `localizedStandardContains` or `caseInsensitiveCompare` |
| `for` loop inside a predicate | `contains { }` or `allSatisfy { }` |
| No `save()` in an actor method | Work is lost; save explicitly |
| Wrapping a model in `ObservableObject` with `@Published` | It is already `Observable`; read it with `@Query` |
| Non-optional to-one relationship without default | Crashes when the object is loaded back; make it optional |
| `.cascade` without `inverse:` | Unreliable deletes; declare the inverse |

## Review checklist

- [ ] Each `@Model` is a class with a designated initializer.
- [ ] Each `@Transient` property has a default value.
- [ ] Relationships state `deleteRule` and `inverse`.
- [ ] `.modelContainer` is attached at the scene or root view.
- [ ] Views display data through `@Query`.
- [ ] Predicates use only supported operations on stored properties.
- [ ] Background work runs on a `@ModelActor` actor and saves explicitly.
- [ ] Only `PersistentIdentifier` crosses actor boundaries.
- [ ] Schema changes ship with a `VersionedSchema` and a `SchemaMigrationPlan`.
- [ ] Large binary data uses `@Attribute(.externalStorage)`.
- [ ] CloudKit models: no uniqueness, optional relationships, no `.deny`, and
      scalars not made optional without a reason.
- [ ] CloudKit sync: iCloud with CloudKit, Remote notifications, and a
      production schema rollout plan are all confirmed.
- [ ] Previews and tests use `ModelConfiguration(isStoredInMemoryOnly: true)`.
- [ ] Models shown in views come from the main context (main actor); background
      actors return identifiers or `Sendable` values, not models.

## References

- [references/swiftdata-advanced.md](references/swiftdata-advanced.md): custom
  data stores, history tracking, CloudKit details, Core Data strategies, batch
  work and performance, complex predicates, composite attributes, inheritance,
  multiple configurations, undo and redo, previews, save notifications, errors.
- [references/swiftdata-queries.md](references/swiftdata-queries.md): every
  `@Query` initializer, `FetchDescriptor` in depth, sorting, sections, dynamic
  queries, background fetches, aggregates, `enumerate`.
- [references/core-data-coexistence.md](references/core-data-coexistence.md):
  running Core Data and SwiftData on one store and moving from one to the other.
- [references/predicate-pitfalls.md](references/predicate-pitfalls.md): runtime
  predicate failures, what is supported, safe capture, debugging.
- [references/indexing.md](references/indexing.md): `#Index`, compound indexes,
  when an index pays off, migration and verification.
