SQLiteData is a library that we feel largely replaces the need for SwiftData in apps that have complex persistence and querying needs. We explored this library deeply in our “Modern Persistence” series, everything from domain modeling and triggers to full-text search and iCloud synchronization. And while we highly recommend people watch those episodes to get broad exposure to many advanced topics in persistence, we feel it would also be nice for us to have a few short and to-the-point episodes showing how to go from zero to moderately complex app with SQLiteData.
And that is exactly what we are going to do for the next few episodes to round out our 2025 season of episodes. We are going to build a little score keeping app that allows you to add games to a list, players to each game, and then assign a score to each player. It is a simple app, but it gives us just enough functionality to explore many parts of our SQLiteData library.
We will get to see that SQLiteData allows us to model our domains as concisely as possible using value types as opposed to reference types, which is what SwiftData uses.
We will show how to perform type-safe and schema-safe queries so that invalid queries just fail to compile. In SwiftData it is completely possible to write invalid queries that compile but crash at runtime.
We will show that SQLiteData has property wrappers similar to SwiftData’s
@Querymacro that allow you to fetch data from your database and observe changes to the database. But, unlike SwiftData, our property wrappers work in contexts besides just SwiftUI, such as UIKit and@Observablemodels.
We will show that with just a few extra lines of code all of the data stored on the user’s device can be synchronized to all of their devices over iCloud. This is quite similar to how SwiftData works too.
But something that SwiftData does not support is record sharing. We will show how it’s possible to share a game, and all associated records, with another iCloud user for collaboration.
And most importantly, everything is powered by SQLite, a battle tested technology that is over 25 years old and one of the most widely deployed pieces of software in history.
So, let’s get started! We will go quickly in building this app because the point of these episodes is not to dive into every little detail of why we do things the way we do them. That was done in our “Modern Persistence” series. This time we are just going to focus on building the app and using SQLiteData to its full potential.
I have a fresh iOS project created in Xcode 26 and the only change I have made to the project so far is that I added SQLiteData as a dependency. However, there is one additional change we highly recommend. The default settings for a new Xcode 26 project have “concurrency checking” set to “minimal” and Swift language mode set to 5. This makes it all too easy to write code that compiles with no warnings yet will crash at runtime due to breaking an invariant of the concurrency system, such as accessing a main actor bound value off the main thread.
So, we recommend either turning “concurrency checking” to “Complete”, or even better changing to Swift 6 language mode. It is the best way to be sure that you are being notified of concurrency problems at compile time rather than crashing at runtime.
And for these episodes we are going to use Swift 6 language mode…
It’s also worth noting that the default Xcode 26 project has “Approachable Concurrency” turned on, which is a grab bag of upcoming features of Swift 6.2 that will someday just be the default of how Swift works. And the project has “Default actor isolation” set to “MainActor”, which in our opinion is quite controversial and not really the right call, but alas that is how it is by default and so we will go with it.
Now that we have our project set up we are going to start with a domain modeling exercise. We are going to design simple Swift value types that represent the data we ultimately want to persist, synchronize to iCloud, and even share with other users. At the top level we have the notion of a game which just needs an identifier and a title:
import Foundation
import SQLiteData
@Table struct Game: Identifiable {
let id: UUID
var title = ""
}
A couple of things to note already:
We are using the
@Tablemacro to generate a whole bunch of boilerplate code for us under the hood, and all of this code gives us access to not only type-safe and schema-safe querying tools, but also to a super performant decoder for loading raw data from SQLite.
We are using a UUID for the ID because that is the kind of ID that is most friendly to use with CloudKit. Really any kind of globally unique identifier will work, but we cannot use simple auto-incrementing integers, which is a common choice for SQLite, for tables that need to be distributed across many devices.
We have also made the ID a
let, which is important because we typically do not want to allow mutating the ID of a player. The ID should be generated when a row is inserted into the database, and should never be changed again.
So far this doesn’t seem so different from SwiftData. You use the @Table macro instead of the @Model macro, but you specify an explicit ID where SwiftData has an implicit ID, and you get to use a struct value type with a compiler-generated initializer instead of a class reference type with an explicit initializer that you define.
Next we are going to define another data type that represents a player within a game. This is where things start to deviate a little from SwiftData. We will define another struct to hold the ID and name of the player, but we will further hold the ID of the game the player belongs to:
@Table struct Player: Identifiable {
let id: UUID
let gameID: Game.ID
var name = ""
var score = 0
}
This is different from SwiftData because in SwiftData you would hold onto the actual game the player belongs to:
var game: Game
The only reason that works in SwiftData is because they use reference types, and that comes with all kinds of downsides. In SQLiteData, we just hold the foreign key pointing to any associated data we need, and then when querying the database we have a choice of loading that association or not, which can lead to more efficient data loading.
OK, that is all we are going to do for our domain modeling exercise for right now. The next step we take is to establish a connection to a local SQLite database and migrate the database to actually create the tables that represent these data types. This is quite different from SwiftData, which magically does that work for you behind the scenes. However, there is a major limitation to that magic, which is that many migrations cannot be implicitly performed and if not handled probably will simply crash your app. And then the steps to handle those migrations manually are so long and laborious that we feel you might as well have just written migrations by hand from the beginning.
However, we do understand that writing SQL by hand to create tables is not what many people would call “modern”. We luckily do have lots of documentation to describe exactly how to migrate your database, and we also have something to preview right now in this episode that is currently unreleased, but will be soon.
We call it the “Point-Free Way”, and it’s a collection of skill documents that help AI coding editors better use our tools. And you may have already noticed that our project as this directory called “the-point-free-way”, and inside there are a bunch of text files. We can even see one named “SQLiteData.md”.
We can reference this file when using Xcode’s intelligence tab. Speaking of which, let’s switch to that and we will see I’ve already got it configured with ChatGPT. I can now ask ChatGPT to create the database connection for us, and migrate the database to match our tables:
Prompt @SQLiteData.md create a database connection to a fully migrated SQLite database for the two new tables: Game and Player.
After a few moments we will see a stream of words and code come in that explain exactly how to accomplish what we asked for. We can also see what files were used for the project context, and it indeed used our SQLiteData.md file, as well as a StructuredQueries.md file.
In the response it lets us know that it will create an appDatabase() function that can create the connection to the database, as well as a bootstrapDatabase() method that can set the connection in the dependencies system so that the connection is available everywhere in the app.
This first step is done with the following code:
func appDatabase() throws -> any DatabaseWriter {
let database = try SQLiteData.defaultDatabase()
var migrator = DatabaseMigrator()
#if DEBUG
migrator.eraseDatabaseOnSchemaChange = true
#endif
migrator.registerMigration("Create 'games' and 'players' tables") { db in
try #sql(
"""
CREATE TABLE "games" (
"id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
"title" TEXT NOT NULL DEFAULT ''
) STRICT
"""
)
.execute(db)
try #sql(
"""
CREATE TABLE "players" (
"id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
"gameID" TEXT NOT NULL REFERENCES "games"("id") ON DELETE CASCADE,
"name" TEXT NOT NULL DEFAULT '',
"score" INTEGER NOT NULL DEFAULT 0
) STRICT
"""
)
.execute(db)
try #sql(
"""
CREATE INDEX "index_players_on_gameID" ON "players"("gameID")
"""
)
.execute(db)
}
try migrator.migrate(database)
return database
}
extension DependencyValues {
mutating func bootstrapDatabase() throws {
defaultDatabase = try appDatabase()
}
}
And amazingly this is correct:
It correctly derived the name of the tables based on the names of the structs, which is to camel-case and pluralize.
It knew which columns to specify as primary keys, and even put an
NOT NULL ON CONFLICT REPLACE DEFAULTclause on them which is what allows one to insert rows withNULLids and have SQLite construct a random UUID for us.
It also understood that the
gameIDcolumn is a foreign key and further put that constraint in the schema.It also add an index on the
gameIDforeign key, which allows for efficient SQL joins
And it even set defaults for columns based on the literal defaults in the structs, such as 0 for
scoreand empty string fortitleandname.
All of these little rules are meticulously described in the SQLiteData.md file, and that is how it was able to do such a good job with this migration. And there are even more rules that have to be followed with more complex schemas, but we don’t have to worry about that now. We of course think you should be intimately familiar with all of these rules and be capable of writing this SQL yourself, but it’s also nice to get a little help.
In the second step it suggests we update the entry point of our app to prepare dependencies using the bootstrap function:
import SwiftUI
import Dependencies
@main
struct ScorekeeperApp: App {
init() {
prepareDependencies {
try! $0.bootstrapDatabase()
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
This step is similar to SwiftData where you create a model context at the entry point of the app and put it in the SwiftUI environment.
With that done we are able to access the database connection from anywhere within our app, and this allows all of the tools of SQLiteData to access the database connection, which we will see more of in just a moment.
OK, we have the first steps completed for our app. We have defined the struct data types that represent the data we want to persist, and we established a connection to the database and migrated the schema by using some upcoming tools from us that allow one to pre-seed the context of your AI code editor with very specific information of how to use our libraries.
But, all of that is the least exciting part of our SQLiteData library, and you may think that this work is more verbose than how one uses SwiftData. However, the little bit of brevity in SwiftData comes at a huge cost, which is the loss of trust in the tools. It is far too easy to write code that compiles just fine and looks perfectly reasonable, yet crashes at runtime.
Our tools aim to fix all of these annoyances by requiring us to do just a little bit of upfront work first, but then after that we can have a lot of confidence that if things compile then it should mostly work.
And now we can start to enjoy the fruits of our labor because from this point on everything we do will be easier and safer than the equivalent in SwiftData. It is now very easy to start fetching data from the database and displaying it on the screen.
Let’s get started.
Let’s create a new file that will house the view and functionality for listing all the players and scores for a game…
And we will want access to both SQLiteData and SwiftUI:
import SQLiteData
import SwiftUI
Let’s paste in the basics of a GameView:
struct GamesView: View {
var body: some View {
List {
}
.navigationTitle("Games")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
} label: {
Label("Add Game", systemImage: "plus")
}
}
}
}
}
Along with a preview:
#Preview {
NavigationStack {
GamesView()
}
}
The simplest way to get data from our database in this view is to use the @FetchAll property wrapper, which is similar to SwiftData’s @Query macro and is capable of making a query to our database to load many rows into an array:
@FetchAll var games: [Game]
That right that will fetch all games from the database with no further filtering or sorting logic applied. And this property works by accessing the database connection that we prepared at the entry point of the app.
And then we can render those games in the list using a ForEach:
ForEach(games) { game in
Text(game.title)
.font(.headline)
}
But right off the bat here we will see in our preview logs that there is a purple warning showing us that something
SQLiteData/DefaultDatabase.swift:85: A blank, in-memory database is being used. To set the database that is used by ‘SQLiteData’ in a preview, use a tool like ‘prepareDependencies’:
This is telling us that we haven’t prepared the default database for our preview. We did it for the app in the entry point, but we have to do the same for previews:
#Preview {
let _ = prepareDependencies {
try! $0.bootstrapDatabase()
}
…
}
Now we don’t get any purple warnings in the logs, but still nothing is displaying, and that’s because, well… our database is empty.
Seeding the database with a few games can be very handy in previews so that we don’t have to create them by hand directly in the UI, and seeds can also be useful in tests. So, let’s define a little seed helper on DatabaseWriter:
extension DatabaseWriter {
func seed() throws {
}
}
Remember DatabaseWriter represents our connection to the database. It comes with a method called write, which starts a transaction that allows us to make any edits to the database that we want:
try write { db in
}
In here we would like to create some Game values to be inserted into the database, and SQLiteData comes with a very handy tool called seed:
try db.seed {
}
This trailing closure is a result builder and it allows us to list any number of values that should be inserted into the database. We can even use the Draft type that is created by the @Table macro so that we can construct games without an ID specified, and allow SQLite to assign a random UUID when inserting the rows:
try db.seed {
Game.Draft(title: "Family gin rummy")
Game.Draft(title: "Weekly poker night")
Game.Draft(title: "Mahjong with grandma")
}
Now we can use this seed function right after we bootstrap the database:
let _ = try prepareDependencies {
try! $0.bootstrapDatabase()
try! $0.defaultDatabase.seed()
}
And we instantly start to see the 3 games we seeded being displayed in the preview.
Let’s make it so that tapping the “+” button creates a new row in the database. From a user experience standpoint we are going to do this by presenting an alert with a text field so that the user can enter the title of the game, and then with a “Save” or “Cancel” button.
To do this we need some state that represents if the alert is being presented:
@State var isNewGameAlertPresented = false
And some state to bind to for the user to enter the title of their game:
@State var newGameTitle = ""
Then when the “+” button is tapped we will flip the boolean state to true and clear out the title:
Button {
newGameTitle = ""
isNewGameAlertPresented = true
} label: {
Label("Add Game", systemImage: "plus")
}
Then we can use the alert(_:isPresented:actions:) view modifier to display an alert when the isNewGameAlertPresented state flips to true, and in that alert we can display a text field and two buttons:
.alert("Create new game", isPresented: $isNewGameAlertPresented) {
TextField("Game title", text: $newGameTitle)
Button("Save") {
}
Button(role: .cancel) {}
}
We don’t need to do anything explicitly in the cancel button because when that is tapped SwiftUI handles writing false to the $isNewGameAlertPresented binding.
But when the “Save” button is tapped we want to write to the database to insert a new game row. We can construct an insert query using the query building tools afforded to us by the @Table macro:
Game.insert { }
Inside this trailing closure we can construct a Game value, and that will represent an INSERT statement we can execute in our database. But constructing a Game value means also providing an ID, and it would be better to let the database choose the ID for us, and so we can use the Draft type to represent a game that is being prepared to be saved:
Game
.insert { Game.Draft(title: newGameTitle) }
This only represents a SQL query. It does not actually save anything on its own. To execute we invoke the execute method:
Game
.insert { Game.Draft(title: newGameTitle) }
.execute(<#db: Database#>)
But to do that we need access to our database connection, which is the thing we prepared in our dependencies at the entry point of the app. To get access to that in this view we simply use the @Dependency property wrapper:
struct GamesView: View {
@Dependency(\.defaultDatabase) var database
…
}
Now we can start a write transaction with our database and execute the query:
try database.write { db in
try Game
.insert { Game.Draft(title: newGameTitle) }
.execute(db)
}
This does throw, and we are not in a throwing context, and any error produced by this query would only be a programmer error, such as if we forgot to prepare our database. It would not be a user error, and so not appropriate to display an alert to the user or anything like that.
So for things like this we like to capture the error and report them as purple runtime warnings in Xcode, and we can use withErrorReporting to do that:
withErrorReporting {
…
}
Now when we tap the “+” button, an alert appears, we can enter a title, tap “Save”, and the row is added to the list. It didn’t have any animation, but that is easy enough to fix with our tools:
@FetchAll(animation: .default) var games: [Game]
Now all changes detected in the database will be animated.
OK, we now have the ability to add new games. Now let’s make it so that we can delete rows. Lists in SwiftUI have an onDelete modifier that automatically give the user the ability to swipe on a row to delete that item:
.onDelete { offsets in
}
This functionality works with multi-select too, and that is why we are handed an entire IndexSet instead of just a single index.
We can map on this index set in order to turn an index into the ID of the game to be deleted:
offsets.map { games[$0].id }
Then we can construct a query that finds all games associated with these IDs:
Game.find(offsets.map { games[$0].id })
Then we can construct a DELETE query that deletes all such games:
Game.find(offsets.map { games[$0].id })
.delete()
And finally we can execute that statement inside a write transaction and wrapping the whole thing in a withErrorReporting since any error emitted would be programmer error, not user error:
withErrorReporting {
try database.write { db in
try Game.find(offsets.map { games[$0].id })
.delete()
.execute(db)
}
}
And just like that we have now implemented deleting in this view. We can see it works in the preview, and we can update the entry point of the app to wrap the GamesView in a NavigationStack:
var body: some Scene {
WindowGroup {
NavigationStack {
GamesView()
}
}
}
And now we can see it works in the simulator too. And in fact, now that we are running it in the simulator we can see that if we kill the app, and re-open it, the data we created previously was restored.
OK, we have now gotten our feet wet with querying the database and making changes to the database using our SQLiteData library. For the most part it really isn’t that different from SwiftData. You use the @FetchAll property wrapper instead of the @Query macro, and make mutations by executing SQL queries instead of mutating reference types and calling methods on a ModelContext. And we personally think it’s actually a superpower that we have direct access to SQL, not a downside, because SQL offers incredible features that are hidden from us in SwiftData, which we will soon see more of.
But for now let’s move onto the 2nd feature of our app, which is the ability to tap on a game, drill down to a new screen, and manage a list of players and their scores for that particular game. This screen is going to have a decent amount of logic because you will be able to add and remove players, increment and decrement their scores, and even sort the players by the score in increasing or decreasing fashion.
And because of this we are going to build this feature so that it uses an observable model to encapsulate its logic and behavior, and that will allow us to show that all of the tools we have been demonstrating work just as well outside of SwiftUI views as they do in SwiftUI views. This is in stark contrast to SwiftData, which practically forces you to put everything in the view, even if that’s not appropriate to do. Our tools work in SwiftUI views, observable models, and even in UIKit view controllers.
So, let’s get started…next time!