Skip to main content
Tutorials

SwiftData Tutorial: Models, Queries and SwiftData Migrations

Last updated

In this SwiftData tutorial, you define models with @Model, attach .modelContainer at the app entry point, read data in views with @Query, insert and delete through modelContext, and migrate existing data with VersionedSchema and SchemaMigrationPlan when the model changes. SwiftData requires iOS 17 or later, so apps that support iOS 16 and earlier should keep using Codable files or Core Data.

The example moves MyTodo, the to-do list from your first SwiftUI app that saves its data to a JSON file, over to SwiftData. All the code compiles with an iOS 17 deployment target in both Swift 5 and Swift 6 language modes. When you’re done, you have a to-do app with search, filtering, editing and groups, and data the old version saved in todos.json is imported automatically on first launch.

What do you need before using SwiftData?

  • Project. Use the MyTodo project from the first SwiftUI tutorial, or create a new one. To create one, run KXBuild: New Project from the Command Palette and pick the “iOS App (SwiftUI)” template, or run the command below in PowerShell.
1kxbuild project create -t app_swiftui -n MyTodo -b com.yourname.mytodo -o D:\projects
  • Set the deployment target to iOS 17. The built-in templates target iOS 15, but SwiftData is only available from iOS 17. In the KXBuild view, right-click the target, choose KXBuild: Edit Target Settings, change iOS Deployment Target to 17.0 on the General page, and press Ctrl+S to save. See Project settings. If you skip this, the build fails with 'Model()' is only available in iOS 17 or newer.
  • Certificates and phone. Set up the keychain folder and connect your device as described in “Run it on your iPhone” in the first SwiftUI tutorial.

As in that tutorial, all the code goes into two files, ContentView.swift and MyTodoApp.swift, so you don’t have to add new files to the project.

Step 1: Define the models and relationships

Replace the whole content of ContentView.swift, starting with the two models below. You no longer need the TodoStore class from the first tutorial. The old TodoItem struct comes back in step 5 under a new name, to import old data.

 1import SwiftUI
 2import SwiftData
 3
 4@Model
 5final class TodoItem {
 6    @Attribute(.unique) var id: UUID
 7    var title: String
 8    var done: Bool
 9    var createdAt: Date
10    var group: TodoGroup?
11
12    init(id: UUID = UUID(), title: String, done: Bool = false, createdAt: Date = .now) {
13        self.id = id
14        self.title = title
15        self.done = done
16        self.createdAt = createdAt
17    }
18}
19
20@Model
21final class TodoGroup {
22    var name: String
23    @Relationship(deleteRule: .cascade, inverse: \TodoItem.group)
24    var items: [TodoItem] = []
25
26    init(name: String) {
27        self.name = name
28    }
29}
  • @Model turns a class into a persistent model. Its stored properties are saved to the database, and the type is automatically observable by SwiftUI, so the UI refreshes when a property changes. Models must be classes, not structs.
  • @Attribute(.unique) makes id unique. When you insert an object whose id already exists, SwiftData updates the existing one instead of adding a new one. The import in step 5 relies on this to avoid duplicates.
  • Relationships. TodoItem.group is many-to-one and TodoGroup.items is one-to-many. inverse says both sides are the same relationship, so changing one side updates the other.
  • Properties you don’t want saved get @Transient and a default value, for example a temporary count used only in the UI.

deleteRule decides what happens to the to-dos in a group when you delete the group.

Delete rule When a TodoGroup is deleted Good for
.nullify (default) Each to-do’s group becomes nil and the to-dos stay Groups that are just labels, where deleting one shouldn’t lose data
.cascade The to-dos in the group are deleted too Child data that’s meaningless without its parent, such as orders and order lines
.deny The group can’t be deleted while it still has to-dos Containers that must be emptied first
.noAction Related to-dos are left alone You maintain references yourself, rarely used

This tutorial uses .cascade, so deleting a group deletes its to-dos. To keep the to-dos, remove the deleteRule: argument and use the default .nullify.

Step 2: Attach the modelContainer

ModelContainer opens the database file, and the .modelContainer modifier puts it in the SwiftUI environment so every view below can use it. Replace MyTodoApp.swift with the following.

 1import SwiftUI
 2import SwiftData
 3
 4@main
 5struct MyTodoApp: App {
 6    var body: some Scene {
 7        WindowGroup {
 8            ContentView()
 9        }
10        .modelContainer(for: TodoItem.self)
11    }
12}

