Section 4 · Shipping a Real App
Saving Data with SwiftData
17 min3 min readFill in the blanks
Lesson at a glance
Turn Expense into a SwiftData model, set up a model container, read with @Query, insert and delete through the model context, and edit saved objects with @Bindable.
- 1Making Expense a model
- 2One container for the app
- 3Reading with @Query, writing through the context
- 4Editing with @Bindable
- 5Filtering
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Add five expenses, quit Pocket Budget, open it again, and the list is empty. Everything lived in memory. Users expect their data to survive restarts, updates and a phone that ran out of battery mid-save. SwiftData is Apple’s persistence framework built for Swift: you describe your model in code, and it handles the database, the schema and keeping SwiftUI in sync.
Making Expense a model
import Foundation
import SwiftData
@Model
final class Expense {
var title: String
var amount: Decimal
var category: Category
var date: Date
var note: String = ""
init(title: String, amount: Decimal, category: Category, date: Date = .now) {
self.title = title
self.amount = amount
self.category = category
self.date = date
}
}Two things changed from the struct you’ve used since the modelling section. First, @Model requires a class. A saved expense has one identity: the list, the detail screen and the edit form must all see the same record, which is the reference semantics you learned to reach for when data is shared. Second, you write an initializer yourself, because classes don’t get a memberwise one.
@Model makes the class conform to PersistentModel, which brings Identifiable and Hashable, so ForEach and NavigationLink(value:) keep working unchanged. The Category enum needs to conform to Codable to be stored; add it to its declaration. You can delete the id property: SwiftData gives every model a persistent identifier.
One container for the app
@main
struct PocketBudgetApp: App {
var body: some Scene {
WindowGroup {
ExpensesScreen()
}
.modelContainer(for: Expense.self)
}
}The model container owns the database file and the schema. Creating it once on the scene also puts a model context into the environment. The context is your workspace: it tracks inserted, changed and deleted models and writes them to disk. The main context saves automatically, so most apps never call save() by hand.
Create the container in exactly one place. A ModelContainer built inside a view’s body or initializer gets recreated whenever SwiftUI rebuilds that view, and each copy opens the store again. Put it on the scene, as here, and every view below shares the same one.
Reading with @Query, writing through the context
struct ExpensesScreen: View {
@Query(sort: \Expense.date, order: .reverse) private var expenses: [Expense]
@Environment(\.modelContext) private var context
var body: some View {
NavigationStack {
List {
ForEach(expenses) { expense in
NavigationLink(value: expense) {
Text(expense.title)
}
}
.onDelete { offsets in
for index in offsets {
context.delete(expenses[index])
}
}
}
.navigationTitle("Expenses")
.navigationDestination(for: Expense.self) { expense in
EditExpenseView(expense: expense)
}
}
}
}@Query fetches every Expense, newest first, and re-runs whenever the stored data changes, so a delete or insert anywhere in the app updates this list on its own. The query result is read-only: you change data through the context. In the add form from the SwiftUI section, the Save button now calls context.insert(Expense(title: title, amount: amount, category: category, date: date)) instead of store.add(...). The expenses no longer live in BudgetStore; it keeps app settings such as the monthly limit and the exchange rates.
Editing with @Bindable
A model is an observable class, so @Bindable gives you bindings straight into it:
struct EditExpenseView: View {
@Bindable var expense: Expense
var body: some View {
Form {
TextField("Title", text: $expense.title)
TextField("Amount", value: $expense.amount, format: .currency(code: "USD"))
DatePicker("Date", selection: $expense.date, displayedComponents: .date)
}
.navigationTitle(expense.title)
}
}There’s no Save button. Typing changes the model, the context notices and autosaves, and the list behind updates too. If you want Cancel semantics instead, copy the values into @State drafts as the add form does, and write them back on Save.
Filtering
Pass a predicate to @Query to fetch a subset. When the filter depends on a value the view receives, build the query in init:
struct RecentExpensesView: View {
@Query private var recent: [Expense]
init(since start: Date) {
_recent = Query(filter: #Predicate<Expense> { $0.date >= start },
sort: \.date, order: .reverse)
}
var body: some View {
List(recent) { Text($0.title) }
}
}#Predicate is checked at compile time and translated into a database query, so filtering happens in storage rather than in a Swift loop. Keep predicates to simple stored properties such as dates, numbers and strings. Filtering on enum properties inside a predicate has historically been unreliable, so filter categories in Swift after fetching, or store the category’s raw value as a String if you need to query by it.
Adding a new property later, like note above, works without extra code when it has a default value: SwiftData migrates the store automatically. A rename needs a hint, @Attribute(originalName: "oldName"), so the old column carries over; changing a property’s type needs a versioned schema and a migration plan, which you can postpone until you have real users with real data.
Pocket Budget now survives restarts. The last lesson makes sure it keeps working as you change it, with Swift Testing, and gets it onto other people’s phones through TestFlight.
Key takeaways
@Modelturns a class into persisted data; SwiftData builds the storage schema from its stored properties..modelContainer(for:)on the app’s scene creates the database once and puts a model context in the environment.@Queryfetches, sorts and filters models for a view and keeps the result up to date as data changes.- Insert and delete through
modelContext; changes to a model’s properties save automatically, including edits bound with@Bindable. - [object Object]