SQLiteData Usage Guide
SQLiteData is a fast, lightweight replacement for SwiftData from Point-Free, powered by SQL and supporting CloudKit synchronization (and even CloudKit sharing). It is built on top of GRDB and StructuredQueries.
Documented against: SQLiteData 1.11.0 / StructuredQueries 0.37.0 (August 2026). Platforms: iOS 16+, macOS 13+, tvOS 16+, watchOS 9+.
Key Dependencies
- GRDB: The underlying SQLite interface library. Used for database connections, transactions, migrations, and observation.
- StructuredQueries: Provides the
@Tablemacro and type-safe query building APIs. - Swift Dependencies: Used for dependency injection (
@Dependency,prepareDependencies).
Reference Files
Load these on demand for deeper topics:
| File | Contents |
| --- | --- |
| references/dynamic-queries-and-pagination.md | $load(...), custom @Fetch requests, offset/cursor pagination, sectioned queries (sectionBy:) |
| references/joins-and-associations.md | One-to-many/many-to-many joins, three-way joins, query shortcuts, computed expressions, safe raw SQL |
| references/cloudkit-sync.md | SyncEngine setup, delegates, CloudKit sharing, manual sync, conflict resolution |
| references/testing-and-previews.md | Previews, seeding, in-memory databases, unit tests with .dependency trait |
| references/advanced-patterns.md | JSON columns, type-safe date functions, package traits, FTS5, triggers |
| references/complete-examples.md | Full SwiftUI app, @Observable model, UIKit view controller |
Table of Contents
- Defining Your Schema
- Preparing the Database
- Fetching Data
- Observing Changes
- CRUD Operations
- Common Pitfalls
- Quick Reference
1. Defining Your Schema
Use the @Table macro from StructuredQueries to define your data types. Unlike SwiftData's @Model (which requires classes), @Table works with structs.
Basic Table Definition
import SQLiteData
@Table
struct Item: Identifiable {
let id: Int // Primary key (auto-generated for Int)
var title = ""
var isInStock = true
var notes = ""
}
UUID Primary Key
@Table
struct RemindersList: Identifiable {
let id: UUID
var title = ""
var position = 0
}
Custom Column Mapping
@Table
struct RemindersList: Hashable, Identifiable {
let id: UUID
@Column(as: Color.HexRepresentation.self)
var color: Color = Self.defaultColor
var position = 0
var title = ""
}
Custom Primary Key
@Table
struct Tag: Hashable, Identifiable {
@Column(primaryKey: true)
var title: String
var id: String { title }
}
Enums in Tables
Enums must conform to QueryBindable:
@Table
struct Reminder: Identifiable {
let id: UUID
var priority: Priority?
var status: Status = .incomplete
enum Priority: Int, QueryBindable {
case low = 1
case medium
case high
}
enum Status: Int, QueryBindable {
case incomplete = 0
case completed = 1
case completing = 2
}
}
To define table columns from an enum (flattened case payloads), enable the
CasePathspackage trait — seereferences/advanced-patterns.md.
@Selection Macro for Custom Result Types
Use @Selection for custom types that hold the results of joins or partial selects:
@Selection
struct ReminderListState: Identifiable {
var id: RemindersList.ID { remindersList.id }
var remindersCount: Int
var remindersList: RemindersList
@Column(as: CKShare?.SystemFieldsRepresentation.self)
var share: CKShare?
}
@Selection
struct Stats {
var allCount = 0
var flaggedCount = 0
var scheduledCount = 0
var todayCount = 0
}
Junction Tables (Many-to-Many)
@Table("remindersTags")
struct ReminderTag: Identifiable {
let id: UUID
let reminderID: Reminder.ID
let tagID: Tag.ID
}
2. Preparing the Database
Step 1: Create appDatabase() Function
import OSLog
import SQLiteData
func appDatabase() throws -> any DatabaseWriter {
@Dependency(\.context) var context
var configuration = Configuration()
// Optional: Enable query tracing for debugging
#if DEBUG
configuration.prepareDatabase { db in
db.trace(options: .profile) {
if context == .preview {
print("\($0.expandedDescription)")
} else {
logger.debug("\($0.expandedDescription)")
}
}
}
#endif
// Create database (auto-provisions unique temp DBs for previews/tests)
let database = try defaultDatabase(configuration: configuration)
logger.info("open '\(database.path)'")
// Migrate
var migrator = DatabaseMigrator()
#if DEBUG
migrator.eraseDatabaseOnSchemaChange = true
#endif
migrator.registerMigration("Create tables") { db in
try #sql("""
CREATE TABLE "items" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL DEFAULT '',
"isInStock" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT NOT NULL DEFAULT ''
) STRICT
""")
.execute(db)
}
// Register more migrations as your app evolves
migrator.registerMigration("Add 'description' column") { db in
try #sql("""
ALTER TABLE "items"
ADD COLUMN "description" TEXT
""")
.execute(db)
}
try migrator.migrate(database)
return database
}
private let logger = Logger(subsystem: "MyApp", category: "Database")
Step 2: Set Default Database in App Entry Point
SwiftUI:
import SQLiteData
import SwiftUI
@main
struct MyApp: App {
init() {
prepareDependencies {
$0.defaultDatabase = try! appDatabase()
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
UIKit AppDelegate:
class AppDelegate: NSObject, UIApplicationDelegate {
func applicationDidFinishLaunching(_ application: UIApplication) {
prepareDependencies {
$0.defaultDatabase = try! appDatabase()
}
}
}
Bootstrap Pattern (Recommended for Multiple Dependencies)
Group database + sync engine setup. Migrations run before the sync engine initializes:
extension DependencyValues {
mutating func bootstrapDatabase() throws {
defaultDatabase = try appDatabase()
defaultSyncEngine = try SyncEngine(
for: defaultDatabase,
tables: RemindersList.self, Reminder.self, Tag.self, ReminderTag.self
)
}
}
// In App entry point:
@main
struct MyApp: App {
init() {
try! prepareDependencies {
try $0.bootstrapDatabase()
}
}
// ...
}
3. Fetching Data
@FetchAll — Fetch a Collection
@FetchAll var items: [Item]
// With ordering:
@FetchAll(Item.order(by: \.title))
var items
// Multiple columns, mixed directions:
@FetchAll(
Item.order {
($0.isInStock,
$0.title.desc())
}
)
var items
// With filtering:
@FetchAll(Reminder.where(\.isCompleted).order { $0.title.desc() })
var completedReminders
// With animation:
@FetchAll(Item.order { $0.id.desc() }, animation: .default)
private var items
For dynamic queries, pagination, and sectioned results, see
references/dynamic-queries-and-pagination.md.
@FetchOne — Fetch a Single Value
@FetchOne(Reminder.count())
var remindersCount = 0
@FetchOne(Reminder.where(\.isCompleted).count())
var completedRemindersCount = 0
Since SQLiteData 1.10.0, @FetchOne also observes primary-keyed records automatically:
@FetchOne(Reminder.find(id))
var reminder: Reminder?
Aggregate computation with @Selection:
@FetchOne(
Reminder.select {
Stats.Columns(
allCount: $0.count(filter: !$0.isCompleted),
flaggedCount: $0.count(filter: $0.isFlagged && !$0.isCompleted),
scheduledCount: $0.count(filter: $0.isScheduled),
todayCount: $0.count(filter: $0.isToday)
)
}
)
var stats = Stats()
@Fetch — Multiple Queries in One Transaction
Define a FetchKeyRequest to fetch several values atomically:
struct Facts: FetchKeyRequest {
struct Value {
var facts: [Fact] = []
var count = 0
}
func fetch(_ db: Database) throws -> Value {
try Value(
facts: Fact.order { $0.id.desc() }.fetchAll(db),
count: Fact.fetchCount(db)
)
}
}
// Use:
@Fetch(Facts(), animation: .default)
private var facts = Facts.Value()
facts.facts // [Fact]
facts.count // Int
For joins and custom selections, see references/joins-and-associations.md.
4. Observing Changes
Property wrappers automatically observe database changes and re-render in SwiftUI views,
@Observable models, and UIKit view controllers.
SwiftUI Views
struct ItemsView: View {
@FetchAll var items: [Item]
var body: some View {
ForEach(items) { item in
Text(item.name)
}
}
}
@Observable Models
Important: Must annotate with
@ObservationIgnoreddue to macro interactions; SQLiteData handles its own observation.
@Observable
@MainActor
class ItemsModel {
@ObservationIgnored
@FetchAll(Item.order { $0.id.desc() }, animation: .default)
var items
@ObservationIgnored
@FetchOne(Item.count(), animation: .default)
var itemsCount = 0
@ObservationIgnored
@Dependency(\.defaultDatabase) var database
func deleteItem(indices: IndexSet) {
withErrorReporting {
try database.write { db in
let ids = indices.map { items[$0].id }
try Item
.where { $0.id.in(ids) }
.delete()
.execute(db)
}
}
}
}
UIKit View Controllers
Use $items.publisher (Combine) or observe (Swift Navigation):
class ItemsViewController: UICollectionViewController {
@FetchAll(Fact.order { $0.id.desc() }, animation: .default)
private var facts
override func viewDidLoad() {
super.viewDidLoad()
observe { [weak self] in
guard let self else { return }
var snapshot = NSDiffableDataSourceSnapshot<Section, Fact>()
snapshot.appendSections([.facts])
snapshot.appendItems(facts, toSection: .facts)
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
See references/complete-examples.md for full UIKit patterns.
5. CRUD Operations
All write operations go through the defaultDatabase dependency.
Access Database
@Dependency(\.defaultDatabase) var database
Create (Insert)
try database.write { db in
try Item.insert {
Item(id: UUID(), title: "New Item", isInStock: true, notes: "")
}
.execute(db)
}
Using Draft (auto-generated type without primary key):
try database.write { db in
try Fact.insert {
Fact.Draft(body: "Some fact text")
}
.execute(db)
}
Insert with defaults (using table defaults):
try database.write { db in
try Item.insert().execute(db)
}
Upsert (Insert or Update)
Insert that updates the existing row on conflict:
try database.write { db in
// Unconditional upsert of a draft:
try Reminder.upsert { draft }.execute(db)
// Conditional upsert with conflict target:
try Reminder.insert {
($0.isCompleted, $0.title)
} values: {
(false, "Get groceries")
} onConflictDoUpdate: {
$0.title += " (Copy)"
}
.execute(db)
}
Read (Fetch)
Use @FetchAll, @FetchOne, or @Fetch property wrappers (covered above).
For one-off reads within a write transaction:
try database.write { db in
let count = try Reminder.fetchCount(db)
let items = try Item.where(\.isInStock).fetchAll(db)
}
Update
Update an existing row:
existingItem.title = "New Title"
try database.write { db in
try Item.update(existingItem).execute(db)
}
Conditional update with query:
try database.write { db in
try Reminder
.where { $0.status.eq(#bind(.completing)) }
.update { $0.status = #bind(.completed) }
.execute(db)
}
Delete
Delete by value:
try database.write { db in
try Item.delete(existingItem).execute(db)
}
Delete matching rows:
try database.write { db in
try Tag
.where { $0.title.in(tagTitles) }
.delete()
.execute(db)
}
Batch Reordering
try database.write { db in
var ids = items.map(\.id)
ids.move(fromOffsets: source, toOffset: destination)
try Item
.where { $0.id.in(ids) }
.update {
let indexedIDs = Array(ids.enumerated())
let (first, rest) = (indexedIDs.first!, indexedIDs.dropFirst())
$0.position = rest
.reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in
cases.when(id.element, then: id.offset)
}
.else($0.position)
}
.execute(db)
}
6. Common Pitfalls
- Forgetting
@ObservationIgnoredon@Fetch*properties inside@Observableclasses — causes macro conflicts and compile errors. - Property wrappers cannot be declared in local scope. Never write
@FetchAll(...)inside a function body; use$items.load(...)or a model instead. - Substring search:
contains/hasPrefix/hasSuffixare deprecated — uselike("%\(query)%"). - Sync engine ordering: initialize
SyncEngineonly after migrations have run. - No lazy relationships: SQLiteData is not an ORM. Fetch related data explicitly with joins.
- Unit test trait import:
.dependency(...)in tests requiresimport DependenciesTestSupport.
7. Quick Reference
| SwiftData | SQLiteData |
| ------------------------------ | -------------------------------------------------- |
| @Model class | @Table struct |
| @Query var items: [Item] | @FetchAll var items: [Item] |
| @Query(sort:) | @FetchAll(Item.order(by:)) |
| @Query(filter:) | @FetchAll(Item.where(...)) |
| N/A | @FetchOne(Item.count()) |
| N/A | @Fetch(CustomRequest()) |
| @Environment(\.modelContext) | @Dependency(\.defaultDatabase) |
| modelContext.insert(item) | try Item.insert { item }.execute(db) |
| try modelContext.save() | (auto-saved within database.write) |
| modelContext.delete(item) | try Item.delete(item).execute(db) |
| ModelContainer(...) | prepareDependencies { $0.defaultDatabase = ... } |
| iOS 17+ only | iOS 16+ supported |
| Views only (@Query) | Views, @Observable, UIKit, anywhere |
Scan to join WeChat group