In the beginner course SwiftData saved one kind of object. A real app has related data: an account has many entries, a category has many expenses, a recipe has ingredients. And real apps live for years, so the shape of the data changes after people already have data on their phones. This lesson covers the three problems that come with that: relationships, migrations and work off the main thread, plus the rules for syncing with iCloud. Because Apple changes these APIs between releases, read the current documentation for your Xcode version, and treat the code here as the shape of the solution.
Relationships
A relationship is a property that points to another model, or to a list of them. Mark the owning side with @Relationship, say what happens to the children when the parent is deleted, and name the property on the other side with inverse, so both sides stay in step.
import SwiftData
import Foundation
@Model
final class Account {
var name: String
@Relationship(deleteRule: .cascade, inverse: \Entry.account)
var entries: [Entry] = []
init(name: String) { self.name = name }
}
@Model
final class Entry {
var title: String
var amount: Double
var date: Date
var account: Account?
init(title: String, amount: Double, date: Date = .now, account: Account? = nil) {
self.title = title
self.amount = amount
self.date = date
self.account = account
}
}
deleteRule: .cascadedeletes the entries when the account is deleted..nullify(the default) keeps the entries and clears their link..denyrefuses to delete an account that still has entries. Pick the one that matches what the user expects.- Set the link on one side and SwiftData updates the other:
entry.account = cashmakescash.entriesinclude the entry. - The
Account?is optional because an entry may exist before it has an account, and because iCloud sync needs optional relationships. @Attribute(.unique)makes a property unique across the store, such as an email address. Note that unique constraints cannot be used with iCloud sync.
Fetching with FetchDescriptor
@Query is for views. In other code, such as a view model, an import job or a test, you fetch with a FetchDescriptor and the model context. It lets you set the filter, the order and a limit.
@MainActor
func largeEntries(in context: ModelContext) throws -> [Entry] {
var descriptor = FetchDescriptor<Entry>(
predicate: #Predicate { $0.amount >= 100 },
sortBy: [SortDescriptor(\Entry.date, order: .reverse)]
)
descriptor.fetchLimit = 20
return try context.fetch(descriptor)
}
let count = try context.fetchCount(FetchDescriptor<Entry>())
A fetchLimit matters for large tables. Loading ten thousand rows to show twenty wastes time and memory. Add an index or a limit before you reach for caching.
Migrations: changing the model after release
When you add a property with a default value, SwiftData can usually migrate the store by itself. Bigger changes, such as renaming a property, splitting one model into two or changing a type, need a versioned schema and a migration plan. You freeze each shipped version of the model in its own type, and describe how to move from one to the next.
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Entry.self] }
@Model final class Entry {
var title: String
var amount: Double
init(title: String, amount: Double) { self.title = title; self.amount = amount }
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Entry.self] }
@Model final class Entry {
var title: String
var amount: Double
var note: String = "" // new in version 2
init(title: String, amount: Double) { self.title = title; self.amount = amount }
}
}
enum EntryMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)]
}
}
// when you create the container
let container = try ModelContainer(for: SchemaV2.Entry.self, migrationPlan: EntryMigrationPlan.self)
- A lightweight stage handles simple changes, such as adding a property with a default. A custom stage lets you run your own code before and after, for harder changes.
- Test migrations with real data. Install the old version of your app, add data, then install the new version over it. A migration that fails can stop the app from opening, which is among the worst bugs a release can have.
- Never edit a shipped schema version. Add a new one.
Work off the main thread: ModelActor
A big import, such as ten thousand rows from a server, must not freeze the screen. SwiftData provides @ModelActor, a macro that makes an actor with its own model context. Work done inside it stays off the main actor.
import SwiftData
@ModelActor
actor EntryImporter {
func importEntries(_ rows: [(title: String, amount: Double)]) throws {
for row in rows {
modelContext.insert(Entry(title: row.title, amount: row.amount))
}
try modelContext.save()
}
}
// from a task:
let importer = EntryImporter(modelContainer: container)
try await importer.importEntries(rows)
Objects belong to the context that made them. Do not pass a model object between actors. Pass its persistentModelID or the plain values, and fetch it again where you need it. SwiftUI's @Query will pick up the saved changes by itself.
Syncing with iCloud
SwiftData can sync a private database through CloudKit, Apple's iCloud service, and the people using the app pay nothing for the storage. You add the iCloud capability with CloudKit and the Background Modes capability with *Remote notifications*, and create the container with a CloudKit configuration. Apple's documentation lists the rules your models must follow. The main ones are that every property needs a default value or must be optional, relationships must be optional, and unique constraints are not allowed. It is easier to design for these from the start than to change a shipped model later.
Where Core Data and other options fit
- Core Data is the older framework under SwiftData. Apps that started before iOS 17 use it, and it supports iOS versions that SwiftData does not. Its ideas are the same: a container, contexts, fetch requests, migrations.
- SQLite libraries such as GRDB suit teams that want full control of SQL. They are a good choice when you need very complex queries.
- A backend such as Firebase or your own server holds data that must be shared between *people*, not only between one person's devices. The app often keeps a local copy as well, so it works offline.
Common mistakes
- Loading all rows when the screen shows twenty.
- Changing a shipped model with no migration.
- Passing model objects between actors.
- Forgetting what the delete rule does, and removing more data than the user expected.
Your task
- Add
AccountandEntrywith a relationship. Delete an account and check what happens to its entries with.cascadeand then.nullify. - Write a
fetchLimitquery for the 20 newest entries over a given amount. - Create
SchemaV1andSchemaV2as above and write down the exact steps you would use to test the migration with real data. - Write the
@ModelActorimporter and import 5,000 rows while a list is on screen. The screen should stay responsive.
How to check it worked
- The delete rule behaves as you expect in both modes.
- The list stays responsive during the import.
- You can say three rules a model must follow to sync with iCloud.