TodoItem.self is enough, because SwiftData follows the relationship and adds TodoGroup too. The modelContext from the environment periodically checks for unsaved changes and saves them automatically, so the insert, delete and update code below doesn’t call save() every time. See Apple’s Preserving your app’s model data across launches.

Step 3: List, add, edit and delete

Add the UI code below the models in ContentView.swift. The text field at the top adds a to-do, tapping the circle toggles done, tapping the title opens the edit screen, and swiping left deletes.

 1struct ContentView: View {
 2    @Environment(\.modelContext) private var context
 3    @State private var newTitle = ""
 4    @State private var searchText = ""
 5    @State private var hideDone = false
 6
 7    var body: some View {
 8        NavigationStack {
 9            List {
10                Section {
11                    HStack {
12                        TextField("New to-do", text: $newTitle)
13                            .onSubmit(add)
14                        Button("Add", action: add)
15                            .disabled(newTitle.trimmingCharacters(in: .whitespaces).isEmpty)
16                    }
17                    Toggle("Hide completed", isOn: $hideDone)
18                }
19                TodoListSection(searchText: searchText, hideDone: hideDone)
20            }
21            .searchable(text: $searchText, prompt: "Search to-dos")
22            .navigationTitle("To-dos")
23            .navigationDestination(for: TodoItem.self) { item in
24                TodoDetailView(item: item)
25            }
26            .toolbar { EditButton() }
27            .task {
28                LegacyImporter.importIfNeeded(into: context)
29                if let url = context.container.configurations.first?.url {
30                    print("SwiftData store location \(url.path)")
31                }
32            }
33        }
34    }
35
36    private func add() {
37        let title = newTitle.trimmingCharacters(in: .whitespaces)
38        guard !title.isEmpty else { return }
39        context.insert(TodoItem(title: title))
40        newTitle = ""
41    }
42}
43
44struct TodoListSection: View {
45    @Environment(\.modelContext) private var context
46    @Query private var items: [TodoItem]
47
48    init(searchText: String, hideDone: Bool) {
49        let predicate = #Predicate<TodoItem> { item in
50            (searchText.isEmpty || item.title.localizedStandardContains(searchText))
51                && (!hideDone || !item.done)
52        }
53        _items = Query(filter: predicate, sort: \TodoItem.createdAt)
54    }
55
56    var body: some View {
57        Section {
58            ForEach(items) { item in
59                HStack {
60                    Button {
61                        item.done.toggle()
62                    } label: {
63                        Image(systemName: item.done ? "checkmark.circle.fill" : "circle")
64                            .foregroundStyle(item.done ? .green : .secondary)
65                    }
66                    .buttonStyle(.plain)
67                    NavigationLink(value: item) {
68                        Text(item.title)
69                            .strikethrough(item.done)
70                    }
71                }
72            }
73            .onDelete { offsets in
74                for index in offsets {
75                    context.delete(items[index])
76                }
77            }
78        } footer: {
79            Text("\(items.count) items")
80        }
81    }
82}
83
84struct TodoDetailView: View {
85    @Bindable var item: TodoItem
86
87    var body: some View {
88        Form {
89            TextField("Title", text: $item.title)
90            Toggle("Done", isOn: $item.done)
91            LabeledContent("Created", value: item.createdAt.formatted(date: .abbreviated, time: .shortened))
92            if let group = item.group {
93                LabeledContent("Group", value: group.name)
94            }
95        }
96        .navigationTitle("Edit to-do")
97    }
98}

LegacyImporter, called in .task, is added in step 5, so don’t build yet.

Here’s how each of the four operations is written.

Operation Code Notes
Create context.insert(TodoItem(title: title)) @Query includes the new object right away and the UI refreshes
Delete context.delete(items[index]) Objects on a .cascade relationship are deleted along with it
Update item.done.toggle(), item.title = "…" Change the property directly, no update statement needed
Read @Query, context.fetch(_:) Use @Query in views and FetchDescriptor elsewhere

@Bindable on the edit screen lets you create bindings to model properties such as $item.title, so the model changes with every character typed in the TextField. @Query only works inside SwiftUI views. It doesn’t work in a plain class.

Step 4: Sort and filter with #Predicate

When @Query is declared on a property, its conditions are fixed. To make the conditions follow a search field or a toggle, do what TodoListSection does above. Pass the conditions into a child view as initializer parameters and assign a new Query to _items in init. When the parent’s searchText changes, the child view is recreated with the new query.

