SwiftData
Persistence as annotated Swift classes — models, queries, relationships, and the migration rules that decide whether an update ships or corrupts.
By the end you will be able to
- Define @Model classes with attributes and relationships, and explain what the macro generates
- Drive views from @Query and explain how it stays live
- Predict which schema changes migrate automatically and which need a plan
A SwiftData @Model is required to be a class, never a struct — the opposite of every "prefer structs" rule so far. Why?
A model is an annotated class
import SwiftData
@Model
final class Trip {
var name: String
var startDate: Date
var isArchived: Bool = false
@Attribute(.unique) var bookingCode: String
@Relationship(deleteRule: .cascade, inverse: \Stop.trip)
var stops: [Stop] = []
init(name: String, startDate: Date, bookingCode: String) {
self.name = name
self.startDate = startDate
self.bookingCode = bookingCode
}
}
@Model
final class Stop {
var city: String
var nights: Int
var trip: Trip?
init(city: String, nights: Int) {
self.city = city
self.nights = nights
}
}
The @Model macro rewrites the stored properties (the property-wrapper/macro machinery you have already seen) so that reads and writes go through the persistence layer, and makes the class observable — a view showing trip.name re-renders when it changes, per-property, exactly like @Observable.
The annotations that matter in practice:
@Attribute(.unique)— an upsert key. Inserting a second trip with the samebookingCodeupdates the existing row instead of duplicating it, which is precisely what syncing server data wants.@Relationship(deleteRule:inverse:)—.cascadedeletes a trip's stops with it; the default.nullifywould orphan them withtrip = nil. Choosing the wrong rule is how apps accumulate ghost rows.- Since iOS 27,
@Attribute(.codable)persists anyCodabletype as an opaque blob — a sealed envelope in a filing cabinet: the cabinet sorts and searches by what is written on the outside (ordinary properties), never by the letter inside. Handy for value-type extras, and exactly why such fields cannot be filtered or sorted on — keys you query must stay written on the outside.
The stack: container and context
@main
struct TravelApp: App {
var body: some Scene {
WindowGroup { ContentView() }
.modelContainer(for: Trip.self) // opens/creates the store
}
}
ModelContaineris the database — schema plus the file on disk.ModelContextis your working session: it tracks the objects you touch and saves changes. Views reach it with@Environment(\.modelContext).
context.insert(trip) // new object → persisted
context.delete(trip) // cascade applies per the delete rules
try context.save() // explicit save; the main context also autosaves
Autosave on the main context means CRUD screens mostly never call save() — you insert, the framework persists. Explicit saves matter at boundaries you must not lose: right after an import, before a background task ends.
@Query — the live view of the store
struct TripList: View {
@Query(filter: #Predicate<Trip> { !$0.isArchived },
sort: \Trip.startDate)
private var trips: [Trip]
@Environment(\.modelContext) private var context
var body: some View {
List(trips) { trip in
TripRow(trip: trip)
}
}
}
@Query is not a one-shot fetch — it is a standing subscription. Insert a trip from a completely different screen and this list re-renders with it, sorted into place. Archive one and it animates out. No refresh calls, no notification plumbing; the identity machinery from the lists lesson gets stable ids for free because models have real identity.
#Predicate is a macro that converts the closure into a database-level filter — the !$0.isArchived runs in the store, not by loading everything into memory and filtering in Swift. That distinction is invisible at 100 rows and decisive at 100,000. The constraint it buys: only expressions the store can evaluate are allowed — property comparisons, boolean logic, contains, date comparisons — not arbitrary Swift calls.
Since iOS 27, @Query also sections directly (sectionBy: \Trip.year feeding a sectioned list), and ResultsObserver gives the same live-query power outside views — to a background exporter or a widget-refresh decision.
The store logic is testable Swift
The predicate/sort/upsert decisions are ordinary logic, which means the architecture lesson applies: keep them in types you can run without a database. This model of the store's behaviour runs right here:
The count stays at three after four upserts — the unique key converted a would-be duplicate into an update. When your sync layer re-imports the same server payload twice (it will — retries exist), this property is what keeps the database from silently doubling.
Migration: the part that decides if your update ships
Users have databases full of data created by old versions of your schema. Every schema change is a promise to carry that data forward.
Lightweight migration is automatic for additive, unambiguous changes: adding a model, adding a property with a default value, removing a property, adding a relationship.
Everything else needs a SchemaMigrationPlan: renaming (looks like remove+add — data loss without a plan), splitting a property, changing types, tightening constraints:
enum TravelMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// e.g. split fullName into first/last for every existing row
}
)]
}
}Upsert semantics again, now with an eye on sync. What does this print?
struct Row {
var code: String
var title: String
}
var rows: [Row] = []
func upsert(_ row: Row) {
if let index = rows.firstIndex(where: { $0.code == row.code }) {
rows[index] = row
} else {
rows.append(row)
}
}
// First sync delivers two rows:
upsert(Row(code: "A", title: "Alpha"))
upsert(Row(code: "B", title: "Beta"))
// The request times out; the retry re-delivers one, with a server-side edit:
upsert(Row(code: "A", title: "Alpha v2"))
print(rows.count)
for row in rows {
print("\(row.code): \(row.title)")
}Version 2 of your app adds var rating: Int to a shipped @Model. Which version of that line ships safely?
Implement the unique-key upsert
Write the upsert that @Attribute(.unique) gives you for free, so re-imports update instead of duplicate.
Predicate plus sort, like @Query
Implement visibleTasks mirroring @Query(filter:sort:): keep incomplete tasks due within the horizon, order by due day, then map to names.
Design the SwiftData schema for a podcast app: shows, episodes, playback position, downloads. Decide: which property is .unique on each model (what does the server key on?), every relationship's delete rule (what should deleting a show take with it — and what must survive?), and one v2 change you can foresee, classified as lightweight or plan-required.
You can now:
- Define
@Modelclasses with unique attributes and relationship delete rules - Explain container vs context, and where autosave does and doesn't cover you
- Drive live lists with
@Queryand push filtering into#Predicate - Classify schema changes by migration risk, and test upgrades like a user
Next up: testing — making all these behaviours permanently checkable.