Persistence and networking · advanced

SwiftData

Storing data in ordinary Swift classes that you mark with one annotation. What @Model generates, how @Query keeps a list live, what delete rules do, and which schema changes carry a user's existing data forward by themselves.

15 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 has to be a class. A struct is not allowed. Every lesson so far has told you to prefer structs, so why the reversal here?

A model is an annotated class

Persistence means keeping data after the app stops running. The user force-quits, the phone reboots, and the trips they saved last week are still there when they come back. SwiftData does that by writing your objects into a database file inside the app's own storage.

You never open that file yourself and you never write SQL. You write an ordinary Swift class and mark it with @Model:

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
    }
}

@Model is a macro: code that runs at compile time and writes more code into your file before the file is built. You saw the same idea in U2-02, where @Observable rewrites each stored property into a computed one. @Model uses its rewrite for two jobs.

  1. Every read and write of a stored property goes through the persistence layer. Setting trip.name is no longer a plain assignment. It records a change that the store will write to disk.
  2. The class becomes observable, in exactly the sense U2-02 described. A view that displays trip.name re-renders when name changes — and only when a property that view actually read changes.

The full set of model classes you hand to SwiftData, with all their properties and relationships, is called the schema. It is the shape of your database. Hold on to that word: the last section of this lesson is entirely about what happens when the shape changes.

Three annotations do most of the work in practice.

@Attribute(.unique) names the property that identifies a row. Insert a second Trip whose bookingCode is "JP-1" when a trip with that code is already stored. SwiftData updates the existing row. It does not add a second one. That combined behaviour — update when the key is already there, insert when it is not — is called an upsert. It is precisely what code that copies data down from a server wants, and a later section in this lesson shows why.

@Relationship(deleteRule:inverse:) says what happens to the other side when you delete something. A relationship is a stored link between two models: a Trip holds many Stops, and each Stop points back at its Trip. Writing inverse: \Stop.trip tells SwiftData that Trip.stops and Stop.trip are the two ends of one link, so setting either end updates the other. The delete rule then decides the fate of the stops when the trip goes. .cascade deletes them along with it — that is a cascade delete, named for the way the deletion falls from an object down to the objects it owns. The default rule is .nullify, which keeps each stop and sets its trip to nil. Nothing points at those stops afterwards and no screen lists them, so they sit in the database forever, taking up space. Rows in that state are called orphans, and choosing the wrong delete rule is how an app collects thousands of them.

Since iOS 27, @Attribute(.codable) stores any Codable value inside a model. SwiftData encodes the value and keeps the result as one opaque block of bytes. It never looks inside. A sealed envelope in a filing cabinet is a fair picture of that. The cabinet is sorted and searched by what is written on the outside of each envelope. The letter inside is never read while searching. So a .codable property can be saved and loaded like any other, but it can never be filtered or sorted on. Anything you need to query has to stay written on the outside, as an ordinary property.

The stack: container and context

Two types sit between your model classes and the file on disk. Their names are similar enough to blur together, and the difference between them is asked in interviews constantly.

@main
struct TravelApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
            .modelContainer(for: Trip.self)      // opens/creates the store
    }
}
  • ModelContainer is the database. It holds the schema and it owns the store file on disk. You create one, normally once, when the app launches. The .modelContainer(for:) modifier above does that for you: it opens the file if it already exists, creates it if it does not, and hands the container to every view in the scene.
  • ModelContext is a working session against that container. It keeps track of the objects you have touched, remembers the changes you made to them, and writes those changes back. A view gets one out of the environment 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

The context a view receives is the container's main context, and it saves by itself. It collects your changes and writes them shortly after you make them. So ordinary screens that create, edit and delete records never call save() at all. You insert the object; the framework stores it.

Call save() yourself at the points where losing the last few changes would actually hurt. Two are common. The first is the end of an import: you have just written two hundred rows and you want them on disk before anything else can go wrong. The second is the end of a background task: the system gave you a short window of running time, and you want the write finished before that window closes. save() can throw, so those calls need try.

One more property of the main context matters as soon as you do work off the screen. It is bound to the main actor, which is Swift's way of saying that its work always runs on the main thread — the same thread that draws your interface. That is why a view can use it with no ceremony at all, and it is also why a background task must never touch it. Background work creates its own context, with ModelContext(container), inside the task that will use it. Model objects are never handed from one context to the other. What you hand over instead is the object's PersistentIdentifier — a small value that names a row — and the receiving context looks that row up for itself. SwiftData also offers a @ModelActor macro, which builds an actor that owns a context for exactly this purpose. D2-06 works through these threading rules in depth, because Core Data has the same ones and does not check them for you.

@Query — the live view of the store

A fetch is one question put to the database: give me the rows matching this test, in this order. You can ask that question directly with context.fetch(_:), and outside a view you often do. Inside a view there is something better:

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 fetch that happens once. It is a standing subscription: the view stays registered with the store, and the store tells it about every change that affects the answer. Insert a trip from a completely different screen and this list re-renders with the new trip already sorted into place. Archive a trip and it animates out. You write no refresh call and no notification handling. The stable row identity that List needs — the subject of U3-01 — costs you nothing here, because a stored object already carries an identity of its own.