#Predicate takes a Swift expression, which SwiftData translates into a database query.

  • Supported. Comparisons, &&, ||, !, string contains and localizedStandardContains (ignores case and diacritics), comparisons on optionals and relationships, and constants captured from outside, such as searchText and hideDone here.
  • Not supported. Arbitrary methods and computed properties. For example $0.title.uppercased() == s fails to compile with The uppercased() function is not supported in this predicate. For case-insensitive search, use localizedStandardContains. For conditions that need complex calculations, store the result in a separate property first.

Pass sorting to sort:, either as a key path or as an array of SortDescriptor. To query outside a view, for example for statistics or export, use FetchDescriptor.

1@MainActor
2func todayUnfinished(context: ModelContext) throws -> [TodoItem] {
3    let todayStart = Calendar.current.startOfDay(for: .now)
4    let descriptor = FetchDescriptor<TodoItem>(
5        predicate: #Predicate { !$0.done && $0.createdAt >= todayStart },
6        sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
7    )
8    return try context.fetch(descriptor)
9}

When you only need the count, use try context.fetchCount(descriptor) instead of fetching all the objects. Compute todayStart into a constant first, because #Predicate can’t call Calendar methods.

Query and assign through relationships

To put a to-do in a group, assign group directly, and TodoGroup.items gains that item automatically.

1@MainActor
2func addWorkItem(context: ModelContext) {
3    let work = TodoGroup(name: "Work")
4    context.insert(work)
5    let item = TodoItem(title: "Write weekly report")
6    item.group = work
7    context.insert(item)
8    print(work.items.count)
9}

If you now call context.delete(work), “Write weekly report” is deleted too, because the delete rule is .cascade.

Step 5: Import old data from the JSON file

The original MyTodo saves to-dos in todos.json in the Documents folder. When a phone that has the old version installed upgrades, move that data into SwiftData once. Add the code below to ContentView.swift. LegacyTodoItem is the old TodoItem struct under a new name, with fields matching the JSON keys.

 1struct LegacyTodoItem: Codable {
 2    var id: UUID
 3    var title: String
 4    var done: Bool
 5}
 6
 7enum LegacyImporter {
 8    @MainActor
 9    static func importIfNeeded(into context: ModelContext) {
10        let fileURL = URL.documentsDirectory.appending(path: "todos.json")
11        guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
12        do {
13            let data = try Data(contentsOf: fileURL)
14            let oldItems = try JSONDecoder().decode([LegacyTodoItem].self, from: data)
15            let start = Date.now.addingTimeInterval(-Double(oldItems.count))
16            for (index, old) in oldItems.enumerated() {
17                context.insert(TodoItem(
18                    id: old.id,
19                    title: old.title,
20                    done: old.done,
21                    createdAt: start.addingTimeInterval(Double(index))
22                ))
23            }
24            try context.save()
25            let doneURL = URL.documentsDirectory.appending(path: "todos.imported.json")
26            try? FileManager.default.removeItem(at: doneURL)
27            try FileManager.default.moveItem(at: fileURL, to: doneURL)
28            print("Imported \(oldItems.count) items from todos.json")
29        } catch {
30            print("Import failed \(error)")
31        }
32    }
33}

This code imports only once and never creates duplicates.

  • Runs once. After a successful import, todos.json is renamed to todos.imported.json, so the next launch doesn’t find the original file and returns right away. Keep the renamed file so you can recover the original data if the import went wrong, and delete it in a later version once you’re sure.
  • No duplicates. The old ids are reused, and with @Attribute(.unique), even if the import is killed halfway and runs again on the next launch, the same item is updated rather than added again.
  • Keeps the order. The old JSON has no creation time, so createdAt is generated one second apart in array order. Sorted by createdAt, the list looks the same as before.
  • Saves immediately. The import calls save() explicitly and only renames the original file after the data is confirmed in the database, instead of relying on when autosave happens.

For large amounts of data, move the import to the background, as shown in the next section.

How do you write SwiftData in bulk on a background thread?

