Back to skills
extension
Category: Development & EngineeringNo API key required

SQLiteData Usage Guide

Comprehensive guide for using the SQLiteData library — a fast, lightweight replacement for SwiftData powered by SQL with CloudKit synchronization support.

personAuthor: jakexiaohubgithub

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 @Table macro 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

  1. Defining Your Schema
  2. Preparing the Database
  3. Fetching Data
  4. Observing Changes
  5. CRUD Operations
  6. Common Pitfalls
  7. 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 CasePaths package trait — see references/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 @ObservationIgnored due 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

  1. Forgetting @ObservationIgnored on @Fetch* properties inside @Observable classes — causes macro conflicts and compile errors.
  2. Property wrappers cannot be declared in local scope. Never write @FetchAll(...) inside a function body; use $items.load(...) or a model instead.
  3. Substring search: contains/hasPrefix/hasSuffix are deprecated — use like("%\(query)%").
  4. Sync engine ordering: initialize SyncEngine only after migrations have run.
  5. No lazy relationships: SQLiteData is not an ORM. Fetch related data explicitly with joins.
  6. Unit test trait import: .dependency(...) in tests requires import 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 |

References