On this page 9 sections
When SwiftData is the right choice
SwiftData suits apps whose data is owned by the user and lives mainly on the device: notes, logs, collections, plans, offline copies of server data. It works well with SwiftUI and can sync through iCloud with little extra code.
Consider something else when:
- the server is the source of truth and the app only shows what it’s given (a small cache in memory or a few files may be all you need);
- you need to share a database with an Android app or a web backend (SQLite through a library, or a sync service, may fit better);
- you have a mature Core Data stack that works. SwiftData and Core Data can share a store, so you can adopt SwiftData for new features without rewriting old ones. Apple covers this in Adopting SwiftData for a Core Data app.
For anything else, UserDefaults is for small settings only, not for records.
Defining models
A SwiftData model is a class with the @Model macro:
import Foundation
import SwiftData
@Model
final class Trip {
var name: String
var startDate: Date
var endDate: Date
var notes: String = ""
@Relationship(deleteRule: .cascade, inverse: \Stop.trip)
var stops: [Stop] = []
init(name: String, startDate: Date, endDate: Date) {
self.name = name
self.startDate = startDate
self.endDate = endDate
}
var durationInDays: Int {
Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
}
}
@Model
final class Stop {
var title: String
var arrival: Date
var trip: Trip?
init(title: String, arrival: Date) {
self.title = title
self.arrival = arrival
}
}Stored properties become persisted attributes. Computed properties such as durationInDays aren’t stored. The cascade delete rule means deleting a trip deletes its stops; the default rule, .nullify, would leave orphaned stops with no trip.
Useful attributes to know:
@Attribute(.unique)makes a property unique; inserting a second object with the same value updates the existing one instead. Handy for server IDs.@Attribute(.externalStorage)keeps large data such as images outside the main database file.@Transientmarks a stored property that shouldn’t be saved.
From iOS 18 you can also declare indexes with the #Index macro for properties you filter or sort on often, which helps once tables grow into the thousands of rows.
Setting up the container
The modelContainer modifier creates the store and puts a context in the environment:
import SwiftUI
import SwiftData
@main
struct TripsApp: App {
var body: some Scene {
WindowGroup {
TripListView()
}
.modelContainer(for: Trip.self)
}
}Listing Trip is enough; SwiftData follows the relationship to include Stop. For previews and tests, use an in-memory store so nothing touches disk:
@MainActor
let previewContainer: ModelContainer = {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Trip.self, configurations: config)
let lisbon = Trip(name: "Lisbon", startDate: .now, endDate: .now.addingTimeInterval(86_400 * 4))
container.mainContext.insert(lisbon)
return container
}()
#Preview {
TripListView()
.modelContainer(previewContainer)
}Reading data with @Query
@Query fetches models and keeps the view up to date as they change:
struct TripListView: View {
@Query(sort: \Trip.startDate, order: .reverse) private var trips: [Trip]
@Environment(\.modelContext) private var context
var body: some View {
NavigationStack {
List {
ForEach(trips) { trip in
NavigationLink(trip.name, value: trip)
}
.onDelete { offsets in
for index in offsets { context.delete(trips[index]) }
}
}
.navigationTitle("Trips")
.navigationDestination(for: Trip.self) { TripDetailView(trip: $0) }
.toolbar {
Button("Add", systemImage: "plus") {
context.insert(Trip(name: "New trip", startDate: .now, endDate: .now))
}
}
}
}
}Inserts and deletes through the main context are saved automatically shortly afterwards, so you rarely need to call save() yourself in UI code. Call it explicitly before something that must see the data on disk, such as handing off to a widget.
For a query that depends on input, such as a search term, build the Query in the view’s initialiser with a #Predicate:
struct FilteredTripsView: View {
@Query private var trips: [Trip]
init(searchText: String, upcomingOnly: Bool) {
let now = Date.now
_trips = Query(
filter: #Predicate<Trip> { trip in
(searchText.isEmpty || trip.name.localizedStandardContains(searchText))
&& (!upcomingOnly || trip.startDate > now)
},
sort: [SortDescriptor(\Trip.startDate)]
)
}
var body: some View {
List(trips) { Text($0.name) }
}
}Note let now = Date.now outside the predicate: predicates are translated into a database query, and they can only capture plain values, not call arbitrary functions. If a predicate won’t compile, that’s usually the reason.
Outside SwiftUI, use FetchDescriptor with the context directly, and set fetchLimit when you only need the first few results.
Editing models
Model objects are observable, so a detail screen can bind straight to them with @Bindable:
struct TripDetailView: View {
@Bindable var trip: Trip
var body: some View {
Form {
TextField("Name", text: $trip.name)
DatePicker("Starts", selection: $trip.startDate, displayedComponents: .date)
DatePicker("Ends", selection: $trip.endDate, in: trip.startDate..., displayedComponents: .date)
Section("Notes") {
TextEditor(text: $trip.notes)
}
}
.navigationTitle(trip.name)
}
}Changes save automatically. If you need a “Cancel” button that throws edits away, edit a copy of the values in local @State and write them back on “Save”, or use a separate ModelContext with autosave turned off and only save it when the user confirms.
Background work with @ModelActor
Model objects belong to the context that loaded them, and the main context belongs to the main actor. Importing a large file or syncing hundreds of records from a server on the main context will make the UI stutter. Do it on a model actor instead:
@ModelActor
actor TripImporter {
func importTrips(_ records: [TripRecord]) throws -> Int {
for record in records {
let trip = Trip(name: record.name, startDate: record.start, endDate: record.end)
modelContext.insert(trip)
}
try modelContext.save()
return records.count
}
}
// From a main-actor model:
let importer = TripImporter(modelContainer: container)
let count = try await importer.importTrips(records)The macro gives the actor its own modelContext on its own executor. Two rules keep this safe:
- Don’t pass model objects between actors. Pass
PersistentIdentifiervalues (trip.persistentModelID) or plainSendablestructs such asTripRecord, and fetch the object again on the other side. - Save in batches for large imports, for example every few hundred inserts, so memory stays flat.
The main context picks up changes saved by the background context, and @Query views refresh.
Schema migrations
The day you ship version 1, your schema becomes a contract with every installed copy of the app. Adding a property with a default value, or a new optional property, is handled automatically as a lightweight migration. Renaming, splitting or changing the type of a property needs a plan.
Declare each shipped schema as a VersionedSchema and describe how to get from one to the next:
enum TripsSchemaV1: VersionedSchema {
static let versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Trip.self] }
@Model final class Trip {
var name: String
var startDate: Date
var endDate: Date
init(name: String, startDate: Date, endDate: Date) {
self.name = name; self.startDate = startDate; self.endDate = endDate
}
}
}
enum TripsSchemaV2: VersionedSchema {
static let versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Trip.self] }
@Model final class Trip {
@Attribute(originalName: "name") var title: String
var startDate: Date
var endDate: Date
var notes: String = ""
init(title: String, startDate: Date, endDate: Date) {
self.title = title; self.startDate = startDate; self.endDate = endDate
}
}
}
enum TripsMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [TripsSchemaV1.self, TripsSchemaV2.self] }
static var stages: [MigrationStage] { [v1ToV2] }
static let v1ToV2 = MigrationStage.lightweight(fromVersion: TripsSchemaV1.self,
toVersion: TripsSchemaV2.self)
}@Attribute(originalName:) tells SwiftData that title is the old name, so a rename stays lightweight. For changes that need code, such as splitting one field into two or removing duplicates before adding a unique constraint, use MigrationStage.custom with willMigrate and didMigrate closures. Then create the container with the plan:
let container = try ModelContainer(for: TripsSchemaV2.Trip.self,
migrationPlan: TripsMigrationPlan.self)In the app, add typealias Trip = TripsSchemaV2.Trip so the rest of the code keeps using the short name.
The habit that saves the most pain: before every release that touches models, install the previous App Store version on a device, add realistic data, then install the new build over it. A failed migration on launch is one of the worst bugs to ship, because the user can’t get past it.
Syncing with iCloud
SwiftData can sync a private database through CloudKit when you add the iCloud capability with CloudKit and a container, plus the Background Modes capability for remote notifications. CloudKit sync puts constraints on the schema: properties need default values or must be optional, relationships must be optional, and unique constraints aren’t supported. Apple lists the details in Syncing model data across a person’s devices.
Decide early. Adding sync to a schema that wasn’t designed for it means a migration to loosen it, so if iCloud sync is likely in a later version, shape the models for it from the start.
Key takeaways
- Use SwiftData for user-owned data that lives on the device; it shares Core Data’s engine and can coexist with it.
@Querywith#Predicatekeeps SwiftUI lists in sync; build dynamic queries in the view’s initialiser.- Do heavy imports in a
@ModelActorand pass identifiers, never model objects, between actors. - Version your schema from the first release and test upgrades from the previous App Store build every time.
- If iCloud sync is on the roadmap, design the models for CloudKit’s rules now.
If your app needs to work offline, sync between devices or move off an older Core Data setup, tell me what you’re working with and I’ll suggest an approach that fits.
Written by Lee Johnson. Published 30 October 2026.