context.container.mainContext and the modelContext in the environment both belong to the main thread. For slow writes such as bulk imports or syncing server data, declare an actor with @ModelActor. It comes with its own modelContext that’s used only inside that actor.

 1@ModelActor
 2actor TodoBackgroundImporter {
 3    func importTitles(_ titles: [String]) throws -> Int {
 4        for title in titles {
 5            modelContext.insert(TodoItem(title: title))
 6        }
 7        try modelContext.save()
 8        return titles.count
 9    }
10}
11
12@MainActor
13func runBackgroundImport(container: ModelContainer) async {
14    let importer = TodoBackgroundImporter(modelContainer: container)
15    do {
16        let count = try await importer.importTitles(["Sample 1", "Sample 2"])
17        print("Imported \(count) items in the background")
18    } catch {
19        print("Background import failed \(error)")
20    }
21}

Call it from a view with await runBackgroundImport(container: context.container). A background context doesn’t save automatically, so call save() when you’re done, or the data never reaches the database. To change an item shown in the UI from the background, pass its persistentModelID, fetch it again inside the actor with modelContext.model(for:) and change it there. Don’t pass the ModelContext itself to another thread. See Apple’s ModelActor.

How do you check the data on your iPhone?

Data survives relaunches and reinstalls

  1. Press F5, and KXBuild builds, signs, installs and launches the app. Add a few to-dos, check one off, and edit a title on the edit screen.
  2. Swipe the app away from the app switcher on the phone and tap its icon again. Your changes are still there.
  3. Change one line of code, for example navigationTitle("To-dos") to navigationTitle("My to-dos"), press Shift+F5 to stop debugging and press F5 again. This installs over the existing app, and the data is still there.
  4. On a phone that had the old version of MyTodo, the first run prints Imported N items from todos.json in Output > KXBuild Console, and the old to-dos appear in the list.

Data is only deleted when the app is uninstalled. Installing over the app and upgrading versions keep the data under the same Bundle ID, so every model change has to account for existing data. See the migration section below.

Find the database file

The .task above prints the store location. After running, Output > KXBuild Console shows a line like the one below. Go by the actual path printed for your phone.

1SwiftData store location /private/var/mobile/Containers/Data/Application/<UUID>/Library/Application Support/default.store

The part after Application/<UUID>/ is the location inside the app sandbox. List the sandbox’s Library folder with kxdevice to confirm the file was written to the phone. You usually see a .store file, possibly with -wal and -shm files of the same name next to it.

1kxdevice file --app com.yourname.mytodo tree /Library

This only works for apps you signed and installed yourself. See the kxdevice command reference.

Inspect data with breakpoints

Set a breakpoint on the context.insert(...) line in add() and add a to-do on the phone. When it stops, type po title in the debug console to see the title being inserted, press F10 to step over the line and F5 to continue. The count at the bottom of the list goes up by one. Toggle “Hide completed” or type in the search field, and the count follows, which shows the filter works. For breakpoints and the debug console, see Run and debug.

How do SwiftData migrations work when the model changes?

If you change the model after release, the database already on users’ phones still has the old schema. When SwiftData opens the database, it compares the old and new schemas, converts what it can automatically, and fails to open otherwise.

Which changes migrate automatically

Change Migration How
Add a property with a default value Lightweight, automatic var priority: Int = 0
Add an optional property Lightweight, automatic var dueDate: Date?
Rename a property Lightweight, with the old name marked @Attribute(originalName: "done") var isCompleted: Bool
Change a relationship’s delete rule Lightweight Change deleteRule:
Add a unique constraint to existing data Not lightweight Use a custom migration stage to remove duplicates first
Add a non-optional property with no default Old data has no value, migration fails Add a default value or make it optional
Change a property’s type, split or merge fields Needs a custom migration Convert the data in a .custom stage

If you rename a property without originalName, SwiftData treats it as one property deleted and a new one added, and the old values are lost. Renames and delete rule changes are lightweight migrations, while adding a unique constraint needs a custom stage. These come from Apple’s WWDC23 session Model your schema with SwiftData.

Manage versions with VersionedSchema

If you only do lightweight migrations, you don’t need migration code. Starting with your first public release, save every released model version as a VersionedSchema, so the version chain is complete when you need a custom migration later.

