EduFlow

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.

  1. 1Making Expense a model
  2. 2One container for the app
  3. 3Reading with @Query, writing through the context
  4. 4Editing with @Bindable
  5. 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

Expense.swift
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

PocketBudgetApp.swift
@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.

The ModelContainer owns the store on disk. Its main ModelContext sits in the SwiftUI environment. Views read through Query and write through the context with insert and delete; the context autosaves to the store and Query results update. Store on disk SQLite file ModelContext autosaves @Query expenses reads, stays up to date context.insert / delete writes created once by .modelContainer(for:)
Container, context and queries

Reading with @Query, writing through the context

ExpensesScreen.swift
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:

Swift
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:

Swift
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

  • @Model turns 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.
  • @Query fetches, 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]

Further reading

Check your understanding

4 questions

Question 1 of 4

Why does Expense change from a struct to a final class when it becomes a SwiftData model?

Question 2 of 4

A list uses @Query private var expenses: [Expense]. How do you remove an expense the user swiped away?

Question 3 of 4

An edit screen binds a TextField to $expense.title through @Bindable var expense: Expense. When is the change saved?

Question 4 of 4

Your SwiftUI preview of the expenses list should show sample data without touching the real database. What do you use?

Practice

Fill in the blanks

Persist expenses with SwiftData

Make Expense a SwiftData model and finish the list: it must fetch expenses newest first, delete the ones the user swipes away, and add a coffee expense when the button is tapped. The app’s scene already has .modelContainer(for: Expense.self).

@ final class Expense { var title: String var amount: Decimal var date: Date init(title: String, amount: Decimal, date: Date = .now) { self.title = title self.amount = amount self.date = date } } struct ExpensesList: View { @(sort: \Expense.date, order: .reverse) private var expenses: [Expense] @Environment(\.modelContext) private var context var body: some View { List { ForEach(expenses) { expense in Text(expense.title) } .onDelete { offsets in for index in offsets { context.(expenses[index]) } } } .toolbar { Button("Add coffee") { context.(Expense(title: "Coffee", amount: 4.5)) } } } }