SwiftData: Save and Query Data on the iPhone

iOS App Development with Swift and SwiftUI: Beginner to Job-Ready Projects · Data and Networking · 8 min read · Updated

Saving with SwiftData

  1. The user adds an expense in the form.
  2. Your code inserts a new object into the model context.
  3. The context saves it into the container, which keeps it on the phone.
  4. The query reads the stored data back, and the list redraws with the new row.

Right now the Expense Tracker forgets everything when you close it. To keep data on the phone you need persistence. Apple's modern tool for it is SwiftData, which arrived with iOS 17. It sits on the same storage technology as the older Core Data, but it is written in plain Swift, and it works well with SwiftUI. You will still see Core Data and plain SQLite in jobs, so know that they exist. Start with SwiftData for new apps with a minimum of iOS 17.

Make the model storable

Change the Expense struct to a class and put @Model in front of it. This one macro makes the class storable and observable. A class is needed here because SwiftData tracks changes to one shared object, which is the case where lesson 4 said a class is the right choice.

import SwiftData
import SwiftUI

enum Category: String, Codable, CaseIterable {
    case food = "Food", travel = "Travel", fun = "Fun", other = "Other"
}

@Model
final class Expense {
    var title: String
    var amount: Double
    var category: Category
    var date: Date
    var note: String

    init(title: String, amount: Double, category: Category = .other,
         date: Date = .now, note: String = "") {
        self.title = title
        self.amount = amount
        self.category = category
        self.date = date
        self.note = note
    }
}

Properties of simple types, text, numbers, dates, booleans and enums that are Codable, are stored automatically. You do not write a table, a schema or any SQL. The id is looked after for you.

Give the app a container

A model container is the database for your app. You attach it once, at the top, and every view below it can use it.

@main
struct ExpenseTrackerApp: App {
    var body: some Scene {
        WindowGroup {
            ExpenseListView()
        }
        .modelContainer(for: Expense.self)
    }
}

Read with @Query, write with modelContext

@Query loads stored objects into a property and keeps it up to date. When something is added or changed, the view redraws by itself, the same way as with @State. To change data you use the model context, which the system puts in the environment.

struct ExpenseListView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \Expense.date, order: .reverse) private var expenses: [Expense]
    @State private var showAdd = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(expenses) { expense in
                    HStack {
                        VStack(alignment: .leading) {
                            Text(expense.title)
                            Text(expense.date, format: .dateTime.day().month())
                                .font(.caption).foregroundStyle(.secondary)
                        }
                        Spacer()
                        Text(expense.amount, format: .currency(code: "USD"))
                    }
                }
                .onDelete(perform: delete)
            }
            .overlay {
                if expenses.isEmpty {
                    ContentUnavailableView("No expenses yet", systemImage: "tray",
                        description: Text("Tap + to add your first one."))
                }
            }
            .navigationTitle("Expenses")
            .toolbar {
                Button("Add", systemImage: "plus") { showAdd = true }
            }
            .sheet(isPresented: $showAdd) {
                ExpenseFormView { expense in
                    modelContext.insert(expense)
                }
            }
        }
    }

    private func delete(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(expenses[index])
        }
    }
}
  • @Query(sort:order:) fetches and sorts. The path \Expense.date points to the property to sort by.
  • modelContext.insert(_:) stores a new object, and modelContext.delete(_:) removes one. SwiftData saves automatically at sensible moments, and you can call try modelContext.save() when you want to be sure.
  • Editing is simply changing a property of a stored object, for example expense.title = "Dinner". SwiftData notices, and the screen updates.
  • ContentUnavailableView is the built-in empty state for a screen with nothing to show.

Filtering

A predicate says which objects to load. It is written in normal-looking Swift with the #Predicate macro. Because the filter value often comes from outside the view, the usual trick is to set the query in the view's init.

struct FilteredExpenses: View {
    @Query private var expenses: [Expense]

    init(minimum: Double) {
        _expenses = Query(
            filter: #Predicate<Expense> { $0.amount >= minimum },
            sort: [SortDescriptor(\Expense.amount, order: .reverse)]
        )
    }

    var body: some View {
        List(expenses) { Text($0.title) }
    }
}

Previews and testing with a temporary database

In previews and tests you do not want to touch real data. Make a container that lives only in memory.

#Preview {
    ExpenseListView()
        .modelContainer(for: Expense.self, inMemory: true)
}

Small settings: AppStorage

For a single setting, such as a currency code or a dark mode choice, a database is too much. @AppStorage keeps a small value in the app's user defaults and acts like @State.

@AppStorage("currencyCode") private var currencyCode = "USD"

Things to know before you ship

  • Changing the model later needs care. Adding a property with a default is usually easy. Renaming or removing one needs a *migration*. While you are learning, deleting the app from the Simulator starts you fresh. Before real users have data, read Apple's notes on schema versions.
  • Money should use Decimal in a real finance app, because Double cannot hold some decimal values exactly. We kept Double to keep the lessons short.
  • Sync across devices is possible with iCloud, but it needs extra set-up in your developer account, and we do not cover it here. Services such as Firebase are another route, and many job posts mention them. Learn them after the basics.
  • Core Data is older and still everywhere in existing apps. Its ideas, a container, a context, fetch requests and saving, map directly to what you just learned.

Your task

  1. Change Expense to a @Model class and add the model container to the app.
  2. Replace the in-memory array with @Query, and make add and swipe to delete work with modelContext.
  3. Close the app completely in the Simulator, open it again, and check that the data is still there.
  4. Add a screen that shows only expenses above an amount typed in a field, using #Predicate.

How to check it worked

  • Your expenses are still there after you restart the app.
  • The newest expense is at the top.
  • You can say the difference between @State, @Query and @AppStorage.

Frequently asked questions

What is SwiftData?

Apple's modern framework for saving data on the device, written in Swift and designed to work with SwiftUI. It needs iOS 17 or newer and builds on the same storage as Core Data.

Should I learn Core Data or SwiftData?

Start with SwiftData for new apps. Core Data is older and still found in many existing apps, and its ideas of container, context and saving are the same.

Why does my data disappear in previews?

Previews often use an in-memory container, so nothing is kept. That is intended. Run the app in the Simulator to test real saving.