Saving Data with SwiftData
Everything your apps have stored so far disappears when the app closes. A reading list, an expense tracker or a notes app must remember data between launches. Apple's modern answer is SwiftData, a framework (iOS 17 and later) that saves your Swift classes to a database on the device, with very little code.
SwiftData is built on the proven storage engine of Core Data, but you describe your data with plain Swift and macros. In this lesson you will define a model, set up storage, show saved data with @Query, insert, edit and delete records, filter with #Predicate, and link models with relationships.
Defining a model
Add the @Model macro to a class, and SwiftData can store it. Each stored property becomes a column in the database. Models must be classes, and like @Observable classes, SwiftUI updates views automatically when their properties change.
import Foundation
import SwiftData
@Model
final class Book {
var title: String
var author: String
var rating: Int
var isRead: Bool
var addedOn: Date
init(title: String, author: String, rating: Int = 3, isRead: Bool = false, addedOn: Date = .now) {
self.title = title
self.author = author
self.rating = rating
self.isRead = isRead
self.addedOn = addedOn
}
}That is the whole database design. Supported property types include the basic types, Date, URL, UUID, Data, Codable structs and enums, and other models. Use @Attribute(.unique) on a property that must never repeat, and @Transient for a property that should not be saved.
Containers and contexts
A model container manages the database file. Attach one to your app's window with .modelContainer(for:). A model context is your working area: you insert, change and delete models through it, and it saves changes to the container. Here is the whole cycle in plain code, using an in-memory store so it starts empty every time:
import SwiftData
do {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Book.self, configurations: config)
let context = ModelContext(container)
context.insert(Book(title: "Godan", author: "Premchand", rating: 5))
context.insert(Book(title: "Gitanjali", author: "Rabindranath Tagore", rating: 4))
context.insert(Book(title: "Malgudi Days", author: "R. K. Narayan", rating: 3))
try context.save()
let favourites = FetchDescriptor<Book>(
predicate: #Predicate { $0.rating >= 4 },
sortBy: [SortDescriptor(\.title)]
)
print(try context.fetchCount(FetchDescriptor<Book>()))
print(try context.fetch(favourites).map(\.title))
} catch {
print("Storage error: \(error)")
}3 ["Gitanjali", "Godan"]
A FetchDescriptor describes a query: an optional filter written with the #Predicate macro, and sort order with SortDescriptor. The predicate looks like a normal closure, but SwiftData translates it into an efficient database query. In an app you rarely create containers by hand like this; the modifier does it for you.
Showing and changing data in SwiftUI
Set up the container once in the App. Then any view can read models with @Query, which fetches them and keeps the view updated, and can reach the context through the environment.
import SwiftUI
import SwiftData
@main
struct ReadingListApp: App {
var body: some Scene {
WindowGroup {
BookListView()
}
.modelContainer(for: Book.self)
}
}
struct BookListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Book.addedOn, order: .reverse) private var books: [Book]
var body: some View {
NavigationStack {
List {
ForEach(books) { book in
NavigationLink(value: book) {
VStack(alignment: .leading) {
Text(book.title)
.font(.headline)
Text("\(book.author), rated \(book.rating)/5")
.foregroundStyle(.secondary)
}
}
}
.onDelete { offsets in
for index in offsets {
context.delete(books[index])
}
}
}
.navigationTitle("My Books")
.navigationDestination(for: Book.self) { book in
BookEditor(book: book)
}
.toolbar {
Button("Add", systemImage: "plus") {
context.insert(Book(title: "New book", author: "Unknown"))
}
}
}
}
}
#Preview {
BookListView()
.modelContainer(for: Book.self, inMemory: true)
}The first launch shows an empty list titled "My Books" with a + button in the navigation bar. Each tap adds a "New book" row at the top (newest first, because of the sort). Swiping a row left and tapping Delete removes it. Close the app completely and open it again: the books are still there, because the container saves to a file on the device. SwiftData autosaves changes made in the main context, so you do not need to call save() here. The preview uses inMemory: true so it never touches real data.
Editing a model
Models are observable, so you can bind controls straight to their properties with @Bindable.
struct BookEditor: View {
@Bindable var book: Book
var body: some View {
Form {
TextField("Title", text: $book.title)
TextField("Author", text: $book.author)
Stepper("Rating: \(book.rating)", value: $book.rating, in: 1...5)
Toggle("Finished reading", isOn: $book.isRead)
}
.navigationTitle(book.title)
}
}Tapping a book opens a form with its title, author, a rating stepper and a "Finished reading" switch. Changing the title updates the navigation title as you type, and when you go back, the list row already shows the new title. There is no "save" button: edits go straight into the model, and SwiftData saves them.
Filtering, relationships and small settings
@Query can filter too. For a filter that depends on a value, build the query in the view's initializer:
struct TopRatedView: View {
@Query private var books: [Book]
init(minimumRating: Int) {
_books = Query(filter: #Predicate<Book> { $0.rating >= minimumRating },
sort: \Book.title)
}
var body: some View {
List(books) { book in
Text(book.title)
}
}
}With the three books from earlier, TopRatedView(minimumRating: 4) shows a plain list with "Gitanjali" and "Godan". Writing _books sets up the query wrapper itself rather than the array.
Models can refer to each other. A Shelf model could declare @Relationship(deleteRule: .cascade) var books: [Book] = [], with var shelf: Shelf? in Book. SwiftData keeps both sides in sync, and .cascade deletes a shelf's books when the shelf is deleted.
For a few simple settings, such as the reader's name or a dark-mode switch, a database is too much. Use @AppStorage("readerName") private var readerName = "" in a view, which saves the value in UserDefaults and works like @State.
Common mistakes
- Forgetting
.modelContainer(for:). Without a container,@QueryandmodelContexthave nothing to work with. - Making a model a struct.
@Modelrequires a class. - Using real data in previews. Use an in-memory container for previews and tests.
- Putting large files in the database. Store images as files and save their names, or mark large
Datawith@Attribute(.externalStorage).
Key points
@Modelturns a Swift class into a stored model; properties become saved fields..modelContainer(for:)sets up storage for the app; the model context inserts, deletes and saves.@Queryfetches models with sorting and filtering and keeps views updated.#PredicateandFetchDescriptordescribe queries in Swift code.- Models are observable, so
@Bindableedits them directly; changes are autosaved. @AppStoragesuits small settings; SwiftData replaces direct Core Data for new apps.
Exercise
Add a Note model for a study app with title, subject and createdOn. Write a list view that shows notes sorted by createdOn (newest first) with the subject in secondary text, a button that inserts a note titled "Revision plan" for subject "Maths", and swipe-to-delete. Describe what the user sees after adding two notes and deleting one.
Show solution
import SwiftUI
import SwiftData
@Model
final class Note {
var title: String
var subject: String
var createdOn: Date
init(title: String, subject: String, createdOn: Date = .now) {
self.title = title
self.subject = subject
self.createdOn = createdOn
}
}
struct NotesView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Note.createdOn, order: .reverse) private var notes: [Note]
var body: some View {
NavigationStack {
List {
ForEach(notes) { note in
VStack(alignment: .leading) {
Text(note.title)
Text(note.subject)
.foregroundStyle(.secondary)
}
}
.onDelete { offsets in
for index in offsets {
context.delete(notes[index])
}
}
}
.navigationTitle("Notes")
.toolbar {
Button("Add", systemImage: "plus") {
context.insert(Note(title: "Revision plan", subject: "Maths"))
}
}
}
}
}After two taps on +, the list shows two rows reading "Revision plan" with "Maths" in grey below. Swiping one away leaves a single row, and that row is still there after the app is closed and reopened, as long as the app's WindowGroup has .modelContainer(for: Note.self).
Quick Quiz
1. Which macro makes a class storable by SwiftData?
@Model turns a class into a persistent SwiftData model.
2. What does @Query(sort: \Book.title) in a view do?
@Query fetches models and keeps the view in sync with the store.
3. How do you remove a book from storage?
Deletions go through the model context.
4. What is the best place for a single "dark mode" setting?
@AppStorage stores small values in UserDefaults and updates views like @State.
Learn Swift with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