Below, the released model is saved as V1, and V2 adds priority and due date fields. Delete the two @Model classes from step 1 in ContentView.swift and replace them with the code below. The rest of the code stays the same.

 1enum TodoSchemaV1: VersionedSchema {
 2    static let versionIdentifier = Schema.Version(1, 0, 0)
 3    static var models: [any PersistentModel.Type] {
 4        [TodoItem.self, TodoGroup.self]
 5    }
 6
 7    @Model
 8    final class TodoItem {
 9        @Attribute(.unique) var id: UUID
10        var title: String
11        var done: Bool
12        var createdAt: Date
13        var group: TodoGroup?
14
15        init(id: UUID = UUID(), title: String, done: Bool = false, createdAt: Date = .now) {
16            self.id = id
17            self.title = title
18            self.done = done
19            self.createdAt = createdAt
20        }
21    }
22
23    @Model
24    final class TodoGroup {
25        var name: String
26        @Relationship(deleteRule: .cascade, inverse: \TodoItem.group)
27        var items: [TodoItem] = []
28
29        init(name: String) {
30            self.name = name
31        }
32    }
33}
34
35enum TodoSchemaV2: VersionedSchema {
36    static let versionIdentifier = Schema.Version(2, 0, 0)
37    static var models: [any PersistentModel.Type] {
38        [TodoItem.self, TodoGroup.self]
39    }
40
41    @Model
42    final class TodoItem {
43        @Attribute(.unique) var id: UUID
44        var title: String
45        var done: Bool
46        var createdAt: Date
47        var priority: Int = 0
48        var dueDate: Date?
49        var group: TodoGroup?
50
51        init(id: UUID = UUID(), title: String, done: Bool = false, createdAt: Date = .now) {
52            self.id = id
53            self.title = title
54            self.done = done
55            self.createdAt = createdAt
56        }
57    }
58
59    @Model
60    final class TodoGroup {
61        var name: String
62        @Relationship(deleteRule: .cascade, inverse: \TodoItem.group)
63        var items: [TodoItem] = []
64
65        init(name: String) {
66            self.name = name
67        }
68    }
69}
70
71typealias TodoItem = TodoSchemaV2.TodoItem
72typealias TodoGroup = TodoSchemaV2.TodoGroup
73
74enum TodoMigrationPlan: SchemaMigrationPlan {
75    static var schemas: [any VersionedSchema.Type] {
76        [TodoSchemaV1.self, TodoSchemaV2.self]
77    }
78
79    static var stages: [MigrationStage] {
80        [migrateV1toV2]
81    }
82
83    static var migrateV1toV2: MigrationStage {
84        .lightweight(fromVersion: TodoSchemaV1.self, toVersion: TodoSchemaV2.self)
85    }
86}
  • V1 must match the released model exactly. Properties, types, relationships and attributes must be identical to the version on users’ phones. SwiftData uses it to recognize which version the old database is.
  • The typealiases point to the latest version. TodoItem in the UI code now refers to V2, so the existing create, read, update and delete code doesn’t change.
  • List schemas from oldest to newest, and list in stages how to migrate between each pair of adjacent versions.

Then replace MyTodoApp.swift with a version that creates the container explicitly and passes the migration plan.

 1import SwiftUI
 2import SwiftData
 3
 4@main
 5struct MyTodoApp: App {
 6    let container: ModelContainer
 7
 8    init() {
 9        do {
10            container = try ModelContainer(
11                for: TodoItem.self, TodoGroup.self,
12                migrationPlan: TodoMigrationPlan.self
13            )
14        } catch {
15            fatalError("Couldn't open the database: \(error)")
16        }
17    }
18
19    var body: some Scene {
20        WindowGroup {
21            ContentView()
22        }
23        .modelContainer(container)
24    }
25}

fatalError prints the actual migration error to Output > KXBuild Console. In a production version, show an error screen and report the log instead. Don’t silently delete and recreate the database.

Custom migration stages

When migration has to compute new fields from old data, for example setting every unfinished to-do to priority 1, use .custom. willMigrate runs before the schema changes and sees the old version’s data, which suits deduplication and cleanup. didMigrate runs after the change and sees the new version’s data, which suits filling in new fields. Replace migrateV1toV2 in TodoMigrationPlan with this.

 1    static var migrateV1toV2: MigrationStage {
 2        .custom(
 3            fromVersion: TodoSchemaV1.self,
 4            toVersion: TodoSchemaV2.self,
 5            willMigrate: nil,
 6            didMigrate: { context in
 7                let items = try context.fetch(FetchDescriptor<TodoSchemaV2.TodoItem>())
 8                for item in items where !item.done {
 9                    item.priority = 1
10                }
11                try context.save()
12            }
13        )
14    }

Test the migration on your iPhone

  1. Install the V1 code on your phone with F5 and add a few to-dos.
  2. Switch to the V2 code with the migration plan and press F5 again to install over it. This simulates a user upgrading from the old version.
  3. If the app launches normally and the old to-dos are all there, the migration worked. If it crashes on launch, read the error in Output > KXBuild Console and check the error table below.

