-
Code-along: Add persistence with SwiftData
Experience SwiftData in action as we add persistence to an existing app. We'll show you how to define your data models and seamlessly integrate persistent data with SwiftUI. You'll also learn foundational skills for managing your app's state using this expressive, declarative API.
Chapters
- 0:00 - Introduction
- 1:05 - Identify relevant state
- 3:17 - Define your schemas
- 9:41 - Define model relationships
- 13:33 - Update the view layer
- 21:47 - Next steps
Resources
Related Videos
WWDC26
WWDC25
-
Search this video…
Hello, I'm Matthew Turk, an engineer on the SwiftData team. Today, I'd like to show you how to take an existing SwiftUI app's dynamic data and connect it to a modern persistence layer that works across all of Apple's platforms using the power of SwiftData. We'll start with the source code of a list-based app called Wishlist. Wishlist helps me organize travel plans on my phone by recording my ideas and grouping trips into seasonal collections. Feel free to download the sample app from developer.apple.com to follow along! In this video, we'll step through the project files to identify data types and variables to use for our SwiftData models and schemas, how those models should relate to one another in a database and how to update our SwiftUI views to present the new models with an eye toward performance, interoperability, and extensibility for more involved use cases.
Let's preview what that data flow is like in action.
Here's the Wishlist tab, with recent trips at the top, and several themed lists of more trips scrolling down.
In the Goals tab, I can see badges for various goals that I'd like to complete on those trips.
And in the Search tab, I can filter through my trips and activities. Let's search for coastal trails. Top result.
This trip has five remaining activities. I already drove to Point Reyes once before and saw 11 elk there, so I'll check this one off.
And back on the Wishlist tab, I'll tap the plus button to add a new trip. I'd like to see another aurora sometime. I'll call the trip "Northern Lights," and choose a photo to go with it.
Press Done, and that's my new trip.
The data flow you just saw is possible because views in Wishlist pull in a DataSource variable through the SwiftUI environment. The DataSource class manages and provides all of the preinstalled trip data. All trips, goals, and search results are filtered and sorted in memory, on demand. For a small example, this works fine. But in practice, optimizations will be necessary to keep the front-facing parts of the app lean. Additionally, Wishlist is relying on RAM while processing this data and for storing it, which is not a stable place where you can save new trips or activities for later.
If I close the app and rerun it, everything about my new trip is gone, reset to the preinstalled content.
But it doesn't have to be this way. This is exactly the kind of problem that SwiftData solves.
So far, we've identified the relevant state, namely trip collections, goal statuses, and search results. And SwiftData can connect that state to a persistent storage medium through a model context, and that's where we'll be by the end of this video.
Our next move is to find where these data structures live in the code and then refactor them as models with SwiftData schemas. That will set us up to replace the in-memory DataSource with a persistent ModelContext, which will enable us to write efficient database queries to drive the views. Let's take a closer look at one such model, the Activity type.
To persist Activities, the first step is to import SwiftData and replace this Observable macro with the Model macro. SwiftData automatically generates observable conformance for us. Nice! On a side note, these didSet observers on name and isComplete set the activity's dateEdited value whenever either property changes.
Suppose I have an activity for going paragliding. I've yet to complete it and last edited it on April 1 at 9:41 in the morning.
Here's a timeline of events. Suppose that I then decide to change the activity from paragliding to swimming. The property observer for name fires and updates the dateEdited. Later, I take a trip to the beach and check off the activity. The property observer for isComplete fires and updates the dateEdited. Having an automatically updating dateEdited property is a great way to sort or filter in a list. However, property observers and computed properties are not always compatible, so I'll use a different technique to keep dateEdited up to date. But before I do that, I'll get the current project building again. I'll come back to this diagram. For now, just remove these didSet blocks and leave dateEdited as is. Okay, the Trip class is next. Same deal, import SwiftData, and swap the macros.
Now let's inspect these build failures to get an idea of what's missing.
This one says that creationDate has to be mutable. So we'll adjust the declaration to use var instead of let.
Now SwiftData can populate this property at runtime with a value loaded from the database. The next error says the TripCollection property, and all model properties, must be Codable. This requirement exists so that SwiftData can serialize properties into database columns. The trip collection stores the seasonal theme of the trip. It should be included in the schema and persisted. I'll command-click to jump to its declaration. And I'll add that Codable conformance explicitly. Several build failures remain. We'll fix these as we continue to build out the schema. Next, let's look at how goal tracking works in Wishlist. With this Goal type, the change is not as straightforward as converting an Observable to a Model.
Goal is declared as an enumeration. Each goal has properties like its name, its kind—like whether it tracks activities completed or trips completed— and the target number of trips or activities to achieve the goal.
There are exactly 18 Goals, all defined ahead of time, because an enumeration defines a closed set of values. That's fine for the original interface demo, but what we need for a persistent model is a class. A class can store properties and can be instantiated any number of times.
To get the interface right, we'll need to rethink the design of Goal, so that it harmonizes with the logic of how Goals are processed and presented.
At a high level, each of these goals corresponds to a badge that will be displayed in Wishlist depending on whether certain criteria are met. To faithfully persist the statuses of these goals, we need some way to capture and store a minimal representation of whether a particular goal's criteria have been met.
In SwiftData, a class is the substrate we choose to express that minimal representation. I'll convert Goal into a class starting with the same three properties: name, kind, and target count.
In the original Wishlist app, the number of items completed was stored separately, since enumerations don't have stored properties. Now that we have a persistent class, let's also store the progress value in the goal itself, called completedCount, and a Boolean property called isComplete. We'll store a value of true when completedCount is greater than or equal to target. Because it's a stored property, it'll come in handy for separating completed goals from upcoming goals when we start querying in the view layer.
Next we have to deal with the Kind property. Wishlist uses it to display different details based on whether the goal has to do with completing trips or completing activities. We can break off these finer differences into new subclasses using SwiftData's support for model inheritance. Inheritance is a software design pattern that tends to pay off when you have a well-defined hierarchy of classes, where each subclass represents the same idea as the superclass, but with more specific manifestations of a common set of properties. For example, a spiral galaxy and a lenticular galaxy are subclasses of galaxy, as they inherit several features that you'll find in any kind of galaxy, like stars and dust, while also having categorical differences in visible shape. Similarly, you could have a trip goal and an activity goal, which inherit some common properties from a superclass of goal.
I'll model the different kinds of goals with inheritance by removing the Kind property from Goal. And introducing subclasses for TripGoals and ActivityGoals.
To learn more about when it makes sense to use model inheritance, check out the "SwiftData: Dive into inheritance and schema migration" session from WWDC 2025. We now have everything we need to start defining the relationships between all of these models. In Wishlist, each trip is associated with a set of activities. This is a to-many relationship. Each persistent Trip model has potentially many persistent Activity models.
Several parts of Wishlist have been approximating to-many relationships by using dictionaries and functions that loop over arrays. For example, there's one that maps from Trips to Activities based on activity IDs.
In a moment, I'll convert these dictionaries to proper to-many relationships between types, where each Trip can have zero or more Activities. Declaring an array is the idiomatic way to tell SwiftData that one kind of model may reference another kind of model from the model context as needed. In Trip, I'll declare an array of activities. Here I'm also adding the relationship macro to explicitly mark the activities array of Trip as a relationship so that deleting a trip from the database also clears out the activity models that were in its itinerary. Lastly, this photoURL property needs to be adjusted. It's just a file path right now, which will lose all meaning if the file is renamed or moved to another directory.
And, the full-resolution image should only be loaded when a view needs to show it in full, like in TripDetailView.
When scrolling through a carousel of trips, we'll just show the thumbnail.
So add a new property here called thumbnailData. This will cache a low-resolution version of a selected photo and inline its raw bytes in the database. Then separately, instead of the URL, we'll store the full-resolution image using a persistent external file reference. In SwiftData, this is done by creating a new model just for the image. I already added that as the TripImage type. We won't cover those implementation details here, but I encourage you to read more in the sample code later. Since photoURL is no longer a URL, let's rename it to photo by right-clicking on the declaration and selecting Rename in this refactor menu.
I'm using multi-cursor editing, so multiple files will be updated at once. And while I'm at it, I can click this comment to update it with the new variable name. Hit Return. Then refactor the labels of the initializer and adjust the body to set activities directly.
By setting up this relationship, we've replicated the ability from before for an activity-driven view to reference and display the name, season, and other details of its parent trip. And now that we've really started integrating these SwiftData features, the project has a few surplus files.
There's no need for TripEditModel anymore since SwiftUI views can bind directly to SwiftData models and propagate edits in real time. All the responsibilities of DataSource are handled automatically by ModelContext, queries, and relationships. Delete.
It's worth reflecting on that last part. We just removed hundreds of lines of code from the project. A good chunk of state management, storage logic, filtering, sorting, relationship traversal, and search will just work.
One last touch on the WindowGroup, and the model layer can be done. This modelContainer scene modifier here tells SwiftUI to use our new schema with the query macro.
Next, we'll update the view layer. With the schemas and model container in place, automatic saving is enabled by default. Before seeing that in action, we're going to integrate these persistent models, starting with efficient, targeted queries for each subview that presents models. Then, we'll use SwiftUI view modifiers to capture and surface possible errors that could occur at runtime, like low disk capacity or unsupported predicates. Lastly, we'll add back any missing property observers so that UI events are propagating all of the right data and side effects that we expect. There are two key points to keep in mind when adding filtering to your SwiftData app.
Inside your queries, the FetchDescriptor is how you plan which models you want to load and show through your model context or query. And secondly, your models will be saved to a storage medium that exists outside of the address space of your app, such as a database in your local filesystem or a remote server. While this kind of storage is the bedrock of a persistence layer, it can be orders of magnitude slower than reading from memory, so when designing a persistence layer in your app, it's critical to think about which data need to be where and when for the best experience. I'll explain. Before, data about trips, activities, and goals would be in global variables like allGoals. They were loaded as part of the compiled binary for the app. Accessing data this way is fast, but if I hardcode lots of elements into allGoals, for example, the app will have a noticeably elevated memory footprint for its entire lifetime. And if I add a new goal, everything about it disappears when I close the app and relaunch it as you've seen. With SwiftData, we can insert goals or update existing goals and save them with our model context. Once they're saved, there are a few ways we can pull them back into the app.
Here's one way. This code fetches all of the Goals and then discards the irrelevant ones. So it uses less ongoing memory, but it incurs more I/O. This approach is a little like asking a librarian to go grab every book from every shelf at a library, so that you can personally identify the ones by your favorite author. You could have asked that librarian for just the books by that author on their trip through the shelves.
When you fetch using a predicate, it's like asking that librarian upfront for what you want. Here, I get only the goals that I asked for.
In GoalsView, we're going to use the query macro with a predicate. This is equivalent to calling fetch on a model context. The advantage is that the SwiftUI view will update automatically when the query result changes. Let's do that now. Import SwiftData and replace this dataSource environment property with a query for fetching achieved goals, sorted by when they were achieved.
And this second query fetches the remaining relevant goals.
Same idea in RecentTripsPageView.
Import SwiftData and replace dataSource with a query.
We'll ask for trips in reverse chronological order and set a fetch limit of 5.
That gets us the five most recent trips, which will go right into this ForEach.
In TripCollectionView, we want to get all the trips and segment them by season. Each season is a trip collection, and there's going to be one instance of this view for each tripCollection. An individual TripCollectionView doesn't know which season it's going to display until it's initialized, so we'll declare the query and then dynamically construct it in the initializer.
Here's the explicit query for all trips matching the desired Collection. Notice that the query receives a predicate, and inside of the predicate, the tripCollection parameter is captured directly from the initializer before heading to the database.
And the results of the query will go into this ForEach.
Next we have SearchResultsListView in the third tab in the app. Its super view, which is shown in the preview on the right, owns the search field and text, and passes the value to this view's initializer. So once again, replace dataSource with the query declarations, and then the parameters from the initializer will guide how the predicates are constructed for these queries.
If the search text is empty, fall back to fetching the three most recent trips. Otherwise, fetch all trips whose name matches the search text, sorted lexicographically. And then we'll do the same for activity search. Check if the text matches the name of any activities belonging to a trip, and again set the property wrapper like so.
Lastly, use the queried values in List.
This list also has an overlay view modifier, which displays a ContentUnavailableView on the top if no search results are found. Let's replace the original dataSource condition with direct checks on trips.isEmpty and activities.isEmpty.
That's enough to get the app running again. I'll add a trip in Wishlist and rerun it.
This time, my Trip is still there.
"Northern Lights." The transition to SwiftData is almost complete. We have just a few loose ends to tie up. Consider this updateGoalAchievements method in ActivityItemView. It directly updates progress values as people complete activities. It can throw errors. I'll capture those errors in a state variable. I'll also pass the error to my telemetry system so that I can improve the app in the future. And when reasonable, I'll present an alert letting people know how to recover from the error. That's an error case handled. There are also a couple of places in the UI where state can go stale. Earlier, we removed the didSet observers that set dateEdited. And we'll want to add back that behavior now. Here's the bug: let's say I want to sort my activities for another trip by dateEdited.
In the sort dropdown I'll select "Date Edited." Then if I check off this activity called "Meditate under a tree," it should move to the top of the list because I just updated one of its properties. But it just sits there.
No errors are thrown here in terms of the persistence layer, but still something isn't right.
Now that we've implemented persistence, we'll re-enable real-time updates to the dateEdited property using the Continuous Observation feature added to the Observation framework in the 2027 releases.
In the initializer of ActivityItemView, set up an observer using the new withContinuousObservation function. This view is where people can edit activities, so it's a good place for the observation. Whenever someone changes either isComplete or the name of an Activity, the observation framework will run this code to set dateEdited to the current time, triggering our query to automatically update the list of activities.
This is also a natural place to add a side effect on Trip. Whenever an activity's status is toggled, or an activity is added or removed, we update the isComplete property for the trip as a whole.
Now you know what it takes to integrate Apple's declarative persistence framework into your own apps. Start by considering the appropriate representation of your app's state, and declare the Model types that make up your schema. From there, write targeted queries using predicates to balance between memory use and on-disk storage. And stay up to date on how you can continue to adjust your SwiftUI views for optimal interoperability with SwiftData.
And with that, I can check off today's last activity, and earn my badge.
Thanks for listening, and safe travels.
-
-
3:39 - Convert Activity to a persistent model with @Model
import Foundation import SwiftData // SwiftData automatically generates Observable conformance @Model class Activity { var name: String var isComplete: Bool = false var dateCreated = Date.now var dateEdited = Date.now } -
6:06 - Add Codable conformance to TripCollection
enum TripCollection: String, CaseIterable, RawRepresentable, Codable { case springEscapes case summerVibes case fallGetaways case winterRetreats } -
10:32 - Set up model relationships between Trip, TripImage, and Activity
import Foundation import SwiftData @Model class Trip { var name: String var collection: TripCollection var photo: TripImage var thumbnailData: Data? @Relationship(deleteRule: .cascade, inverse: \Activity.trip) var activities: [Activity] = [] private(set) var creationDate = Date.now var subtitle: String? var isComplete: Bool = false } -
13:21 - Enable interoperability between your schema and SwiftUI views
import SwiftUI import SwiftData @main struct WishlistApp: App { let container: ModelContainer = { do { let modelContainer = try ModelContainer(for: Trip.self, Activity.self, TripImage.self, Goal.self, TripGoal.self, ActivityGoal.self) try SampleData.seedIfNeeded(in: modelContainer.mainContext) return modelContainer } catch { fatalError("Could not create model container: \(error)") } }() var body: some Scene { WindowGroup { ContentView() .preferredColorScheme(.dark) } .modelContainer(container) } } -
16:27 - Fetch achieved and upcoming goals
@Query(filter: #Predicate<Goal> { $0.isAchieved }, sort: \Goal.dateAchieved, order: .reverse) private var achievedGoals: [Goal] @Query(filter: #Predicate<Goal> { !$0.isAchieved }, sort: \Goal.sortOrder) private var upcomingGoals: [Goal] -
16:49 - Fetch recent trips
import SwiftUI import SwiftData struct RecentTripsPageView: View { // Fetch most recent trips in reverse chronological order @Query(FetchDescriptor<Trip>(sortBy: [SortDescriptor(\Trip.creationDate, order: .reverse)], fetchLimit: 5)) private var trips: [Trip] @Namespace private var namespace var body: some View { TabView { ForEach(trips) { trip in NavigationLink { TripDetailView(trip: trip) .navigationTransition( .zoom(sourceID: trip.id, in: namespace)) } label: { TripImageView(trip: trip) .overlay(alignment: .bottomLeading) { VStack(alignment: .leading) { Text("RECENTLY ADDED") .font(.subheadline) .fontWeight(.bold) .foregroundStyle(.limeGreen) Text(trip.name) .font(.title) .fontWidth(.expanded) .fontWeight(.medium) .foregroundStyle(.primary) } .padding(.horizontal) .padding(.bottom, 54) } .matchedTransitionSource(id: trip.id, in: namespace) } .buttonStyle(.plain) } } .tabViewStyle(.page) .containerRelativeFrame([.horizontal, .vertical]) { length, axis in if axis == .vertical { return length / 1.3 } else { return length } } } } -
17:26 - Dynamically construct a query in the initializer of TripCollectionView
init(tripCollection: TripCollection, cardSize: TripCard.Size, namespace: Namespace.ID) { _trips = Query(filter: #Predicate<Trip> { $0.collection == tripCollection }, sort: \Trip.name) self.tripCollection = tripCollection self.cardSize = cardSize self.namespace = namespace } -
18:13 - Search for trips and activities by name
import SwiftUI import SwiftData private struct SearchResultsListView: View { @Query(sort: \Trip.name) private var trips: [Trip] @Query(sort: \Activity.name) private var activities: [Activity] var searchText: String var namespace: Namespace.ID init(searchText: String, namespace: Namespace.ID) { self.searchText = searchText self.namespace = namespace if searchText.isEmpty { _trips = Query(FetchDescriptor(sortBy: [SortDescriptor(\Trip.creationDate, order: .reverse)], fetchLimit: 3)) _activities = Query(filter: #Predicate<Activity> { _ in false }) } else { // All trips whose name matches searchText, sorted lexicographically let tripSearchPredicate = #Predicate<Trip> { $0.name.localizedStandardContains(searchText) } _trips = Query(filter: tripSearchPredicate, sort: \Trip.name) // All matching activities that belong to a trip let activitySearchPredicate = #Predicate<Activity> { $0.trip != nil && $0.name.localizedStandardContains(searchText) } _activities = Query(filter: activitySearchPredicate, sort: \Activity.name) } } var body: some View { List { if !trips.isEmpty { TripSearchSectionView(trips: trips, namespace: namespace, title: searchText.isEmpty ? "Recent Trips" : "Trips") } if !activities.isEmpty { ActivitySearchSectionView(activities: activities) } } .overlay { if trips.isEmpty && activities.isEmpty { ContentUnavailableView( "No results for “\(searchText)”", systemImage: "magnifyingglass", description: Text("Check spelling or try a new search.") ) } } .listStyle(.plain) } } -
19:42 - Capture and report errors from ActivityItemView
var body: some View { HStack(alignment: .firstTextBaseline, spacing: 17) { Group { if isEditing { rowContentWhenEditing } else { rowContentWhenNotEditing } } .transition(.opacity.animation(.snappy)) .animation(.snappy, value: isEditing) } .onDisappear { do { try updateGoalAchievements() } catch { updateError = error reportError(error) } } .alert(error: $updateError) { // Customize the presentation of the error } } -
21:04 - Update dateEdited and propagate side effects on property changes
init(activity: Activity, isLast: Bool, isEditing: Bool) { activity.token = withContinuousObservation(options: .didSet) { event in _ = activity.name _ = activity.isComplete if event.matches(\Activity.name) { activity.dateEdited = .now } if event.matches(\Activity.isComplete) { activity.dateEdited = .now activity.trip?.isComplete = activity.trip?.activities.isEmpty == false && activity.trip?.activities.allSatisfy { $0.isComplete } == true } } self.activity = activity self.isLast = isLast self.isEditing = isEditing }
-
-
- 0:00 - Introduction
An introduction to the Wishlist sample app and the three steps for adopting SwiftData: identifying relevant state, defining schemas, and defining model relationships.
- 1:05 - Identify relevant state
Identify the data types and variables in Wishlist — trip collections, goal statuses, and the DataSource — that will become SwiftData models connected through a ModelContext.
- 3:17 - Define your schemas
Convert Activity, Trip, and Goal into @Model types. Covers handling property observers with the @Model macro, refactoring the Goal enumeration into a class hierarchy using inheritance with TripGoal and ActivityGoal subclasses, and inlining thumbnail data.
- 9:41 - Define model relationships
Declare to-many relationships between Trip and Activity using the @Relationship macro, remove the now-redundant DataSource and TripEditModel helpers, and attach the modelContainer scene modifier to complete the model layer.
- 13:33 - Update the view layer
Replace environment DataSource properties with @Query macros and targeted FetchDescriptor predicates in each subview. Covers autosave, surfacing runtime errors with SwiftUI view modifiers, and re-enabling dateEdited property observers using the new withContinuousObservation API.
- 21:47 - Next steps
Key takeaways: design a schema that fits your data model, balance memory and disk usage with targeted queries, and plan for interoperability and extensibility as your app evolves.