Mastering Data Persistence in iOS with SwiftData

Mastering Data Persistence in iOS with SwiftData

iOSSwift
Fix bugs faster! Log Collection Made Easy
START NOW!

Introduced in 2023, SwiftData is the latest addition to the range of database framework options in Swift, Apple’s primary programming language for iOS. Built on top of Core Data, two levels above SQLite, it’s great for simplifying our persistent stores and it allows us to use declarative code, which is a really useful time-saver.

However, for all its flexible functionality, SwiftData framework presents certain challenges for us devs, particularly if we’re migrating from other database tools. So here, we’re going to walk you through the basics of SwiftData, so you can jump-start your learning and feel confident whenever you use this amazing framework that simplifies data persistence and makes app development easier without the deep knowledge required to master Core Data.

But first off, what are the benefits of using SwiftData?

Well, there are lots! But here some of the benefits that will really impact your day-to-day work.

  • You can use SwiftData with minimal code, enabling you divert your creative energies to other aspects of your sprint.
  • You can write the entire model layer (essentially, the business logic bit) of your app with just this one framework.
  • There are no external file formats to worry about.
  • It’s got SwiftUI integration baked in. In fact, it’s pretty much Swift-native.
  • The syntax is easy to get to grips with.

So, basically, it’ll save you a lot of time and remove a lot of the pain-points you’ll find with other frameworks. Which means you can build clean, robust and reliable apps more quickly.

Ok, now let’s create a SwiftData database

In this article we’re going to look at how to build a SwiftData model database for a simplified version of a social media Post. For the purpose of this demonstration, we’ll need to persist (preserve data once the program has stopped) with just three objects: form our basic schema:

SwiftData Schema

A Post that will have:

  1. A title
  2. An author
  3. A list of likes .

A User that will:

  1. Have a username
  2. Follow other Users .

Like objects that:

  1. Have a date
  2. Are associated with a User.

Now, let’s set up SwiftData

Before SwiftData can persist the objects in our database, we’ll need to create we’ll need to create each object as the model class shown below::

struct Post {
    var author: User
    var likes: [Like]
    var title: String
}

struct User {
    var username: String
    var follows: [User]
}

struct Like {
    var user: User
    var date: Date
}

We’ve created the objects of our schema as Structs, but to make them SwiftData-compliant they’ll need to be considered SwiftData Objects. This means making them @Model objects, which is done by simply adding @Model , as you can see below:

@Model struct Post {
    var author: User
    var likes: [Like]
    var title: String
}

 @Model struct User {
    var username: String
    var follows: [User]
}

@Model struct Like {
    var user: User
    var date: Date
}

After we’ve added @Model the compiler will return the following error:

@Model requires an initializer be provided for 'Post'
@Model requires an initializer be provided for 'User'
@Model requires an initializer be provided for 'Like'

Don’t worry, this is expected behavior. @Model requires all structs have both implicit and explicit initializers associated with them, and we can solve the issue by adding an explicit initializer to our model type:

@Model struct Post {
    var author: User
    var likes: [Like]
    var title: String
    
    init(author: User, likes: [Like], title: String) {
        self.author = author
        self.likes = likes
        self.title = title
    }
}

@Model struct User {
    var username: String
    var follows: [User]
    
    init(username: String, follows: [User] = []) {
        self.username = username
				self.follows = follows
    }
}

@Model struct Like {
    var user: User
    var date: Date
 
    init(user: User, date: Date = Date.now) {
        self.user = user
        self.date = date
    }
}

The @Model we have used is one of the schema macros provided by SwiftData framework, these schema macros add functionality to our model classes without requiring the developer to code complex strategies.

Now the SwiftData model classes are ready, it’s time to prepare our app to store them by making the app a ModelContainer.

To do this, simply go to the root of the app and add the line below:

@main
struct SwiftDataApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer (for: [Post.self, User.self, Like.self])
    }
}

Great! Now the app is a SwiftData modelContainer making our app a model container, which means it can store SwiftData objects and it’s a persistent store.

Next step: model macro functions

As the Swift Macro provides a convenient user interface, we don’t really need **to know what the @Model macro adds to our types. However, we can easily check by right-clicking the Macro and choosing expand Macro.

Basic database operations

Now our data models are configured, let’s see how can we use them to store, read and delete objects.

Adding to SwiftData

Having set up the modelContainer, we can access the modelContext in any view we choose. In this example, we’ll create a view with a button for adding users to our database:

import SwiftUI
import SwiftData

struct ContentView: View {
		@Environment(\.modelContext) private var modelContext

