| name | axiom-sqlitedata |
| description | Use when working with SQLiteData @Table models, CRUD operations, query patterns, CloudKit SyncEngine setup, or batch imports. Covers model definitions, @FetchAll/@FetchOne, upsert patterns, database setup with Dependencies. |
| license | MIT |
| metadata | {"version":"3.0.0","last-updated":"2025-12-19"} |
SQLiteData
Overview
Type-safe SQLite persistence using SQLiteData (pointfreeco/sqlite-data) by Point-Free. A fast, lightweight replacement for SwiftData with CloudKit synchronization support, built on GRDB (groue/GRDB.swift) and StructuredQueries (pointfreeco/swift-structured-queries).
Core principle: Value types (struct) + @Table macro + database.write { } blocks for all mutations.
For advanced patterns (CTEs, views, custom aggregates, schema composition), see the axiom-sqlitedata-ref reference skill.
Requires: iOS 17+, Swift 6 strict concurrency
License: MIT
When to Use SQLiteData
Choose SQLiteData when you need:
- Type-safe SQLite with compiler-checked queries
- CloudKit sync with record sharing
- Large datasets (50k+ records) with near-raw-SQLite performance
- Value types (structs) instead of classes
- Swift 6 strict concurrency support
Use SwiftData instead when:
- Simple CRUD with native Apple integration
- Prefer
@Model classes over structs
- Don't need CloudKit record sharing
Use raw GRDB when:
- Complex SQL joins across 4+ tables
- Custom migration logic beyond schema changes
- Performance-critical operations needing manual SQL
Quick Reference
@Table nonisolated struct Item: Identifiable {
let id: UUID
var title = ""
var notes: String?
@Column(as: Color.Hex.self)
var color: Color = .blue
@Ephemeral var isSelected = false
}
prepareDependencies { $0.defaultDatabase = try! appDatabase() }
@Dependency(\.defaultDatabase) var database
@FetchAll var items: [Item]
@FetchAll(Item.order(by: \.title).where(\.isInStock)) var items
@FetchOne(Item.count()) var count = 0
try Item.fetchAll(db)
.find(db, key: id)
database.write { db
.insert { .(title: ) }.execute(db)
}
database.write { db
.find(id).update { .title #bind() }.execute(db)
}
database.write { db
.where(\.isInStock).update { .notes #bind() }.execute(db)
}
database.write { db
.find(id).delete().execute(db)
.where { .id.in(ids) }.delete().execute(db)
}
.where(\.isActive)
.where { .title.contains() }
.where { .status.eq(#bind(.done)) }
.order(by: \.title)
.order { .createdAt.desc() }
.limit().offset()
#sql()
#sql()
prepareDependencies {
.defaultSyncEngine (
for: .defaultDatabase,
tables: .
)
}
(\.defaultSyncEngine) syncEngine
syncEngine.fetchChanges()
syncEngine.sendChanges()
syncEngine.syncChanges()
syncEngine.isSendingChanges
syncEngine.isFetchingChanges
syncEngine.isSynchronizing
Anti-Patterns (Common Mistakes)
❌ Using == in predicates
.where { $0.status == .completed }
.where { $0.status.eq(#bind(.completed)) }
❌ Missing #bind in update assignments (StructuredQueries 0.31+)
Item.find(id).update { $0.title = "New" }.execute(db)
Item.find(id).update { $0.title = #bind("New") }.execute(db)
Item.find(id).update { $0.title += "!" }.execute(db)
❌ Wrong update order
Item.update { $0.title = #bind("X") }.where { $0.id.eq(#bind(id)) }
Item.find(id).update { $0.title = #bind("X") }.execute(db)
Item.where(\.isOld).update { $0.archived = #bind(true) }.execute(db)
❌ Instance methods for insert
let item = Item(id: UUID(), title: "Test")
try item.insert(db)
try Item.insert { Item.Draft(title: "Test") }.execute(db)
❌ Missing nonisolated
@Table struct Item { ... }
@Table nonisolated struct Item { ... }
❌ Awaiting inside write block
try await database.write { db in ... }
try database.write { db in
try Item.insert { ... }.execute(db)
}
❌ Forgetting .execute(db)
try database.write { db in
Item.insert { Item.Draft(title: "X") }
}
try database.write { db in
try Item.insert { Item.Draft(title: "X") }.execute(db)
}
@Table Model Definitions
Basic Table
import SQLiteData
@Table
nonisolated struct Item: Identifiable {
let id: UUID
var title = ""
var isInStock = true
var notes = ""
}
Key patterns:
- Use
struct, not class (value types)
- Add
nonisolated for Swift 6 concurrency
- First
let property is automatically the primary key
- Use defaults (
= "", = true) for non-nullable columns
- Optional properties (
String?) map to nullable SQL columns
Custom Primary Key
@Table
nonisolated struct Tag: Hashable, Identifiable {
@Column(primaryKey: true)
var title: String
var id: String { title }
}
Column Customization
@Table
nonisolated struct RemindersList: Hashable, Identifiable {
let id: UUID
@Column(as: Color.HexRepresentation.self)
var color: Color = .blue
var position = 0
var title = ""
}
Foreign Keys
@Table
nonisolated struct Reminder: Hashable, Identifiable {
let id: UUID
var title = ""
var remindersListID: RemindersList.ID
}
@Table
nonisolated struct Attendee: Hashable, Identifiable {
let id: UUID
var name = ""
var syncUpID: SyncUp.ID
}
Note: SQLiteData uses explicit foreign key columns. Relationships are expressed through joins, not @Relationship macros.
Querying Related Tables (Joins)
Don't fetch all records and filter in Swift — push filtering to the database:
let allReminders = try database.read { try Reminder.all.fetch($0) }
let filtered = allReminders.filter { $0.remindersListID == listID }
let filtered = try database.read {
try Reminder.all
.filter { $0.remindersListID.eq(#bind(listID)) }
.fetch($0)
}
let remindersWithList = try database.read {
try Reminder.all
.join(RemindersList.all) { $0.remindersListID.eq($1.id) }
.filter { $1.name.eq(#bind("Shopping")) }
.fetch($0)
}
let allWithOptionalList = try database.read {
try Reminder.all
.leftJoin(RemindersList.all) { $0.remindersListID.eq($1.id) }
.fetch($0)
}
For complex joins across 4+ tables, drop down to raw GRDB (see axiom-grdb).
@Ephemeral — Non-Persisted Properties
Mark properties that exist in Swift but not in the database:
@Table
nonisolated struct Item: Identifiable {
let id: UUID
var title = ""
var price: Decimal = 0
@Ephemeral
var isSelected = false
@Ephemeral
var formattedPrice: String {
"$\(price)"
}
}
Use cases:
- UI state (selection, expansion, hover)
- Computed properties derived from stored columns
- Transient flags for business logic
- Default values for properties not yet in schema
Important: @Ephemeral properties must have default values since they won't be populated from the database.
Database Setup
Create Database
import Dependencies
import SQLiteData
import GRDB
func appDatabase() throws -> any DatabaseWriter {
var configuration = Configuration()
configuration.prepareDatabase { db in
db.trace { print("SQL: \($0)") }
}
let database = try DatabaseQueue(configuration: configuration)
var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in
try #sql(
"""
CREATE TABLE "items" (
"id" TEXT PRIMARY KEY NOT NULL DEFAULT (uuid()),
"title" TEXT NOT NULL DEFAULT '',
"isInStock" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT NOT NULL DEFAULT ''
) STRICT
"""
)
.execute(db)
}
try migrator.migrate(database)
return database
}
Register in Dependencies
extension DependencyValues {
var defaultDatabase: any DatabaseWriter {
get { self[DefaultDatabaseKey.self] }
set { self[DefaultDatabaseKey.self] = newValue }
}
}
private enum DefaultDatabaseKey: DependencyKey {
static let liveValue: any DatabaseWriter = {
try! appDatabase()
}()
}
prepareDependencies {
$0.defaultDatabase = try! appDatabase()
}
Query Patterns
Property Wrappers (@FetchAll, @FetchOne)
The primary way to observe database changes in SwiftUI:
struct ItemsList: View {
@FetchAll(Item.order(by: \.title)) var items
var body: some View {
List(items) { item in
Text(item.title)
}
}
}
Key behaviors:
- Automatically subscribes to database changes
- Updates when any
Item changes
- Runs on the main thread
- Cancels observation when view disappears (iOS 17+)
@FetchOne for Aggregates
struct StatsView: View {
@FetchOne(Item.count()) var totalCount = 0
@FetchOne(Item.where(\.isInStock).count()) var inStockCount = 0
var body: some View {
Text("Total: \(totalCount), In Stock: \(inStockCount)")
}
}
Lifecycle-Aware Fetching (v1.4.0+)
Use .task to automatically cancel observation when view disappears:
struct ItemsList: View {
@Fetch(Item.all, animation: .default)
private var items = [Item]()
@State var searchQuery = ""
var body: some View {
List(items) { item in
Text(item.title)
}
.searchable(text: $searchQuery)
.task(id: searchQuery) {
try? await $items.load(
Item.where { $0.title.contains(searchQuery) }
.order(by: \.title)
).task
}
}
}
Before v1.4.0 (manual cleanup):
.task {
try? await $items.load(query)
}
.onDisappear {
Task { try await $items.load(Item.none) }
}
With v1.4.0 (automatic):
.task {
try? await $items.load(query).task
}
Filtering
let active = Item.where(\.isActive)
let recent = Item.where { $0.createdAt > lastWeek && !$0.isArchived }
let matches = Item.where { $0.title.contains("phone") }
let starts = Item.where { $0.title.hasPrefix("iPhone") }
Sorting
let sorted = Item.order(by: \.title)
let descending = Item.order { $0.createdAt.desc() }
let multiSort = Item.order { ($0.priority, $0.createdAt.desc()) }
Static Fetch Helpers (v1.4.0+)
Cleaner syntax for fetching:
let items = try Item.all.fetchAll(db)
let item = try Item.find(id).fetchOne(db)
let items = try Item.fetchAll(db)
let item = try Item.find(db, key: id)
let active = try Item.where(\.isActive).find(db, key: id)
Key improvement: .find(db, key:) returns non-optional, throwing an error if not found.
Insert / Update / Delete
Insert
try database.write { db in
try Item.insert {
Item.Draft(title: "New Item", isInStock: true)
}
.execute(db)
}
Insert with RETURNING (get generated ID)
let newId = try database.write { db in
try Item.insert {
Item.Draft(title: "New Item")
}
.returning(\.id)
.fetchOne(db)
}
Update Single Record
try database.write { db in
try Item.find(itemId)
.update { $0.title = #bind("Updated Title") }
.execute(db)
}
Update Multiple Records
try database.write { db in
try Item.where(\.isArchived)
.update { $0.isDeleted = #bind(true) }
.execute(db)
}
Delete
try database.write { db in
try Item.find(id).delete().execute(db)
}
try database.write { db in
try Item.where { $0.createdAt < cutoffDate }
.delete()
.execute(db)
}
Upsert (Insert or Update)
SQLite's UPSERT (INSERT ... ON CONFLICT ... DO UPDATE) expresses "insert if missing, otherwise update" in one statement.
try database.write { db in
try Item.insert {
item
} onConflict: { cols in
(cols.libraryID, cols.remoteID)
} doUpdate: { row, excluded in
row.name = excluded.name
row.notes = excluded.notes
}
.execute(db)
}
Parameters
onConflict: — Columns defining "same row" (must match UNIQUE constraint/index)
doUpdate: — What to update on conflict
row = existing database row
excluded = proposed insert values (SQLite's excluded table)
With Partial Unique Index
When your UNIQUE index has a WHERE clause, add a conflict filter:
try Item.insert {
item
} onConflict: { cols in
(cols.libraryID, cols.remoteID)
} where: { cols in
cols.remoteID.isNot(nil)
} doUpdate: { row, excluded in
row.name = excluded.name
}
.execute(db)
Schema Requirement
CREATE UNIQUE INDEX idx_items_sync_identity
ON items (libraryID, remoteID)
WHERE remoteID IS NOT NULL
Merge Strategies
Replace All Mutable Fields (Sync Mirror)
doUpdate: { row, excluded in
row.name = excluded.name
row.notes = excluded.notes
row.updatedAt = excluded.updatedAt
}
Merge Without Clobbering
doUpdate: { row, excluded in
row.name = excluded.name.ifnull(row.name)
row.notes = excluded.notes.ifnull(row.notes)
}
Last-Write-Wins (Raw SQL)
try db.execute(sql: """
INSERT INTO items (id, name, updatedAt) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
updatedAt = excluded.updatedAt
WHERE excluded.updatedAt >= items.updatedAt
""", arguments: [item.id, item.name, item.updatedAt])
❌ Common Upsert Mistakes
Missing UNIQUE Constraint
onConflict: { ($0.libraryID, $0.remoteID) }
Using INSERT OR REPLACE
try db.execute(sql: "INSERT OR REPLACE INTO items ...")
try Item.insert { ... } onConflict: { ... } doUpdate: { ... }
Batch Operations
Batch Insert
try database.write { db in
try Item.insert {
($0.title, $0.isInStock)
} values: {
items.map { ($0.title, $0.isInStock) }
}
.execute(db)
}
Transaction Safety
All mutations inside database.write { } are wrapped in a transaction:
try database.write { db in
try Item.insert { ... }.execute(db)
try Item.find(id).update { ... }.execute(db)
try OtherTable.find(otherId).delete().execute(db)
}
If any operation throws, the entire transaction rolls back.
Raw SQL with #sql Macro
When you need custom SQL expressions beyond the type-safe query builder, use the #sql macro from StructuredQueries:
Custom Query Expressions
nonisolated extension Item.TableColumns {
var isPastDue: some QueryExpression<Bool> {
@Dependency(\.date.now) var now
return !isCompleted && #sql("coalesce(date(\(dueDate)) < date(\(now)), 0)")
}
}
let overdue = try Item.where { $0.isPastDue }.fetchAll(db)
Raw SQL Queries
try #sql("SELECT * FROM items WHERE price > \(minPrice)").execute(db)
let tableName = "items"
try #sql("SELECT * FROM \(raw: tableName)").execute(db)
Why #sql
- Type-safe parameter binding (prevents SQL injection)
- Compile-time syntax checking
- Seamless integration with query builder
- Parameter interpolation automatically escapes values
For schema creation (CREATE TABLE, migrations), see the axiom-sqlitedata-ref reference skill for complete examples.
CloudKit Sync
Basic Setup
import CloudKit
extension DependencyValues {
var defaultSyncEngine: SyncEngine {
get { self[DefaultSyncEngineKey.self] }
set { self[DefaultSyncEngineKey.self] = newValue }
}
}
private enum DefaultSyncEngineKey: DependencyKey {
static let liveValue = {
@Dependency(\.defaultDatabase) var database
return try! SyncEngine(
for: database,
tables: Item.self,
privateTables: SensitiveItem.self,
startImmediately: true
)
}()
}
prepareDependencies {
$0.defaultDatabase = try! appDatabase()
$0.defaultSyncEngine = try! SyncEngine(
for: $0.defaultDatabase,
tables: Item.self
)
}
Manual Sync Control (v1.3.0+)
Control when sync happens instead of automatic background sync:
@Dependency(\.defaultSyncEngine) var syncEngine
try await syncEngine.fetchChanges()
try await syncEngine.sendChanges()
try await syncEngine.syncChanges()
Use cases:
- User-triggered "Refresh" button
- Sync after critical operations
- Custom sync scheduling
- Testing sync behavior
Sync State Observation (v1.2.0+)
Show UI feedback during sync:
struct SyncStatusView: View {
@Dependency(\.defaultSyncEngine) var syncEngine
var body: some View {
HStack {
if syncEngine.isSynchronizing {
ProgressView()
if syncEngine.isSendingChanges {
Text("Uploading...")
} else if syncEngine.isFetchingChanges {
Text("Downloading...")
}
} else {
Image(systemName: "checkmark.circle")
Text("Synced")
}
}
}
}
Observable properties:
isSendingChanges: Bool — True during CloudKit upload
isFetchingChanges: Bool — True during CloudKit download
isSynchronizing: Bool — True if either sending or fetching
isRunning: Bool — True if sync engine is active
Query Sync Metadata (v1.3.0+)
Access CloudKit sync information for records:
import CloudKit
let metadata = try SyncMetadata.find(item.syncMetadataID).fetchOne(db)
let itemsWithSync = try Item.all
.leftJoin(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
.select { (item: $0, metadata: $1) }
.fetchAll(db)
let sharedItems = try Item.all
.join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
.where { $1.isShared }
.fetchAll(db)
Migration Helpers
Migrate primary keys when switching sync strategies:
try await syncEngine.migratePrimaryKeys(
from: OldItem.self,
to: NewItem.self
)
When to Drop to GRDB
SQLiteData is built on GRDB. Use raw GRDB when you need:
- Complex joins across 4+ tables
- Window functions (ROW_NUMBER, RANK, etc.)
- Performance-critical paths where you've profiled and confirmed the query builder is the bottleneck
See axiom-grdb for raw SQL patterns, ValueObservation, and DatabaseMigrator usage.
tvOS
SQLiteData with CloudKit SyncEngine is the recommended tvOS data solution. tvOS has no persistent local storage — the system deletes Caches (including Application Support) under storage pressure. With SyncEngine, iCloud is your persistent store and the local database is just a cache that rebuilds automatically after deletion. See axiom-tvos for full tvOS storage constraints.
Resources
GitHub: pointfreeco/sqlite-data, pointfreeco/swift-structured-queries, groue/GRDB.swift
Skills: axiom-sqlitedata-ref, axiom-sqlitedata-migration, axiom-database-migration, axiom-grdb
Targets: iOS 17+, Swift 6
Framework: SQLiteData 1.4+
History: See git log for changes