
Swiftdata
- 2.9k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
swiftdata is a SwiftUI persistence skill for Model setup, Query and Predicate usage, migrations, CloudKit sync, and ModelActor concurrency on iOS 26+.
About
SwiftData covers persistence, querying, and schema management for iOS 26+ apps using Swift 6.3. It documents Model classes with Attribute, Relationship, Transient, Unique, and Index macros, ModelContainer configuration including in-memory previews and group containers, and CloudKit sync prerequisites with capabilities and schema compatibility verdicts. Query guidance spans Query in SwiftUI, Predicate expressions, FetchDescriptor tuning with prefetch and batch enumeration, VersionedSchema migrations, and ModelActor background work with PersistentIdentifier boundaries. A Core Data coexistence section defines shared SQLite store rules, originalName mapping, and single-writer ownership during migration. Common mistakes call out struct models, missing modelContainer, unsupported Predicate expressions, ObservableObject misuse, and DispatchQueue background fetches. Reference files cover advanced stores, predicate pitfalls, and indexing guidance. Use it when implementing data layers, planning schema migrations, enabling CloudKit sync, or moving screens from Core Data to SwiftData while keeping concurrency safe across actors.
- Model definition with Attribute, Relationship, Transient, Unique, and Index macros.
- Query, Predicate, and FetchDescriptor patterns for SwiftUI reactive lists.
- VersionedSchema and SchemaMigrationPlan for lightweight and custom migrations.
- CloudKit sync capabilities checklist and schema compatibility constraints.
- ModelActor concurrency with PersistentIdentifier cross-actor boundaries.
Swiftdata by the numbers
- 2,889 all-time installs (skills.sh)
- +127 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #53 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
swiftdata capabilities & compatibility
- Capabilities
- model macro configuration and relationship inver · swiftui query and dynamic fetchdescriptor querie · predicate authoring within supported operators · versionedschema lightweight and custom migration · cloudkit sync capability and schema compatibilit · modelactor background processing and identifier · core data coexistence boundary guidance
- Use cases
- database · frontend
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftdataAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.9k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do I implement SwiftData models, queries, migrations, and CloudKit sync without Predicate crashes or actor boundary bugs?
Define SwiftData models, queries, migrations, CloudKit sync, and ModelActor concurrency for iOS 26+ apps with Swift 6.3.
Who is it for?
iOS developers adding or migrating local persistence with SwiftData and SwiftUI integration.
Skip if: Skip for pure Core Data stack setup; use the sibling core-data skill for NSManagedObject work.
When should I use this skill?
User defines Model classes, Query, Predicate, ModelContainer, migrations, CloudKit sync, or Core Data coexistence.
What you get
Working SwiftData stack with safe queries, migrations, optional CloudKit sync, and background ModelActor imports.
- @Model schema definitions
- migration plans
- query and sync configuration
By the numbers
- Targets iOS 26+ with Swift 6.3
- Covers 6+ SwiftData property wrappers including @Model, @Attribute, and @Relationship
Files
SwiftData
Persist, query, and manage structured data in iOS 26+ apps using SwiftData with Swift 6.3.
Contents
- Model Definition
- ModelContainer Setup
- CloudKit Sync
- CRUD Operations
- `@Query in SwiftUI`
- #Predicate
- FetchDescriptor
- Schema Versioning and Migration
- Core Data Coexistence Boundary
- Concurrency (`@ModelActor`)
- SwiftUI Integration
- Common Mistakes
- Review Checklist
- References
Model Definition
Apply @Model to a class (not struct). Generates PersistentModel, Observable, Sendable.
@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
// 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" } } // CollectionSupported: ==, !=, <, <=, >, >=, &&, ||, !, 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.
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 work6. 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
@Modelis a class with a designated initializer - [ ] All
@Transientproperties have default values - [ ] Relationships specify
deleteRuleandinverse - [ ]
.modelContainerattached at scene/root view level - [ ]
@Queryused for reactive data display in SwiftUI - [ ]
#Predicateuses only supported operators - [ ] Background work uses
@ModelActor - [ ]
PersistentIdentifierused 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@ModelActormethods - [ ] Previews use
ModelConfiguration(isStoredInMemoryOnly: true) - [ ]
@Modelclasses accessed from SwiftUI views are on@MainActorvia@ModelActoror MainActor isolation
References
- references/swiftdata-advanced.md — custom data stores, history tracking, CloudKit, composite attributes, model inheritance, undo/redo, performance
- references/swiftdata-queries.md —
@Queryvariants, FetchDescriptor deep dive, sectioned queries, dynamic queries, background fetch - references/core-data-coexistence.md — Core Data + SwiftData coexistence and migration boundaries
- references/predicate-pitfalls.md — #Predicate runtime crashes, unsupported expressions, safe patterns
- references/indexing.md — #Index macro, compound indexes, when to index, migration
{
"skill_name": "swiftdata",
"evals": [
{
"id": 1,
"prompt": "Review this SwiftData CloudKit schema for an iOS 26 app: Profile uses @Attribute(.unique) on email, has a required @Relationship(deleteRule: .deny) owner, stores avatarData as Data, and a reviewer says every scalar property must become optional for sync. Give concise corrected guidance with a model sketch.",
"expected_output": "A review that flags SwiftData CloudKit schema incompatibilities, keeps scalar optionality precise, and covers CloudKit capability and schema rollout concerns.",
"files": [],
"assertions": [
"Flags @Attribute(.unique), #Unique, or unique constraints as incompatible with SwiftData CloudKit sync.",
"Flags the required relationship and recommends optional relationships for CloudKit-backed SwiftData models.",
"Flags the .deny delete rule as incompatible with SwiftData CloudKit sync.",
"Recommends @Attribute(.externalStorage) for large Data payloads such as image blobs.",
"Does not claim every scalar property must be optional.",
"Mentions iCloud CloudKit capability, Remote notifications background mode, or CloudKit schema promotion/additive rollout."
]
},
{
"id": 2,
"prompt": "A SwiftData list is slow. It filters trips by destination, sorts by startDate, and uses #Predicate with Date.now directly plus a helper method call on the model. The draft fix adds indexes to every property and says adding indexes never needs migration testing. Review the plan and give corrected predicate and indexing guidance.",
"expected_output": "A review that fixes predicate capture and unsupported-call risks, recommends targeted #Index usage for frequent filters/sorts, and avoids unsourced migration guarantees.",
"files": [],
"assertions": [
"Recommends capturing changing values such as Date.now into a local let before building the predicate.",
"Warns against arbitrary unsupported helper method calls inside #Predicate and suggests stored properties or post-fetch filtering when needed.",
"Recommends targeted #Index declarations for frequently filtered or sorted properties rather than indexing every property.",
"Mentions compound indexes when a query filters and sorts by a repeated property combination.",
"Does not claim adding or removing indexes is always migration-free or never needs testing.",
"Suggests profiling or Instruments/Core Data diagnostics to verify query behavior."
]
},
{
"id": 3,
"prompt": "A Core Data app wants to add SwiftData screens against the existing SQLite store while keeping the old stack alive. The model includes a Codable Address struct and a renamed persisted field. Explain how to route the work and what boundary guidance matters, without turning the answer into a full Core Data tutorial.",
"expected_output": "A boundary answer that treats Core Data + SwiftData coexistence or migration as SwiftData-owned, avoids unsupported Codable availability claims, and preserves store/schema alignment guidance.",
"files": [],
"assertions": [
"States that Core Data + SwiftData coexistence or migration should use the swiftdata skill.",
"Explains that SwiftData must point at the existing persistent store URL when sharing or migrating data.",
"Mentions matching entity names, property names, types, and relationships across the Core Data model and SwiftData @Model classes.",
"Mentions @Attribute(originalName:) or equivalent rename mapping for renamed persisted fields.",
"States that compatible Codable structs or enums can be stored as SwiftData model properties without asserting an unsupported iOS 18+ threshold for Codable value storage.",
"Avoids expanding into a standalone Core Data stack tutorial."
]
}
]
}
Core Data Coexistence
Guidance for using SwiftData alongside an existing Core Data store and for planning a transition from Core Data to SwiftData. For apps that are staying on Core Data without SwiftData, use the sibling core-data skill instead.
When a request says "Core Data" but the actual work is shared-store coexistence, gradual screen migration to @Model, or mapping a .xcdatamodeld to SwiftData types, route to this SwiftData skill. Answer only the high-level boundary first; do not turn the response into a full SwiftData migration plan unless the user asks for implementation detail.
Contents
Core Data + SwiftData Coexistence
Apple's current coexistence sample is documented as iOS 27 / Xcode 27 beta. Use it as source-grounded migration guidance, not as an unconditional iOS 26 platform guarantee. For shipping work on earlier SDKs, verify the exact deployment target and test against copies of real stores.
Docs: Adopting SwiftData for a Core Data app
Using the Same Underlying Store
Both stacks must point to the same SQLite file and agree on the schema. The Core Data .xcdatamodeld and SwiftData @Model classes must describe the same entities and properties.
import SwiftData
import CoreData
// 1. Determine the store URL that Core Data already uses
let storeURL = NSPersistentContainer.defaultDirectoryURL()
.appendingPathComponent("MyAppModel.sqlite")
// 2. Point SwiftData at the same store
let config = ModelConfiguration(
"MyAppModel",
url: storeURL
)
let container = try ModelContainer(
for: Trip.self,
configurations: config
)ModelConfiguration Pointing to Existing Core Data Store
Key rules for coexistence:
1. The @Model class name must match the Core Data entity name. 2. Property names, relationship shapes, and value types must match the existing store schema. 3. Use @Attribute(originalName:) when the SwiftData property name differs from the persisted Core Data property name. 4. Both stacks should use the same store file. 5. Standalone Core Data remains the right scope for apps that are not adopting SwiftData.
// Core Data entity: CDTrip (entity name "Trip" in .xcdatamodeld)
// Attributes: name (String), destination (String), startDate (Date),
// isFavorite (Boolean), imageData (Binary Data)
// Matching SwiftData model
@Model
class Trip {
var name: String
var destination: String
var startDate: Date
var isFavorite: Bool = false
@Attribute(.externalStorage) var imageData: Data?
init(name: String, destination: String, startDate: Date) {
self.name = name
self.destination = destination
self.startDate = startDate
}
}Gradual Coexistence Strategy
// Phase 1: Core Data stack still handles writes;
// SwiftData reads the same store for new UI
@main
struct MyApp: App {
let existingCoreDataStoreURL = NSPersistentContainer.defaultDirectoryURL()
.appendingPathComponent("MyAppModel.sqlite")
var body: some Scene {
WindowGroup {
ContentView()
}
// SwiftData reads from the same store
.modelContainer(for: Trip.self, configurations:
ModelConfiguration(url: existingCoreDataStoreURL)
)
}
}
// Phase 2: New features use SwiftData for both reads and writes
// Phase 3: Migrate remaining Core Data code to SwiftData
// Phase 4: Remove Core Data stack and .xcdatamodeldImportant Coexistence Rules
- Do not write to the same entity from both stacks simultaneously. Pick
one stack per entity for writes to avoid conflicts.
- Enable persistent history tracking on the Core Data side when using Apple's
beta coexistence pattern; SwiftData history can then detect relevant changes.
- Test thoroughly -- schema mismatches between the
.xcdatamodeldand
@Model cause crashes.
Migration from Core Data to SwiftData
Step 1: Map Core Data Entities to @Model Classes
Create a @Model class for each Core Data entity. Property names and types must align with the .xcdatamodeld definition.
// Core Data entity "Article"
// Attributes: id (UUID), title (String), body (String),
// createdAt (Date), isDraft (Boolean)
// Relationships: author (to-one → Author), tags (to-many → Tag)
@Model
class Article {
@Attribute(.unique) var id: UUID
var title: String
var body: String
var createdAt: Date
var isDraft: Bool = true
@Relationship(deleteRule: .nullify, inverse: \Author.articles)
var author: Author?
@Relationship(deleteRule: .nullify, inverse: \Tag.articles)
var tags: [Tag] = []
init(id: UUID = UUID(), title: String, body: String, createdAt: Date = .now) {
self.id = id
self.title = title
self.body = body
self.createdAt = createdAt
}
}
@Model
class Author {
@Attribute(.unique) var id: UUID
var name: String
var articles: [Article] = []
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
}
@Model
class Tag {
@Attribute(.unique) var id: UUID
var name: String
var articles: [Article] = []
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
}Type Mapping Reference
| Core Data Type | SwiftData Type |
|---|---|
| String | String |
| Boolean | Bool |
| Integer 16/32/64 | Int |
| Float / Double | Float / Double |
| Date | Date |
| Binary Data | Data |
| UUID | UUID |
| URI | URL |
| Decimal | Decimal |
| Transformable | Compatible Codable value type; Schema.CompositeAttribute is iOS 17+, while @Attribute(.codable) is iOS 27 beta |
| To-one relationship | Optional reference to @Model |
| To-many relationship | Array of @Model |
Step 2: Schema Versioning Considerations
If the Core Data store has existing data, SwiftData must be able to open it. Use VersionedSchema and SchemaMigrationPlan for non-trivial changes.
// If the SwiftData model exactly matches the Core Data schema,
// no migration is needed -- SwiftData opens the store directly.
// For schema differences, define versioned schemas:
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Article.self, Author.self] }
@Model class Article {
var id: UUID
var title: String
var body: String
var createdAt: Date
init(id: UUID, title: String, body: String, createdAt: Date) {
self.id = id; self.title = title
self.body = body; self.createdAt = createdAt
}
}
@Model class Author {
var id: UUID
var name: String
init(id: UUID, name: String) { self.id = id; self.name = name }
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Article.self, Author.self, Tag.self] }
@Model class Article {
var id: UUID
var title: String
var body: String
var createdAt: Date
var isDraft: Bool = true // New property
init(id: UUID, title: String, body: String, createdAt: Date) {
self.id = id; self.title = title
self.body = body; self.createdAt = createdAt
}
}
@Model class Author {
var id: UUID
var name: String
init(id: UUID, name: String) { self.id = id; self.name = name }
}
@Model class Tag {
var id: UUID
var name: String
init(id: UUID, name: String) { self.id = id; self.name = name }
}
}
enum ArticleMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)]
}
}Step 3: Testing Migration Paths
Always test migration with real production data copies before shipping.
import XCTest
import SwiftData
final class MigrationTests: XCTestCase {
func testCoreDataToSwiftDataMigration() throws {
// 1. Copy a known Core Data store into the test bundle
let sourceURL = Bundle(for: type(of: self))
.url(forResource: "TestStore", withExtension: "sqlite")!
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let destURL = tempDir.appendingPathComponent("TestStore.sqlite")
try FileManager.default.copyItem(at: sourceURL, to: destURL)
// Copy WAL and SHM if they exist
for ext in ["-wal", "-shm"] {
let src = sourceURL.deletingLastPathComponent()
.appendingPathComponent("TestStore.sqlite\(ext)")
if FileManager.default.fileExists(atPath: src.path) {
try FileManager.default.copyItem(
at: src,
to: tempDir.appendingPathComponent("TestStore.sqlite\(ext)")
)
}
}
// 2. Open with SwiftData
let config = ModelConfiguration(url: destURL)
let container = try ModelContainer(
for: SchemaV2.Article.self,
migrationPlan: ArticleMigrationPlan.self,
configurations: config
)
// 3. Verify data survived migration
let context = ModelContext(container)
let articles = try context.fetch(FetchDescriptor<SchemaV2.Article>())
XCTAssertFalse(articles.isEmpty, "Migration should preserve existing articles")
// 4. Verify new properties have defaults
for article in articles {
XCTAssertTrue(article.isDraft, "New isDraft property should default to true")
}
// Cleanup
try FileManager.default.removeItem(at: tempDir)
}
}Migration Checklist
- [ ] Every Core Data entity has a matching
@Modelclass with identical property names and types - [ ] Relationship inverse properties are specified in both directions
- [ ]
VersionedSchemaandSchemaMigrationPlandefined for non-trivial schema changes - [ ]
ModelConfigurationpoints to the existing Core Data SQLite file - [ ] Tested migration with a copy of production data
- [ ] Only one stack writes to each entity during coexistence
- [ ]
automaticallyMergesChangesFromParentenabled on Core Data'sviewContext - [ ]
.xcdatamodeldremoved only after full migration is verified
SwiftData Indexing
Indexes speed up queries on frequently filtered or sorted properties.
Docs: Index macro-74ia2)
#Index (iOS 18+)
Single-Property Index
@Model
class Trip {
@Attribute(.unique) var id: UUID
var destination: String
var startDate: Date
var isFavorite: Bool
init(destination: String, startDate: Date) {
self.id = UUID()
self.destination = destination
self.startDate = startDate
self.isFavorite = false
}
}Use #Index on the model to declare indexes:
@Model
class Trip {
#Index<Trip>([\.destination], [\.startDate])
// ...
}This creates two single-column indexes: one on destination and one on startDate.
Compound Index
@Model
class Trip {
#Index<Trip>([\.isFavorite, \.startDate])
// ...
}A compound index on (isFavorite, startDate) accelerates queries that filter on isFavorite and sort by startDate.
When to Index
Index when:
- A property appears in
#Predicatefilters on large collections (1000+ rows) - A property is used in
SortDescriptoron large collections
Don't index when:
- The collection is small (< few hundred rows)
- The property is rarely queried
- The property changes very frequently (index maintenance cost)
- The property has very low cardinality (e.g., a Boolean on a small table)
- Another schema constraint or existing index already covers the query pattern,
unless profiling shows a separate index helps
Index and Migration
Treat adding or removing an index as a schema change. Test it on representative stores and keep a VersionedSchema / SchemaMigrationPlan ready for any release where the aggregate schema changes exceed SwiftData's automatic migration support.
Verifying Index Usage
Use Instruments > Core Data template to verify that queries use indexes. Look for "Full Table Scan" warnings on frequently executed fetches — these indicate a missing index.
#Predicate Pitfalls
Common runtime crashes and build errors when using #Predicate with SwiftData.
Unsupported Expressions
#Predicate compiles Swift expressions into a query representation. Only a subset of Swift is supported. Unsupported expressions compile but crash at runtime.
| Pattern | Crash? | Fix |
|---|---|---|
$0.name.uppercased() == "PARIS" | Runtime crash | Use localizedStandardContains or caseInsensitiveCompare |
$0.name.count > 5 | Runtime crash | Store length in a separate property or filter after fetch |
$0.tags.count == 0 | Runtime crash | Use $0.tags.isEmpty (iOS 17.4+) or a stored flag |
$0.name.isEmpty on optional String | Runtime crash on some OS versions | Use `$0.name == nil \ |
| Custom computed property | Runtime crash | Only stored @Attribute properties work in predicates |
Date.now captured by value | Stale predicate | Create a let now = Date() before the predicate and capture it |
| Enum raw value comparison | Runtime crash (pre-iOS 18) | Store the raw value as a separate property, or target iOS 18+ |
| Loops, declarations, mutation, or switch-heavy control flow | Build error or runtime failure | Use boolean logic, ternary expressions, optional chaining, or optional binding patterns supported by Foundation Predicate |
| Arbitrary method calls | Runtime crash | Only supported methods (see below) work |
Supported Operations
Comparisons: ==, !=, <, <=, >, >=
Logic: &&, ||, !
String: localizedStandardContains(_:), contains(_:), starts(with:), caseInsensitiveCompare(_:)
Collections: contains(where:), allSatisfy(_:), filter(_:), .isEmpty
Other: optional chaining, nil coalescing (??), ternary (? :), arithmetic (+, -, *, /), type casting (as?, is)
Safe Pattern: Build Predicates Dynamically
func tripPredicate(searchText: String, favoritesOnly: Bool) -> Predicate<Trip> {
let now = Date()
return #Predicate<Trip> { trip in
(searchText.isEmpty || trip.destination.localizedStandardContains(searchText))
&& (!favoritesOnly || trip.isFavorite)
&& trip.startDate > now
}
}Capture all external values as let bindings outside the predicate closure. The predicate captures them by value at creation time.
Debugging Predicate Crashes
When a predicate crashes at runtime with SwiftData.PredicateError or NSInvalidArgumentException:
1. Simplify the predicate to a single clause and add clauses back one at a time 2. Check each clause uses only supported operations on stored properties 3. Test on the minimum deployment target — some operations were added in later iOS versions
SwiftData Advanced Reference
Deep reference for custom data stores, history tracking, CloudKit integration, Core Data coexistence, batch operations, complex predicates, composite attributes, model inheritance, multiple containers, undo/redo, and preview patterns.
---
Contents
- Custom Data Stores (iOS 18+)
- History Tracking and Change Detection (iOS 18+)
- CloudKit Integration
- Core Data Coexistence and Migration
- Batch Operations and Performance
- Complex #Predicate Patterns
- Composite Attributes and Codable Values
- Model Inheritance (iOS 26+)
- Multiple ModelContainer Configurations
- Undo/Redo Support
- Preview Patterns with In-Memory Stores
- Notification Observation
- Error Handling
Custom Data Stores (iOS 18+)
DataStore Protocol
Implement the DataStore protocol to replace the default SQLite-backed store with a custom persistence backend (JSON files, in-memory caches, REST APIs, etc.).
final class JSONStore: DataStore {
typealias Configuration = JSONStoreConfiguration
typealias Snapshot = DefaultSnapshot
let configuration: JSONStoreConfiguration
let identifier: String
let schema: Schema
init(_ configuration: JSONStoreConfiguration,
migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
self.configuration = configuration
self.identifier = configuration.name
self.schema = configuration.schema ?? Schema()
}
func fetch<T: PersistentModel>(
_ request: DataStoreFetchRequest<T>
) throws -> DataStoreFetchResult<T, DefaultSnapshot> {
// Load data from JSON file, apply predicate/sort from request.descriptor
let snapshots: [DefaultSnapshot] = [] // Populate from file
return DataStoreFetchResult(
descriptor: request.descriptor,
fetchedSnapshots: snapshots,
relatedSnapshots: [:]
)
}
func fetchCount<T: PersistentModel>(
_ request: DataStoreFetchRequest<T>
) throws -> Int {
try fetch(request).fetchedSnapshots.count
}
func fetchIdentifiers<T: PersistentModel>(
_ request: DataStoreFetchRequest<T>
) throws -> [PersistentIdentifier] {
try fetch(request).fetchedSnapshots.map(\.persistentIdentifier)
}
func save(
_ request: DataStoreSaveChangesRequest<DefaultSnapshot>
) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
// Persist inserted, updated; remove deleted
return DataStoreSaveChangesResult(
for: identifier,
remappedIdentifiers: [:],
snapshotsToReregister: [:]
)
}
func erase() throws {
// Remove all persisted data
}
func initializeState(for editingState: EditingState) {}
func invalidateState(for editingState: EditingState) {}
func cachedSnapshots(
for identifiers: [PersistentIdentifier],
editingState: EditingState
) throws -> [PersistentIdentifier: DefaultSnapshot] {
[:]
}
}DataStoreConfiguration
struct JSONStoreConfiguration: DataStoreConfiguration {
typealias Store = JSONStore
let name: String
var schema: Schema?
let fileURL: URL
init(name: String, fileURL: URL) {
self.name = name
self.fileURL = fileURL
}
func validate() throws {
// Validate file URL is accessible
}
}Using a Custom Store
let config = JSONStoreConfiguration(
name: "JSONStore",
fileURL: URL.documentsDirectory.appending(path: "data.json")
)
let container = try ModelContainer(
for: Trip.self,
configurations: config
)Optional Conformances
- `DataStoreBatching`: Implement
delete(_:)for batch delete support. - `HistoryProviding`: Implement
fetchHistory(_:)anddeleteHistory(_:)
for change tracking.
DataStoreError Cases
Handle these when implementing custom stores:
| Case | Meaning |
|---|---|
.invalidPredicate | Predicate cannot be evaluated by the store |
.preferInMemoryFilter | Store cannot filter; framework filters in memory |
.preferInMemorySort | Store cannot sort; framework sorts in memory |
.unsupportedFeature | Store does not support the requested operation |
---
History Tracking and Change Detection (iOS 18+)
Enable History Tracking
Set the author property on ModelContext to tag changes with an identifier. Mark attributes with .preserveValueOnDeletion to retain values in tombstones after deletion.
@Model
class Trip {
@Attribute(.preserveValueOnDeletion) var name: String
@Attribute(.preserveValueOnDeletion) var destination: String
var startDate: Date
init(name: String, destination: String, startDate: Date) {
self.name = name
self.destination = destination
self.startDate = startDate
}
}
// Tag context for history attribution
modelContext.author = "mainApp"Fetch History Transactions
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
// Filter by token (only new changes since last check)
if let lastToken = savedToken {
descriptor.predicate = #Predicate<DefaultHistoryTransaction> { transaction in
transaction.token > lastToken
}
}
// iOS 26+: Sort by timestamp
descriptor.sortBy = [SortDescriptor(\.timestamp, order: .reverse)]
let transactions = try modelContext.fetchHistory(descriptor)
for transaction in transactions {
for change in transaction.changes {
switch change {
case .insert(let insert):
let insertedID = insert.changedPersistentIdentifier
// Process new record
case .update(let update):
let updatedID = update.changedPersistentIdentifier
let changedAttributes = update.updatedAttributes
// Process modification
case .delete(let delete):
let deletedID = delete.changedPersistentIdentifier
let tombstone = delete.tombstone
// Access preserved values
if let name = tombstone[\.name] as? String {
// Use preserved name for sync/audit
}
}
}
// Save token for next incremental fetch
savedToken = transaction.token
}Delete Stale History
let cutoffDate = Calendar.current.date(byAdding: .month, value: -3, to: .now)!
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
descriptor.predicate = #Predicate<DefaultHistoryTransaction> { transaction in
transaction.timestamp < cutoffDate
}
try modelContext.deleteHistory(descriptor)DefaultHistoryTransaction Properties
| Property | Type | Description |
|---|---|---|
author | String? | The context author that made the change |
changes | [HistoryChange] | Insert, update, delete changes |
storeIdentifier | String | Store that owns the transaction |
timestamp | Date | When the transaction occurred |
token | DefaultHistoryToken | Opaque token for incremental queries |
transactionIdentifier | ... | Unique transaction ID |
bundleIdentifier | String | Bundle that made the change |
processIdentifier | String | Process that made the change |
Cross-Process Change Detection
Use bundleIdentifier and processIdentifier to differentiate changes from widgets, extensions, or the main app.
for transaction in transactions {
if transaction.author == "widget" {
// Handle widget-originated changes
}
}---
CloudKit Integration
Configuration Options
// Automatic: uses CloudKit entitlement from the app
let autoConfig = ModelConfiguration(
cloudKitDatabase: .automatic
)
// Explicit private database
let privateConfig = ModelConfiguration(
cloudKitDatabase: .private("iCloud.com.example.myapp")
)
// No CloudKit sync
let localConfig = ModelConfiguration(
cloudKitDatabase: .none
)Setup Requirements
1. Enable iCloud capability in Xcode. 2. Add CloudKit entitlement (com.apple.developer.icloud-services). 3. Configure a CloudKit container identifier. 4. Enable Background Modes > Remote notifications. 5. Use the container identifier in ModelConfiguration.
CloudKit-Compatible Model Design
@Model
class SyncedNote {
// Keep required scalars nonoptional when defaults/initializers support them
var title: String = ""
var body: String?
// Encrypt sensitive fields in CloudKit
@Attribute(.allowsCloudEncryption) var secretContent: String?
// Store large data externally
@Attribute(.externalStorage) var attachment: Data?
// Avoid .unique with CloudKit -- CloudKit does not enforce server-side uniqueness
// Use @Attribute(.unique) only for local-only stores
init(title: String? = nil, body: String? = nil) {
self.title = title
self.body = body
}
}CloudKit Limitations
- Unique constraints: CloudKit does not enforce uniqueness server-side.
Avoid @Attribute(.unique) and #Unique on CloudKit-synced models. Use cloudKitDatabase: .none for local-only stores that need uniqueness.
- Relationships: CloudKit requires optional relationships. Do not make every
scalar optional just for CloudKit; keep required scalars when defaults, initializers, or migrations provide valid values.
- Delete rules:
.denyis unsupported for CloudKit sync; enforce that
invariant in app logic if needed.
- Schema changes: Initialize and verify the development schema in
nonproduction builds, promote it before release, and treat production changes as additive-only.
Multiple Stores: Local + Synced
let localConfig = ModelConfiguration(
"Local",
schema: Schema([DraftNote.self]),
cloudKitDatabase: .none
)
let syncedConfig = ModelConfiguration(
"Synced",
schema: Schema([PublishedNote.self]),
cloudKitDatabase: .private("iCloud.com.example.app")
)
let container = try ModelContainer(
for: Schema([DraftNote.self, PublishedNote.self]),
configurations: [localConfig, syncedConfig]
)---
Core Data Coexistence and Migration
Read references/core-data-coexistence.md when the task involves sharing an existing Core Data store, adding SwiftData screens to a Core Data app, or planning migration from Core Data to SwiftData. Keep standalone Core Data stack guidance in the sibling core-data skill.
---
Batch Operations and Performance
Batch Enumeration
Process large result sets without loading all objects into memory:
try modelContext.enumerate(
FetchDescriptor<Trip>(),
batchSize: 5000,
allowEscapingMutations: false
) { trip in
trip.isProcessed = true
}batchSize: Number of objects loaded per batch (default 5000).allowEscapingMutations: Set totrueonly if mutations need to persist
beyond the enumeration block.
Batch Delete
try modelContext.delete(
model: Trip.self,
where: #Predicate { $0.isArchived == true },
includeSubclasses: true // iOS 26+ with inheritance
)Fetching Only Identifiers
When full objects are not needed (e.g., for counting or cross-actor references):
let ids = try modelContext.fetchIdentifiers(FetchDescriptor<Trip>())Fetch Count
let count = try modelContext.fetchCount(
FetchDescriptor<Trip>(predicate: #Predicate { $0.isFavorite == true })
)Partial Property Fetch
Fetch only specific properties to reduce memory:
var descriptor = FetchDescriptor<Trip>()
descriptor.propertiesToFetch = [\.name, \.startDate]
let trips = try modelContext.fetch(descriptor)Relationship Prefetching
Avoid N+1 query problems by prefetching related objects:
var descriptor = FetchDescriptor<Trip>()
descriptor.relationshipKeyPathsForPrefetching = [\.accommodation, \.tags]
let trips = try modelContext.fetch(descriptor)Performance Tips
- Use
fetchLimitandfetchOffsetfor pagination. - Use
enumerateinstead offetchfor processing large datasets. - Use
fetchCountwhen only the count is needed. - Use
fetchIdentifierswhen only IDs are needed. - Use
propertiesToFetchto limit loaded data. - Use
@Attribute(.externalStorage)for largeDatapayloads such as images
and blobs.
- Disable
includePendingChangesif unsaved data is not needed in results. - Call
modelContext.save()periodically during large imports to flush memory.
---
Complex #Predicate Patterns
Nested Collection Predicates
// Trips with at least one high-priority tag
#Predicate<Trip> { trip in
trip.tags.contains { tag in
tag.priority > 5
}
}
// Trips where all items are packed
#Predicate<Trip> { trip in
trip.packingList.allSatisfy { item in
item.isPacked == true
}
}Optional Chaining
// Trips with accommodation in a specific city
#Predicate<Trip> { trip in
trip.accommodation?.city == "Paris"
}
// Nil coalescing
#Predicate<Trip> { trip in
(trip.accommodation?.rating ?? 0) >= 4
}String Operations
// Case-insensitive search
#Predicate<Trip> { trip in
trip.destination.localizedStandardContains(searchText)
}
// Prefix matching
#Predicate<Trip> { trip in
trip.name.starts(with: "Summer")
}Date and Numeric Ranges
let startOfYear = Calendar.current.date(from: DateComponents(year: 2026, month: 1, day: 1))!
let endOfYear = Calendar.current.date(from: DateComponents(year: 2026, month: 12, day: 31))!
#Predicate<Trip> { trip in
trip.startDate >= startOfYear && trip.startDate <= endOfYear
}
// Arithmetic
#Predicate<Trip> { trip in
trip.budget - trip.spent > 100.0
}Ternary Expressions
#Predicate<Trip> { trip in
(trip.isFavorite ? trip.name : trip.destination).localizedStandardContains(searchText)
}Combining Multiple Predicates
Build predicates incrementally using captured variables:
func buildPredicate(
searchText: String,
onlyFavorites: Bool,
minDate: Date?
) -> Predicate<Trip> {
#Predicate<Trip> { trip in
(searchText.isEmpty || trip.name.localizedStandardContains(searchText))
&& (!onlyFavorites || trip.isFavorite == true)
&& (minDate == nil || trip.startDate >= (minDate ?? .distantPast))
}
}Type Casting in Predicates (iOS 26+, with Inheritance)
// Filter for business trips only
#Predicate<Trip> { trip in
trip is BusinessTrip
}---
Composite Attributes and Codable Values
Compatible Codable structs can be represented as composite attributes in the SwiftData schema. Current Apple docs expose Schema.CompositeAttribute on iOS 17+, while the explicit @Attribute(.codable) option is iOS 27 beta. Do not describe Codable value storage as an iOS 18-only feature.
struct Address: Codable {
var street: String
var city: String
var state: String
var zip: String
}
@Model
class Person {
var name: String
var homeAddress: Address // Stored as composite attribute
var workAddress: Address?
init(name: String, homeAddress: Address) {
self.name = name
self.homeAddress = homeAddress
}
}Composite attributes appear as Schema.CompositeAttribute in the schema. Sub-properties are stored inline in the same table. Query individual fields via key-path navigation in #Predicate:
#Predicate<Person> { person in
person.homeAddress.city == "San Francisco"
}---
Model Inheritance (iOS 26+)
Base and Subclass Pattern
@Model
class Trip {
var name: String
var destination: String
var startDate: Date
var endDate: Date
init(name: String, destination: String, startDate: Date, endDate: Date) {
self.name = name
self.destination = destination
self.startDate = startDate
self.endDate = endDate
}
}
@Model
class PersonalTrip: Trip {
var companion: String?
}
@Model
class BusinessTrip: Trip {
var company: String
var expenseReport: Data?
init(name: String, destination: String, startDate: Date, endDate: Date,
company: String) {
self.company = company
super.init(name: name, destination: destination,
startDate: startDate, endDate: endDate)
}
}Querying with Inheritance
// Fetch all trips (includes PersonalTrip and BusinessTrip)
let allTrips = try modelContext.fetch(FetchDescriptor<Trip>())
// Fetch only business trips
let businessTrips = try modelContext.fetch(FetchDescriptor<BusinessTrip>())
// Delete with subclass inclusion
try modelContext.delete(
model: Trip.self,
where: #Predicate { $0.destination == "Cancelled" },
includeSubclasses: true
)Container Registration
Register the base class; subclasses are included automatically:
let container = try ModelContainer(for: Trip.self)
// PersonalTrip and BusinessTrip are included via inheritance---
Multiple ModelContainer Configurations
Separate Stores for Different Data
// Local-only data (no sync)
let localConfig = ModelConfiguration(
"Local",
schema: Schema([AppSettings.self, CacheEntry.self]),
isStoredInMemoryOnly: false,
cloudKitDatabase: .none
)
// Synced data
let syncConfig = ModelConfiguration(
"Synced",
schema: Schema([UserDocument.self, SharedNote.self]),
cloudKitDatabase: .private("iCloud.com.example.app")
)
let container = try ModelContainer(
for: Schema([AppSettings.self, CacheEntry.self, UserDocument.self, SharedNote.self]),
configurations: [localConfig, syncConfig]
)Read-Only Bundled Database
let bundledURL = Bundle.main.url(forResource: "seed", withExtension: "store")!
let readOnlyConfig = ModelConfiguration(
"SeedData",
schema: Schema([ReferenceItem.self]),
url: bundledURL,
allowsSave: false
)App Group Sharing (Widget / Extension)
let sharedConfig = ModelConfiguration(
groupContainer: .identifier("group.com.example.myapp")
)
let container = try ModelContainer(for: Trip.self, configurations: sharedConfig)---
Undo/Redo Support
Setup
let context = ModelContext(container)
context.undoManager = UndoManager()SwiftUI Integration
@main
struct MyApp: App {
let container: ModelContainer
init() {
do {
container = try ModelContainer(for: Trip.self)
container.mainContext.undoManager = UndoManager()
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}Using Undo/Redo
struct TripEditorView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.undoManager) private var undoManager
var body: some View {
VStack {
// ... editing UI ...
}
.toolbar {
ToolbarItemGroup {
Button("Undo") {
modelContext.undoManager?.undo()
}
.disabled(!(modelContext.undoManager?.canUndo ?? false))
Button("Redo") {
modelContext.undoManager?.redo()
}
.disabled(!(modelContext.undoManager?.canRedo ?? false))
}
}
.onAppear {
modelContext.undoManager = undoManager
}
}
}Process pending changes to register undo actions:
modelContext.insert(trip)
modelContext.processPendingChanges()
// Now undo is available for the insertion---
Preview Patterns with In-Memory Stores
Basic Preview Container
@MainActor
let previewContainer: ModelContainer = {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Trip.self, configurations: config)
// Seed sample data
let sampleTrips = [
Trip(name: "Summer in Paris", destination: "Paris",
startDate: .now, endDate: .now.addingTimeInterval(86400 * 7)),
Trip(name: "Tokyo Adventure", destination: "Tokyo",
startDate: .now.addingTimeInterval(86400 * 30),
endDate: .now.addingTimeInterval(86400 * 37)),
]
for trip in sampleTrips {
container.mainContext.insert(trip)
}
return container
}()
#Preview {
TripListView()
.modelContainer(previewContainer)
}Preview with Relationships
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(
for: Trip.self, LivingAccommodation.self,
configurations: config
)
let trip = Trip(name: "Beach Trip", destination: "Malibu",
startDate: .now, endDate: .now.addingTimeInterval(86400 * 3))
let hotel = LivingAccommodation(name: "Beach Resort")
trip.accommodation = hotel
container.mainContext.insert(trip)
return TripDetailView(trip: trip)
.modelContainer(container)
}Preview Trait (iOS 18+)
Use PreviewModifier for reusable preview configurations:
struct SampleDataPreview: PreviewModifier {
static func makeSharedContext() async throws -> ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Trip.self, configurations: config)
// Insert sample data
return container
}
func body(content: Content, context: ModelContainer) -> some View {
content.modelContainer(context)
}
}
extension PreviewTrait where T == Preview.ViewTraits {
static var sampleData: Self = .modifier(SampleDataPreview())
}
#Preview(traits: .sampleData) {
TripListView()
}---
Notification Observation
Observing Save Events
NotificationCenter.default.publisher(for: ModelContext.didSave, object: modelContext)
.sink { notification in
if let insertedIDs = notification.userInfo?[
ModelContext.NotificationKey.insertedIdentifiers
] as? Set<PersistentIdentifier> {
// Handle new insertions
}
if let updatedIDs = notification.userInfo?[
ModelContext.NotificationKey.updatedIdentifiers
] as? Set<PersistentIdentifier> {
// Handle updates
}
if let deletedIDs = notification.userInfo?[
ModelContext.NotificationKey.deletedIdentifiers
] as? Set<PersistentIdentifier> {
// Handle deletions
}
}Available Notification Keys
| Key | Description |
|---|---|
.insertedIdentifiers | IDs of newly inserted models |
.updatedIdentifiers | IDs of updated models |
.deletedIdentifiers | IDs of deleted models |
.invalidatedAllIdentifiers | All data invalidated (e.g., store reset) |
.queryGeneration | Query generation token |
---
Error Handling
SwiftDataError Cases
do {
let trips = try modelContext.fetch(descriptor)
} catch let error as SwiftDataError {
switch error {
case SwiftDataError.unsupportedPredicate:
// Predicate uses unsupported operations
case SwiftDataError.unsupportedSortDescriptor:
// Sort descriptor cannot be processed
case SwiftDataError.modelValidationFailure:
// Model fails validation (e.g., unique constraint)
case SwiftDataError.loadIssueModelContainer:
// Container could not load the store
default:
// Handle other SwiftData errors
}
} catch {
// Handle non-SwiftData errors
}Common Error Categories
| Category | Errors |
|---|---|
| Fetch | .unsupportedPredicate, .unsupportedSortDescriptor, .unsupportedKeyPath, .includePendingChangesWithBatchSize |
| Configuration | .duplicateConfiguration, .configurationFileNameContainsInvalidCharacters, .configurationSchemaNotFoundInContainerSchema |
| Container | .loadIssueModelContainer |
| Context | .modelValidationFailure, .missingModelContext |
| Migration | .backwardMigration, .unknownSchema |
| History (iOS 18+) | .historyTokenExpired, .invalidTransactionFetchRequest |
SwiftData Queries Reference
Deep reference for all @Query initializer variants, FetchDescriptor options, sort descriptors, sectioned queries, dynamic query switching, background fetch patterns, and aggregate queries.
---
Contents
- `@Query Initializer Variants`
- FetchDescriptor Deep Dive
- Complex Sort Descriptors
- Sectioned Queries Pattern
- Dynamic Query Switching
- Background Fetch Patterns with `@ModelActor`
- Aggregate Queries
- Enumerate for Large Datasets
@Query Initializer Variants
@Query is a SwiftUI property wrapper (DynamicProperty) that automatically fetches and observes persistent model data. All variants are @MainActor.
Basic (No Filter, No Sort)
// Fetch all, default order
@Query private var trips: [Trip]
// With animation
@Query(animation: .default) private var trips: [Trip]
// With transaction
@Query(transaction: Transaction(animation: .spring)) private var trips: [Trip]Filter + SortDescriptor Array
@Query(
filter: #Predicate<Trip> { $0.isFavorite == true },
sort: [SortDescriptor(\.startDate, order: .reverse)]
)
private var favoriteTrips: [Trip]
// With animation
@Query(
filter: #Predicate<Trip> { $0.isFavorite == true },
sort: [SortDescriptor(\.startDate, order: .reverse)],
animation: .default
)
private var favoriteTrips: [Trip]Filter + KeyPath Sort
@Query(
filter: #Predicate<Trip> { $0.destination != "" },
sort: \.startDate,
order: .forward
)
private var upcomingTrips: [Trip]
// With optional key path sort
@Query(
sort: \.endDate, // KeyPath<Trip, Date?> -- optional sort key
order: .reverse
)
private var tripsByEndDate: [Trip]FetchDescriptor
static var recentDescriptor: FetchDescriptor<Trip> {
let now = Date()
var d = FetchDescriptor<Trip>(
predicate: #Predicate { $0.startDate > now },
sortBy: [SortDescriptor(\.startDate)]
)
d.fetchLimit = 10
return d
}
@Query(RecentTripsView.recentDescriptor) private var recentTrips: [Trip]
// With animation
@Query(RecentTripsView.recentDescriptor, animation: .default)
private var recentTrips: [Trip]Query Properties
| Property | Type | Description |
|---|---|---|
wrappedValue | Result (typically [Element]) | Most recent fetched results |
modelContext | ModelContext | The context used for fetching |
fetchError | (any Error)? | Error from most recent fetch, if any |
Access fetchError to detect query failures:
struct TripListView: View {
@Query private var trips: [Trip]
var body: some View {
Group {
if let error = $trips.fetchError {
ContentUnavailableView("Fetch Error",
systemImage: "exclamationmark.triangle",
description: Text(error.localizedDescription))
} else {
List(trips) { trip in
Text(trip.name)
}
}
}
}
}---
FetchDescriptor Deep Dive
Full Property Reference
var descriptor = FetchDescriptor<Trip>()| Property | Type | Default | Description |
|---|---|---|---|
predicate | Predicate<T>? | nil | Filter condition |
sortBy | [SortDescriptor<T>] | [] | Sort order |
fetchLimit | Int? | nil | Maximum results |
fetchOffset | Int? | nil | Skip first N results |
includePendingChanges | Bool | true | Include unsaved in-memory changes |
propertiesToFetch | [PartialKeyPath<T>] | all | Specific properties to load |
relationshipKeyPathsForPrefetching | [PartialKeyPath<T>] | [] | Related models to eagerly load |
fetchLimit and fetchOffset (Pagination)
func fetchPage(page: Int, pageSize: Int) throws -> [Trip] {
var descriptor = FetchDescriptor<Trip>(
sortBy: [SortDescriptor(\.startDate)]
)
descriptor.fetchLimit = pageSize
descriptor.fetchOffset = page * pageSize
return try modelContext.fetch(descriptor)
}includePendingChanges
When true (default), the fetch includes objects inserted or modified in the current context but not yet saved. Set to false for read-only queries against the persisted store only.
var descriptor = FetchDescriptor<Trip>()
descriptor.includePendingChanges = false // Only persisted dataNote: includePendingChanges: true cannot be used with batched fetches (fetch(_:batchSize:)). This will throw SwiftDataError.includePendingChangesWithBatchSize.
propertiesToFetch (Partial Loading)
Load only specific attributes to reduce memory footprint:
var descriptor = FetchDescriptor<Trip>()
descriptor.propertiesToFetch = [\.name, \.destination, \.startDate]
let trips = try modelContext.fetch(descriptor)
// Only name, destination, startDate are loaded; other properties fault on accessrelationshipKeyPathsForPrefetching
Eagerly load related objects to avoid N+1 query patterns:
var descriptor = FetchDescriptor<Trip>()
descriptor.relationshipKeyPathsForPrefetching = [
\.accommodation,
\.tags
]
let trips = try modelContext.fetch(descriptor)
// Accessing trip.accommodation does not trigger a separate fetchFetch Variants on ModelContext
| Method | Returns | Use Case |
|---|---|---|
fetch(_:) | [T] | Standard fetch, all results in memory |
fetch(_:batchSize:) | FetchResultsCollection<T> | Lazy batched loading |
fetchCount(_:) | Int | Count only, no objects loaded |
fetchIdentifiers(_:) | [PersistentIdentifier] | IDs only, lightweight |
fetchIdentifiers(_:batchSize:) | FetchResultsCollection<PersistentIdentifier> | Batched ID loading |
enumerate(_:batchSize:...) | Void | Process large sets in batches |
FetchResultsCollection (Batched Fetch)
let results: FetchResultsCollection<Trip> = try modelContext.fetch(
FetchDescriptor<Trip>(sortBy: [SortDescriptor(\.name)]),
batchSize: 100
)
// Iterate lazily -- only 100 objects in memory at a time
for trip in results {
print(trip.name)
}Requirements for batched fetch:
includePendingChangesmust befalse(or will throw).- Results are read-only snapshots.
---
Complex Sort Descriptors
Single Sort
SortDescriptor(\.name, order: .forward) // A-Z
SortDescriptor(\.name, order: .reverse) // Z-A
SortDescriptor(\.startDate) // Ascending (default)Multi-Level Sort
let descriptor = FetchDescriptor<Trip>(
sortBy: [
SortDescriptor(\.isFavorite, order: .reverse), // Favorites first
SortDescriptor(\.startDate, order: .forward), // Then by date
SortDescriptor(\.name, order: .forward) // Then alphabetical
]
)Optional Key Path Sort
Sort on optional properties -- nil values sort to the end:
@Query(sort: \.endDate, order: .reverse) private var trips: [Trip]
// endDate is Date? -- trips without endDate appear lastDynamic Sort Switching
struct TripListView: View {
@State private var sortOrder: SortOrder = .forward
@State private var sortKey: TripSortKey = .name
var body: some View {
SortedTripList(sortKey: sortKey, sortOrder: sortOrder)
.toolbar {
Picker("Sort", selection: $sortKey) {
Text("Name").tag(TripSortKey.name)
Text("Date").tag(TripSortKey.date)
Text("Destination").tag(TripSortKey.destination)
}
}
}
}
enum TripSortKey: String, CaseIterable {
case name, date, destination
}
struct SortedTripList: View {
@Query private var trips: [Trip]
init(sortKey: TripSortKey, sortOrder: SortOrder) {
let sortDescriptor: SortDescriptor<Trip> = switch sortKey {
case .name: SortDescriptor(\.name, order: sortOrder)
case .date: SortDescriptor(\.startDate, order: sortOrder)
case .destination: SortDescriptor(\.destination, order: sortOrder)
}
_trips = Query(sort: [sortDescriptor])
}
var body: some View {
List(trips) { trip in
TripRow(trip: trip)
}
}
}---
Sectioned Queries Pattern
Current Apple docs include sectionBy: Query initializers for sectioned queries in iOS 27 / Xcode 27 beta. Use them only when the deployment target and SDK support those beta APIs.
For iOS 26-compatible guidance, custom grouping, or section keys derived from formatters/business logic, build sectioned views manually:
Using Dictionary Grouping
struct SectionedTripListView: View {
@Query(sort: \.startDate) private var trips: [Trip]
private var sections: [(String, [Trip])] {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
let grouped = Dictionary(grouping: trips) { trip in
formatter.string(from: trip.startDate)
}
return grouped.sorted { $0.key < $1.key }
}
var body: some View {
List {
ForEach(sections, id: \.0) { section, trips in
Section(section) {
ForEach(trips) { trip in
TripRow(trip: trip)
}
}
}
}
}
}Using Enum-Based Sections
enum TripStatus: String, CaseIterable {
case upcoming = "Upcoming"
case current = "Current"
case past = "Past"
}
struct StatusSectionedView: View {
@Query(sort: \.startDate) private var trips: [Trip]
private func trips(for status: TripStatus) -> [Trip] {
let now = Date()
return trips.filter { trip in
switch status {
case .upcoming: trip.startDate > now
case .current: trip.startDate <= now && trip.endDate >= now
case .past: trip.endDate < now
}
}
}
var body: some View {
List {
ForEach(TripStatus.allCases, id: \.self) { status in
let sectionTrips = trips(for: status)
if !sectionTrips.isEmpty {
Section(status.rawValue) {
ForEach(sectionTrips) { trip in
TripRow(trip: trip)
}
}
}
}
}
}
}---
Dynamic Query Switching
Filter + Sort Controlled by Parent
struct TripBrowserView: View {
@State private var searchText = ""
@State private var showFavoritesOnly = false
var body: some View {
NavigationStack {
FilteredTripList(
searchText: searchText,
favoritesOnly: showFavoritesOnly
)
.searchable(text: $searchText)
.toolbar {
Toggle("Favorites", isOn: $showFavoritesOnly)
}
}
}
}
struct FilteredTripList: View {
@Query private var trips: [Trip]
init(searchText: String, favoritesOnly: Bool) {
let predicate = #Predicate<Trip> { trip in
(searchText.isEmpty || trip.name.localizedStandardContains(searchText))
&& (!favoritesOnly || trip.isFavorite == true)
}
_trips = Query(
filter: predicate,
sort: [SortDescriptor(\.startDate, order: .reverse)]
)
}
var body: some View {
List(trips) { trip in
NavigationLink(trip.name) {
TripDetailView(trip: trip)
}
}
}
}Full Dynamic Descriptor
struct AdvancedTripList: View {
@Query private var trips: [Trip]
init(
destination: String?,
minDate: Date?,
sortKey: KeyPath<Trip, some Comparable>,
ascending: Bool,
limit: Int?
) {
var descriptor = FetchDescriptor<Trip>(
sortBy: [SortDescriptor(\.startDate, order: ascending ? .forward : .reverse)]
)
if let destination {
descriptor.predicate = #Predicate<Trip> { trip in
trip.destination == destination
}
}
if let limit {
descriptor.fetchLimit = limit
}
_trips = Query(descriptor)
}
var body: some View {
List(trips) { trip in
TripRow(trip: trip)
}
}
}---
Background Fetch Patterns with @ModelActor
Basic Background Fetch
@ModelActor
actor TripDataHandler {
func fetchUpcomingTrips() throws -> [PersistentIdentifier] {
let now = Date()
let descriptor = FetchDescriptor<Trip>(
predicate: #Predicate { $0.startDate > now },
sortBy: [SortDescriptor(\.startDate)]
)
return try modelContext.fetchIdentifiers(descriptor)
}
func fetchTripCount(destination: String) throws -> Int {
let descriptor = FetchDescriptor<Trip>(
predicate: #Predicate<Trip> { trip in
trip.destination == destination
}
)
return try modelContext.fetchCount(descriptor)
}
}
// Usage from SwiftUI view
struct TripDashboardView: View {
@Environment(\.modelContext) private var modelContext
@State private var upcomingCount = 0
var body: some View {
Text("Upcoming: \(upcomingCount)")
.task {
let handler = TripDataHandler(
modelContainer: modelContext.container
)
upcomingCount = (try? await handler.fetchTripCount(
destination: "Paris"
)) ?? 0
}
}
}Background Import with Progress
@ModelActor
actor ImportHandler {
func importTrips(
_ records: [TripRecord],
progress: @Sendable (Int) -> Void
) throws -> Int {
var imported = 0
for (index, record) in records.enumerated() {
let trip = Trip(
name: record.name,
destination: record.destination,
startDate: record.startDate,
endDate: record.endDate
)
modelContext.insert(trip)
imported += 1
// Save periodically to flush memory
if index % 500 == 0 {
try modelContext.save()
progress(imported)
}
}
try modelContext.save()
return imported
}
}Resolving Identifiers on MainActor
@ModelActor
actor DataHandler {
func findDuplicateIDs() throws -> [PersistentIdentifier] {
// Complex logic to find duplicates
let all = try modelContext.fetch(FetchDescriptor<Trip>())
var seen = Set<String>()
var duplicateIDs: [PersistentIdentifier] = []
for trip in all {
if seen.contains(trip.name) {
duplicateIDs.append(trip.persistentModelID)
}
seen.insert(trip.name)
}
return duplicateIDs
}
}
// On MainActor, resolve IDs to objects
struct DuplicateReviewView: View {
@Environment(\.modelContext) private var modelContext
@State private var duplicates: [Trip] = []
var body: some View {
List(duplicates) { trip in
Text(trip.name)
}
.task {
let handler = DataHandler(modelContainer: modelContext.container)
let ids = (try? await handler.findDuplicateIDs()) ?? []
duplicates = ids.compactMap { id in
modelContext.registeredModel(for: id) as Trip?
?? (try? modelContext.model(for: id) as? Trip)
}
}
}
}---
Aggregate Queries
SwiftData does not provide built-in aggregate functions (SUM, AVG, etc.). Compute aggregates using fetch + Swift computation.
Count
let count = try modelContext.fetchCount(
FetchDescriptor<Trip>(predicate: #Predicate { $0.isFavorite == true })
)Sum, Average, Min, Max
let trips = try modelContext.fetch(FetchDescriptor<Trip>())
let totalBudget = trips.reduce(0.0) { $0 + $1.budget }
let averageBudget = trips.isEmpty ? 0 : totalBudget / Double(trips.count)
let maxBudget = trips.map(\.budget).max() ?? 0
let minBudget = trips.map(\.budget).min() ?? 0Efficient Aggregates with Partial Fetch
Fetch only the property needed for aggregation:
var descriptor = FetchDescriptor<Trip>()
descriptor.propertiesToFetch = [\.budget]
let trips = try modelContext.fetch(descriptor)
let total = trips.reduce(0.0) { $0 + $1.budget }Background Aggregate Computation
@ModelActor
actor StatsHandler {
struct TripStats: Sendable {
let totalCount: Int
let favoriteCount: Int
let averageDuration: TimeInterval
}
func computeStats() throws -> TripStats {
let allTrips = try modelContext.fetch(FetchDescriptor<Trip>())
let favoriteCount = try modelContext.fetchCount(
FetchDescriptor<Trip>(predicate: #Predicate { $0.isFavorite == true })
)
let totalDuration = allTrips.reduce(0.0) { sum, trip in
sum + trip.endDate.timeIntervalSince(trip.startDate)
}
let avgDuration = allTrips.isEmpty ? 0 : totalDuration / Double(allTrips.count)
return TripStats(
totalCount: allTrips.count,
favoriteCount: favoriteCount,
averageDuration: avgDuration
)
}
}---
Enumerate for Large Datasets
Use enumerate instead of fetch when processing many records to keep memory usage constant:
// Process all trips without loading all into memory
try modelContext.enumerate(
FetchDescriptor<Trip>(sortBy: [SortDescriptor(\.startDate)]),
batchSize: 1000,
allowEscapingMutations: false
) { trip in
// Process each trip
trip.isProcessed = true
}
try modelContext.save()Parameters:
batchSize: Objects per batch (default 5000). Lower values use less memory.allowEscapingMutations: Whenfalse, objects are autoreleased after the
block. Set to true only if mutations must persist beyond the block.
Related skills
How it compares
Pick swiftdata for native SwiftData on modern iOS; use Core Data skills when the app must stay on legacy NSPersistentContainer stacks.
FAQ
Can Model be a struct?
No. Model requires a class with reference semantics and a designated initializer.
How do I pass models across actors?
Pass PersistentIdentifier, not Model instances, across actor boundaries.
What blocks CloudKit sync?
Missing iCloud capability, unique constraints, or non-optional relationships without inverses.
Is Swiftdata safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.