swiftdata

$npx mdskill add dpearson2699/swift-ios-skills/swiftdata

Persist, query, and manage structured data using SwiftData.

  • Defines @Model classes with attributes, relationships, and constraints.
  • Uses ModelContainer, ModelContext, @Query, #Predicate, and FetchDescriptor.
  • Recommends schema migrations, CloudKit sync, or Core Data coexistence.
  • Outputs code snippets, configuration steps, and review checklist items.

SKILL.md

.github/skills/swiftdataView on GitHub ↗
---
name: swiftdata
description: "Implement, review, or improve data persistence using SwiftData. Use when defining @Model classes with @Attribute, @Relationship, @Transient, #Unique, or #Index; when querying with @Query, #Predicate, FetchDescriptor, or SortDescriptor; when configuring ModelContainer and ModelContext for SwiftUI or background work with @ModelActor; when planning schema migrations with VersionedSchema and SchemaMigrationPlan; when setting up CloudKit sync with ModelConfiguration; or when coexisting with or migrating from Core Data."
---

# SwiftData

Persist, query, and manage structured data in iOS 26+ apps using SwiftData
with Swift 6.3.

## Contents

- [Model Definition](#model-definition)
- [ModelContainer Setup](#modelcontainer-setup)
- [CloudKit Sync](#cloudkit-sync)
- [CRUD Operations](#crud-operations)
- [`@Query in SwiftUI`](#query-in-swiftui)
- [#Predicate](#predicate)
- [FetchDescriptor](#fetchdescriptor)
- [Schema Versioning and Migration](#schema-versioning-and-migration)
- [Core Data Coexistence Boundary](#core-data-coexistence-boundary)
- [Concurrency (`@ModelActor`)](#concurrency-modelactor)
- [SwiftUI Integration](#swiftui-integration)
- [Common Mistakes](#common-mistakes)
- [Review Checklist](#review-checklist)
- [References](#references)

## Model Definition

Apply `@Model` to a **class** (not struct). It synthesizes `PersistentModel`
conformance. Model instances remain context/actor-bound; pass their
`PersistentIdentifier`, not the instance, across actors.

```swift
@Model
class Trip {
    var name: String
    var destination: String
    var startDate: Date
    var endDate: Date
    var isFavorite: Bool = false
    @Attribute(.externalStorage) var imageData: Data?
    @Relationship(deleteRule: .cascade, inverse: \LivingAccommodation.trip)
    var accommodation: LivingAccommodation?
    @Transient var isSelected: Bool = false  // Always provide default

    init(name: String, destination: String, startDate: Date, endDate: Date) {
        self.name = name; self.destination = destination
        self.startDate = startDate; self.endDate = endDate
    }
}
```

**`@Attribute` options**: `.externalStorage`, `.unique`, `.spotlight`, `.allowsCloudEncryption`, `.preserveValueOnDeletion`, `.ephemeral`, `.transformable(by:)`. Rename: `@Attribute(originalName: "old_name")`.

**`@Relationship`**: `deleteRule:` `.cascade`/`.nullify`(default)/`.deny`/`.noAction`. Specify `inverse:` for reliable behavior. Unidirectional (iOS 18+): `inverse: nil`.

**#Unique (iOS 18+)**: `#Unique<Person>([\.firstName, \.lastName])` -- compound uniqueness.

**Inheritance (iOS 26+)**: `@Model class BusinessTrip: Trip { var company: String }`.

Supported types: `Bool`, `Int`/`UInt` variants, `Float`, `Double`, `String`, `Date`, `Data`, `URL`, `UUID`, `Decimal`, `Array`, `Dictionary`, `Set`, `Codable` enums, `Codable` structs and other compatible `Codable` value types, and relationships to `@Model` classes.

## ModelContainer Setup

```swift
// Basic
let container = try ModelContainer(for: Trip.self, LivingAccommodation.self)

// Configured
let config = ModelConfiguration("Store", isStoredInMemoryOnly: false,
    groupContainer: .identifier("group.com.example.app"),
    cloudKitDatabase: .private("iCloud.com.example.app"))
let container = try ModelContainer(for: Trip.self, configurations: config)

// With migration plan
let container = try ModelContainer(for: SchemaV2.Trip.self,
    migrationPlan: TripMigrationPlan.self)

// In-memory (previews/tests)
let container = try ModelContainer(for: Trip.self,
    configurations: ModelConfiguration(isStoredInMemoryOnly: true))
```

## CloudKit Sync

`ModelConfiguration(..., cloudKitDatabase:)` opts a SwiftData store into
automatic CloudKit sync, but app entitlements still gate sync.

For any SwiftData CloudKit setup or schema-review task, include a separate
**Capabilities** verdict before schema findings:

- **Capabilities**: Xcode target has the iCloud capability with CloudKit enabled
  and the intended container selected, plus Background Modes > Remote
  notifications. Without these entitlements, automatic sync is not fully
  configured even if `cloudKitDatabase` is set.
- **Schema compatibility**: no `@Attribute(.unique)` or `#Unique`;
  relationships are optional, have explicit inverses where needed, and avoid
  `.deny`; large `Data` uses `@Attribute(.externalStorage)`.
- **Scalar attributes**: do not make every scalar optional just for CloudKit.
  Keep required scalars nonoptional when initializers, defaults, or migrations
  provide valid values.
- **Schema rollout**: initialize the development schema only in nonproduction
  builds, verify it in CloudKit Dashboard, promote before release, and treat
  production changes as additive only.

## CRUD Operations

For destructive batches and migrations, first run the exact predicate or
version hop against a disposable copy and record affected identifiers/counts.
Execute with explicit transaction/save semantics, refetch, and verify values,
relationships, counts, and invariants. On failure, fix the predicate/schema and
restore a pristine fixture before retrying; never blindly replay a destructive
operation.

```swift
// CREATE
let trip = Trip(name: "Summer", destination: "Paris", startDate: .now, endDate: .now + 86400*7)
modelContext.insert(trip)
try modelContext.save()  // or rely on autosave

// READ
let trips = try modelContext.fetch(FetchDescriptor<Trip>(
    predicate: #Predicate { $0.destination == "Paris" },
    sortBy: [SortDescriptor(\.startDate)]))

// UPDATE -- modify properties directly; autosave handles persistence
trip.destination = "Rome"

// DELETE
modelContext.delete(trip)
try modelContext.delete(model: Trip.self, where: #Predicate { $0.isFavorite == false })

// TRANSACTION (atomic)
try modelContext.transaction {
    modelContext.insert(trip); trip.isFavorite = true
}
```

## `@Query` in SwiftUI

```swift
struct TripListView: View {
    @Query(filter: #Predicate<Trip> { $0.isFavorite == true },
           sort: \.startDate, order: .reverse)
    private var favorites: [Trip]

    var body: some View { List(favorites) { trip in Text(trip.name) } }
}

// Dynamic query via init
struct SearchView: View {
    @Query private var trips: [Trip]
    init(search: String) {
        _trips = Query(filter: #Predicate<Trip> { trip in
            search.isEmpty || trip.name.localizedStandardContains(search)
        }, sort: [SortDescriptor(\.name)])
    }
    var body: some View { List(trips) { trip in Text(trip.name) } }
}

// FetchDescriptor query
struct RecentView: View {
    static var desc: FetchDescriptor<Trip> {
        var d = FetchDescriptor<Trip>(sortBy: [SortDescriptor(\.startDate)])
        d.fetchLimit = 5; return d
    }
    @Query(RecentView.desc) private var recent: [Trip]
    var body: some View { List(recent) { trip in Text(trip.name) } }
}
```

## #Predicate

```swift
#Predicate<Trip> { $0.destination.localizedStandardContains("paris") }  // String
let now = Date()
#Predicate<Trip> { $0.startDate > now }                                 // Date
#Predicate<Trip> { $0.isFavorite && $0.destination != "Unknown" }       // Compound
#Predicate<Trip> { $0.accommodation?.name != nil }                      // Optional
#Predicate<Trip> { $0.tags.contains { $0.name == "adventure" } }        // Collection
```

Supported: `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `!`, `contains()`, `allSatisfy()`, `filter()`, `starts(with:)`, `localizedStandardContains()`, `caseInsensitiveCompare()`, arithmetic, conditional expressions, optional chaining and binding, nil coalescing, type casting. **Avoid**: loops, nested declarations, mutations, and arbitrary unsupported method calls.

## FetchDescriptor

```swift
var d = FetchDescriptor<Trip>(predicate: ..., sortBy: [...])
d.fetchLimit = 20; d.fetchOffset = 0
d.includePendingChanges = true
d.propertiesToFetch = [\.name, \.startDate]
d.relationshipKeyPathsForPrefetching = [\.accommodation]
let trips = try modelContext.fetch(d)
let count = try modelContext.fetchCount(d)
let ids = try modelContext.fetchIdentifiers(d)
try modelContext.enumerate(d, batchSize: 1000) { trip in trip.isProcessed = true }
```

## Schema Versioning and Migration

```swift
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Trip.self] }
    @Model class Trip { var name: String; init(name: String) { self.name = name } }
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [Trip.self] }
    @Model class Trip {
        var name: String; var startDate: Date?  // New property
        init(name: String) { self.name = name }
    }
}

enum TripMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
    static var stages: [MigrationStage] { [migrateV1toV2] }
    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
}

// Custom migration for data transformation
static let migrateV2toV3 = MigrationStage.custom(
    fromVersion: SchemaV2.self, toVersion: SchemaV3.self,
    willMigrate: nil,
    didMigrate: { context in
        let trips = try context.fetch(FetchDescriptor<SchemaV3.Trip>())
        for trip in trips { trip.displayName = trip.name.capitalized }
        try context.save()
    })
```

Lightweight handles: adding optional/defaulted properties, renaming (`originalName`), removing properties, adding model types.
Verify the stage list covers every supported version hop, then migrate a fresh
copy of each old store and assert post-migration data before release.

## Core Data Coexistence Boundary

Use this skill when the work is to run SwiftData alongside an existing Core
Data store or migrate screens from Core Data to SwiftData over time. Keep pure
Core Data stack setup, `NSManagedObjectContext`, `NSFetchRequest`, and batch
Core Data operations in the sibling `core-data` skill.

For coexistence, give boundary guidance before detailed migration advice:

- Point SwiftData and Core Data at the same SQLite store URL.
- Match Core Data entity names, property names, types, and relationship shapes
  in the SwiftData `@Model` definitions.
- Use `@Attribute(originalName:)` for SwiftData properties whose persisted Core
  Data names differ from the Swift names.
- Do not write the same entity from both stacks at the same time; assign one
  stack as the writer for each entity during migration.

## Concurrency (`@ModelActor`)

```swift
@ModelActor
actor DataHandler {
    func importTrips(_ records: [TripRecord]) throws {
        for r in records {
            modelContext.insert(Trip(name: r.name, destination: r.dest,
                                    startDate: r.start, endDate: r.end))
        }
        try modelContext.save()  // Always save explicitly in @ModelActor
    }

    func process(tripID: PersistentIdentifier) throws {
        guard let trip = self[tripID, as: Trip.self] else { return }
        trip.isProcessed = true; try modelContext.save()
    }
}

let handler = DataHandler(modelContainer: container)
try await handler.importTrips(records)
```

**Rules**: `ModelContainer` is `Sendable`. `ModelContext` is NOT -- use on its creating actor. Pass `PersistentIdentifier` (Sendable) across boundaries. Never pass `@Model` objects across actors.

## SwiftUI Integration

```swift
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
            .modelContainer(for: [Trip.self, LivingAccommodation.self])
    }
}

struct DetailView: View {
    @Environment(\.modelContext) private var modelContext
    let trip: Trip
    var body: some View {
        Text(trip.name)
        Button("Delete") { modelContext.delete(trip) }
    }
}

#Preview {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: Trip.self, configurations: config)
    container.mainContext.insert(Trip(name: "Preview", destination: "London",
        startDate: .now, endDate: .now + 86400))
    return TripListView().modelContainer(container)
}
```

## Common Mistakes

**1. `@Model` on struct** -- Use class. `@Model` requires reference semantics.

**2. `@Transient` without default** -- Always provide default: `@Transient var x: Bool = false`.

**3. Missing .modelContainer** -- `@Query` returns empty without a container on the view hierarchy.

**4. Passing model objects across actors:**
```swift
// WRONG: await handler.process(trip: trip)
// CORRECT: await handler.process(tripID: trip.persistentModelID)
```

**5. ModelContext on wrong actor:**
```swift
// WRONG: Task.detached { context.fetch(...) }
// CORRECT: Use @ModelActor for background work
```

**6. Unsupported #Predicate expressions:**
```swift
// WRONG: #Predicate<Trip> { $0.name.uppercased() == "PARIS" }
// CORRECT: #Predicate<Trip> { $0.name.localizedStandardContains("paris") }
```

**7. Flow control in #Predicate:**
```swift
// WRONG: #Predicate<Trip> { for tag in $0.tags { ... } }
// CORRECT: #Predicate<Trip> { $0.tags.contains { $0.name == "x" } }
```

**8. No save in `@ModelActor`** -- Always call `try modelContext.save()` explicitly.

**9. ObservableObject with `@Model`** -- Never use `ObservableObject`/`@Published`. `@Model` generates `Observable`. Use `@Query` in views.

**10. Non-optional relationship without default:**
```swift
// WRONG: var accommodation: LivingAccommodation  // crashes on reconstitution
// CORRECT: var accommodation: LivingAccommodation?
```

**11. Cascade without inverse** -- Specify `inverse:` for reliable cascade delete behavior.

**12. DispatchQueue for background data work:**
```swift
// WRONG: DispatchQueue.global().async { ModelContext(container).fetch(...) }
// CORRECT: @ModelActor actor Handler { func fetch() throws { ... } }
```

## Review Checklist

- [ ] Every `@Model` is a class with a designated initializer
- [ ] All `@Transient` properties have default values
- [ ] Relationships specify `deleteRule` and `inverse`
- [ ] `.modelContainer` attached at scene/root view level
- [ ] `@Query` used for reactive data display in SwiftUI
- [ ] `#Predicate` uses only supported operators
- [ ] Background work uses `@ModelActor`
- [ ] `PersistentIdentifier` used across actor boundaries
- [ ] Schema changes have `VersionedSchema` + `SchemaMigrationPlan`
- [ ] Large data uses `@Attribute(.externalStorage)`
- [ ] CloudKit models avoid uniqueness, use optional relationships, avoid `.deny`, and do not blanket-optionalize scalars
- [ ] CloudKit sync has iCloud + CloudKit, Remote notifications, and production schema rollout checked
- [ ] Explicit `save()` in `@ModelActor` methods
- [ ] Previews use `ModelConfiguration(isStoredInMemoryOnly: true)`
- [ ] `@Model` classes accessed from SwiftUI views are on `@MainActor` via `@ModelActor` or MainActor isolation

## References

- [references/swiftdata-advanced.md](references/swiftdata-advanced.md) — custom data stores, history tracking, CloudKit, composite attributes, model inheritance, undo/redo, performance
- [references/swiftdata-queries.md](references/swiftdata-queries.md) — `@Query` variants, FetchDescriptor deep dive, sectioned queries, dynamic queries, background fetch
- [references/core-data-coexistence.md](references/core-data-coexistence.md) — Core Data + SwiftData coexistence and migration boundaries
- [references/predicate-pitfalls.md](references/predicate-pitfalls.md) — #Predicate runtime crashes, unsupported expressions, safe patterns
- [references/indexing.md](references/indexing.md) — #Index macro, compound indexes, when to index, migration

More from dpearson2699/swift-ios-skills

SkillDescription
accessorysetupkitDiscover and configure Bluetooth and Wi-Fi accessories using AccessorySetupKit. Use when presenting a privacy-preserving accessory picker, defining discovery descriptors for BLE or Wi-Fi devices, handling accessory session events, migrating from CoreBluetooth permission-based scanning, or setting up accessories without requiring broad Bluetooth permissions.
activitykitImplement, review, or improve Live Activities and Dynamic Island experiences in iOS apps using ActivityKit. Use when building real-time updating widgets for the Lock Screen and Dynamic Island — delivery tracking, sports scores, ride-sharing status, workout timers, media playback, or any time-sensitive information that updates in real time. Also use when working with ActivityKit, ActivityAttributes, Activity lifecycle (request/update/end), Dynamic Island layouts (compact/minimal/expanded), push-to-update Live Activities, or Lock Screen live widgets.
adattributionkitMeasure ad effectiveness with privacy-preserving attribution using AdAttributionKit. Use when registering ad impressions, handling attribution postbacks, updating conversion values, implementing re-engagement attribution, configuring publisher or advertiser apps, or replacing SKAdNetwork with AdAttributionKit for ad measurement.
alarmkitImplement AlarmKit alarms and countdown timers for iOS and iPadOS with Lock Screen, Dynamic Island, StandBy, and paired Apple Watch system UI. Covers AlarmManager scheduling, AlarmAttributes and AlarmPresentation, system Stop and AlarmButton secondary actions, authorization, state observation, countdown widget-extension handoff, and Live Activity integration. Use when building wake-up alarms, countdown timers, or alarm-style alerts that need Apple's system alarm experience.
app-clipsBuild iOS App Clips with invocation URLs, App Clip Codes, NFC, QR codes, Safari banners, Maps, Messages, target setup, App Store Connect experiences, size/capability constraints, NSUserActivity routing, SKOverlay promotion, App Group/keychain handoff, ephemeral notifications, location confirmation, and full-app migration. Use when creating App Clips or wiring App Clip invocation, experience configuration, or full-app handoff.
app-intentsImplement App Intents for Siri, Shortcuts, Spotlight, widgets, Control Center, and Apple Intelligence on iOS. Covers AppIntent actions, AppEntity and EntityQuery models, AppShortcutsProvider phrases, IndexedEntity Spotlight indexing, WidgetConfigurationIntent, SnippetIntent, and assistant schemas. Use when exposing app actions or entities to system surfaces.
app-store-reviewAudits App Store submission readiness and rejection risk across current review guidelines, PrivacyInfo.xcprivacy and required-reason APIs, privacy labels, ATT, StoreKit payments, metadata, entitlements, widgets, and Live Activities. Use when preparing a submission, responding to rejection, reconciling privacy evidence, or separating upload blockers from cleanup.
apple-on-device-aiBuild private, on-device AI features on iPhone, iPad, and Mac with Foundation Models, Core ML, MLX Swift, or llama.cpp. Use when choosing an Apple-local model runtime, building an Apple Intelligence chatbot or tool-calling feature, running an LLM on Apple Silicon, converting or compressing a Python model for Core ML, or comparing on-device inference backends. For Swift Core ML loading and prediction code, use the coreml skill.
appmigrationkitTransfer app data to or from other platforms using AppMigrationKit. Use when implementing system-orchestrated one-time migration between iOS and Android or another platform, building an AppMigrationExtension, packaging transportable resources with ResourcesArchiver, importing resources on the destination device, reporting import progress, handling migration errors and app group cleanup, checking MigrationStatus, or testing migration code with AppMigrationTester.
audioaccessorykitSupport automatic audio switching for paired third-party Bluetooth headphones or earbuds with AudioAccessoryKit. Use when a companion app registers an audio accessory, an app extension reports worn/removed placement or connected source-device changes, or AccessoryControlDevice capabilities and errors need handling. Do not use for general AVAudioSession routing, Bluetooth transport, or initial accessory pairing.