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.

ContextWhat it usually means
Reads “@Model macro”Knows SwiftData generates a Core Data entity under the hood at compile time
Reads “@QueryHas built a SwiftUI list off SwiftData and seen the auto-refresh magic
Reads “#PredicateHas 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 @Model class is one annotation, no .xcdatamodeld file, no codegen step, no NSManagedObject subclass.
  • Type-safe predicates. #Predicate<Habit> { $0.streak > 5 } is checked at compile time. NSPredicate strings were not.
  • SwiftUI-native. @Query re-renders your view when the underlying data changes, with sort and filter inline. No @FetchRequest syntax 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:

  1. No try context.save() after every mutation. SwiftData autosaves the main-context ModelContext on a debounced timer (and on backgrounding). Call try context.save() explicitly only when you need a guarantee before reading.
  2. @Query updates the view. The list animates when you insert or delete; no diffing code.
  3. @Bindable habit (used in HabitDetailView below) 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

  1. “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 @Model class names. You can open the store with the Core Data debugging tools.
  2. @Query is free.” Each @Query triggers a fetch on every view invalidation that changes its parameters. Don’t put a @Query filtered by @State inside a tight loop of view rebuilds; build a FetchDescriptor manually instead.
  3. #Predicate can do anything NSPredicate could.” It can’t. No SUBQUERY, no aggregate functions beyond a small set, no custom function calls. Complex predicates either get rewritten or fall back to NSPredicate(format:).
  4. “Autosave means I never call save().” Autosave runs when the app enters background and on a debounce. If you fetch immediately after insert from a different context, the row may not exist yet. try context.save() synchronizes.
  5. “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 deny delete 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 a Previewable ModelContext. Saves you from having “PreviewData.swift” leak into the App Store build.

WARNING: Do not mark a @Model class final unless you control all callers. @Model synthesizes 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 it final (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 your Scene, and use @Query to read or modelContext.insert to write. SwiftData autosaves.

Mid: “How does @Query interact with SwiftUI’s view lifecycle?”

@Query is a property wrapper that holds a FetchDescriptor and registers an observer on the ModelContext. 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 VersionedSchema enums, V1 and V2. Define V2’s Habit with the relationship and the Tag model. Build a SchemaMigrationPlan with a .custom MigrationStage between them. In the willMigrate closure, fetch V1 habits, parse the tag string, dedupe, instantiate Tag models in the destination context, and wire the relationship. In didMigrate, drop the old tags: String attribute 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