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 Projectfrom 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.0on 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
keychainfolder 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}
@Modelturns 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)makesidunique. When you insert an object whoseidalready 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.groupis many-to-one andTodoGroup.itemsis one-to-many.inversesays both sides are the same relationship, so changing one side updates the other. - Properties you don’t want saved get
@Transientand 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,
&&,||,!, stringcontainsandlocalizedStandardContains(ignores case and diacritics), comparisons on optionals and relationships, and constants captured from outside, such assearchTextandhideDonehere. - Not supported. Arbitrary methods and computed properties. For example
$0.title.uppercased() == sfails to compile withThe uppercased() function is not supported in this predicate. For case-insensitive search, uselocalizedStandardContains. 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.jsonis renamed totodos.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
createdAtis generated one second apart in array order. Sorted bycreatedAt, 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
- 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.
- Swipe the app away from the app switcher on the phone and tap its icon again. Your changes are still there.
- Change one line of code, for example
navigationTitle("To-dos")tonavigationTitle("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. - On a phone that had the old version of MyTodo, the first run prints
Imported N items from todos.jsonin 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.TodoItemin the UI code now refers to V2, so the existing create, read, update and delete code doesn’t change. - List
schemasfrom oldest to newest, and list instageshow 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
- Install the V1 code on your phone with F5 and add a few to-dos.
- 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.
- 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.