Persistence and networking · advanced

SwiftData

Persistence as annotated Swift classes — models, queries, relationships, and the migration rules that decide whether an update ships or corrupts.

13 min read13 min practice0/2 exercises5 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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 same bookingCode updates the existing row instead of duplicating it, which is precisely what syncing server data wants.
  • @Relationship(deleteRule:inverse:).cascade deletes a trip's stops with it; the default .nullify would orphan them with trip = nil. Choosing the wrong rule is how apps accumulate ghost rows.
  • Since iOS 27, @Attribute(.codable) persists any Codable type 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
    }
}
  • ModelContainer is the database — schema plus the file on disk.
  • ModelContext is 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:

Query semantics in miniature: live filtering, sorting, unique-key upserts.

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
            }
        )]
    }
}
Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

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)")
}
Check yourself

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 code

Write the upsert that @Attribute(.unique) gives you for free, so re-imports update instead of duplicate.

Solution unlocks after 3 attempts

Predicate plus sort, like @Query

Write the code

Implement visibleTasks mirroring @Query(filter:sort:): keep incomplete tasks due within the horizon, order by due day, then map to names.

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

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.

Checkpoint

You can now:

  • Define @Model classes with unique attributes and relationship delete rules
  • Explain container vs context, and where autosave does and doesn't cover you
  • Drive live lists with @Query and 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.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.