#Predicate is a macro, not a closure that runs later. You write it like a closure. At compile time the macro turns !$0.isArchived into a description of a test. SwiftData translates that description into a query, and the database carries the test out itself.

Why that matters is easiest to see with numbers. Take a store holding 100,000 trips, of which 40 are unarchived. Follow what each version does:

  1. Filtering in the database, which is what #Predicate gives you. The store applies the test as it reads and hands back 40 rows. Your app builds 40 objects.
  2. Filtering in Swift instead. The store hands back all 100,000 rows. Every one of them is loaded into memory as an object. Your code then throws 99,960 of them away.

Both versions display the same 40 trips, so nothing looks wrong in a screenshot. The second one spends the memory and the main-thread time on the exact screen the user is staring at. At 100 rows you would never notice. At 100,000 it is the difference between a list that appears instantly and one that hangs when it opens.

The price of running inside the database is that only expressions the database understands are allowed: comparing properties, &&, || and !, contains, date comparisons. An arbitrary Swift function call is rejected, because there is no way to run your Swift code inside the store. When the compiler refuses your predicate, that is what it is telling you.

Since iOS 27, @Query can also group its own results. You pass sectionBy: a key path that leads to a String property, and the query hands back the rows already divided into sections, exposed as a sections property you iterate in a List. If Trip carried a destination string, @Query(sort: \Trip.startDate, sectionBy: \.destination) would section the list by destination, and you would write no grouping code at all. The same release added ResultsObserver, which gives that live-query behaviour to code that is not a view — a background exporter, say, or the decision about whether a widget needs refreshing.

The store logic is testable Swift

Which property is the unique key, what the filter says, how the results are ordered — those are decisions, and decisions are ordinary logic. D1-01's rule applies: keep them in types you can run with no database attached, and they stay cheap to test. The model of that behaviour below runs right here on this page, with no SwiftData involved.

Loading runnable Swift…

Four upserts, three rows. The fourth call carried a booking code that was already in the store, so it replaced a row instead of adding one.

Keep that result in mind, because your sync code will import the same server payload more than once. Requests time out and get retried. A pull-to-refresh overlaps with a refresh already running. Without a unique key, each of those repeats appends another copy of every row, and the user sees the same trip listed twice.

Migration: the part that decides whether your update ships

Your users' phones hold databases created by older versions of your app. Change a model class — add a property, rename one, change its type — and the schema in your new build no longer matches the schema those files were written with. Migration is the process of carrying the data that already exists into the new shape. Every schema change is a promise that this will work.

SwiftData handles two kinds of migration.

Lightweight migration is automatic. SwiftData compares the old schema with the new one, works out the change by itself, and applies it when the store opens. It can do that whenever the change is additive and has only one possible reading: adding a model, adding a property that has a default value, removing a property, adding a relationship.

Every other change needs a migration plan that you write. Renaming a property is the classic case. All the comparison sees is that name is gone and title has appeared, and nothing in the schema says those are the same property, so everything stored under name is dropped. Splitting one property into two, changing a property's type, and applying a stricter rule to existing data are the same story. The framework cannot guess your intent, so you state it — in a SchemaMigrationPlan:

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
            }
        )]
    }
}

Three type names appear in that plan, and one of them is not defined in the snippet.

  • A versioned schema is a snapshot of your models as they looked at one point in your app's history. For each shape you have released you write a type conforming to VersionedSchema, listing the model classes and a version number. SchemaV1 and SchemaV2 above are two such snapshots, and they are what SwiftData compares.
  • schemas lists those snapshots in order, oldest first.
  • A stage is one step from one snapshot to the next. .lightweight says the framework can take that step on its own. .custom says you are taking it, and hands you two places to put your code. willMigrate runs before the step, while the old shape is still in place. didMigrate runs after it, with the new shape available. Splitting fullName into firstName and lastName belongs in didMigrate, because the two new properties only exist by then, and you fill them in for every existing row.

You pass the plan in when you create the ModelContainer, alongside the current schema. It runs once, at launch, before any view asks the store for data.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

The same upsert, this time seen from the sync layer's side. Work out what it prints before you run it.

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?

Loading exercise…
Loading exercise…
Design itSaved on this device. Never graded.

Design the SwiftData schema for a podcast app that stores shows, episodes, playback positions and downloads. Write down three answers. Which property is the unique key on each model — what would the server key on? What is the delete rule on each relationship: when the user deletes a show, what should go with it, and what has to survive? And name one change you can already foresee shipping in version 2, then classify it as lightweight or plan-required and say why.

Checkpoint

You can now:

  • Define @Model classes, choose a unique key, and set the delete rule on a relationship
  • Explain the difference between container and context, and say where autosave covers you and where it does not
  • Drive a live list with @Query, and push the filtering into #Predicate so it runs inside the store
  • Classify a schema change by migration risk, and test an update the way a user experiences it

Next up: caching — keeping data close to the screen that needs it, and the invalidation problem that arrives with it.

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.