Overview
SwiftData (iOS 17+) is built on Core Data and exposes a Swift-native API: @Model classes replace the data model editor, @Query replaces @FetchRequest, and ModelContainer and ModelContext replace the persistent container and managed object context. Choose it for new apps; the decision against core-data is in core-data-swiftdata-comparison.
Declare models as plain classes and attach the container once
Annotate each model with @Model and attach .modelContainer(for:) at the scene root; any view can then use @Query and @Environment(\.modelContext).
@Model
final class Trip {
var destination: String
var startDate: Date
init(destination: String, startDate: Date) {
self.destination = destination
self.startDate = startDate
}
}
WindowGroup { RootView() }.modelContainer(for: Trip.self)Filter and sort in the query so the store does the work, and fetch only what the screen shows: @Query(filter: #Predicate<Trip> { $0.destination == "Lisbon" }, sort: \.startDate).
Version the schema before the first release
Put each shipped schema in a VersionedSchema and describe the path in a SchemaMigrationPlan with MigrationStage.lightweight or a custom stage. Adding that structure after users have data is far harder than starting with it.
enum TripsMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [TripsSchemaV1.self, TripsSchemaV2.self] }
static var stages: [MigrationStage] {
[.lightweight(fromVersion: TripsSchemaV1.self, toVersion: TripsSchemaV2.self)]
}
}
let container = try ModelContainer(
for: Schema(versionedSchema: TripsSchemaV2.self), migrationPlan: TripsMigrationPlan.self)Index what you query and declare uniqueness in the model
Use #Index<Trip>([\.destination], [\.startDate]) for attributes in frequent predicates and sorts, @Attribute(.unique) or #Unique for natural keys, and @Attribute(.preserveValueOnDeletion) for values that history consumers still need after a delete. Unique constraints cannot be used with CloudKit sync (below).
Use inheritance only for a true “is-a” hierarchy
iOS 26 added @Model subclassing. Apple’s guidance: subclass when models form a natural hierarchy and you query both the parent (deep) and specific subtypes (shallow). Use a protocol when models only share a few properties, and flatten when you only ever fetch leaf types. Register every subclass in the container and filter by type with #Predicate { $0 is PersonalTrip }. Gate the new schema version behind #available(iOS 26, *).
Use iOS 27 sections and observers instead of hand-rolled glue
@Query(sort: \Trip.startDate, sectionBy: \.destination)groups results; read them through_trips.sections. The key path must lead to aStringon the model.ResultsObserverfetches and observes outside SwiftUI views through Swift Observation, with the same filter, sort, and sectioning as@Query.HistoryObserverexposes an observableeventCounterthat changes when new transactions land, and filters by model type and author, so a sync engine can ignore its own writes.@Attribute(.codable)persists a type you do not own as an opaque value. It cannot be used in filters, sorts, or migrations, and changing the underlying type does not trigger automatic migration, so keep typed attributes for anything you query.
Do background work in a @ModelActor
@Model instances and ModelContext are not Sendable. Run imports and syncs inside a @ModelActor actor, which gives you an isolated context, and pass PersistentIdentifier values (which are Sendable) across actor boundaries, re-fetching on the other side. See swift-concurrency.
Design for CloudKit constraints from day one
SwiftData syncs the private CloudKit database; shared and public databases need Core Data’s NSPersistentCloudKitContainer. A CloudKit-backed model needs optional properties or defaults on every attribute, optional relationships, and no unique constraints. Enable sync with ModelConfiguration(cloudKitDatabase: .automatic).