Modeling data with @Model
Unit 11 · SwiftData persistence. This unit is prose-and-code: SwiftData needs a running store, so it can't execute in the graded sandbox. Read the patterns carefully — you'll wire this into Checklist (Project 2) and every data-backed project after.
SwiftData is Apple's modern persistence framework. You annotate a normal-looking class with
@Model, register it with the app once, and the framework gives you a database: objects you
insert are saved, survive relaunch, and can be queried and observed by SwiftUI. There's no
schema file to edit and no boilerplate fetch code — the macro generates it.
Defining a model
@Model turns a class into a persisted type:
import SwiftData @Model final class Task { var title: String var isDone: Bool var createdAt: Date init(title: String, isDone: Bool = false, createdAt: Date = .now) { self.title = title self.isDone = isDone self.createdAt = createdAt } }
A few rules the macro imposes. It must be a final class, not a struct — persistence needs
reference identity so that a change to one instance is the same row everywhere. Every stored
var becomes a persisted attribute automatically; you write no column definitions. And you
provide an init as usual. Under the hood @Model rewrites each property to read and write
through the store, but your call sites look like ordinary property access.
@Model is a class, but this is not a return to reference-type modeling. You still prefer
structs for plain data (Unit 1). A @Model class earns its reference semantics because a
persisted record has identity — the same row, observed in many views, must reflect one edit
everywhere. That's the deliberate "shared mutable identity" case structs don't cover.
Registering the container
The app needs exactly one model container — the actual database, backing every context.
Register it at the app root with .modelContainer(for:):
import SwiftUI import SwiftData @main struct ChecklistApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer(for: Task.self) } }
That single modifier creates the store for Task (list multiple types in an array), and injects
a main-actor ModelContext into the environment for every view below it. You now have
persistence with three lines and no configuration file.
Reaching the context and mutating it
A view reads the context from the environment:
struct AddTaskButton: View { @Environment(\.modelContext) private var context var body: some View { Button("Add") { let task = Task(title: "New task") context.insert(task) // staged into the context // context.save() is usually implicit — see below } } }
The ModelContext is your unit of work. Its core operations:
context.insert(object)— start tracking a new object for persistence.context.delete(object)— remove it from the store.context.save()— flush pending changes to disk.
In a SwiftUI app the environment's context autosaves by default — changes are written on the
next run-loop tick — so you rarely call save() explicitly. Call it yourself when you need a
write to complete now (before a critical navigation, say) or when you've turned autosave off.
If you've used an ORM like Prisma or TypeORM, @Model is the entity decorator and the
ModelContext is the session/unit-of-work you insert and save through. The difference is
there's no separate schema migration file to generate for the basics — the macro derives the
store shape from the class, and SwiftUI observes it directly, so an insert re-renders the list
without a manual refetch.
Knowledge check
Q: Why must a @Model type be a class and not a struct?
Persistence needs stable identity — the same stored record, observed from many places, must
reflect a single edit everywhere. That's reference semantics, which only a class provides. It's
the deliberate exception to "struct by default."
Q: You insert an object in a SwiftUI view and never call save(). Is it persisted?
Yes, in the normal case. The environment's ModelContext autosaves, flushing pending changes on
the next tick. You call save() explicitly only to force an immediate write or when autosave is
disabled.