6.2 — SwiftData
Opening scenario
You’re at a hackathon. 48 hours. The idea: a habit tracker with daily check-ins, streaks, and a chart of the last 30 days. You’re alone. You will not be writing an NSPersistentContainer boilerplate file. You open Xcode, hit ⌘N, type Habit, slap @Model on it, drop a .modelContainer(for: Habit.self) on your WindowGroup, and you’re persisting to disk before the third coffee.
That’s the SwiftData pitch. It’s Core Data, with three decades of “we should have done it this way” applied.
| Context | What it usually means |
|---|---|
Reads “@Model macro” | Knows SwiftData generates a Core Data entity under the hood at compile time |
Reads “@Query” | Has built a SwiftUI list off SwiftData and seen the auto-refresh magic |
Reads “#Predicate” | Has hit the “this Swift expression can’t be translated” wall |
| Reads “ModelActor” | Has tried background work in SwiftData and felt the rough edges |
| Reads “schema migrations” | Has shipped a SwiftData app to production users |
Concept → Why → How → Code
Concept
SwiftData is a Swift-native wrapper around Core Data, introduced at WWDC 2023 (iOS 17). The schema is your code: classes annotated with @Model are the entities. Property wrappers replace key path strings. #Predicate macros translate Swift expressions into Core Data predicates at compile time. ModelContainer, ModelContext, ModelConfiguration map roughly to NSPersistentContainer, NSManagedObjectContext, NSPersistentStoreDescription.
Why
- Less boilerplate. A
@Modelclass is one annotation, no.xcdatamodeldfile, no codegen step, noNSManagedObjectsubclass. - Type-safe predicates.
#Predicate<Habit> { $0.streak > 5 }is checked at compile time.NSPredicatestrings were not. - SwiftUI-native.
@Queryre-renders your view when the underlying data changes, with sort and filter inline. No@FetchRequestsyntax weirdness. - CloudKit toggle. A single config flag enables iCloud sync (with caveats — see Chapter 6.5).
You don’t want SwiftData when: your minimum deployment is below iOS 17, you have a complex existing Core Data schema (interop is possible but painful), you need fine-grained migration control today, or you need cross-platform with a non-Apple system (Realm or GRDB are still the answer there).
How
A SwiftData app looks like this end to end:
import SwiftUI
import SwiftData
@Model
final class Habit {
@Attribute(.unique) var id: UUID
var name: String
var createdAt: Date
var streak: Int
@Relationship(deleteRule: .cascade, inverse: \CheckIn.habit)
var checkIns: [CheckIn] = []
init(name: String) {
self.id = UUID()
self.name = name
self.createdAt = .now
self.streak = 0
}
}
@Model
final class CheckIn {
var date: Date
var habit: Habit?
init(date: Date = .now, habit: Habit) {
self.date = date
self.habit = habit
}
}
@main
struct HabitsApp: App {
var body: some Scene {
WindowGroup {
HabitListView()
}
.modelContainer(for: [Habit.self, CheckIn.self])
}
}
That’s it. The .modelContainer modifier creates the container, registers the schema, opens an SQLite store at the default location, and injects a ModelContext into the environment.
Code — list, create, update, delete
struct HabitListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Habit.createdAt, order: .reverse) private var habits: [Habit]
@State private var newHabitName = ""
var body: some View {
NavigationStack {
List {
ForEach(habits) { habit in
NavigationLink(value: habit) {
HabitRow(habit: habit)
}
}
.onDelete(perform: delete)
}
.navigationTitle("Habits")
.navigationDestination(for: Habit.self) { HabitDetailView(habit: $0) }
.safeAreaInset(edge: .bottom) {
HStack {
TextField("New habit", text: $newHabitName)
.textFieldStyle(.roundedBorder)
Button("Add", action: add)
.disabled(newHabitName.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding()
.background(.bar)
}
}
}
private func add() {
let habit = Habit(name: newHabitName)
context.insert(habit)
newHabitName = ""
}
private func delete(at offsets: IndexSet) {
for index in offsets {
context.delete(habits[index])
}
}
}
Three things to notice:
- No
try context.save()after every mutation. SwiftData autosaves the main-contextModelContexton a debounced timer (and on backgrounding). Calltry context.save()explicitly only when you need a guarantee before reading. @Queryupdates the view. The list animates when you insert or delete; no diffing code.@Bindable habit(used inHabitDetailViewbelow) gives you two-way bindings into model properties:
struct HabitDetailView: View {
@Bindable var habit: Habit
var body: some View {
Form {
TextField("Name", text: $habit.name)
Stepper("Streak: \(habit.streak)", value: $habit.streak, in: 0...365)
}
}
}
Predicates and complex queries
@Query(
filter: #Predicate<Habit> { $0.streak >= 7 },
sort: \Habit.streak,
order: .reverse
) private var streakHeroes: [Habit]
For dynamic filters, build the descriptor manually:
struct HabitSearchView: View {
@Environment(\.modelContext) private var context
@State private var searchText = ""
@State private var results: [Habit] = []
var body: some View {
List(results) { Text($0.name) }
.searchable(text: $searchText)
.onChange(of: searchText) { _, query in
let predicate = #Predicate<Habit> { habit in
habit.name.localizedStandardContains(query)
}
let descriptor = FetchDescriptor<Habit>(
predicate: predicate,
sortBy: [SortDescriptor(\.name)]
)
results = (try? context.fetch(descriptor)) ?? []
}
}
}
#Predicate is a macro. It can only translate a subset of Swift to SQL. String methods (contains, hasPrefix), comparison operators, basic logical operators, optional unwrapping, and collection membership all work. Calling your own functions does not.
ModelConfiguration & multiple stores
let appConfig = ModelConfiguration("AppData", schema: Schema([Habit.self, CheckIn.self]))
let analyticsConfig = ModelConfiguration("Analytics", schema: Schema([Event.self]), isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Schema([Habit.self, CheckIn.self, Event.self]),
configurations: appConfig, analyticsConfig)
Use a second store (in-memory) for previews, tests, or ephemeral data you don’t want polluting iCloud.
Background work with @ModelActor
The main-context approach works for UI mutations. For batch imports use @ModelActor:
@ModelActor
actor HabitImporter {
func importHabits(_ payload: [HabitDTO]) throws {
for dto in payload {
let habit = Habit(name: dto.name)
habit.streak = dto.streak
modelContext.insert(habit)
}
try modelContext.save()
}
}
// usage
let importer = HabitImporter(modelContainer: container)
try await importer.importHabits(payload)
@ModelActor synthesizes the actor with its own ModelContext bound to the actor’s executor. Object identifiers (PersistentIdentifier) cross actor boundaries; the objects themselves do not.
Schema migrations
SwiftData uses versioned schemas — Swift enums conforming to VersionedSchema:
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Habit.self] }
@Model final class Habit { /* original */ }
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Habit.self] }
@Model final class Habit { /* new shape: added `category` */ }
}
enum HabitMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)]
}
}
let container = try ModelContainer(for: SchemaV2.Habit.self,
migrationPlan: HabitMigrationPlan.self)
For data-rewriting migrations use .custom(...) with willMigrate/didMigrate closures.
In the wild
- Apple’s WWDC sample apps (Backyard Birds, Trip Planner) are pure SwiftData. They’re the most accurate model of “Apple thinks this is how you should write it.”
- Day One Journal announced SwiftData adoption for new features in 2024 while keeping Core Data for legacy sync paths.
- Several breakout indie apps from 2024–2025 (Cubby, Bento, Athlytic v3) ship pure SwiftData. The pattern: small team, fresh codebase, iOS 17+ floor.
- Hard-NO list: anyone still supporting iOS 16 (a chunk of enterprise apps), anyone with a Core Data schema older than two years (the migration story isn’t worth it), anyone whose data layer is the product (Notion-style note apps need control SwiftData doesn’t yet expose).
Common misconceptions
- “SwiftData is not Core Data.” It is Core Data with a Swift façade. Look at the persistent store — it’s still SQLite, with table names derived from your
@Modelclass names. You can open the store with the Core Data debugging tools. - “
@Queryis free.” Each@Querytriggers a fetch on every view invalidation that changes its parameters. Don’t put a@Queryfiltered by@Stateinside a tight loop of view rebuilds; build aFetchDescriptormanually instead. - “
#Predicatecan do anythingNSPredicatecould.” It can’t. NoSUBQUERY, no aggregate functions beyond a small set, no custom function calls. Complex predicates either get rewritten or fall back toNSPredicate(format:). - “Autosave means I never call
save().” Autosave runs when the app enters background and on a debounce. If youfetchimmediately afterinsertfrom a different context, the row may not exist yet.try context.save()synchronizes. - “SwiftData supports CloudKit out of the box and it Just Works.” It supports CloudKit. It does not Just Work. See Chapter 6.5 for the list of constraints (all attributes optional or with defaults, no unique constraints, no
denydelete rule, public databases not supported).
Seasoned engineer’s take
I shipped my first SwiftData app in late 2023 expecting to like it. I liked the API. I did not like the bugs. Through iOS 17.0–17.3 there were enough crash reports filed against SwiftData symbols to make me roll my own Core Data layer for a paid app. By iOS 17.4 the worst sharp edges were filed off and by iOS 18 SwiftData crossed into “I’d recommend this for a greenfield app” territory for me.
In May 2026, with iOS 19 around the corner, my heuristic is:
- Greenfield app, iOS 17.4+ floor, simple schema, no public CloudKit data: SwiftData. The velocity gain is real.
- Greenfield app, complex schema, CloudKit shared databases, or supporting iOS 16: Core Data.
- Existing Core Data app: stay on Core Data. Interop adds work and removes very little.
The thing nobody tells you: SwiftData migrations are less powerful than Core Data migrations today, not more. The MigrationStage API is cleaner, but features like multi-step heavyweight migrations with mapping models don’t have a direct equivalent. Plan your schema carefully early.
TIP: In SwiftUI previews, use
.modelContainer(for: Habit.self, inMemory: true)and seed sample data with aPreviewableModelContext. Saves you from having “PreviewData.swift” leak into the App Store build.
WARNING: Do not mark a
@Modelclassfinalunless you control all callers.@Modelsynthesizes initializers via macros that interact with class inheritance; some macros work, some don’t, and the diagnostics are catastrophic. Honestly, in 99% of cases you should make itfinal(above example does), but know the macros are why it matters.
Interview corner
Junior: “How do you persist a model in SwiftData?”
Annotate the class with
@Model, attach a.modelContainer(for:)to yourScene, and use@Queryto read ormodelContext.insertto write. SwiftData autosaves.
Mid: “How does @Query interact with SwiftUI’s view lifecycle?”
@Queryis a property wrapper that holds aFetchDescriptorand registers an observer on theModelContext. When the context publishes a change notification matching the predicate, the wrapped value updates and SwiftUI invalidates the view. The query re-runs against the store; results are cached per-descriptor between fetches.
Senior: “Walk me through migrating a v1 schema where Habit.tags: String (comma-separated) becomes v2 with a Tag model and many-to-many relationship.”
Two
VersionedSchemaenums, V1 and V2. Define V2’sHabitwith the relationship and theTagmodel. Build aSchemaMigrationPlanwith a.customMigrationStagebetween them. In thewillMigrateclosure, fetch V1 habits, parse the tag string, dedupe, instantiateTagmodels in the destination context, and wire the relationship. IndidMigrate, drop the oldtags: Stringattribute if it isn’t already gone via the schema. Ship a test that loads a fixture SQLite store from V1 and migrates it through, asserting the V2 invariants.
Red flag: “Whenever we change the schema we just delete the local store on launch.”
Same red flag as the Core Data chapter, with a different framework name. Production data loss for any user who updates.
Lab preview
Lab 6.1 — Journal App with SwiftData builds a full CRUD journal app with relationships, a #Predicate-driven search, and an optional CloudKit sync toggle you’ll wire up after reading Chapter 6.5.
Next: CloudKit