# SwiftData: advanced topics

## Contents

- [Custom data stores](#custom-data-stores)
- [History tracking](#history-tracking)
- [CloudKit in detail](#cloudkit-in-detail)
- [Core Data strategies](#core-data-strategies)
- [Batch work and performance](#batch-work-and-performance)
- [Complex predicates](#complex-predicates)
- [Codable structs as composite attributes](#codable-structs-as-composite-attributes)
- [Model inheritance](#model-inheritance)
- [Several configurations in one container](#several-configurations-in-one-container)
- [Undo and redo](#undo-and-redo)
- [Previews with in-memory stores](#previews-with-in-memory-stores)
- [Save notifications](#save-notifications)
- [Errors](#errors)

## Custom data stores

From iOS 18 you can replace the SQLite backend by conforming to `DataStore`:
a JSON file, a cache, a REST service, anything that can answer fetches and
accept saves.

Shape of a store:

```swift
import SwiftData
import Foundation

struct ArchiveStoreConfiguration: DataStoreConfiguration {
    typealias Store = ArchiveStore
    var name: String
    var schema: Schema?
    let fileURL: URL

    func validate() throws {
        guard fileURL.isFileURL else { throw DataStoreError.unsupportedFeature }
    }
}

final class ArchiveStore: DataStore {
    typealias Configuration = ArchiveStoreConfiguration
    typealias Snapshot = DefaultSnapshot

    let configuration: ArchiveStoreConfiguration
    let identifier: String
    let schema: Schema

    init(_ configuration: ArchiveStoreConfiguration,
         migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
        self.configuration = configuration
        self.identifier = configuration.name
        self.schema = configuration.schema ?? Schema()
    }

    func fetch<T>(_ request: DataStoreFetchRequest<T>) throws
        -> DataStoreFetchResult<T, Snapshot> where T: PersistentModel {
        let snapshots: [DefaultSnapshot] = []   // read from the archive, apply request.descriptor
        return DataStoreFetchResult(descriptor: request.descriptor,
                                    fetchedSnapshots: snapshots,
                                    relatedSnapshots: [:])
    }

    func save(_ request: DataStoreSaveChangesRequest<Snapshot>) throws
        -> DataStoreSaveChangesResult<Snapshot> {
        // write request.inserted, request.updated, request.deleted
        DataStoreSaveChangesResult(for: identifier,
                                   remappedIdentifiers: [:],
                                   snapshotsToReregister: [:])
    }
}
```

The sketch shows the two central members. The protocol also requires
`fetchCount`, `fetchIdentifiers`, `erase()`, `initializeState(for: EditingState)`,
`invalidateState(for:)` and `cachedSnapshots(for:editingState:)`.

Use it like any other configuration:

```swift
let archive = ArchiveStoreConfiguration(name: "Archive", schema: nil, fileURL: archiveURL)
let archivedNotes = try ModelContainer(for: ArchivedNote.self, configurations: archive)
```

Optional extras:

- `DataStoreBatching` adds `delete(_:)` for batch deletes.
- `HistoryProviding` adds `fetchHistory(_:)` and `deleteHistory(_:)`.

When a request is beyond the store, throw a `DataStoreError`:
`.invalidPredicate`, `.preferInMemoryFilter` (SwiftData filters in memory
instead), `.preferInMemorySort`, or `.unsupportedFeature`.

## History tracking

From iOS 18 the default store records a history of transactions, so an app can
catch up on what changed since it last looked, including changes made by
widgets and extensions.

Label your own writes:

```swift
context.author = "main-app"
```

Mark attributes whose values should survive into deletion records:

```swift
@Model
final class Receipt {
    @Attribute(.preserveValueOnDeletion) var number: String
    var amount: Decimal
    init(number: String, amount: Decimal) { self.number = number; self.amount = amount }
}
```

Read changes since a saved token. `mirror` stands in for whatever your app does
with each change (refresh a cache, update a widget, push to a server). The
`.update` and `.delete` payloads are existentials, so cast them to
`DefaultHistoryUpdate<Model>` / `DefaultHistoryDelete<Model>` before reading
`updatedAttributes` or `tombstone`; a tombstone value comes back as
`(any Sendable)?`:

```swift
func applyChanges(since lastToken: DefaultHistoryToken?,
                  in context: ModelContext,
                  mirror: HistoryMirror) throws -> DefaultHistoryToken? {
    var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
    if let lastToken {
        descriptor.predicate = #Predicate { $0.token > lastToken }
    }
    let transactions = try context.fetchHistory(descriptor)
    for transaction in transactions {
        for change in transaction.changes {
            switch change {
            case .insert(let inserted):
                mirror.added(inserted.changedPersistentIdentifier)
            case .update(let updated):
                if let receipt = updated as? DefaultHistoryUpdate<Receipt> {
                    mirror.changed(receipt.changedPersistentIdentifier,
                                   fields: receipt.updatedAttributes)
                }
            case .delete(let deleted):
                if let receipt = deleted as? DefaultHistoryDelete<Receipt> {
                    mirror.removed(receipt.changedPersistentIdentifier,
                                   number: receipt.tombstone[\.number] as? String)
                }
            @unknown default:
                break
            }
        }
    }
    return transactions.last?.token ?? lastToken
}
```

Persist the returned token and pass it in next time. Sorting a
`HistoryDescriptor` by `\.timestamp` needs iOS 26.

Prune old history so it does not grow forever:

```swift
let cutoff = Date.now.addingTimeInterval(-90 * 24 * 60 * 60)
var old = HistoryDescriptor<DefaultHistoryTransaction>()
old.predicate = #Predicate { $0.timestamp < cutoff }
try context.deleteHistory(old)
```

`DefaultHistoryTransaction` carries `author`, `changes`, `storeIdentifier`,
`timestamp`, `token` (a `DefaultHistoryToken`), `transactionIdentifier`,
`bundleIdentifier` and `processIdentifier`. Use `author`, `bundleIdentifier`
and `processIdentifier` to tell app, widget and extension writes apart.

## CloudKit in detail

`cloudKitDatabase:` on `ModelConfiguration` takes:

- `.automatic`: use the container named in the app's entitlements.
- `.private("iCloud.example.app")`: a specific private database.
- `.none`: never sync this store.

Setup:

1. Add the iCloud capability to the target.
2. Check CloudKit; this adds the `com.apple.developer.icloud-services`
   entitlement.
3. Pick or create the container identifier.
4. Under Background Modes, turn on Remote notifications.
5. Name the same container in `ModelConfiguration`.

A model that syncs cleanly:

```swift
@Model
final class JournalEntry {
    var headline: String = ""
    var body: String?
    @Attribute(.allowsCloudEncryption) var mood: String?
    @Attribute(.externalStorage) var sketch: Data?
    var notebook: Notebook?

    init(headline: String, body: String? = nil) {
        self.headline = headline
        self.body = body
    }
}
```

`headline` stays a non-optional `String` with a default, and the initializer
takes a non-optional value for it.

- CloudKit does not enforce uniqueness on the server, so avoid
  `@Attribute(.unique)` and `#Unique` in synced models. A store that needs them
  should use `cloudKitDatabase: .none`.
- Relationships must be optional. Scalars do not all have to be.
- `.deny` delete rules are not supported; enforce that invariant in app code.

Keeping part of the data local while the rest syncs:

```swift
let syncedSchema = Schema([JournalEntry.self, Notebook.self])
let localSchema = Schema([DraftCache.self])

let synced = ModelConfiguration("Synced", schema: syncedSchema,
                                cloudKitDatabase: .private("iCloud.example.journal"))
let local = ModelConfiguration("Local", schema: localSchema,
                               cloudKitDatabase: .none)

let container = try ModelContainer(
    for: Schema([JournalEntry.self, Notebook.self, DraftCache.self]),
    configurations: [synced, local]
)
```

## Core Data strategies

Three paths for an app that has Core Data today:

| Path | When |
|---|---|
| Stay on Core Data | The stack works and nothing needs SwiftData |
| Move fully to SwiftData | New app, or a planned rewrite |
| Run both on one store | Gradual move, screen by screen |

Running both requires:

- Persistent history on the Core Data side:
  set `NSPersistentHistoryTrackingKey` to `true` on the store description
  before loading it.
- Matching entity names. Keep the Swift class names different so they do not
  collide, and give the Core Data class the same entity name:
  `CDInvoice: NSManagedObject` for the entity `Invoice`, and
  `@Model final class Invoice` on the SwiftData side.
- Both stacks opening the same store URL.

Where the store lives: by default under Application Support; with an app group,
at the root of the group container; or anywhere you choose with
`ModelConfiguration(url:)`.

Moving over, in order:

1. Write a `VersionedSchema` that matches the current Core Data model.
2. Write `@Model` classes that match each entity.
3. Add a `SchemaMigrationPlan`.
4. Turn on persistent history in Core Data.
5. Open the shared store file from both stacks.
6. Move reads to `@Query` and `FetchDescriptor`.
7. Move writes to `ModelContext`.
8. Remove the Core Data stack.

Details and a migration test: [core-data-coexistence.md](core-data-coexistence.md).

## Batch work and performance

`delete(model:where:includeSubclasses:)` deletes by predicate without loading
rows. `includeSubclasses` matters once you use model inheritance (iOS 26+).

```swift
try context.delete(model: Invoice.self,
                   where: #Predicate { $0.total == 0 },
                   includeSubclasses: true)
```

Performance habits:

- Page with `fetchLimit` and `fetchOffset`.
- Walk large sets with `enumerate`.
- Count with `fetchCount`; collect IDs with `fetchIdentifiers`.
- Load only needed columns with `propertiesToFetch`.
- Keep blobs out of the table with `.externalStorage`.
- Set `includePendingChanges = false` when unsaved edits do not matter.
- In long imports, save every few hundred rows.

## Complex predicates

These assume an `Invoice` model with `lines`, `client`, `discount`, `memo`,
`issuedAt`, `total` and `budget`. All outside values are bound before the macro.

```swift
let minimum = 3
let cal = Calendar.current
let week = cal.dateInterval(of: .weekOfYear, for: .now)
let startOfWeek = week?.start ?? .distantPast
let endOfWeek = week?.end ?? .distantFuture
let needle = "tax"
let searchNotes = true
let earliest: Date? = nil

// Into a to-many relationship
let hasBigLine = #Predicate<Invoice> { invoice in
    invoice.lines.contains { $0.quantity > minimum }
}
let allPaid = #Predicate<Invoice> { invoice in
    invoice.lines.allSatisfy { $0.isPaid }
}

// Optional chaining and nil coalescing
let clientInBerlin = #Predicate<Invoice> { $0.client?.city == "Berlin" }
let lowDiscount = #Predicate<Invoice> { ($0.discount ?? 0) < 10 }

// Prefix and case-insensitive search
let draftNumber = #Predicate<Invoice> { $0.number.starts(with: "DRAFT-") }
let mentionsTax = #Predicate<Invoice> { $0.memo.localizedStandardContains(needle) }

// Date window and arithmetic between properties
let thisWeek = #Predicate<Invoice> { $0.issuedAt >= startOfWeek && $0.issuedAt < endOfWeek }
let overBudget = #Predicate<Invoice> { $0.total > $0.budget * 1.1 }

// Ternary chooses the field to search
let flexible = #Predicate<Invoice> {
    searchNotes ? $0.memo.localizedStandardContains(needle)
                : $0.number.localizedStandardContains(needle)
}

// Optional parameter that may or may not filter
let floor = earliest ?? .distantPast
let sinceMaybe = #Predicate<Invoice> {
    earliest == nil || $0.issuedAt >= floor
}
```

With model inheritance (iOS 26+) a predicate can test the concrete type:

```swift
let onlyRecurring = #Predicate<Invoice> { $0 is RecurringInvoice }
```

## Codable structs as composite attributes

A `Codable` struct property is stored as a composite attribute
(`Schema.CompositeAttribute`): its fields become columns in the owning model's
table rather than a separate table. Optional composites are allowed. Storing
Codable structs and enums has been supported since SwiftData's first release;
what varies by OS is how well predicates reach inside them, so test those on
your oldest deployment target.

```swift
struct PostalAddress: Codable, Hashable {
    var street: String
    var city: String
    var postcode: String
}

@Model
final class Customer {
    var name: String
    var billing: PostalAddress
    var shipping: PostalAddress?

    init(name: String, billing: PostalAddress) {
        self.name = name
        self.billing = billing
    }
}

let local = #Predicate<Customer> { $0.billing.city == "Lyon" }
```

## Model inheritance

From iOS 26 a `@Model` class can subclass another. The subclass adds stored
properties and calls `super.init`.

```swift
@available(iOS 26, *)
@Model
class Invoice {
    var number: String
    var total: Decimal
    init(number: String, total: Decimal) { self.number = number; self.total = total }
}

@available(iOS 26, *)
@Model
final class RecurringInvoice: Invoice {
    var intervalMonths: Int
    init(number: String, total: Decimal, intervalMonths: Int) {
        self.intervalMonths = intervalMonths
        super.init(number: number, total: total)
    }
}
```

- Fetching `Invoice` returns plain invoices and every subclass.
- Fetching `RecurringInvoice` returns only that subclass.
- `ModelContainer(for: Invoice.self)` registers the subclasses too.

## Several configurations in one container

Separate stores with different sync settings: see the local plus synced
example in [CloudKit in detail](#cloudkit-in-detail).

A read-only seed store shipped in the bundle:

```swift
if let seedURL = Bundle.main.url(forResource: "Species", withExtension: "store") {
    let seed = ModelConfiguration("Seed", schema: Schema([Species.self]),
                                  url: seedURL, allowsSave: false)
    let user = ModelConfiguration("User", schema: Schema([Sighting.self]))
    let fieldGuide = try ModelContainer(for: Schema([Species.self, Sighting.self]),
                                        configurations: [seed, user])
}
```

Sharing with widgets and extensions:

```swift
let shared = ModelConfiguration(groupContainer: .identifier("group.example.journal"))
```

## Undo and redo

Give the context an `UndoManager`, for example once at launch:

```swift
@main
struct LedgerApp: App {
    let container: ModelContainer

    init() {
        do {
            container = try ModelContainer(for: Invoice.self)
        } catch {
            fatalError("Store failed to open: \(error)")
        }
        container.mainContext.undoManager = UndoManager()
    }

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

Or adopt the window's undo manager from a view:

```swift
struct UndoControls: View {
    @Environment(\.modelContext) private var context
    @Environment(\.undoManager) private var undoManager

    var body: some View {
        HStack {
            Button("Undo") { context.undoManager?.undo() }
                .disabled(!(context.undoManager?.canUndo ?? false))
            Button("Redo") { context.undoManager?.redo() }
                .disabled(!(context.undoManager?.canRedo ?? false))
        }
        .onAppear { context.undoManager = undoManager }
    }
}
```

After an insert that must be undoable as its own step, call
`context.processPendingChanges()` so the undo manager records it.

## Previews with in-memory stores

One shared container, seeded once:

```swift
@MainActor
let sampleLedger: ModelContainer = {
    do {
        let container = try ModelContainer(
            for: Customer.self, Invoice.self,
            configurations: ModelConfiguration(isStoredInMemoryOnly: true)
        )
        let customer = Customer(name: "Ada",
                                billing: PostalAddress(street: "1 Main", city: "Lyon", postcode: "69001"))
        container.mainContext.insert(customer)
        container.mainContext.insert(Invoice(number: "INV-1", total: 120))
        return container
    } catch {
        fatalError("Preview store failed: \(error)")
    }
}()

#Preview { InvoiceList().modelContainer(sampleLedger) }
```

With relationships, register every related type, connect the objects, then
insert the parent; inserting the parent inserts the children with it.

From iOS 18, a `PreviewModifier` builds the container once and shares it across
previews:

```swift
struct LedgerSampleData: PreviewModifier {
    typealias Context = ModelContainer

    static func makeSharedContext() async throws -> Context {
        let container = try ModelContainer(
            for: Invoice.self,
            configurations: ModelConfiguration(isStoredInMemoryOnly: true)
        )
        container.mainContext.insert(Invoice(number: "INV-7", total: 42))
        return container
    }

    func body(content: Content, context: Context) -> some View {
        content.modelContainer(context)
    }
}

extension PreviewTrait<Preview.ViewTraits> {
    @MainActor static var ledgerSample: Self = .modifier(LedgerSampleData())
}

#Preview(traits: .ledgerSample) { InvoiceList() }
```

## Save notifications

Observe `ModelContext.didSave` for a specific context:

```swift
let token = NotificationCenter.default.addObserver(
    forName: ModelContext.didSave, object: context, queue: .main
) { note in
    let inserted = note.userInfo?[ModelContext.NotificationKey.insertedIdentifiers.rawValue]
        as? Set<PersistentIdentifier> ?? []
    refreshBadges(for: inserted)
}
```

Keys under `ModelContext.NotificationKey`: `.insertedIdentifiers`,
`.updatedIdentifiers` and `.deletedIdentifiers` (each a
`Set<PersistentIdentifier>`), plus `.invalidatedAllIdentifiers` and
`.queryGeneration`.

## Errors

Catch `SwiftDataError` and compare against its static values:

```swift
do {
    try context.save()
} catch let error as SwiftDataError {
    switch error {
    case .modelValidationFailure:
        // for example a uniqueness conflict
        break
    case .unsupportedPredicate, .unsupportedSortDescriptor:
        break
    case .loadIssueModelContainer:
        break
    default:
        break
    }
} catch {
    // not a SwiftDataError; without this clause the do-catch is not exhaustive
}
```

Grouped by where they come from:

| Area | Errors |
|---|---|
| Fetch | `.unsupportedPredicate`, `.unsupportedSortDescriptor`, `.unsupportedKeyPath`, `.includePendingChangesWithBatchSize` |
| Configuration | `.duplicateConfiguration`, `.configurationFileNameContainsInvalidCharacters`, `.configurationSchemaNotFoundInContainerSchema` |
| Container | `.loadIssueModelContainer` |
| Context | `.modelValidationFailure`, `.missingModelContext` |
| Migration | `.backwardMigration`, `.unknownSchema` |
| History (iOS 18+) | `.historyTokenExpired`, `.invalidTransactionFetchRequest` |