    var body: some View {
        VStack {
            Button {
                modelContext.insert(RandomGenerator.user())
            } label: {
                Text("Add user")
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

class RandomGenerator {
    static func user() -> User {
        let usernames = ["Quantum", "Giraffe", "Mystic", "Penguin", "Nebula", "Phoenix", "Zenith", "Whisper", "Radiant", "Tiger", "Lunar", "Cascade", "Celestial", "Breeze", "Ephemeral", "Dragon", "Cosmic", "Echo", "Enigma", "Sparrow"]
        
        let index1 = Int.random(in: 0...usernames.count - 1)
        let index2 = Int.random(in: 0...usernames.count - 1)

        return User(username: "\(usernames[index1])\(usernames[index2])")
    }
}

The most important elements here are the connection to our SwiftData Context, and the insertion of the object into this context.

		@Environment(\.modelContext) private var modelContext

...

		modelContext.insert(RandomGenerator.user())

We can check the button is working as it should by querying the data, which we’ll cover next.

Querying from SwiftData

Another of the great things about SwiftData is that we can see all users in our database simply by adding a List to our SwiftUI app view.

To do this, we simply modify our ContentView as follows:

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query var users: [User]
    
    var body: some View {
        VStack {
            Button {
                modelContext.insert(RandomGenerator.user())
            } label: {
                Text("Add user")
            }

            List(users) { user in
                Text(user.username)
            }
        }
        .padding()
    }
}

By adding just four simple lines of code, we’re now able to query all users from our data model and get a full breakdown of them.

Deleting from SwiftData

So we’ve seen how to write and read data from our database. Now, let’s take the next natural step and look at how to delete.

We’ll need to make a small change to how we display our List to make it easier to add the onDelete modifier. Again, however, this is simple.

We just need to change:

List(users) { user in
   Text(user.username)
}

to:

List {
  ForEach(users, id: \.self) { user in
    Text(user.username)
  }
}

This will not change the overall functionality of our UI or app, but we can check everything still works by simply running the app.

Next we’ll add the onDelete modifier, after which our full List should look like the below:

List {
	ForEach(users, id: \.self) { user in
     Text(user.username)
  }
  .onDelete(perform: { offsets in
      modelContext.delete(users[offsets.first ?? 0])
  })
}

Now we can view, add and delete users in our app:

More advanced operations

Having mastered the creation of basic SwiftData operations using a User struct , we can now consider more complex examples such as bulk add/delete, search, and sort operations, using all our structures.

Generating more complex structures

If we want to make data available to demonstrate, we’ll first need to change our RandomGenerator class, so we can generate more complete and complex sets of data.

The new RandomGenerator class we’ll be using for these examples is as follows:

class RandomGenerator {
    static func user(thatFollows follows: [User] = []) -> User {
        let usernames = ["Quantum", 
                         "Giraffe",
                         "Mystic",
                         "Penguin",
                         "Nebula",
                         "Phoenix",
                         "Zenith",
                         "Whisper",
                         "Radiant",
                         "Tiger",
                         "Lunar",
                         "Cascade",
                         "Celestial",
                         "Breeze",
                         "Ephemeral",
                         "Dragon",
                         "Cosmic",
                         "Echo",
                         "Enigma",
                         "Sparrow"]
        
        let index1 = Int.random(in: 0...usernames.count - 1)
        let index2 = Int.random(in: 0...usernames.count - 1)

        return User(username: "\(usernames[index1])\(usernames[index2])", follows: follows)
    }
    
    static func posts() -> [Post] {
        var oneHundredUsers: [User] = []
        
        for _ in 1...100 {
            let otherUsers = Int.random(in: 0...100)

            var follows: [User] = []
            
            for _ in 0...otherUsers {
                follows.append(RandomGenerator.user())
            }
            
            oneHundredUsers.append(RandomGenerator.user(
                thatFollows: follows
            ))
        }

        let blogpostTitleArray = [
            "Resilience",
            "Serendipity",
            "Thrive",
            "Wanderlust",
            "Illuminate",
            "Empower",
            "Unleash",
            "Harmonize",
            "Catalyst",
            "Flourish",
            "Zenith",
            "Enchant",
            "Pinnacle",
            "Catalyst",
            "Odyssey",
            "Quench",
            "Jubilant",
            "Synergy",
            "Revitalize",
            "Traverse"
        ]
        
        
        var fiveHundredPosts: [Post] = []
        
        for _ in 1...500 {
            let index1 = Int.random(in: 0...blogpostTitleArray.count - 1)
            let index2 = Int.random(in: 0...blogpostTitleArray.count - 1)
            
            fiveHundredPosts.append(Post(
                author: oneHundredUsers[Int.random(in: 0...oneHundredUsers.count - 1)],
                likes: RandomGenerator
                    .randomLikes(from: oneHundredUsers),
                title: "\(blogpostTitleArray[index1]) \(blogpostTitleArray[index2])"))
        }
        
        return fiveHundredPosts
    }
    
    static func randomLikes(from users: [User]) -> [Like] {
        let amountOfLikes = Int.random(in: 0...users.count - 1)

        var likes: [Like] = []
        
        for likeIndex in 0...amountOfLikes {
            likes.append(Like(user: users[likeIndex]))
        }
        
        return likes
    }
}

Now, rather than generating single users, we can generate multiple users, each with a list of other users they follow. In addition we can create as many as 500 posts at a time, each with authors, titles and a (random) number of likes.

Bulk add/delete operations

So, we’ve used our RandomGenerator to generate 500 posts and we now want to add them to our database.

We could just add them to our database individually, as we saw earlier:

let posts = RandomGenerator.posts()
for post in posts {
    modelContext.insert(post)
}

Or we can make this process more efficient by creating a transaction object, and simply saving the items to the database after the entire transaction is set.

We do this as follows:

let posts = RandomGenerator.posts()
                do {
                    try modelContext.transaction {
                        for post in posts {
                            modelContext.insert(post)
                        }
                        do {
                            try modelContext.save()
                        } catch {
                            // Handle your error here
                        }
                    }
                } catch {
                    // Handle your error here
                }

While the first method took 12 seconds to complete, the second only took nine seconds. That may seem like a marginal gain, but every little advantage helps when we’re sprinting towards a target.

Deleting follows the exact same process. We just need to replace:

modelContext.insert(...

with:

modelContext.delete(...

Sorting operations

Our base View, which shows us the Post, author and number of likes, will look like the one below:

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query var posts: [Post]

    var body: some View {
        VStack {
            Button {
                let posts = RandomGenerator.posts()
                do {
                    try modelContext.transaction {
                        for post in posts {
                            modelContext.insert(post)
                        }
                        do {
                            try modelContext.save()
                        } catch {
                            // Handle error
                        }
                    }
                } catch {
                    // Handle error
                }
            } label: {
                Text("Add posts")
            }

            List(posts) { post in
                VStack {
                    Text(post.title).font(.system(size: 24))
                    HStack {
                        Text(post.author.username)
                        Spacer()
                        Text(post.likes.count.description + " likes")
                    }
                }
            }
        }
        .padding()
    }
}

Sorting data

Now let’s look at some examples of queries to sort the data, which will be crucial as our app evolves and we need to extract specific information.

Sorting posts by author/user

To sort by author/user, we can use a SortDescriptor and we’ll need to change our Query as follows:

@Query (sort: [SortDescriptor(\Post.author.username)]) 
var posts: [Post]

Now the Posts will be grouped by author/user when they are displayed.

Show only posts by a specified author/user

Let’s say we only wanted to see posts created by the user called CascadeMystic. In this case, we would use a Predicate, as below:

@Query (filter: #Predicate<Post> { post in
        post.author.username == "CascadeMystic" 
})
var posts: [Post]

Just FYI, we use SortDescriptors to sort by a specific keypath, and Predicates for filtering the objects we want to fetch.

To sum up

To use SwiftData in your app, you need to remember the following two crucial steps:

  • Make the Structs/Classes you want to store conform to @Model.
  • Make your app a Container of your @Models with .modelContainer (for: [MyModel.self]).

Once that’s done, you can use SwiftData in any View by adding a model context to it:@Environment(\.modelContext) private var modelContext

So now, you can read your saved types with a query:

@Query var myTypeArray: [MyModel]

Add new elements with an insert:

modelContext.insert(myElement)

And delete them:

modelContext.delete(myElement)

The more you practice and play around with this, the more you’ll see the benefits of SwiftData when adding and using local database capabilities in your apps. You can really get creative here, so let your imagination roam!

Expect the Unexpected! Debug Faster with Bugfender
START FOR FREE

Trusted By

/assets/images/svg/customers/highprofile/volkswagen.svg/assets/images/svg/customers/highprofile/macys.svg/assets/images/svg/customers/projects/eye-d.svg/assets/images/svg/customers/highprofile/adt.svg/assets/images/svg/customers/cool/starbucks.svg/assets/images/svg/customers/highprofile/schneider_electric.svg/assets/images/svg/customers/cool/napster.svg/assets/images/svg/customers/projects/vorwerk.svg

Already Trusted by Thousands

Bugfender is the best remote logger for mobile and web apps.

Get Started for Free, No Credit Card Required