PluginBench
Skill
Pass
Audit score 90

swiftdata

dpearson2699/swift-ios-skills

Implement, review, and improve data persistence in iOS 18+ apps using SwiftData with Swift 6.3.

What is swiftdata?

SwiftData is Apple's modern data persistence framework for iOS 18+ that replaces Core Data with a simpler, Swift-native API. Use it when defining @Model classes, querying with @Query and #Predicate, configuring ModelContainer and ModelContext, planning schema migrations, setting up CloudKit sync, or migrating from Core Data.

  • Define @Model classes with @Attribute, @Relationship, @Transient, #Unique, and #Index decorators for structured data
  • Query data using @Query in SwiftUI, #Predicate, FetchDescriptor, and SortDescriptor with type-safe syntax
  • Configure ModelContainer and ModelContext for SwiftUI integration and background work with @ModelActor
  • Set up automatic CloudKit sync with ModelConfiguration and manage schema versioning with VersionedSchema and SchemaMigrationPlan
  • Perform CRUD operations (create, read, update, delete) with transaction support and autosave semantics
  • Coexist with or migrate from Core Data while maintaining data integrity

How to install swiftdata

npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftdata
Prerequisites
  • iOS 18 or later deployment target
  • Swift 6.3 or later
  • Xcode with SwiftData framework support
  • For CloudKit sync: iCloud capability with CloudKit enabled and Background Modes > Remote notifications configured
Claude Code
Cursor
Windsurf
Cline

How to use swiftdata

  1. 1.Define your data model by applying @Model to a class and decorating properties with @Attribute, @Relationship, or @Transient as needed
  2. 2.Create a ModelContainer in your app's main entry point, specifying all model types and optional configurations like CloudKit database or in-memory storage
  3. 3.In SwiftUI views, use @Query with #Predicate filters and sort descriptors to fetch and display data reactively
  4. 4.For background work or cross-actor data access, use @ModelActor to ensure thread-safe mutations and pass PersistentIdentifier instead of model instances
  5. 5.Plan schema migrations by defining VersionedSchema versions and a SchemaMigrationPlan, then pass the plan to ModelContainer initialization
  6. 6.Test CRUD operations (insert, update, delete) with explicit save() calls and verify data integrity, relationships, and invariants before deploying

Use cases

Good for
  • Building a travel-planning app that persists trips, accommodations, and relationships with CloudKit sync across devices
  • Implementing a search view with dynamic @Query filters that update in real-time as users type
  • Migrating an existing Core Data app to SwiftData while preserving data and handling schema evolution
  • Designing a background task that atomically inserts or updates records using @ModelActor for thread safety
  • Creating a favorites feature with compound predicates and sorted results in SwiftUI lists
Who it's for
  • iOS app developers building apps for iOS 18+
  • Teams migrating from Core Data to a modern Swift-native persistence layer
  • SwiftUI developers who need reactive data binding with @Query
  • Backend or full-stack engineers reviewing data models and schema designs

swiftdata FAQ

When should I use @Transient instead of a regular property?

Use @Transient for computed or temporary state that should not be persisted to disk. Always provide a default value. Example: @Transient var isSelected: Bool = false.

How do I safely pass model instances between actors?

Do not pass model instances directly across actor boundaries. Instead, pass their PersistentIdentifier and refetch the model in the target actor's ModelContext.

What is the difference between @Relationship(deleteRule: .cascade) and .nullify?

.cascade deletes related objects when the parent is deleted; .nullify (default) sets the relationship to nil. Use .cascade for owned relationships and .nullify for optional references.

Can I use SwiftData with Core Data in the same app?

Yes, but they must use separate stores and not share models. Define a clear boundary and migrate data explicitly if transitioning from Core Data to SwiftData.

How do I enable CloudKit sync for SwiftData?

Set cloudKitDatabase in ModelConfiguration (e.g., .private("iCloud.com.example.app")), ensure your Xcode target has iCloud and CloudKit capabilities enabled, and configure Background Modes > Remote notifications.

Full instructions (SKILL.md)

Source of truth, from dpearson2699/swift-ios-skills.


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

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.

@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

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

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

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

#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

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

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)

@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

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

// WRONG: await handler.process(trip: trip)
// CORRECT: await handler.process(tripID: trip.persistentModelID)

5. ModelContext on wrong actor:

// WRONG: Task.detached { context.fetch(...) }
// CORRECT: Use @ModelActor for background work

6. Unsupported #Predicate expressions:

// WRONG: #Predicate<Trip> { $0.name.uppercased() == "PARIS" }
// CORRECT: #Predicate<Trip> { $0.name.localizedStandardContains("paris") }

7. Flow control in #Predicate:

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

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

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