During development, before the model is released, if the app fails to launch after you change fields freely, delete the app from the phone and reinstall to start with an empty database. The command is kxdevice apps uninstall com.yourname.mytodo. Once you’ve released, you can’t do this and have to write a migration.

How do you fix common SwiftData errors?

Symptom or error Cause Fix
Build fails with 'Model()' is only available in iOS 17 or newer The deployment target is below iOS 17 Change iOS Deployment Target to 17.0 on the General page of KXBuild: Edit Target Settings
Build fails with main actor-isolated property 'mainContext' cannot be accessed from outside of the actor mainContext is used in Task.detached or on a background queue Use @ModelActor for background writes and use the main context only on the main thread
Build fails with non-Sendable type 'ModelContext' ... cannot exit main actor-isolated context, a warning in Swift 5 mode A ModelContext was taken to another thread Give each actor its own context and only pass persistentModelID across threads
Build fails with The uppercased() function is not supported in this predicate #Predicate calls an unsupported method Use a supported form such as localizedStandardContains, or store the computed result in a property
The app crashes on launch after you change model fields You added a non-optional property with no default, or renamed or retyped a property without a matching migration Give new properties a default or make them optional, add originalName for renames, write a .custom stage for complex changes
Old data won’t open after you add @Attribute(.unique) A unique constraint isn’t a lightweight migration, and old data may already contain duplicates Add a new version and remove duplicates in the .custom stage’s willMigrate
The app crashes as soon as it inserts data or shows the list .modelContainer isn’t attached, or is attached outside the views that use the data Attach .modelContainer to the WindowGroup
Data written in the background is gone after relaunch The background context never called save() Call try modelContext.save() when you’re done writing
Old data is duplicated on every launch The import doesn’t mark itself done and the model has no unique id Rename the original file after importing, reuse the old ids and add @Attribute(.unique)
@Query results don’t change with the search field The conditions are fixed on the property Pass the conditions into a child view and rebuild the Query in init

You can see the full call stack and variables for a crash in the debug view. See Debug iOS apps on Windows with LLDB, breakpoints and crash logs. For full fixes for concurrency errors, see common Swift 6 strict concurrency errors.

What if you need to support iOS 16 and earlier?

Every SwiftData type requires iOS 17. There’s no backport and no version you can bundle separately. If your minimum version is below iOS 17, choose by how much data you have.

Situation Recommendation
Tens to hundreds of simple records Keep the Codable + JSON file approach from the first SwiftUI tutorial, with ObservableObject driving the UI
Lots of data, conditional queries, relationships Use Core Data, available since iOS 3. @FetchRequest in SwiftUI works much like @Query
You plan to raise the minimum to iOS 17 in a release or two Use Codable files now and move the data into SwiftData with the import code from step 5 when you upgrade

Avoid maintaining SwiftData and a second storage layer side by side in one app with if #available(iOS 17, *). Both code paths need separate testing, and users who update iOS need a cross-store migration, which costs far more than it saves. For choosing a minimum version, see SwiftUI or UIKit for a new project in 2026.

FAQ

What is the minimum iOS version for SwiftData?

iOS 17. @Model, ModelContainer and @Query all require iOS 17 or later, and the build fails with a lower deployment target. Later additions such as #Unique and #Index require iOS 18.

Where does SwiftData store the database file in my app?

By default, under the Library folder of the app sandbox. The most reliable way to find it is to print context.container.configurations.first?.url, check Output > KXBuild Console after running, and confirm the file is on the phone with kxdevice file --app <Bundle ID> tree /Library.

My app crashes on launch after I changed the SwiftData model. What should I do?

During development, uninstall and reinstall the app to start with an empty database. For released versions, give new properties a default value or make them optional, use @Attribute(originalName:) for renames, and write a migration with VersionedSchema and SchemaMigrationPlan for complex changes. Then test by installing the new version over the old one.

Do I need to call save() manually in SwiftData?

The modelContext from the environment and mainContext save automatically, so you usually don’t need to. Contexts you create yourself with @ModelActor or ModelContext(container) need a save() after writing, and operations that must be on disk, such as importing old data, should save explicitly too.

Should I use SwiftData or Core Data?

Use SwiftData for new projects with a minimum of iOS 17. Models and queries live in your Swift code and it works most smoothly with SwiftUI. If you need iOS 16 or earlier, or already have a mature Core Data model, stay with Core Data.