| 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
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
let container = try ModelContainer(for: Trip.self, LivingAccommodation.self)
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)
let container = try ModelContainer(for: SchemaV2.Trip.self,
migrationPlan: TripMigrationPlan.self)
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.
let trip = Trip(name: "Summer", destination: "Paris", startDate: .now, endDate: .now + 86400*7)
modelContext.insert(trip)
try modelContext.save()
let trips = try modelContext.fetch(FetchDescriptor<Trip>(
predicate: #Predicate { $0.destination == "Paris" },
sortBy: [SortDescriptor(\.startDate)]))
trip.destination = "Rome"
modelContext.delete(trip)
try modelContext.delete(model: Trip.self, where: #Predicate { $0.isFavorite == false })
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) } }
}
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) } }
}
struct RecentView: View {
static var desc: FetchDescriptor<Trip> {
var d <>(sortBy: [(\.startDate)])
d.fetchLimit ; d
}
(.desc) recent: []
body: { (recent) { trip (trip.name) } }
}
#Predicate
#Predicate<Trip> { $0.destination.localizedStandardContains("paris") }
let now = Date()
#Predicate<Trip> { $0.startDate > now }
#Predicate<Trip> { $0.isFavorite && $0.destination != "Unknown" }
#Predicate<Trip> { $0.accommodation?.name != nil }
#Predicate<Trip> { $0.tags.contains { $0.name == "adventure" } }
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?
init(: ) { .name name }
}
}
: {
schemas: [ .] { [., .] }
stages: [] { [migrateV1toV2] }
migrateV1toV2 .lightweight(
fromVersion: ., toVersion: .)
}
migrateV2toV3 .custom(
fromVersion: ., toVersion: .,
willMigrate: ,
didMigrate: { context
trips context.fetch(<.>())
trip trips { trip.displayName trip.name.capitalized }
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()
}
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:
5. ModelContext on wrong actor:
6. Unsupported #Predicate expressions:
7. Flow control in #Predicate:
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:
11. Cascade without inverse -- Specify inverse: for reliable cascade delete behavior.
12. DispatchQueue for background data work:
Review Checklist
References
- references/swiftdata-advanced.md — custom data stores, history tracking, CloudKit, composite attributes, model inheritance, undo/redo, performance
- references/swiftdata-queries.md —
@Query variants, 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