An iOS Engineering Reference

Learning iOS GRDB

32 Sections Swift / iOS Long-Form

A deep, hands-on guide to GRDB — a Swift toolkit for SQLite — used to build serious data layers on iOS. We’ll work through the framework end to end: setting up DatabaseQueue and DatabasePool, designing schema with migrations, mapping rows to Swift types via FetchableRecord and PersistableRecord, the type-safe query interface, associations and eager loading, observation with ValueObservation, full-text search, the concurrency model, performance tuning, and SwiftUI integration in both the convenient and the testable styles. iOS-specific examples throughout, with attention to the patterns that hold up in production.

GRDB is different from Core Data and SwiftData. It’s a thin, Swift-idiomatic wrapper around SQLite — you write SQL when you want to, and use a type-safe query interface when you don’t. There’s no object graph manager, no managed contexts, no schema in .xcdatamodeld, no implicit faulting. Records are plain Swift types (often structs) that you fetch, mutate, and save explicitly. This trades some convenience for a great deal of control, transparency, and testability.

If you’ve used Core Data, you’ll find GRDB simpler in many ways and more demanding in others. SQL is back in your face — which is wonderful when you need it, and a small tax when you don’t. If you’ve never written SQL, this guide will teach you enough of it to be productive.

Table of Contents

  1. What GRDB Is (and Why You’d Use It)
  2. Installation and Project Setup
  3. DatabaseQueue and DatabasePool: The Two Database Types
  4. Your First Tables: Schema with Migrations
  5. CRUD with Raw SQL
  6. Records: Mapping Rows to Swift Types
  7. The Codable Bridge
  8. Database Migrations in Depth
  9. Reads and Writes: Sync, Async, Throwing
  10. Transactions and Save Points
  11. The Query Interface
  12. Filtering with Column and SQLExpression
  13. Sorting, Limits, and Aggregation
  14. Associations: belongsTo, hasMany, hasOne
  15. Eager Loading: including, joining, having
  16. Saving Records: insert, update, save, upsert
  17. Conflict Resolution and onConflict
  18. Indexes: Single, Composite, Partial, Expression
  19. Full-Text Search with FTS5
  20. Triggers, Views, and Generated Columns
  21. Database Observation: ValueObservation
  22. Combine Integration
  23. AsyncSequence and Swift Concurrency
  24. SwiftUI Integration: GRDBQuery
  25. SwiftUI Without GRDBQuery: @Observable Stores
  26. SwiftUI Without GRDBQuery: View Model + AsyncSequence
  27. The Repository Pattern with GRDB
  28. Concurrency Model: WAL, Readers, and the Writer
  29. Performance: Statement Caching, Batches, Profiling
  30. Encryption with SQLCipher
  31. Common Gotchas and Anti-Patterns
  32. Where to Go Deeper

1. What GRDB Is (and Why You’d Use It)

GRDB is a Swift wrapper around SQLite, written by Gwendal Roué and used in production by many serious iOS apps. It’s not a singleton you import; it’s an active project that’s evolved alongside Swift itself, embracing structured concurrency, Combine, SwiftUI integration, and modern Swift idioms while staying close to SQLite — close enough that you can drop into raw SQL whenever you need to.

The single most important sentence to internalize: GRDB is a SQLite library for Swift, not an ORM. It gives you a Swift-idiomatic API for talking to a SQLite database, with optional layers (records, query interface, associations, observation) that make common tasks ergonomic. But under the hood, it’s still SQLite — your tables are real tables, your queries are real SQL, your performance characteristics are SQLite’s performance characteristics.

This positions GRDB very differently from Core Data and SwiftData, which are object graph managers that happen to use SQLite as a backing store. GRDB doesn’t manage an object graph. It doesn’t fault. It doesn’t track changes implicitly. It doesn’t unify objects across queries. When you fetch, you get fresh values. When you save, you write to the database. The mental model is closer to “I have a SQL database; here’s a nice API for it.”

The shape of GRDB

A short tour to set expectations:

  • A database is a file. Open it with DatabaseQueue (single-writer, single-reader-at-a-time) or DatabasePool (single-writer, many-readers).
  • Tables are defined via migrations. You write a migration that creates tables; GRDB runs migrations once and tracks which have been applied.
  • Records are plain Swift types. Make a struct Player that conforms to Codable, FetchableRecord, PersistableRecord — and now Player can be fetched from and saved to the database.
  • Queries can be SQL or type-safe. Player.fetchAll(db, sql: "SELECT * FROM player") works. So does Player.order(Column("score").desc).limit(10).fetchAll(db).
  • Observation is explicit. Subscribe to a ValueObservation and you get a stream of fresh values whenever the underlying data changes. This works with Combine and AsyncSequence.

That’s the framework. Everything else builds on these primitives.

Comparing to Core Data and SwiftData

Where they differ, in shape:

Aspect Core Data / SwiftData GRDB
Schema .xcdatamodeld editor / @Model macro Migration code that runs SQL
Records NSManagedObject / @Model classes Plain Swift structs
Queries NSFetchRequest with NSPredicate / #Predicate Type-safe query interface or raw SQL
Object graph Yes — graph of identical objects per context No — fetches return fresh values
Faulting Automatic, lazy attribute loading None — values are loaded fully when fetched
Concurrency Each context bound to a queue Reader/writer semantics, structured
Migrations Lightweight automatic + custom Migration code, deterministic
Observation NSFetchedResultsController / @Query ValueObservation

There’s no “right answer.” Each framework suits different problems:

  • Core Data / SwiftData shine when your app’s natural shape is an object graph — entities with relationships you traverse, where keeping a single source of truth across views matters more than fine query control.
  • GRDB shines when you think relationally — you want to write the queries you’d write in any database, you want fine control over schema and indexes, you want to test things easily, you want to see what’s happening under the hood.

The choice is partly about cognitive style. If “an order has many line items” feels naturally like “a join between orders and line_items”, GRDB will feel like home. If it feels naturally like “an Order object holds a relationship to LineItem objects”, Core Data will.

When GRDB fits well

  • You want full control over schema and SQL. GRDB doesn’t hide it; you can write any SQL you want.
  • You need cross-platform sync (server + iOS). SQLite is everywhere; GRDB doesn’t trap you in Apple’s ecosystem.
  • You want testability. In-memory databases are trivial; everything is a value type; mocking is straightforward.
  • You need performance characteristics you can profile and reason about. SQL → SQLite, no implicit layer adding overhead.
  • You’re comfortable (or want to be comfortable) with SQL. The framework rewards SQL knowledge.
  • You need full-text search. SQLite’s FTS5 is excellent; GRDB exposes it cleanly.
  • You want a thin, deterministic library. GRDB is small enough that you can read its source and understand what’s happening.

When GRDB doesn’t fit

  • You want CloudKit sync with one line of code. GRDB doesn’t have a built-in CloudKit story. You can build sync on top of it, but you’re writing the sync code.
  • You want SwiftUI to “just work” with auto-updating views and zero boilerplate. @Query with SwiftData is shorter. GRDB needs more wiring (we’ll cover the patterns).
  • You want to model an object graph as objects, not tables. GRDB’s records are values, not graph nodes; the graph mental model maps less cleanly.
  • You need automatic schema migration with a UI editor. GRDB migrations are SQL/code; there’s no visual designer.
  • Your team strongly prefers an ORM. GRDB occupies a position close to the database, which some find too low-level.

What you’ll learn from this guide

By the end:

  • The two database types and how to choose between them.
  • Schema design via migrations, including indexes and FTS5.
  • Records: how to make Swift types fetchable and persistable.
  • The query interface for type-safe queries, and when to fall back to SQL.
  • Associations and how to avoid N+1 with eager loading.
  • The concurrency model and using it without footguns.
  • Observation: how to get UI to update automatically as data changes.
  • SwiftUI integration in both quick-and-easy and testable-architecture styles.
  • Performance tuning and profiling.
  • The patterns that hold up in production.

A note on philosophy

GRDB embraces what SQLite is — a fast, reliable, embedded database with sophisticated features (FTS5, virtual tables, partial indexes, ATTACH, etc.). GRDB doesn’t try to hide SQLite; it exposes it well. This means you’ll learn SQLite as you learn GRDB. That’s a feature, not a bug — SQLite knowledge transfers everywhere, including to non-iOS contexts.

If you’ve spent years fighting Core Data’s opacity, GRDB will feel refreshing. If you’ve spent years writing SQL by hand, GRDB will feel like a thoughtful Swift wrapper that doesn’t get in your way.

What to internalize

GRDB is a SQLite library, not an ORM. Records are values, not graph nodes. Schema lives in migration code. Queries can be raw SQL or type-safe. Observation is explicit via ValueObservation. Choose GRDB when you want control, transparency, and SQL fluency. Choose Core Data or SwiftData when you want an object graph manager.


2. Installation and Project Setup

GRDB ships as a Swift Package. Adding it to your project takes a minute. The interesting decisions come right after — where to put your database file, how to organize your data layer, and how to test it.

Adding GRDB via Swift Package Manager

In Xcode: File → Add Package Dependencies → enter the URL https://github.com/groue/GRDB.swift. Pick the latest stable major version. Add the GRDB library to your app target.

In a Package.swift:

dependencies: [
    .package(url: "https://github.com/groue/GRDB.swift", from: "6.0.0")
]

Then in your target:

.product(name: "GRDB", package: "GRDB.swift")

(Version numbers evolve. Check the GitHub repo for the current major version.)

For SQLCipher (encrypted databases), there’s a separate product GRDB-SQLCipher. We’ll cover encryption in section 30.

Where to put the database file

iOS apps have several writable directories. The right choice depends on what the database stores:

  • Application Support directory (Library/Application Support/). For data the user creates that should persist across app updates. Not backed up to iCloud/iTunes by default unless you opt in. The most common choice for app data.
  • Documents directory (Documents/). For user-visible documents. Backed up to iCloud/iTunes, visible in Files.app if you enable file sharing. Use only when the database file is itself a “document” the user might see.
  • Caches directory (Library/Caches/). For data that can be regenerated. The system may delete this under storage pressure. Use for cached server data.
  • tmp/. For ephemeral data; deleted between launches.

For most apps, Application Support is right. Set it up:

import Foundation

extension URL {
    static var applicationSupportDirectory: URL {
        try! FileManager.default.url(
            for: .applicationSupportDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: true
        )
    }

    static var defaultDatabaseURL: URL {
        applicationSupportDirectory.appendingPathComponent("database.sqlite")
    }
}

The create: true ensures the directory exists. The trailing filename is yours to pick — database.sqlite is fine; some apps use <AppName>.sqlite so it’s recognizable in Finder when debugging the simulator.

A first database setup

A complete minimal setup:

import GRDB
import Foundation

final class AppDatabase {
    let dbWriter: any DatabaseWriter

    init(_ dbWriter: any DatabaseWriter) throws {
        self.dbWriter = dbWriter
        try migrator.migrate(dbWriter)
    }

    private var migrator: DatabaseMigrator {
        var migrator = DatabaseMigrator()

        migrator.registerMigration("createPlayer") { db in
            try db.create(table: "player") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("name", .text).notNull()
                t.column("score", .integer).notNull()
            }
        }

        return migrator
    }
}

extension AppDatabase {
    static func makeShared() -> AppDatabase {
        do {
            let url = URL.defaultDatabaseURL
            let dbPool = try DatabasePool(path: url.path)
            return try AppDatabase(dbPool)
        } catch {
            fatalError("Database setup failed: \(error)")
        }
    }
}

What’s happening:

  • AppDatabase wraps a DatabaseWriter (the abstract type covering both DatabaseQueue and DatabasePool). We’ll cover both in section 3.
  • migrator.migrate(dbWriter) runs any pending migrations at startup, in order.
  • The migration creates a player table with three columns.
  • The makeShared factory builds the database at the conventional location and returns the configured wrapper.

You’d typically construct this once at app launch:

@main
struct PlayersApp: App {
    let database = AppDatabase.makeShared()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.appDatabase, database)
        }
    }
}

The .environment(\.appDatabase, database) requires you to define an environment key — covered in section 24 when we discuss SwiftUI integration. For now, treat it as injection.

Database lifecycle

GRDB databases are heavyweight enough that you create them once at app launch, not on demand. Reasons:

  • Opening the SQLite file involves I/O and migration checks.
  • A DatabasePool allocates connection-pool resources.
  • Sharing one connection across the app reduces contention.

It’s perfectly reasonable to make AppDatabase a singleton-ish — created at launch, passed around. Avoid lazy-on-first-use patterns for the database itself.

Testing setup

For tests, you want an in-memory database — fast, isolated, no on-disk pollution:

extension AppDatabase {
    static func makeForTests() throws -> AppDatabase {
        let dbQueue = try DatabaseQueue()
        return try AppDatabase(dbQueue)
    }
}

DatabaseQueue() with no arguments opens an in-memory database. It runs the same migrations as the production database, so your tests exercise real schema. Each test gets a fresh AppDatabase instance — no state bleeds between tests.

In test code:

final class PlayerTests: XCTestCase {
    var database: AppDatabase!

    override func setUp() async throws {
        database = try AppDatabase.makeForTests()
    }

    func test_insertPlayer() async throws {
        try await database.dbWriter.write { db in
            try db.execute(sql: "INSERT INTO player (name, score) VALUES (?, ?)",
                           arguments: ["Arthur", 100])
        }

        let count = try await database.dbWriter.read { db in
            try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM player") ?? 0
        }
        XCTAssertEqual(count, 1)
    }
}

Each test runs in milliseconds. The schema is real. The tests exercise the same migrations that run in production — catching schema bugs early.

Inspecting the database file

When debugging, you’ll often want to see what’s in the file. Three approaches:

  • In the simulator, use Xcode’s “Console” (Window → Devices and Simulators → select sim → “Open Console”). The simulator’s app container is at: ~/Library/Developer/CoreSimulator/Devices/<device-uuid>/data/Containers/Data/Application/<app-uuid>/Library/Application Support/database.sqlite
  • Open with a SQLite browser app like DB Browser for SQLite or the sqlite3 command-line tool.
  • During development, log queries with GRDB’s Configuration.publicStatementArguments: swift var config = Configuration() config.publicStatementArguments = true // DEBUG only let dbPool = try DatabasePool(path: url.path, configuration: config) This logs SQL with arguments substituted, useful for “what query is GRDB actually running?” debugging.

The -shm and -wal files alongside database.sqlite are part of SQLite’s write-ahead log. They’re normal; don’t delete them while the app is running.

File protection on iOS

By default, files in Application Support are protected with NSFileProtectionCompleteUntilFirstUserAuthentication. This means the file is readable only after the user has unlocked the device since boot. After unlock, the file is accessible — even when the device is locked again — until reboot.

If you store sensitive data, you can request stronger protection:

var config = Configuration()
config.observesSuspensionNotifications = true
let dbPool = try DatabasePool(path: url.path, configuration: config)

// Then, separately, set the file protection level
try (url as NSURL).setResourceValue(
    URLFileProtection.complete,
    forKey: .fileProtectionKey
)

URLFileProtection.complete means the file is unreadable while the device is locked. Combined with observesSuspensionNotifications, GRDB releases its connections on suspension, which prevents corruption when the file becomes unavailable.

Concurrency target

GRDB works with Swift Concurrency. As of recent versions:

  • DatabaseQueue.write { db in ... } is sync.
  • DatabaseQueue.asyncWrite { ... } and DatabasePool.read { db in ... } etc. have async forms.
  • All database access is done via closures that receive a Database parameter.

We’ll cover the access patterns in detail in sections 3 and 9.

Getting the version right

GRDB has had several major versions. As of writing, GRDB 6+ is the modern stable line. Use the latest. Older GRDB 4.x has different APIs in places; tutorials and forum posts about GRDB 4 may not match what you see in current code.

What to internalize

Add GRDB via SPM. Put your database file in Application Support. Wrap it in an AppDatabase type that holds a DatabaseWriter and runs migrations at init. Build at app launch, share via injection. Use in-memory DatabaseQueue() for tests. Inspect files via simulator paths or a SQLite browser. Set file protection if you store sensitive data.


3. DatabaseQueue and DatabasePool: The Two Database Types

GRDB exposes two ways to access a SQLite database: DatabaseQueue and DatabasePool. They differ in concurrency characteristics, and choosing between them affects how your app performs under load. For most iOS apps, the answer is DatabasePool — but understanding why matters.

The fundamental distinction

SQLite supports concurrent reads but serializes writes. The two database types expose this differently:

  • DatabaseQueue — a single connection to the database, used for both reads and writes. Operations are serialized on a queue. Simple, predictable, no concurrent reads.
  • DatabasePool — one writer connection plus a pool of reader connections. Writes serialize through the writer; reads can happen concurrently from any reader. Fast for read-heavy workloads.

If you’re not sure which to pick: DatabasePool is almost always the right answer for an iOS app with any meaningful database work. The exception is read-heavy code that has very low write rates and trivially small data — DatabaseQueue is slightly simpler and uses fewer resources.

Setting up a DatabaseQueue

import GRDB

let dbQueue = try DatabaseQueue(path: "/path/to/database.sqlite")

Or in-memory:

let dbQueue = try DatabaseQueue()

You read and write through closures:

try dbQueue.write { db in
    try db.execute(sql: "INSERT INTO player (name, score) VALUES (?, ?)",
                   arguments: ["Alice", 100])
}

let count = try dbQueue.read { db in
    try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM player") ?? 0
}

The closure receives a Database instance — the actual SQLite connection. You issue queries through it.

write and read block the calling thread until the closure completes. They’re synchronous. Async versions exist:

try await dbQueue.write { db in /* ... */ }
let count = try await dbQueue.read { db in /* ... */ }

The async versions don’t block the calling thread; they suspend until the operation runs on GRDB’s internal queue.

Setting up a DatabasePool

let dbPool = try DatabasePool(path: "/path/to/database.sqlite")

The API is the same as DatabaseQueue:

try dbPool.write { db in /* ... */ }
let result = try dbPool.read { db in /* ... */ }

What’s different: dbPool.read calls can happen concurrently. If you have ten read operations queued, multiple run in parallel on different reader connections. Writes still serialize through the single writer.

The DatabaseWriter protocol

DatabaseQueue and DatabasePool both conform to DatabaseWriter. Code that just needs “something I can read and write to” should accept any DatabaseWriter:

final class PlayerService {
    let dbWriter: any DatabaseWriter

    init(_ dbWriter: any DatabaseWriter) {
        self.dbWriter = dbWriter
    }

    func savePlayer(_ player: Player) async throws {
        try await dbWriter.write { db in
            try player.save(db)
        }
    }
}

In production, you instantiate with DatabasePool. In tests, with DatabaseQueue (in-memory). The service code is identical.

There’s also DatabaseReader — both types conform — for code that only needs reads. Less commonly used because most data layers do both.

Why concurrent reads matter

iOS apps often have a usage pattern like “many small reads from the UI, occasional writes from user actions or sync.” With DatabaseQueue, each read serializes — if the UI thread is reading while a background sync is also reading, one waits. With DatabasePool, both proceed in parallel.

Concrete example: a list view shows 100 cells. Each cell asynchronously fetches some data. With DatabaseQueue, those fetches happen one at a time. With DatabasePool, they can run on concurrent reader connections (up to a limit, configurable).

For modern iOS apps with reactive UIs, the parallelism matters. Use DatabasePool unless you have a reason not to.

Configuration

Both database types take a Configuration to customize behavior:

var config = Configuration()
config.label = "MainDatabase"  // for debugging
config.maximumReaderCount = 5   // pool only — number of concurrent readers (default: 5)
config.busyMode = .timeout(10)  // wait up to 10 seconds when locked
config.qos = .userInitiated     // QoS for GRDB's internal queues

let dbPool = try DatabasePool(path: path, configuration: config)

Useful options:

  • label — appears in console logs and Instruments traces; helps when you have multiple databases.
  • maximumReaderCount — for pools, how many reader connections to keep. More readers means more parallelism, more memory. Default 5 is reasonable for most apps.
  • busyMode — what to do when the database is locked by a writer. Default is to retry up to a small budget. .timeout(seconds) waits longer.
  • qos — quality-of-service for GRDB’s internal serialization queues. Higher QoS for app-critical data, lower for background-only.

Foreign keys

SQLite has foreign keys but doesn’t enforce them by default. Always enable enforcement:

var config = Configuration()
config.foreignKeysEnabled = true  // default in GRDB; explicit for clarity

let dbPool = try DatabasePool(path: path, configuration: config)

GRDB enables foreign keys by default in recent versions. You almost always want this on. Without it, you can have orphaned rows whose foreign keys point to nonexistent records.

Database lifecycle and the WAL

When you open a DatabasePool, GRDB switches the database to WAL (write-ahead log) mode. This is what enables concurrent reads. The mode persists in the database file — once a file is in WAL mode, it stays that way until explicitly converted back.

WAL mode produces three files instead of one:

  • database.sqlite — the main database.
  • database.sqlite-wal — the write-ahead log.
  • database.sqlite-shm — shared-memory file (a small index used coordinating WAL access).

If you’re packaging the database, copying it, or backing it up, all three files are needed. The -wal file in particular can be substantial — up to several MB during heavy write activity. SQLite checkpoints the WAL into the main file periodically.

When to use DatabaseQueue

A few scenarios where DatabaseQueue is the right pick:

  • Embedded read-only data that ships with the app (e.g., a reference table). Read-only access; concurrency doesn’t matter; queue is simpler.
  • Tests in many cases — DatabaseQueue() is the in-memory form. (Pools also support in-memory but queues are usually cleaner.)
  • Apps with extremely small data and almost no concurrency — a settings store, a tiny cache. The pool is overkill; the queue’s simplicity is fine.

Otherwise, default to DatabasePool.

Backup and migration considerations

For backups:

let backupPath = "/path/to/backup.sqlite"
try dbPool.backup(to: backupPath)

This creates a fresh, consistent snapshot of the database. The original keeps running.

For migrations between database versions, write code in your migrator (covered in section 8). You don’t manipulate the database type itself.

Multiple databases

You can open multiple databases in one app — for example, a “user data” pool and a “cache” pool:

let userPool = try DatabasePool(path: userDataURL.path)
let cachePool = try DatabasePool(path: cachesURL.path)

Each is independent. Code accessing user data uses userPool.read/write; code accessing cache uses cachePool.read/write.

You can also ATTACH databases together for cross-database queries, but it’s rare and we won’t go deep here. SQLite’s documentation has details.

Closing the database

Database connections close automatically when the type is deallocated. For most apps, this means at app termination — fine, no action needed. If you do need to explicitly close:

// DatabasePool can be released by setting the variable to nil, given no closures are running.
// For more controlled shutdown, see GRDB's documentation.

In practice, you don’t manually close. The OS reaps the file handles when the process exits.

Pitfalls

Holding Database references outside the closure. The Database parameter passed to your closure is valid only inside the closure. Don’t capture and use it later — it’s bound to the GRDB queue.

Wrapping reads inside writes. dbPool.write { db in ... } inside a dbPool.read { db in ... } deadlocks. The write needs the writer; the read holds a reader; they can interfere. Don’t nest different access types.

Multiple writes from concurrent tasks. Concurrent writes are serialized. If you await dbPool.write from many tasks at once, they execute one after another — fine, just expect serial behavior.

Forgetting to enable foreign keys. While GRDB defaults to on, double-check. Explicitly setting it makes intent clear to other developers.

Choosing DatabaseQueue out of conservatism. “I don’t need the parallelism” is often wrong — you don’t realize you need it until your UI starts hitching. Default to pool.

What to internalize

DatabaseQueue is single-connection, serialized. DatabasePool has one writer + multiple readers, parallelism for reads. For iOS apps with active UIs, default to DatabasePool. Both implement DatabaseWriter, so service code is type-agnostic. Pools enable WAL mode; expect three database files. Configure label, foreign keys, busy mode, QoS. Don’t escape Database references from closures. Don’t nest reads in writes or vice versa.


4. Your First Tables: Schema with Migrations

In GRDB, your schema lives in code. There’s no .xcdatamodeld editor and no @Model macro — you write migrations that run SQL statements (or call GRDB’s schema-builder DSL) in a deterministic order. Each migration is registered with a name, runs once, and GRDB tracks which migrations have been applied.

This is more code than visual schema editors but more transparent. You can read your migrations top-to-bottom and see exactly how the schema evolved.

What a migration looks like

A migration is a closure that takes a Database and modifies the schema:

import GRDB

var migrator = DatabaseMigrator()

migrator.registerMigration("createPlayer") { db in
    try db.create(table: "player") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("name", .text).notNull()
        t.column("score", .integer).notNull().defaults(to: 0)
        t.column("createdAt", .datetime).notNull()
    }
}

registerMigration("name", body) registers a named migration. The name should be stable — once shipped, never rename a migration, because GRDB stores names in a special table to know which have run.

The closure receives a Database. You call methods on it that issue SQL: db.create(table:), db.alter(table:), db.create(index:), db.execute(sql:), etc.

Running migrations

You apply migrations once, at app startup:

final class AppDatabase {
    let dbWriter: any DatabaseWriter

    init(_ dbWriter: any DatabaseWriter) throws {
        self.dbWriter = dbWriter
        try migrator.migrate(dbWriter)
    }

    private var migrator: DatabaseMigrator {
        var migrator = DatabaseMigrator()

        migrator.registerMigration("createPlayer") { db in
            // ... as above
        }

        // More migrations registered here...

        return migrator
    }
}

migrator.migrate(dbWriter) is synchronous and idempotent. On first launch with an empty database, it runs every migration. On subsequent launches, it runs only migrations that haven’t run before. If a migration fails, the whole migrate call throws, and your app should treat this as fatal.

Migrations are wrapped in transactions automatically — if a migration’s body throws partway through, the partial changes are rolled back, the migration is not recorded as applied, and migrate throws. The next launch will retry.

The schema-builder DSL

db.create(table:) accepts a closure that builds the table:

try db.create(table: "player") { t in
    t.autoIncrementedPrimaryKey("id")
    t.column("name", .text).notNull()
    t.column("score", .integer).notNull().defaults(to: 0)
    t.column("createdAt", .datetime).notNull()
    t.column("teamId", .integer)
        .references("team", onDelete: .cascade)
}

Each t.column(...) declares a column. The first argument is the name; the second is the type affinity (.text, .integer, .real, .blob, .datetime, .numeric, .boolean, .date).

Column modifiers chain:

  • .notNull() — required.
  • .unique() — unique constraint.
  • .defaults(to: value) — default for the column.
  • .indexed() — create an index on this column (single-column).
  • .collate(.nocase) — collation, e.g., for case-insensitive sorting.
  • .references("table", onDelete: .cascade) — foreign key.
  • .check { $0 >= 0 } — CHECK constraint.

For primary keys:

  • t.autoIncrementedPrimaryKey("id")INTEGER PRIMARY KEY AUTOINCREMENT.
  • t.primaryKey("id", .text) — primary key on a specific column.
  • t.primaryKey(["columnA", "columnB"]) — composite primary key.

For multi-column constraints:

  • t.uniqueKey(["columnA", "columnB"]) — composite unique constraint.
  • t.foreignKey(["columnA"], references: "team", onDelete: .cascade) — composite foreign key.

A more complete schema

A notes app:

migrator.registerMigration("createSchema") { db in
    try db.create(table: "folder") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("name", .text).notNull()
        t.column("createdAt", .datetime).notNull()
    }

    try db.create(table: "tag") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("name", .text).notNull().unique()
    }

    try db.create(table: "note") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("title", .text).notNull()
        t.column("body", .text).notNull().defaults(to: "")
        t.column("createdAt", .datetime).notNull()
        t.column("updatedAt", .datetime).notNull()
        t.column("folderId", .integer)
            .references("folder", onDelete: .cascade)
            .indexed()
    }

    // Many-to-many between note and tag
    try db.create(table: "noteTag") { t in
        t.column("noteId", .integer)
            .notNull()
            .references("note", onDelete: .cascade)
        t.column("tagId", .integer)
            .notNull()
            .references("tag", onDelete: .cascade)
        t.primaryKey(["noteId", "tagId"])
    }

    try db.create(index: "noteByUpdatedAt", on: "note", columns: ["updatedAt"])
}

Read top-to-bottom: folders, tags, notes (referencing folders), and a join table for many-to-many notes↔tags. Foreign keys cascade on delete. An index on updatedAt for time-ordered queries.

This is one migration registering a complete schema. As you evolve the app, you’ll add more migrations.

Type affinity

SQLite has a flexible type system. The type: parameter on columns is a type affinity — it suggests what kind of values to expect, but SQLite doesn’t strictly enforce it.

Common affinities and what they mean in practice:

  • .text — strings.
  • .integer — integers (Int, Int64, Bool stored as 0/1).
  • .real — floating-point (Double, Float).
  • .blobData blobs.
  • .numeric — anything; SQLite picks the best representation.
  • .datetime — dates. SQLite stores them as text (ISO 8601) or numeric (Unix timestamps). GRDB defaults to ISO 8601 strings.
  • .boolean — booleans. Stored as 0/1.
  • .date — date-only.

Pick the affinity that matches the Swift type you’ll use. For dates, .datetime is the standard.

Naming conventions

A few conventions worth adopting:

  • Lowercase, singular table names: player, not Players or players. (Some teams use plural; pick one and stick to it.)
  • camelCase column names: createdAt, not created_at. This matches Swift property names directly when you use Codable records.
  • Foreign keys named after the referenced table: folderId references folder.id.
  • Indexes named with intent: noteByUpdatedAt indicates “an index on note for sorting/filtering by updatedAt.”

Conventions don’t matter to SQLite. They matter to your future self.

Check before alter

For a brand-new database, you write the schema as one big create migration. As your app evolves, schema changes go in new migrations:

migrator.registerMigration("v1.createSchema") { db in
    // initial schema
}

migrator.registerMigration("v1.1.addArchivedToNote") { db in
    try db.alter(table: "note") { t in
        t.add(column: "archived", .boolean).notNull().defaults(to: false)
    }
}

migrator.registerMigration("v1.2.addTagColor") { db in
    try db.alter(table: "tag") { t in
        t.add(column: "color", .text).notNull().defaults(to: "blue")
    }
}

Each is a separate migration. Each has a stable name. Once shipped, never modify a previous migration — modify forward.

The naming pattern v<release>.<intent> is helpful for tracking when a migration was added; “v1.0” through “v1.5” makes the chronology obvious. Other teams use timestamps (20240115_addArchived) or sequential numbers. Pick one.

eraseDatabaseOnSchemaChange — for development

During early development, you may iterate on the schema rapidly. Renaming a migration breaks GRDB’s tracking and causes errors. To make iteration easier:

migrator.eraseDatabaseOnSchemaChange = true

With this flag, if the migrator detects a schema mismatch (your migrations changed names or content in ways incompatible with what’s recorded), GRDB erases the database and re-applies all migrations from scratch.

Use this flag only during development. In production, you must not erase user data on schema mismatches. Ship with eraseDatabaseOnSchemaChange = false (the default).

A common pattern:

private var migrator: DatabaseMigrator {
    var migrator = DatabaseMigrator()

    #if DEBUG
    migrator.eraseDatabaseOnSchemaChange = true
    #endif

    // register migrations...

    return migrator
}

Running migrations transactionally

Each registered migration runs inside a transaction by default. If the migration body throws, the transaction rolls back, the migration is not marked as applied, and the next launch will retry.

You can opt out of transactions for migrations that need it (rare):

migrator.registerMigration("longRunningMigration", foreignKeyChecks: .deferred) { db in
    // For migrations that need to defer foreign key checks
}

The foreignKeyChecks option controls whether foreign keys are deferred during the migration — useful for restructuring schemas where temporary inconsistency is unavoidable.

Inspecting applied migrations

Useful for debugging:

try dbWriter.read { db in
    let applied = try migrator.appliedMigrations(db)
    print("Applied migrations: \(applied)")
}

This returns the names of migrations that have run on this database. If your app launches and you’re confused about what state the schema is in, dump this list.

Common mistakes

Renaming a migration after shipping. GRDB tracks names; renaming breaks tracking. Existing users’ databases will be in a confused state.

Modifying a previous migration’s body after shipping. Same issue. The old migration ran with old logic; new launches don’t re-run it. Modify forward instead — write a new migration that fixes whatever was wrong.

Schema changes outside migrations. If you do a db.execute("CREATE TABLE...") outside a migration, the table exists but GRDB doesn’t know about it for migration tracking. Always go through migrations.

Migrations that depend on app state. Migrations should be pure schema operations; they shouldn’t fetch user data from the network or read app preferences. If you need post-migration data tasks, run them separately at app launch.

Forgetting to call migrate. Without migrator.migrate(dbWriter), no migrations run, your tables don’t exist, and queries fail.

What to internalize

Schema lives in migration code. Each migration has a stable name and runs once. migrator.migrate(dbWriter) at app startup applies pending migrations idempotently. Use the schema DSL (db.create(table:)) for clarity. Use eraseDatabaseOnSchemaChange = true during development, off in production. Once a migration ships, never modify it — only add new ones forward.


5. CRUD with Raw SQL

GRDB’s raw-SQL API is the foundation. Even if you mostly use records and the query interface, knowing raw SQL CRUD makes you understand what’s happening underneath. And for one-off operations, ad-hoc queries, or patterns the higher layers don’t express well, raw SQL is the right tool.

Executing a SQL statement

The most basic write:

try dbPool.write { db in
    try db.execute(sql: """
        INSERT INTO player (name, score, createdAt)
        VALUES (?, ?, ?)
        """, arguments: ["Arthur", 100, Date()])
}

db.execute(sql:arguments:) runs a SQL statement. Arguments are passed via arguments: — never interpolate values into the SQL string itself. The ? placeholders are bound to the arguments in order, with proper escaping that prevents SQL injection.

For multiple statements:

try db.execute(sql: """
    INSERT INTO player (name, score) VALUES ('Alice', 100);
    INSERT INTO player (name, score) VALUES ('Bob', 80);
    """)

When passing multiple statements as a string, you can’t use ? placeholders — the whole string is parsed and run as-is. For parameterized inserts, use a single statement and run it multiple times.

Reading: fetching values

try dbPool.read { db in
    let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM player") ?? 0
    print("Player count: \(count)")
}

Int.fetchOne(db, sql:) runs a query and returns the first column of the first row as an Int?. Many basic types support fetchOne, fetchAll, fetchCursor:

  • Int.fetchOne(db, sql:)Int?
  • Int.fetchAll(db, sql:)[Int]
  • String.fetchAll(db, sql: "SELECT name FROM player")[String]
  • Double.fetchAll(db, sql:)[Double]
  • Date.fetchAll(db, sql:)[Date]
  • Data.fetchAll(db, sql:)[Data]

For full rows:

let rows = try Row.fetchAll(db, sql: "SELECT * FROM player ORDER BY score DESC")
for row in rows {
    let name: String = row["name"]
    let score: Int = row["score"]
    print("\(name): \(score)")
}

Row is a dictionary-like type indexed by column name. Subscripting returns an inferred type via type-coercion. If a column doesn’t exist, you get nil; if it does but the value is null, you get nil (when subscripting as an optional) or a crash (when subscripting as non-optional).

For typed access:

let name = row["name"] as String
let score = row["score"] as Int
let createdAt = row["createdAt"] as Date

Arguments

GRDB supports two argument styles:

Positional (?):

try db.execute(sql: "INSERT INTO player (name, score) VALUES (?, ?)",
               arguments: ["Alice", 100])

Arguments fill placeholders in order.

Named (:name):

try db.execute(sql: """
    INSERT INTO player (name, score) VALUES (:name, :score)
    """, arguments: ["name": "Alice", "score": 100])

Named arguments make complex statements more readable. The keys in the dictionary match the names in the SQL.

You can mix both in different statements but not in the same statement.

Updating

Update is just SQL:

try db.execute(sql: """
    UPDATE player SET score = ? WHERE id = ?
    """, arguments: [200, playerId])

To know how many rows were affected:

try db.execute(sql: "UPDATE player SET score = score + 10 WHERE score < 100")
let count = db.changesCount
print("\(count) rows updated")

db.changesCount returns the number of rows affected by the last execute. Useful for “did we actually update anything?” checks.

Deleting

try db.execute(sql: "DELETE FROM player WHERE id = ?", arguments: [playerId])
let deletedCount = db.changesCount

To delete all:

try db.execute(sql: "DELETE FROM player")

Note that DELETE FROM table is not the same as DROP TABLE table — the table still exists, just empty. Schema operations should always go through migrations.

Last inserted row ID

After an INSERT INTO ... AUTOINCREMENT table:

try db.execute(sql: "INSERT INTO player (name, score) VALUES (?, ?)",
               arguments: ["Alice", 100])
let id = db.lastInsertedRowID
print("New player has id \(id)")

lastInsertedRowID is the auto-incremented id of the row just inserted. Useful when you need to know the new row’s ID for subsequent queries.

Cursors

For fetching many rows without loading them all at once:

try dbPool.read { db in
    let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM player")
    while let row = try cursor.next() {
        let name: String = row["name"]
        // process one row at a time
    }
}

A cursor reads rows lazily. For 10,000-row queries where you process and discard, this avoids buffering 10,000 rows in memory. For typical fetches, just use fetchAll.

Cursors are not Sequence-conforming because their iteration can throw. Use the while let pattern.

Querying by columns

Sometimes you want a single column from many rows:

let names = try String.fetchAll(db, sql: "SELECT name FROM player")
// names: [String]

Or pairs:

let rows = try Row.fetchAll(db, sql: "SELECT name, score FROM player")
let pairs = rows.map { ($0["name"] as String, $0["score"] as Int) }

Aggregates

let totalScore = try Int.fetchOne(db, sql: "SELECT SUM(score) FROM player") ?? 0
let avgScore = try Double.fetchOne(db, sql: "SELECT AVG(score) FROM player") ?? 0
let maxScore = try Int.fetchOne(db, sql: "SELECT MAX(score) FROM player") ?? 0

Standard SQL aggregates work fine. The query interface (covered later) gives type-safe equivalents.

EXPLAIN QUERY PLAN

For understanding query performance:

let plan = try Row.fetchAll(db, sql: "EXPLAIN QUERY PLAN SELECT * FROM player WHERE score > 50")
for row in plan {
    print(row)
}

The output shows whether SQLite uses an index (SEARCH player USING INDEX) or a full scan (SCAN player). For slow queries, this is the diagnostic to run.

Transactions

db.execute and Type.fetchAll operate against the database. To group multiple statements in one atomic transaction, use the explicit transaction APIs (covered in section 10), or pass them within a single write closure:

try dbPool.write { db in
    try db.execute(sql: "INSERT INTO player ...")
    try db.execute(sql: "UPDATE team SET playerCount = playerCount + 1 WHERE id = ?")
    // Both succeed or both rollback (the write closure is one transaction)
}

Each write closure is wrapped in a transaction automatically. If the closure throws, GRDB rolls back. If it returns successfully, GRDB commits. We’ll see explicit transactions for finer control later.

Performance: prepared statements

When you run the same query many times, parsing it each time costs. SQLite supports prepared statements — parsing once, executing many times with different arguments.

GRDB does this transparently if you use the records API or the query interface. With raw SQL, you can do it manually:

try dbPool.write { db in
    let statement = try db.makeStatement(sql: """
        INSERT INTO player (name, score) VALUES (?, ?)
        """)

    for (name, score) in players {
        try statement.execute(arguments: [name, score])
    }
}

db.makeStatement parses the SQL once. Then we execute it many times with different arguments. For inserting 10,000 rows, this is significantly faster than 10,000 separate db.execute calls.

(GRDB also caches statements internally for raw SQL with the same string — the savings from explicit prepared statements are real but not always dramatic.)

Pitfalls

SQL injection via interpolation. Don’t:

// ❌ NEVER
try db.execute(sql: "SELECT * FROM player WHERE name = '\(userInput)'")

Always use parameter binding:

// ✅
try db.execute(sql: "SELECT * FROM player WHERE name = ?", arguments: [userInput])

Missing read vs write distinction. db.execute for INSERT/UPDATE/DELETE must be inside write. Inside read, it’ll fail.

Forgetting to handle nil. Int.fetchOne returns Int?. The query might return zero rows (no result) or a row with NULL. Both produce nil. Handle accordingly.

Inconsistent column names between SQL and Swift. If your SQL aliases (SELECT name AS playerName ...), the row’s column is playerName, not name. Match.

Long-running closures. Anything inside a write blocks other writers. Don’t do network calls or heavy computation inside the closure; do them outside, then write the result.

What to internalize

db.execute(sql:arguments:) for inserts/updates/deletes; Type.fetchAll(db, sql:) for queries. Use parameter binding, not string interpolation. lastInsertedRowID for inserted IDs; changesCount for affected rows. Row.fetchAll for full rows; Type.fetchAll for single-column. Use cursors for huge results. Each write closure is a transaction. Prepared statements for repeated queries.


6. Records: Mapping Rows to Swift Types

Raw SQL is the foundation, but most of your code shouldn’t deal in Row instances. GRDB’s records are Swift types — usually structs — that map cleanly to database rows. You declare which protocols a type conforms to, and you get fetching and saving methods for free, with full type safety.

This is where GRDB starts to feel ergonomic.

The protocols

Three core protocols:

  • FetchableRecord — the type can be loaded from a database row. Provides fetchOne, fetchAll, fetchCursor static methods.
  • PersistableRecord — the type can be saved to a database. Provides insert, update, save, delete instance methods.
  • TableRecord — the type knows what table it belongs to. Required for query-interface use.

Most records conform to all three (often via the convenience FetchableRecord & PersistableRecord pair, which inherits TableRecord indirectly).

A first record

Define a Player struct:

import GRDB

struct Player: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var name: String
    var score: Int
    var createdAt: Date

    // For autoincrement to work, capture the ID after insert
    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

A few notes:

  • Codable conformance is what GRDB uses to decode rows into the type and encode the type into row values. The property names match the column names.
  • MutablePersistableRecord is for types whose insert can mutate self (specifically to assign the new ID). Use this for autoincrement IDs.
  • didInsert(_:) runs after a successful insert. We use it to capture the new row ID into the struct.

If you don’t have an autoincrement ID — say, you generate UUIDs yourself — you can use PersistableRecord (immutable) instead.

Fetching

try dbPool.read { db in
    // All players
    let players = try Player.fetchAll(db)

    // One player by ID
    let player = try Player.fetchOne(db, key: 42)

    // Players matching a SQL query
    let highScorers = try Player.fetchAll(db, sql: """
        SELECT * FROM player WHERE score > ?
        """, arguments: [50])
}

Player.fetchAll(db) returns [Player]. The SQL is generated automatically from the table name (which GRDB derives from the type name — Playerplayer). For custom queries, pass sql:.

Player.fetchOne(db, key: id) looks up by primary key. Returns Player? — nil if no row matches.

Specifying the table name

If your table name doesn’t match the convention:

struct Player: Codable, FetchableRecord, MutablePersistableRecord, TableRecord {
    static var databaseTableName: String = "players"  // plural form
    // ...
}

databaseTableName is a static property of TableRecord. Default is the lowercased type name; override if needed.

Inserting and saving

try dbPool.write { db in
    var player = Player(id: nil, name: "Alice", score: 100, createdAt: Date())
    try player.insert(db)
    print("Inserted with id: \(player.id!)")

    player.score = 150
    try player.update(db)

    // Or "save" — inserts if id is nil/0, updates otherwise
    try player.save(db)

    try player.delete(db)
}

The semantics:

  • insert(db) — INSERT a new row. Calls didInsert to update the record’s ID.
  • update(db) — UPDATE an existing row by primary key. Throws if no matching row.
  • save(db) — UPSERT-like: tries update, falls back to insert if the row doesn’t exist.
  • delete(db) — DELETE the row. Returns whether a row was deleted.

For most workflows, save is what you want. For finer control, use insert or update explicitly.

Custom column names

If your Swift property names don’t match column names exactly:

struct Player: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var name: String
    var score: Int
    var createdDate: Date  // column is `createdAt`

    // Map property names to column names
    enum CodingKeys: String, CodingKey {
        case id, name, score
        case createdDate = "createdAt"
    }
}

CodingKeys is standard Swift Codable. GRDB respects it.

Counting

let count = try Player.fetchCount(db)
let highCount = try Player.filter(Column("score") > 100).fetchCount(db)

fetchCount runs SELECT COUNT(*) — fast, no row materialization.

Existence check

let exists = try Player.fetchOne(db, key: 42) != nil

Or more efficiently:

let exists = try Player.filter(key: 42).fetchCount(db) > 0

Compound primary keys

For tables with composite primary keys:

struct NoteTag: Codable, FetchableRecord, PersistableRecord {
    var noteId: Int64
    var tagId: Int64
}

// Fetching by composite key:
try NoteTag.fetchOne(db, key: ["noteId": 1, "tagId": 2])

// Or via filter:
try NoteTag.filter(Column("noteId") == 1 && Column("tagId") == 2).fetchOne(db)

The dictionary form is convenient for arbitrary primary keys.

Hashable, Equatable, Identifiable

For SwiftUI integration and general Swift use:

struct Player: Codable, FetchableRecord, MutablePersistableRecord, Hashable, Identifiable {
    var id: Int64?
    var name: String
    var score: Int
    var createdAt: Date

    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

Hashable is automatic for structs whose properties are all Hashable. Identifiable requires an id property of an Hashable type. With var id: Int64?, Player is Identifiable for SwiftUI’s List and ForEach.

Equatable is automatic too — useful for diffing in SwiftUI.

Read-only records

A record might be useful for fetching but not saving. Conform to only FetchableRecord:

struct PlayerStats: Codable, FetchableRecord {
    let playerId: Int64
    let totalScore: Int
    let gameCount: Int
}

try dbPool.read { db in
    let stats = try PlayerStats.fetchAll(db, sql: """
        SELECT player.id AS playerId,
               SUM(game.score) AS totalScore,
               COUNT(*) AS gameCount
        FROM player
        JOIN game ON game.playerId = player.id
        GROUP BY player.id
        """)
}

PlayerStats is a fetch-only record. It can decode rows from any query producing matching columns. It doesn’t correspond to a single table — that’s fine; FetchableRecord doesn’t require it.

EncodableRecord for inserts only

For records you write but don’t read:

struct Event: Codable, EncodableRecord, PersistableRecord {
    let type: String
    let timestamp: Date
    let payload: String

    static let databaseTableName = "event"
}

Write-only is rare but valid. Most records are both fetchable and persistable.

Class-based records

You can use classes too, though structs are idiomatic. Classes are inherited from a base Record class:

final class PlayerRecord: Record {
    var id: Int64?
    var name: String
    var score: Int

    override class var databaseTableName: String { "player" }

    init(name: String, score: Int) {
        self.name = name
        self.score = score
        super.init()
    }

    required init(row: Row) throws {
        id = row["id"]
        name = row["name"]
        score = row["score"]
        try super.init(row: row)
    }

    override func encode(to container: inout PersistenceContainer) throws {
        container["id"] = id
        container["name"] = name
        container["score"] = score
    }

    override func didInsert(_ inserted: InsertionSuccess) {
        super.didInsert(inserted)
        id = inserted.rowID
    }
}

Classes give you mutability, identity, and reference semantics. Trade-offs:

  • ✅ You can pass instances around, mutate, observe via property changes.
  • ❌ You manage equality, hashing, observation manually.
  • ❌ More boilerplate.
  • ❌ Doesn’t fit cleanly into Codable.

For most apps, structs are better. Use classes only when reference semantics is essential.

Customizing Codable

If Codable doesn’t quite fit, override the encoding/decoding:

struct Player: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var name: String
    var score: Int
    var rank: PlayerRank  // custom enum

    enum PlayerRank: Int, Codable {
        case bronze = 1, silver, gold, platinum
    }

    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

Codable handles the enum naturally. The column rank stores integers (1, 2, 3, 4); the Swift type is the enum.

For more complex transformations, implement init(from decoder:) and encode(to encoder:) manually. Section 7 covers Codable in depth.

Pitfalls

Forgetting didInsert. Without it, insert succeeds but the record’s id stays nil. Subsequent update calls fail because there’s no key.

MutablePersistableRecord vs PersistableRecord. Use MutablePersistableRecord when insert needs to mutate (assign autoincrement ID). Use PersistableRecord for immutable operations (e.g., when ID is provided externally).

Mismatched column names. If your Player has a joinedAt property but the column is createdAt, decoding fails silently — the struct gets a default zero date. Use CodingKeys to map properties.

Unknown table. If databaseTableName doesn’t match an actual table (or you forgot to register it via TableRecord), fetches throw “no such table.” Check the table name.

Records with unsupported types. Not every type is automatically fetchable. URLs, custom enums with associated values, complex nested types — handle via Codable customization or store as serialized blobs.

What to internalize

Records map Swift types to database rows. Conform to Codable, FetchableRecord, MutablePersistableRecord (or PersistableRecord for non-autoincrement). Capture inserted IDs in didInsert. Use CodingKeys to map property-to-column. Static methods like Player.fetchAll(db) and instance methods like player.save(db) give you ergonomic data access. Structs preferred over classes.


7. The Codable Bridge

GRDB integrates deeply with Swift’s Codable. When your record conforms to Codable, GRDB uses the synthesized encoding/decoding to translate between Swift values and database row values. This is convenient — but the bridge has rules. Knowing them prevents subtle bugs.

How GRDB uses Codable

When you fetch a record:

  1. GRDB reads a row from SQLite.
  2. It builds a synthesized “decoder” that extracts column values from the row.
  3. The Swift Codable machinery uses this decoder to construct your struct.

When you save a record:

  1. The Swift Codable machinery encodes your struct into a synthesized “encoder.”
  2. The encoder produces values for each column.
  3. GRDB binds these values to the parameters of an INSERT or UPDATE statement.

This means: anything Codable can encode/decode, GRDB can save/fetch. The breadth of Swift types you can use is determined by Codable’s reach.

Type mappings

Standard types map naturally:

Swift SQLite
String TEXT
Int, Int64, Int32, etc. INTEGER
Double, Float REAL
Bool INTEGER (0/1)
Date TEXT (ISO 8601) by default, or REAL (Unix timestamp)
Data BLOB
URL TEXT
UUID TEXT (default) or BLOB

Optionals work: String? maps to a TEXT column that may be NULL.

Enums with raw values are encoded as their raw value: enum Rank: String, Codable { case gold } is stored as the string “gold”.

Enums

enum PlayerStatus: String, Codable {
    case active, inactive, banned
}

struct Player: Codable, FetchableRecord, PersistableRecord {
    var id: Int64?
    var name: String
    var status: PlayerStatus
}

The status column is TEXT. Codable maps the enum to its raw value automatically.

For Int-raw enums:

enum Priority: Int, Codable {
    case low = 1, medium = 2, high = 3
}

Stored as INTEGER.

Date handling

By default, Codable encodes Date as the seconds-since-2001 reference date (a Double). GRDB overrides this to use ISO 8601 strings — which is more readable and portable.

You can customize:

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .secondsSince1970

// GRDB uses its own encoders; for custom date strategies, you typically use Codable's
// or override individual properties via custom init(from:) and encode(to:)

Most apps don’t need custom date handling. Just use Date and trust GRDB’s defaults.

If you need numeric timestamps (e.g., for compatibility with a server that expects Unix timestamps), encode/decode manually:

struct Event: Codable, FetchableRecord, PersistableRecord {
    var id: Int64?
    var name: String
    var timestamp: Date

    enum CodingKeys: String, CodingKey {
        case id, name, timestamp
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decodeIfPresent(Int64.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        let unixTimestamp = try container.decode(Double.self, forKey: .timestamp)
        timestamp = Date(timeIntervalSince1970: unixTimestamp)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(id, forKey: .id)
        try container.encode(name, forKey: .name)
        try container.encode(timestamp.timeIntervalSince1970, forKey: .timestamp)
    }
}

The column timestamp is REAL (a Double). The Swift property is Date. We translate at the boundary.

Optional vs required

A String? property maps to a column that allows NULL. A String property requires the column to be non-NULL.

If your schema says the column is required (.notNull()) but you use String? in Swift, GRDB will accept saving — because Swift’s String? can be non-nil — but you lose compile-time safety against accidentally sending nil.

If your schema says nullable but Swift says required, you’ll crash on null when fetching.

Match them. Required columns → required Swift types. Nullable columns → optional Swift types.

Nested structs

A struct property whose type is Codable is encoded as JSON in a TEXT column:

struct Address: Codable {
    var street: String
    var city: String
}

struct Player: Codable, FetchableRecord, PersistableRecord {
    var id: Int64?
    var name: String
    var address: Address  // stored as JSON in TEXT column
}

The schema:

try db.create(table: "player") { t in
    t.autoIncrementedPrimaryKey("id")
    t.column("name", .text).notNull()
    t.column("address", .text)  // stores JSON
}

When GRDB serializes the address, it sees a custom-Codable property — it uses JSONEncoder to produce a string. Decoding is the reverse.

This works but has trade-offs:

  • ✅ Convenient — no separate table.
  • ❌ Can’t query into the JSON (well, you can with SQLite’s JSON1 functions, but it’s awkward).
  • ❌ Updates are wholesale — you can’t update just address.city directly.

For data you query against, normalize into separate columns or tables. For data that’s purely a “blob of structured info” associated with the row, JSON works.

Arrays and dictionaries

Arrays of Codable values are also stored as JSON:

struct Player: Codable, FetchableRecord, PersistableRecord {
    var id: Int64?
    var name: String
    var skills: [String]  // stored as JSON array in TEXT column
}

Saved as ["fishing", "hiking", "cooking"] — a JSON string.

For querying within the array, you’d need SQL JSON functions:

try db.execute(sql: """
    SELECT * FROM player WHERE EXISTS (
        SELECT 1 FROM json_each(player.skills) WHERE value = ?
    )
    """, arguments: ["fishing"])

This works but feels heavy. For data you’ll query, model it as a related table (a playerSkill table). For data that’s just a “list of strings the user attached”, JSON works.

Custom types with Codable

Any type conforming to Codable can be a property of a record, with GRDB serializing/deserializing it via JSON. This includes complex nested types:

struct Settings: Codable {
    var theme: String
    var notifications: Bool
    var preferences: [String: String]
}

struct User: Codable, FetchableRecord, PersistableRecord {
    var id: Int64?
    var name: String
    var settings: Settings
}

The settings column is TEXT, holding the JSON representation. Convenient for “blob of config” patterns.

DatabaseValueConvertible — for non-Codable types

Some types don’t conform to Codable but you still want to use them. DatabaseValueConvertible is the lower-level protocol:

import GRDB

struct Color: DatabaseValueConvertible {
    var red: Double
    var green: Double
    var blue: Double

    var databaseValue: DatabaseValue {
        // Encode as a hex string
        let hex = String(format: "%02X%02X%02X",
                         Int(red * 255), Int(green * 255), Int(blue * 255))
        return hex.databaseValue
    }

    static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Color? {
        guard let hex = String.fromDatabaseValue(dbValue),
              hex.count == 6 else { return nil }
        let r = Int(hex.prefix(2), radix: 16) ?? 0
        let g = Int(hex.dropFirst(2).prefix(2), radix: 16) ?? 0
        let b = Int(hex.suffix(2), radix: 16) ?? 0
        return Color(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
    }
}

DatabaseValueConvertible is the foundation that Codable builds on. Direct conformance gives you full control. Most types prefer Codable for simplicity.

Snake_case ↔ camelCase

If your team uses snake_case in SQL but camelCase in Swift, configure the JSON encoder/decoder:

struct Player: Codable, FetchableRecord, PersistableRecord {
    var id: Int64?
    var firstName: String
    var lastName: String
}

// In your AppDatabase or migration:
try db.create(table: "player") { t in
    t.autoIncrementedPrimaryKey("id")
    t.column("first_name", .text).notNull()
    t.column("last_name", .text).notNull()
}

Use CodingKeys to map:

struct Player: Codable, FetchableRecord, PersistableRecord {
    var id: Int64?
    var firstName: String
    var lastName: String

    enum CodingKeys: String, CodingKey {
        case id
        case firstName = "first_name"
        case lastName = "last_name"
    }
}

The verbose form. For consistency, the easier path is to use camelCase column names everywhere — Swift convention, less translation.

Pitfalls

Forgetting init(from:) for non-trivial decoders. If you customize encoding, you must also customize decoding (and vice versa). Asymmetric Codable produces hard-to-debug errors.

Implicit JSON serialization of complex types. A Codable struct property is silently serialized as JSON. The first time you try to query it (WHERE settings.theme = ?), you realize it’s not a column — it’s a string with JSON in it.

Date timezone gotchas. Codable encodes Date with the encoder’s strategy. GRDB’s default is ISO 8601. If you read these strings outside GRDB (e.g., a tool that doesn’t parse ISO 8601), you’ll have surprises.

Optional with default value. var status: String = "active" is non-optional with a default. It still requires a value when decoding. If the column is missing or null, decoding fails. Use var status: String? if it might be missing.

UUID storage size. UUIDs as strings take 36 bytes. As BLOB, 16 bytes. For high-volume tables, BLOB is more efficient. Configure via DatabaseValueConvertible if needed.

What to internalize

GRDB uses Codable for record encoding/decoding. Standard Swift types map naturally. Enums with raw values map to their raw values. Dates are ISO 8601 strings by default. Nested Codable structs are stored as JSON in TEXT columns — convenient but not queryable. Use CodingKeys for property-to-column mapping. Use DatabaseValueConvertible for non-Codable types.


8. Database Migrations in Depth

We’ve used migrations in section 4. Now let’s go deeper — the patterns for evolving schema over time, the ordering rules, the foreign-key handling, the recipes for non-trivial changes. Migrations are how your app’s database survives across releases. Get them right and updates are seamless. Get them wrong and users lose data.

The mental model

Each migration:

  • Has a stable, unique name.
  • Runs at most once per database.
  • Runs inside a transaction (default).
  • Either fully succeeds or fully fails (rolls back).

GRDB tracks applied migrations in a hidden table called grdb_migrations. On each launch:

  1. Open the database.
  2. Read grdb_migrations to find which migrations have already run.
  3. Run any registered migrations not in that list, in registration order.
  4. After each successful migration, record its name in grdb_migrations.

This is deterministic: a fresh database goes through every migration; an updated database only runs new ones. There’s no inference, no dynamic schema diffing — what your migrations say happens.

Initial schema

Your first migration creates the entire schema:

migrator.registerMigration("initial") { db in
    try db.create(table: "user") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("email", .text).notNull().unique()
        t.column("name", .text).notNull()
    }

    try db.create(table: "post") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("userId", .integer)
            .notNull()
            .references("user", onDelete: .cascade)
        t.column("title", .text).notNull()
        t.column("body", .text).notNull()
        t.column("createdAt", .datetime).notNull()
    }

    try db.create(index: "postByUser", on: "post", columns: ["userId"])
}

Adding a column

A common evolution — add an archived flag:

migrator.registerMigration("addArchivedToPost") { db in
    try db.alter(table: "post") { t in
        t.add(column: "archived", .boolean).notNull().defaults(to: false)
    }
}

db.alter(table:) adds or modifies columns. t.add(column:) adds a new column. The default value is essential — existing rows get this value when the column is added.

If you forget the default and the column is notNull(), the migration fails because existing rows would have NULL in a non-null column.

For optional columns, you don’t need a default:

t.add(column: "deletedAt", .datetime)  // optional, defaults to NULL

Renaming a column

SQLite supports column rename since version 3.25. GRDB exposes it:

migrator.registerMigration("renameTitleToHeadline") { db in
    try db.alter(table: "post") { t in
        t.rename(column: "title", to: "headline")
    }
}

After this migration, the column is headline. Update your records’ CodingKeys (or property names) to match.

For a rename that crosses a release boundary — old client uses title, new uses headline — be careful. If both clients access the database (e.g., via a shared store with an extension), the schema is whatever the last migration set; older clients may break. Plan deployments carefully.

Dropping a column

SQLite supports column drop since version 3.35:

migrator.registerMigration("dropOldField") { db in
    try db.alter(table: "post") { t in
        t.drop(column: "oldField")
    }
}

For older SQLite versions (older iOS deployment targets), you’d recreate the table without the column:

migrator.registerMigration("dropOldField") { db in
    try db.create(table: "newPost") { t in
        // schema without oldField
        t.autoIncrementedPrimaryKey("id")
        t.column("title", .text).notNull()
        // ...
    }
    try db.execute(sql: "INSERT INTO newPost SELECT id, title FROM post")
    try db.drop(table: "post")
    try db.rename(table: "newPost", to: "post")
}

This pattern is general — it works for any column drop or type change SQLite doesn’t directly support. The cost is you’re reading and rewriting the entire table.

Adding a table

migrator.registerMigration("addTags") { db in
    try db.create(table: "tag") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("name", .text).notNull().unique()
    }

    try db.create(table: "postTag") { t in
        t.column("postId", .integer)
            .notNull()
            .references("post", onDelete: .cascade)
        t.column("tagId", .integer)
            .notNull()
            .references("tag", onDelete: .cascade)
        t.primaryKey(["postId", "tagId"])
    }
}

Adding tables is straightforward — just declare them. Existing tables aren’t affected.

Backfilling data in a migration

Sometimes a schema change requires reorganizing existing data. Suppose v1 has a fullName column; v2 splits into firstName and lastName:

migrator.registerMigration("splitFullName") { db in
    // 1. Add new columns
    try db.alter(table: "user") { t in
        t.add(column: "firstName", .text)
        t.add(column: "lastName", .text)
    }

    // 2. Backfill from old column
    let cursor = try Row.fetchCursor(db, sql: "SELECT id, fullName FROM user")
    while let row = try cursor.next() {
        let id: Int64 = row["id"]
        let fullName: String = row["fullName"]
        let parts = fullName.split(separator: " ", maxSplits: 1).map(String.init)
        let first = parts.first ?? ""
        let last = parts.dropFirst().first ?? ""
        try db.execute(sql: """
            UPDATE user SET firstName = ?, lastName = ? WHERE id = ?
            """, arguments: [first, last, id])
    }

    // 3. Drop old column
    try db.alter(table: "user") { t in
        t.drop(column: "fullName")
    }
}

This is one migration that:

  1. Adds the new columns.
  2. Walks existing rows and computes new values.
  3. Drops the old column.

Do all three in one migration so it’s atomic — either all succeed (transaction commits) or none do (transaction rolls back).

For performance with large tables, consider doing it in chunks. But typically migrations are run on user devices with limited data, so this kind of read-update-write is fine.

Migration ordering

GRDB applies migrations in registration order, not name order. So this:

migrator.registerMigration("zPlatformUpdate") { db in /* ... */ }
migrator.registerMigration("aFix") { db in /* ... */ }

runs zPlatformUpdate first, then aFix. Names are just identifiers; order is the order you registered.

This is why migration names should reflect intent and chronology, but the actual ordering comes from registration code. Keep your migration registrations in chronological order in your source.

Foreign-key checks during migrations

By default, GRDB defers foreign key checking inside migrations. This lets you do things like:

migrator.registerMigration("restructure") { db in
    // Temporarily violate FK constraints during restructuring
    try db.execute(sql: "INSERT INTO temp_table SELECT * FROM old_table")
    try db.drop(table: "old_table")
    // FK referenced from elsewhere — checked at end of migration
}

If at the end of the migration the constraints aren’t satisfied, the whole migration fails.

You can opt out:

migrator.registerMigration("noChecks", foreignKeyChecks: .disabled) { db in
    // Foreign keys not checked at all in this migration
}

.disabled skips checks entirely — useful if you’re rebuilding the table structure in ways where transient FK violations are intentional.

eraseDatabaseOnSchemaChange

We touched on this earlier. During development:

#if DEBUG
migrator.eraseDatabaseOnSchemaChange = true
#endif

If GRDB sees that the migrations registered now don’t match what’s in grdb_migrations, it erases the database and re-applies all migrations from scratch. Useful when iterating — you don’t have to manually reset the simulator.

Never enable this in production. It would erase user data.

A subtle case: even with this flag, if you rename a previously-shipped migration after release, you can’t safely change it again — production users have it under the old name. The flag is for local iteration only.

Testing migrations

A common pattern: snapshot a database from a released version and verify migrations bring it forward correctly.

class MigrationTests: XCTestCase {
    func test_migrationFromV1ToV3() throws {
        // Create a v1-state database
        let dbQueue = try DatabaseQueue()
        try dbQueue.write { db in
            try db.create(table: "user") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("fullName", .text).notNull()
            }
            try db.execute(sql: "INSERT INTO user (fullName) VALUES (?)",
                          arguments: ["John Doe"])
            try db.execute(sql: "INSERT INTO user (fullName) VALUES (?)",
                          arguments: ["Jane Smith"])
            // Mark the v1 migration as applied (so it doesn't re-run)
            try db.execute(sql: """
                CREATE TABLE IF NOT EXISTS grdb_migrations (
                    identifier TEXT NOT NULL PRIMARY KEY
                )
                """)
            try db.execute(sql: """
                INSERT INTO grdb_migrations (identifier) VALUES (?)
                """, arguments: ["v1.initial"])
        }

        // Now run the full migrator
        let appDatabase = try AppDatabase(dbQueue)

        // Verify migrations applied correctly
        try dbQueue.read { db in
            let users = try Row.fetchAll(db, sql: "SELECT firstName, lastName FROM user")
            XCTAssertEqual(users.count, 2)
            XCTAssertEqual(users[0]["firstName"] as String, "John")
            XCTAssertEqual(users[0]["lastName"] as String, "Doe")
        }
    }
}

This is more involved than typical tests but catches migration regressions.

Defensive patterns

Idempotency. A migration runs once. But during development, you might be re-running with eraseDatabaseOnSchemaChange = true, which means full schema rebuild. Make sure your migrations work from a fresh database, not just from the version that preceded them.

No dependencies on app state. Migrations should be pure schema operations + data manipulation. Don’t fetch from the network, don’t read user defaults, don’t depend on anything outside the database.

Migrations as code review focus. When you ship a migration, every team member should review it. Migrations on user devices are run-once events; bugs are unfixable after the fact (you’d ship another migration to compensate).

Test on real data. Migrate in development with a database close to what users have — same row counts, same data shapes. Migrations that work on 10 rows might be slow on 100,000.

Multiple tables in one migration

A single migration can do many things:

migrator.registerMigration("v2.bigRefactor") { db in
    try db.create(table: "newTable") { t in /* ... */ }
    try db.alter(table: "user") { t in
        t.add(column: "newField", .text)
    }
    try db.execute(sql: "UPDATE user SET newField = 'default'")
    try db.create(index: "userByEmail", on: "user", columns: ["email"])
    try db.drop(table: "obsoleteTable")
}

All of these happen in one transaction. If any step throws, all are rolled back — the migration is not marked applied.

For very large refactorings, split into multiple migrations. Smaller migrations are easier to debug and roll back if something goes wrong.

DatabaseSchemaChange

GRDB exposes the migrator’s state programmatically:

try dbWriter.read { db in
    let applied = try migrator.appliedMigrations(db)
    let hasCompleted = try migrator.hasCompletedMigrations(db)
    print("Applied: \(applied), all done: \(hasCompleted)")
}

Useful for diagnostics, or for showing a “first launch — setting up…” UI during initial migration on a fresh install.

Pitfalls

Renaming a registered migration. Breaks tracking. Old users have it tracked under the old name; new code has it under the new name. Don’t.

Modifying a migration’s body after release. Same as above. The old code ran with old logic; new launches don’t re-run it. Modify forward.

Migrations with side effects. A migration that calls a network API doesn’t fit the model — migrations should be deterministic and idempotent in shape. Side effects belong in app launch code, after migration.

Schema changes outside migrations. If you db.execute("CREATE TABLE...") outside a migration, the table exists but isn’t tracked. Future migrations that depend on it might fail on databases where someone created it manually but not via migration. Always go through migrations.

Using eraseDatabaseOnSchemaChange in production. Catastrophic data loss for users.

What to internalize

Each migration has a stable name, runs once, runs in a transaction. Order is registration order. Use the schema DSL for clarity. Backfill data within the migration that introduces the change. Test migrations with realistic data. Use eraseDatabaseOnSchemaChange = true only in DEBUG. Never modify a migration after release.


9. Reads and Writes: Sync, Async, Throwing

GRDB has a small but important set of access methods on DatabaseWriter/DatabaseReader. Knowing them — and when to use which — is core to writing correct, ergonomic GRDB code.

The sync forms

The most basic:

try dbPool.write { db in
    // Synchronous write block.
    // Blocks the current thread.
    try db.execute(sql: "INSERT INTO player ...")
}

let count = try dbPool.read { db in
    // Synchronous read block.
    try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM player") ?? 0
}

write and read are sync. They block the calling thread until the closure completes. The closure runs on GRDB’s internal queue — your code waits there.

Use sync forms when:

  • You’re already on a background queue.
  • The operation is fast (a few rows).
  • You need the result immediately for subsequent logic.

Don’t use sync forms on the main thread for anything non-trivial — you’ll freeze the UI.

The async forms

try await dbPool.write { db in
    try db.execute(sql: "INSERT INTO player ...")
}

let count = try await dbPool.read { db in
    try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM player") ?? 0
}

The async forms suspend the calling task instead of blocking. Conceptually identical to the sync forms but cooperate with Swift Concurrency.

These are the modern path. For new code, prefer async. The async closure body still runs on GRDB’s queue; you just don’t block the caller.

read vs write

The distinction:

  • read uses a reader connection. With DatabasePool, multiple reads can happen concurrently. With DatabaseQueue, reads serialize through the single queue.
  • write uses the writer connection. Always serialized — only one write at a time.

Inside a read, you can only run SELECT-style queries. Trying to INSERT/UPDATE/DELETE inside a read closure throws. (Strict reader/writer separation prevents accidental writes.)

Inside a write, you can do both — but if the operation only reads, prefer read so you’re not unnecessarily holding the writer lock.

unsafeRead and unsafeReentrantWrite

GRDB exposes “unsafe” variants for advanced patterns:

try dbPool.unsafeRead { db in
    // Same as read, but allows the closure to call write reentrantly
    // (only safe under specific conditions)
}

The default read/write enforce non-reentrancy: you can’t call dbPool.write from inside a dbPool.read closure (deadlock). The unsafe variants relax this. Use only when you understand the implications. For 99% of code, stick with read/write.

Returning values from closures

The closures return whatever you want:

let players: [Player] = try await dbPool.read { db in
    try Player.fetchAll(db)
}

let totalScore: Int = try await dbPool.read { db in
    try Player.select(sum(Column("score"))).fetchOne(db) ?? 0
}

struct Stats {
    let count: Int
    let avgScore: Double
}

let stats: Stats = try await dbPool.read { db in
    let count = try Player.fetchCount(db)
    let avg = try Player.select(average(Column("score"))).fetchOne(db) ?? 0
    return Stats(count: count, avgScore: avg)
}

Whatever the closure returns becomes the return value of read/write. You can return tuples, structs, anything — it crosses the queue boundary.

Throwing closures

The closures can throw. If they do, the transaction (for writes) rolls back, and the error propagates out:

do {
    try await dbPool.write { db in
        try db.execute(sql: "INSERT INTO player ...")
        throw MyError.something  // rolls back the insert
    }
} catch {
    // The insert is undone
}

This is structured error handling — failures are atomic.

For non-database errors (e.g., business logic checks):

try await dbPool.write { db in
    let count = try Player.fetchCount(db)
    if count >= 100 {
        throw AppError.tooManyPlayers
    }
    try Player(name: "New", score: 0, createdAt: Date()).insert(db)
}

The check happens in the same transaction as the insert — atomic. If the count is over 100, no insert. Otherwise both succeed together.

Sendable considerations

The closure runs on GRDB’s queue, not the calling thread. Anything you capture must be safe to send across.

For typical code (capturing values, calling methods on services), this works fine. For complex captures, watch for the Swift compiler warning you about non-Sendable types.

Database instances are absolutely not Sendable across closure boundaries. Don’t capture and use them outside.

Long-running operations

Database closures hold the queue. The longer they run, the longer other operations wait. Best practice: keep closures short, do as much computation outside as possible.

// ❌ slow: do the work inside the write
try await dbPool.write { db in
    let url = URL(string: "https://api.example.com/data")!
    let data = try await URLSession.shared.data(from: url)  // network!
    let players = try JSONDecoder().decode([Player].self, from: data.0)
    for player in players {
        try player.insert(db)
    }
}

// ✅ fetch first, then write
let url = URL(string: "https://api.example.com/data")!
let (data, _) = try await URLSession.shared.data(from: url)
let players = try JSONDecoder().decode([Player].self, from: data)

try await dbPool.write { db in
    for player in players {
        try player.insert(db)
    }
}

The second version blocks the writer for milliseconds (just the inserts) instead of seconds (network call).

Writing inside an actor

If you have a @ModelActor-style pattern (a service actor that holds a DatabaseWriter):

actor PlayerService {
    let dbWriter: any DatabaseWriter

    init(_ dbWriter: any DatabaseWriter) {
        self.dbWriter = dbWriter
    }

    func createPlayer(name: String, score: Int) async throws -> Player {
        try await dbWriter.write { db in
            var player = Player(id: nil, name: name, score: score, createdAt: Date())
            try player.insert(db)
            return player
        }
    }
}

The actor’s method is async; it awaits the database write. The actor’s executor and GRDB’s executor are separate, but they cooperate via Swift Concurrency’s suspension model.

Actors don’t add extra concurrency safety for database access — GRDB’s own concurrency rules still apply. The actor mainly serializes access to the actor’s own state (if any).

Committing partway

Sometimes you want to “save what we have so far” inside a long write. Use savepoints (covered in section 10):

try await dbPool.write { db in
    for batch in dataBatches {
        try db.inSavepoint {
            for item in batch {
                try insertItem(item, db: db)
            }
            return .commit
        }
    }
    // No outer commit needed — write closure commits when it returns
}

This isn’t typical. Most code does one transaction per write closure, and that’s fine.

Read-only databases

For read-only operations on a database file you don’t write to (e.g., a bundled reference file):

var config = Configuration()
config.readonly = true
let dbQueue = try DatabaseQueue(path: bundledPath, configuration: config)

Now write calls fail. Only read works. Useful for app-bundled reference data that you ship and never modify.

The DatabaseSnapshot (advanced)

DatabasePool can produce read-only snapshots:

let snapshot = try dbPool.makeSnapshot()
let count = try snapshot.read { db in
    try Player.fetchCount(db)
}

A snapshot is a read-only view of the database at the moment it was created. Subsequent writes don’t affect what the snapshot sees. Useful for reports or exports that need a consistent view across many queries.

Snapshots hold resources (a reader connection); release them when done by letting them go out of scope.

Pitfalls

Calling write from inside read (or vice versa). Deadlocks. Don’t nest different access types.

Holding Database references outside closures. Always invalid after the closure returns.

Synchronous writes on main thread. Freeze UI. Use async forms or do writes off main.

Forgetting that writes are serialized. If you await two writes from concurrent tasks, they execute one after another. Plan for this.

Mixing Combine, async, sync. If you have a Combine pipeline emitting values that trigger writes, the writes serialize anyway — but it can be unintuitive. Be explicit about the concurrency model.

What to internalize

read for queries, write for mutations. Sync forms block the caller; async forms suspend. Closure body runs on GRDB’s queue. Return values cross the boundary; objects must be Sendable. Each write closure is a transaction — throws roll back. Don’t nest read and write. For long operations, do non-database work outside the closure.


10. Transactions and Save Points

Each write closure is a transaction. For most code, that’s enough. But sometimes you need finer control — explicit transactions you can commit or rollback based on logic, savepoints for partial commits within a larger write, or read transactions for consistent multi-query reads.

Transactions are automatic

By default:

try await dbPool.write { db in
    try player1.save(db)
    try player2.save(db)
}

If both saves succeed, the transaction commits. If either throws, the entire write is rolled back.

For most code, you don’t think about it explicitly — the closure boundary is the transaction boundary.

Explicit transactions

Sometimes you want to commit partway, or decide based on a result. The inTransaction API:

try await dbPool.write { db in
    try db.inTransaction {
        try insertSomething(db)
        if /* some condition */ {
            return .rollback
        }
        try insertSomethingElse(db)
        return .commit
    }
}

Inside inTransaction, you control whether to commit or rollback. The closure returns .commit or .rollback.

If you throw, the transaction rolls back automatically.

This gives you finer control than the implicit closure-boundary transaction. Use cases:

  • Multi-step operations where some steps might be conditionally skipped.
  • Performance optimization (multiple smaller transactions vs one big one).
  • Recovery from validation failures within a larger operation.

Savepoints

Savepoints are nested transactions within a transaction. SQLite supports them; GRDB exposes them:

try await dbPool.write { db in
    try player.save(db)

    try db.inSavepoint {
        try riskyOperation(db)
        return .commit
    }
    // Even if the savepoint rolls back, player.save still committed in the outer transaction

    try anotherSafeOperation(db)
}

Savepoints are useful when:

  • You want to retry a failing operation without rolling back everything.
  • You’re processing batches and want each batch to commit/rollback independently within a larger session.

A savepoint that throws (or returns .rollback) only undoes its own work; the outer transaction continues.

try await dbPool.write { db in
    for batch in batches {
        do {
            try db.inSavepoint {
                for item in batch {
                    try processItem(item, db)
                }
                return .commit
            }
        } catch {
            // This batch failed; previous batches are still committed (in this outer write)
            print("Batch failed: \(error)")
        }
    }
}

This pattern processes batches with per-batch fault tolerance — one bad item doesn’t lose the entire import.

Read transactions

For consistent multi-query reads:

try await dbPool.read { db in
    // This whole closure runs in a read transaction.
    // Multiple queries see the same database state, even if writers commit during.

    let count = try Player.fetchCount(db)
    let scores = try Int.fetchAll(db, sql: "SELECT score FROM player")
    let topPlayer = try Player.order(Column("score").desc).fetchOne(db)

    // All three queries see the database as it was at the start of the closure.
}

By default, read is already a read transaction in WAL mode (the snapshot at the time of the first query is used for the rest). So you don’t usually need explicit read transactions.

For older modes or to be explicit:

try dbPool.read { db in
    try db.inTransaction(.deferred) {
        let count = try Player.fetchCount(db)
        let scores = try Int.fetchAll(db, sql: "SELECT score FROM player")
        return .commit
    }
}

Transaction kinds

SQLite has three transaction kinds:

  • DEFERRED (default) — acquires a read lock on first read, write lock on first write.
  • IMMEDIATE — acquires a write lock immediately.
  • EXCLUSIVE — acquires an exclusive lock, blocking even reads.

For GRDB-managed transactions, the choice is usually irrelevant — DEFERRED is fine. For specific scenarios (e.g., long-running writes where you want to preempt reads up front), IMMEDIATE may help.

try db.inTransaction(.immediate) {
    // Writes from here are guaranteed to succeed without conflict
    return .commit
}

Most code doesn’t need this.

Foreign key checks within transactions

By default, foreign keys are checked at statement execution time. If you do something that temporarily violates FKs, the statement fails immediately.

For complex operations that need to defer until end-of-transaction, set PRAGMA defer_foreign_keys = ON:

try db.execute(sql: "PRAGMA defer_foreign_keys = ON")
// FK violations now allowed during the transaction; checked at commit time

Set in your migration if a specific schema operation needs it; or set per-transaction if a specific operation needs it.

Errors and rollback

When a transaction is rolled back:

  • All inserts/updates/deletes are undone.
  • Auto-incremented IDs are not reused (the counter advances regardless).
  • Indexes are restored to pre-transaction state.

The “ID advances even on rollback” behavior is sometimes surprising. After a rollback, if you start a new transaction and insert, you get the next ID, not the rolled-back one.

To check whether a transaction is currently active:

if db.isInsideTransaction {
    // ...
}

Combining with async work

A common pattern: transactionally insert and then do non-database work.

try await dbPool.write { db in
    try player.save(db)
    let id = player.id!

    // Schedule async work — runs after the transaction commits
    Task.detached {
        await indexInBackgroundService(playerId: id)
    }
}

The Task.detached runs concurrently. The outer write commits when the closure returns. The detached task starts running on its own; its access to the database (if any) is via separate read/write calls.

If you want the async work to happen as part of the same transaction, you can’t — transactions are bounded by the closure. Either fit all work in the closure or accept that it happens after.

Checkpoints

For DatabasePool (which uses WAL), the WAL accumulates writes. Periodically, SQLite checkpoints — moves WAL data into the main database file. This usually happens automatically.

To force a checkpoint:

try dbPool.checkpoint()

Useful before backing up the database file (so the -wal is empty and the main file has all data) or in low-memory situations.

Pitfalls

Forgetting to return .commit. If you forget to return from inTransaction, the closure can’t compile (Swift requires a return value). But returning .rollback accidentally throws away your work.

Nested transactions without savepoints. SQLite doesn’t support truly nested transactions; only savepoints. If you call inTransaction inside another inTransaction, GRDB uses savepoints under the hood.

Long-running transactions. A write transaction blocks all other writers. If your transaction takes seconds, your app hangs for everyone else trying to write. Keep them short.

Side effects on rollback. If your transaction body has side effects (file writes, network calls), they happen regardless of whether the transaction commits or rolls back. Side effects don’t unwind. Handle compensating logic explicitly.

Task.detached confusion. A detached task started inside a transaction runs concurrently — it doesn’t wait for the transaction. Don’t expect it to see the transaction’s changes until after the outer closure returns.

What to internalize

Each write closure is a transaction. Use db.inTransaction for commit/rollback control within a closure. Use db.inSavepoint for nested transaction-like blocks within a transaction. Read transactions provide consistent multi-query views (automatic in WAL). Keep transactions short — long ones block writers. Side effects don’t roll back; design accordingly.


11. The Query Interface

The query interface is GRDB’s type-safe alternative to raw SQL. Instead of writing strings, you compose Swift expressions: Player.filter(Column("score") > 100).order(Column("name")).limit(10). The compiler checks types. The result is still SQL — just generated for you.

For most app code, the query interface is the right level of abstraction. You drop to raw SQL only when the query is unusually complex or uses SQLite features the interface doesn’t expose.

The basics

import GRDB

let players = try Player.fetchAll(db)

let alice = try Player.filter(Column("name") == "Alice").fetchOne(db)

let topPlayers = try Player
    .filter(Column("score") > 100)
    .order(Column("score").desc)
    .limit(10)
    .fetchAll(db)

Each method returns a QueryInterfaceRequest<Player>. Methods chain. At the end, you call fetchAll, fetchOne, fetchCursor, or fetchCount.

The chain is lazy — no SQL runs until the fetch. You can build up a request, pass it around, and execute it later.

Column

Column("name") represents a column in the table. Combine with operators:

Column("score") > 100
Column("score") >= 100
Column("score") < 100
Column("score") == 100
Column("score") != 100
Column("name") == "Alice"
Column("name") != nil  // IS NOT NULL
Column("score").between(50...100)

The result is a SQLExpression — a building block for predicates.

Combining predicates

Player
    .filter(Column("score") > 100)
    .filter(Column("name") != "Banned")

Calling filter multiple times AND-combines. To OR-combine, use the operator:

Player.filter(Column("score") > 100 || Column("name") == "Champion")

For NOT:

Player.filter(!(Column("score") > 100))

LIKE and string predicates

Player.filter(Column("name").like("A%"))  // starts with A
Player.filter(Column("name").like("%Smith%"))  // contains Smith

like uses SQLite’s LIKE operator (case-insensitive by default for ASCII). For more sophisticated string matching, use raw SQL or FTS5.

For prefix/suffix:

import GRDB

extension Column {
    func startsWith(_ prefix: String) -> SQLExpression {
        return self.like("\(prefix)%")
    }

    func contains(_ substring: String) -> SQLExpression {
        return self.like("%\(substring)%")
    }
}

(LIKE patterns can be escaped if your input contains % or _. For user-provided search, prefer FTS5 or sanitize inputs.)

IN

Player.filter([1, 2, 3].contains(Column("id")))

Reads naturally: “where the column’s value is contained in the array.”

NULL checks

Player.filter(Column("deletedAt") == nil)
Player.filter(Column("deletedAt") != nil)

The query interface handles NULL semantics correctly (using IS NULL / IS NOT NULL).

Sorting

Player.order(Column("score").desc)  // descending
Player.order(Column("name"))         // ascending (default)
Player.order(Column("score").desc, Column("name"))  // multi-column

For null-handling:

Player.order(Column("score").desc.nullsFirst)
Player.order(Column("score").desc.nullsLast)

Limit and offset

Player.order(Column("score").desc).limit(10)
Player.order(Column("score").desc).limit(10, offset: 20)  // page 3, 10 per page

Selecting specific columns

To fetch only specific columns:

let names = try Player.select(Column("name"), as: String.self).fetchAll(db)
// names: [String]

Or for a custom record type:

struct PlayerSummary: Codable, FetchableRecord {
    let id: Int64
    let name: String
}

let summaries = try Player
    .select(Column("id"), Column("name"))
    .asRequest(of: PlayerSummary.self)
    .fetchAll(db)

select narrows the columns. asRequest(of:) re-types the request to decode into a different struct. The resulting query is SELECT id, name FROM player — slimmer than fetching the whole record.

Aggregates

let count = try Player.fetchCount(db)

let totalScore = try Player.select(sum(Column("score")), as: Int.self).fetchOne(db) ?? 0
let avgScore = try Player.select(average(Column("score")), as: Double.self).fetchOne(db) ?? 0
let maxScore = try Player.select(max(Column("score")), as: Int.self).fetchOne(db) ?? 0
let minScore = try Player.select(min(Column("score")), as: Int.self).fetchOne(db) ?? 0

The sum, average, etc. functions are part of GRDB’s expression DSL.

For aggregates with grouping:

struct PlayerCount: Codable, FetchableRecord {
    let teamId: Int64
    let count: Int
}

let counts = try Player
    .select(Column("teamId"), count().forKey("count"))
    .group(Column("teamId"))
    .asRequest(of: PlayerCount.self)
    .fetchAll(db)

group(...) adds GROUP BY. The result has the team ID and count per team.

Distinct

let uniqueNames = try Player.select(Column("name"), as: String.self).distinct().fetchAll(db)

Removes duplicates. For more sophisticated distinct semantics, use raw SQL.

Updating via the query interface

Most updates go through record.update(db) or save. For bulk updates without loading records:

try Player
    .filter(Column("score") < 50)
    .updateAll(db, [Column("status").set(to: "inactive")])

updateAll runs UPDATE player SET status = 'inactive' WHERE score < 50 — a single SQL statement, no record materialization.

Deleting via the query interface

try Player.filter(Column("score") < 50).deleteAll(db)

Bulk delete by predicate. Returns the number of deleted rows.

Combining with raw SQL

When the query interface doesn’t quite express what you want, you can mix:

let activeCount = try Player
    .filter(sql: "EXISTS (SELECT 1 FROM game WHERE game.playerId = player.id)")
    .fetchCount(db)

Raw SQL fragments slot into otherwise-typed queries. Useful for complex predicates the interface doesn’t have a method for.

Building dynamic queries

A common need: a search view with optional filters.

func searchPlayers(name: String?, minScore: Int?, in db: Database) throws -> [Player] {
    var request = Player.all()

    if let name, !name.isEmpty {
        request = request.filter(Column("name").like("%\(name)%"))
    }

    if let minScore {
        request = request.filter(Column("score") >= minScore)
    }

    request = request.order(Column("score").desc).limit(50)

    return try request.fetchAll(db)
}

Each conditional filter adds an AND clause. The compiled SQL only has the relevant predicates.

Common requests as static properties

For frequently-used queries, define them on the type:

extension Player {
    static let active = Player.filter(Column("status") == "active")
    static let topByScore = Player.order(Column("score").desc).limit(10)

    static func inFolder(_ folderId: Int64) -> QueryInterfaceRequest<Player> {
        Player.filter(Column("folderId") == folderId)
    }
}

// Usage:
try Player.active.fetchAll(db)
try Player.topByScore.fetchAll(db)
try Player.inFolder(42).fetchAll(db)

This builds a small DSL for your domain. Reads naturally; reuses across the codebase.

CodingKey-based columns

Instead of Column("name"), you can use Swift’s CodingKeys directly:

struct Player: Codable, FetchableRecord, MutablePersistableRecord, TableRecord {
    var id: Int64?
    var name: String
    var score: Int

    enum Columns {
        static let id = Column(CodingKeys.id)
        static let name = Column(CodingKeys.name)
        static let score = Column(CodingKeys.score)
    }
}

// Usage:
Player.filter(Player.Columns.score > 100)

This catches typos at compile time — Player.Columns.scre won’t compile, but Column("scre") would.

For maximum safety, define all columns this way and reference them via the type.

What the SQL looks like

You can ask GRDB to show you the generated SQL:

let request = Player.filter(Column("score") > 100).order(Column("name"))
let sql = try request.makeSQL(db)
print(sql)  // SELECT * FROM "player" WHERE "score" > 100 ORDER BY "name"

Useful for debugging. Or run with Configuration.publicStatementArguments = true (see section 2) to log every executed query.

Pitfalls

Mutable requests. A QueryInterfaceRequest is a value type — methods return a new request rather than mutating. Don’t expect request.filter(...) to modify in place; use the return value.

Forgetting to fetch. Building a request without calling fetchAll/fetchOne doesn’t run any SQL. The request is just a description.

Type mismatches in select. If you select(Column("name")) but try to fetch as [Int], you get a runtime error. Match the column types to the fetch type.

Operator precedence. Column("a") == 1 || Column("b") == 2 && Column("c") == 3 parses as a == 1 || (b == 2 && c == 3). Add parens for clarity.

LIKE with user input. Column("name").like("%\(userInput)%") doesn’t sanitize against % or _ characters in user input. For real search, use FTS5 (section 19).

What to internalize

The query interface is a Swift DSL that produces SQL. Use it for type-safe, composable queries. Column("name") for column references; comparison operators for predicates; chain filter, order, limit. select to narrow columns; asRequest(of:) to re-type into a different struct. Build dynamic queries by conditionally adding filters. Define common queries as static properties on the type. Drop to raw SQL when the interface doesn’t cover what you need.


12. Filtering with Column and SQLExpression

We’ve used predicates throughout. Now let’s go deeper. The SQLExpression type is GRDB’s representation of “a thing that produces a SQL value” — columns, literals, function calls, arithmetic, comparisons. Understanding SQLExpression and the operators around it lets you build sophisticated predicates with full type safety.

What SQLExpression is

Anything that, when rendered, produces a SQL fragment that resolves to a value. Examples:

  • A column reference: Column("name")"name".
  • A literal: 100 (when used in expression context) → 100.
  • A comparison: Column("score") > 100"score" > 100.
  • A function call: length(Column("name"))LENGTH("name").
  • An arithmetic expression: Column("score") + 10"score" + 10.

SQLExpression values can be combined with operators, passed to filter, used in order, select, update. The whole query interface is built on them.

Comparison operators

Column("score") == 100
Column("score") != 100
Column("score") > 100
Column("score") >= 100
Column("score") < 100
Column("score") <= 100

These return SQLExpressions that resolve to booleans.

For comparing two columns:

Column("startDate") < Column("endDate")

Both sides can be expressions.

Logical operators

Column("score") > 100 && Column("status") == "active"  // AND
Column("score") > 100 || Column("name").like("Champ%")  // OR
!(Column("status") == "banned")                         // NOT

GRDB overloads &&, ||, ! for SQLExpression. The result is another expression.

Arithmetic

Column("score") + 10
Column("score") - Column("penalties")
Column("score") * 2
Column("score") / 3

Useful in select (computed columns) and update:

try Player.updateAll(db, [Column("score").set(to: Column("score") + 10)])
// UPDATE player SET score = score + 10

String functions

length(Column("name"))            // LENGTH("name")
lower(Column("name"))              // LOWER("name")
upper(Column("name"))              // UPPER("name")
Column("name").like("A%")          // "name" LIKE 'A%'
Column("name").lowercased         // collation-aware lowercase

length, lower, upper are GRDB-provided helpers. Other SQLite functions can be invoked via raw SQL.

NULL handling

Column("deletedAt") == nil   // "deletedAt" IS NULL
Column("deletedAt") != nil   // "deletedAt" IS NOT NULL
Column("name").coalescing("Anonymous")  // COALESCE("name", 'Anonymous')

The query interface uses SQL-correct NULL semantics. column == nil becomes IS NULL, not = NULL (which would always be false).

IN / NOT IN

[1, 2, 3].contains(Column("id"))   // "id" IN (1, 2, 3)
![1, 2, 3].contains(Column("id"))  // "id" NOT IN (1, 2, 3)

For dynamic lists:

let bannedIds: [Int64] = [...]
Player.filter(!bannedIds.contains(Column("id")))

For IN with a sub-query:

let activePlayerIds = Player.filter(Column("status") == "active").select(Column("id"))
Game.filter(activePlayerIds.contains(Column("playerId")))
// SELECT * FROM game WHERE playerId IN (SELECT id FROM player WHERE status = 'active')

The select request acts as a sub-query.

BETWEEN

Column("score").between(50, 100)        // "score" BETWEEN 50 AND 100
Column("createdAt").between(start, end) // ranges of dates

Inclusive on both sides, like SQL BETWEEN.

CASE expressions

let request = Player.select(
    Column("name"),
    Case<String>([
        (when: Column("score") > 100, then: "high"),
        (when: Column("score") > 50, then: "medium"),
    ], else: "low").forKey("category"),
    as: PlayerCategory.self
)

struct PlayerCategory: Codable, FetchableRecord {
    let name: String
    let category: String
}

CASE WHEN expressions for conditional logic in queries. Often easier in raw SQL:

let request = Player.select(sql: """
    name,
    CASE
        WHEN score > 100 THEN 'high'
        WHEN score > 50 THEN 'medium'
        ELSE 'low'
    END AS category
    """)

Both work. Pick by readability.

EXISTS

let playersWithGames = Player.filter(
    Game.filter(Column("playerId") == Column("id")).exists()
)

subquery.exists() produces an EXISTS check. The sub-query references the outer query’s columns via Column("id") (the outer player’s ID).

This is a powerful pattern for “find X where related Y exists.”

Negated predicates

Player.filter(!(Column("status") == "banned"))
Player.filter(Column("status") != "banned")

Both work; the second reads better. For complex negations:

Player.filter(!(Column("score") > 100 && Column("status") == "active"))
// NOT (score > 100 AND status = 'active')
// equivalent to: score <= 100 OR status != 'active'

Whichever form is clearer.

Date predicates

Dates are tricky. The default storage is ISO 8601 strings; comparisons are string-based but happen to work for sortable formats:

let yesterday = Date().addingTimeInterval(-86400)
Player.filter(Column("createdAt") > yesterday)

Internally this becomes "createdAt" > '2024-01-15T10:30:45.123Z' — a string compare. Because ISO 8601 strings sort lexicographically the same as the dates, this works correctly.

For specific date arithmetic, use SQLite functions:

Player.filter(sql: "DATE(createdAt) = DATE('now')")  // today's players

Or rely on Swift to compute date ranges and pass them as bounds:

let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: Date())
let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfDay)!

Player.filter(Column("createdAt") >= startOfDay && Column("createdAt") < startOfTomorrow)

This is more idiomatic Swift and works without raw SQL.

Building reusable predicates

Define named predicates as functions:

extension Player {
    static func active() -> SQLExpression {
        Column("status") == "active"
    }

    static func minimumScore(_ score: Int) -> SQLExpression {
        Column("score") >= score
    }

    static func recentSignup() -> SQLExpression {
        let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
        return Column("createdAt") >= cutoff
    }
}

// Use:
Player.filter(Player.active() && Player.minimumScore(50)).fetchAll(db)

Composable predicates as a domain DSL. Reads beautifully.

Custom column types

If you have a custom type like PlayerStatus (a Codable enum) and want type-safe predicates:

enum PlayerStatus: String, Codable, DatabaseValueConvertible {
    case active, inactive, banned
}

extension PlayerStatus {
    static var allActive: SQLExpression {
        Column("status") == "active"
    }
}

Player.filter(Column("status") == PlayerStatus.active)

For an enum that’s DatabaseValueConvertible, you can compare directly. The raw value is what gets compared in SQL.

Pitfalls

Forgetting parentheses with &&/||. a == 1 && b == 2 || c == 3 parses as (a == 1 && b == 2) || c == 3 due to operator precedence. Use parens.

String comparison with case sensitivity. SQLite’s default LIKE is case-insensitive for ASCII; case-sensitive for Unicode. For consistent case-insensitive search, use lower(Column("name")).like("%alice%") or FTS5.

Comparing optional columns. A column that might be NULL behaves correctly with IS NULL, but column > 5 excludes NULL rows. If you want NULLs included as “low scores”, use column > 5 || column IS NULL.

Sub-query performance. EXISTS and IN with sub-queries can be slow if not indexed. Always check generated SQL with EXPLAIN QUERY PLAN.

What to internalize

Column produces a column reference; comparison and arithmetic operators produce SQLExpressions. &&, ||, ! for logical combinations. NULL handling is correct (== nil becomes IS NULL). IN via array.contains(column); EXISTS via subquery.exists(). Build reusable predicates as static methods. For complex date arithmetic, compute bounds in Swift and pass them as comparison values.


13. Sorting, Limits, and Aggregation

We’ve touched on these throughout. This section consolidates the patterns and goes deeper into aggregation and grouping — the SQL features that make a database useful for more than just “give me all the rows.”

Sorting

Player.order(Column("score").desc)
Player.order(Column("score"))  // ascending (default)
Player.order(Column("score").desc, Column("name"))

Multiple sort columns are applied in order — primary sort first, secondary breaks ties.

For collations:

Player.order(Column("name").collating(.nocase))  // case-insensitive sort
Player.order(Column("name").collating(.localizedCaseInsensitive))

.nocase is a basic case-insensitive collation. .localizedCaseInsensitive follows the user’s locale (Æ properly placed, accent handling, etc.). For user-facing displays, prefer the localized variant.

For null handling:

Player.order(Column("score").desc.nullsFirst)
Player.order(Column("score").desc.nullsLast)

By default, SQL doesn’t specify whether nulls come first or last. Be explicit if it matters.

Random ordering

let randomPlayer = try Player.order(sql: "RANDOM()").fetchOne(db)

For random samples:

let sample = try Player.order(sql: "RANDOM()").limit(10).fetchAll(db)

RANDOM() is a SQLite function. Random ordering does a full scan + sort — slow for large tables. For frequent random sampling on large tables, alternative approaches (e.g., maintain a “random rank” column and update periodically) may be faster.

Limits

Player.limit(10)
Player.limit(10, offset: 20)

limit(n) returns first n results. limit(n, offset: m) skips m, returns n. Useful for paging.

For a paged display:

func players(page: Int, pageSize: Int = 20, in db: Database) throws -> [Player] {
    try Player
        .order(Column("createdAt").desc)
        .limit(pageSize, offset: page * pageSize)
        .fetchAll(db)
}

Page 0: rows 0-19. Page 1: rows 20-39. Etc.

For very large tables, OFFSET is slow because SQLite computes-and-discards the skipped rows. Prefer keyset pagination:

func players(after lastDate: Date?, pageSize: Int = 20, in db: Database) throws -> [Player] {
    var request = Player.order(Column("createdAt").desc).limit(pageSize)
    if let lastDate {
        request = request.filter(Column("createdAt") < lastDate)
    }
    return try request.fetchAll(db)
}

The first call passes nil cursor; subsequent calls pass the last row’s createdAt. Each query uses an indexed comparison — fast at any depth.

The trade-off: you can’t jump to “page 47” — must walk forward. For infinite-scroll feeds (the common UX), keyset is right. For “go to page 47” UI (less common in mobile), offset is fine for moderate datasets.

Counting

let count = try Player.fetchCount(db)
let activeCount = try Player.filter(Column("status") == "active").fetchCount(db)

fetchCount is SELECT COUNT(*) — fast, no row materialization.

For counts within groups, see grouping below.

Aggregates

GRDB exposes SQL aggregates as functions:

import GRDB

let request = Player.select(
    sum(Column("score")).forKey("total"),
    average(Column("score")).forKey("avg"),
    max(Column("score")).forKey("max"),
    min(Column("score")).forKey("min"),
    count(Column("id")).forKey("count"),
    as: PlayerStats.self
)

struct PlayerStats: Codable, FetchableRecord {
    let total: Int
    let avg: Double
    let max: Int
    let min: Int
    let count: Int
}

let stats = try request.fetchOne(db)!
print("Total: \(stats.total), avg: \(stats.avg)")

forKey("total") is the column alias — SUM(score) AS total. The result struct’s properties match the aliases.

GROUP BY

For aggregation by a column:

struct TeamStats: Codable, FetchableRecord {
    let teamId: Int64
    let playerCount: Int
    let totalScore: Int
}

let teamStats = try Player
    .select(
        Column("teamId"),
        count().forKey("playerCount"),
        sum(Column("score")).forKey("totalScore")
    )
    .group(Column("teamId"))
    .asRequest(of: TeamStats.self)
    .fetchAll(db)

group(...) adds GROUP BY. Each result row is a unique value of the grouping column with aggregates over its group.

HAVING

To filter aggregated results:

let activeTeams = try Player
    .select(Column("teamId"), count().forKey("count"))
    .group(Column("teamId"))
    .having(count() >= 5)
    .asRequest(of: TeamCount.self)
    .fetchAll(db)

HAVING filters by aggregate values, where WHERE filters rows before aggregation. Use HAVING for “groups with at least 5 players”; use WHERE for “players with score > 50.”

DISTINCT

let uniqueNames = try Player
    .select(Column("name"))
    .distinct()
    .asRequest(of: String.self)
    .fetchAll(db)

distinct() adds DISTINCT, deduplicating results.

For COUNT(DISTINCT column):

let uniqueNameCount = try Player
    .select(count(Column("name"), distinct: true).forKey("count"))
    .asRequest(of: Int.self)
    .fetchOne(db) ?? 0

Pagination with sort changes

A pitfall: changing the sort between pages produces inconsistent results. If page 1 sorts by date, page 2 must too. Otherwise the underlying ordering shifts and your paging breaks.

let pageSize = 20
let sortDescriptors = [Column("createdAt").desc, Column("id")]

func players(page: Int, in db: Database) throws -> [Player] {
    try Player
        .order(sortDescriptors)
        .limit(pageSize, offset: page * pageSize)
        .fetchAll(db)
}

The id sort breaks ties for createdAt. Without it, two rows with the same createdAt could appear in different orders on different pages.

Combining limit, offset, group, having

Order matters in SQL but the query interface methods can be called in any order — GRDB assembles correctly:

let request = Player
    .filter(Column("status") == "active")
    .group(Column("teamId"))
    .having(count() >= 3)
    .select(Column("teamId"), count().forKey("count"))
    .order(sql: "count DESC")
    .limit(10)
    .asRequest(of: TeamCount.self)

let topTeams = try request.fetchAll(db)

The generated SQL:

SELECT teamId, COUNT(*) AS count
FROM player
WHERE status = 'active'
GROUP BY teamId
HAVING COUNT(*) >= 3
ORDER BY count DESC
LIMIT 10

GRDB orders the clauses correctly regardless of how you chained the methods.

Aggregation and indexes

Aggregates often benefit from indexes. SELECT MAX(score) FROM player with an index on score is O(1) — SQLite reads the last entry of the index. Without the index, it’s O(n) — full scan.

For:

SELECT teamId, MAX(score) FROM player GROUP BY teamId

A composite index (teamId, score) makes this efficient.

Always profile aggregation queries on realistic data. EXPLAIN QUERY PLAN tells you whether indexes are used.

Pitfalls

Implicit type in aggregates. SUM(integer column) returns INTEGER but might overflow. SUM(real) returns REAL. Be explicit about the result type via as: Int.self etc.

NULL handling in aggregates. SUM(column) on all-NULL rows returns NULL. COUNT(*) counts rows; COUNT(column) counts non-NULL values.

Forgetting GROUP BY columns in SELECT. SELECT-ing a column that isn’t in GROUP BY (and isn’t aggregated) produces undefined results. SQL standard forbids it; SQLite tolerates it with weird semantics.

Inefficient pagination on huge tables. OFFSET 1000000 is slow. Use keyset pagination.

LIMIT without ORDER BY. Results are non-deterministic. Always order.

What to internalize

order for sorting; multiple columns for tie-breaking; collations for locale-aware sort. limit(n, offset: m) for paging; keyset pagination for large tables. fetchCount for fast counts. SQL aggregates exposed as sum, average, min, max, count. group for GROUP BY; having to filter aggregates. distinct for deduplication. Indexes matter for aggregates; profile with EXPLAIN.


14. Associations: belongsTo, hasMany, hasOne

GRDB has a powerful associations system — a way to declare that “a Note belongs to a Folder”, “a Folder has many Notes”, “a Note has many Tags through NoteTag.” Once associated, you can query and load related records ergonomically.

This is where GRDB starts to feel like an ORM (lightly). The mechanism is still real SQL underneath, but the surface lets you write Note.including(required: Note.folder).fetchAll(db) rather than the JOIN by hand.

The four association types

  • belongsTo — many-to-one. A Note belongs to a Folder. The Note table has a folderId foreign key.
  • hasMany — one-to-many. A Folder has many Notes. The “many” side has the foreign key.
  • hasOne — one-to-one. A User has one Profile. Either side can hold the foreign key, conventionally the dependent side.
  • hasMany through — many-to-many. A Note has many Tags through a noteTag join table.

Plus their query equivalents (joining without loading) and the eager-loading variants (loading related rows).

belongsTo

A Note belongs to a Folder:

struct Note: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var folderId: Int64?  // nullable: not every note has a folder
    var title: String
    var body: String

    static let folder = belongsTo(Folder.self)
}

struct Folder: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var name: String
}

The static let folder = belongsTo(Folder.self) declares the association. By convention, GRDB looks for a folderId column in Note to link to Folder.id. You can override:

static let folder = belongsTo(Folder.self, using: ForeignKey(["folderId"]))

Useful if your column naming is unconventional.

hasMany

The reverse on Folder:

struct Folder: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var name: String

    static let notes = hasMany(Note.self)
}

GRDB looks for a column on Note that references Folder. With conventional naming (folderId), this works automatically.

hasOne

For one-to-one:

struct User: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var email: String

    static let profile = hasOne(Profile.self)
}

struct Profile: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var userId: Int64
    var bio: String
}

The Profile has a userId foreign key; the User declares hasOne(Profile.self). You can fetch the user with their profile in one query (covered next section).

hasMany through

For many-to-many through a join table:

struct Note: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var title: String

    static let noteTags = hasMany(NoteTag.self)
    static let tags = hasMany(Tag.self, through: noteTags, using: NoteTag.tag)
}

struct Tag: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var name: String

    static let noteTags = hasMany(NoteTag.self)
    static let notes = hasMany(Note.self, through: noteTags, using: NoteTag.note)
}

struct NoteTag: Codable, FetchableRecord, PersistableRecord {
    var noteId: Int64
    var tagId: Int64

    static let note = belongsTo(Note.self)
    static let tag = belongsTo(Tag.self)
}

Three associations:

  • Note.tags — through noteTags and the tag association on NoteTag.
  • Tag.notes — symmetric.
  • NoteTag itself — a record per junction row.

Now Note.tags looks like a direct hasMany — but it goes through the join table.

Querying with associations

The simplest use: filtering by association presence.

// Notes with at least one tag
try Note.having(Note.tags.isNotEmpty).fetchAll(db)

// Notes with no tag
try Note.having(Note.tags.isEmpty).fetchAll(db)

isEmpty and isNotEmpty work on hasMany associations — they generate appropriate EXISTS subqueries.

Including (eager loading)

To fetch a note along with its folder in a single query:

struct NoteWithFolder: Codable, FetchableRecord {
    var note: Note
    var folder: Folder?
}

let request = Note.including(optional: Note.folder)
let results = try NoteWithFolder.fetchAll(db, request)

Or use the convenience:

let request = Note.including(optional: Note.folder)
let rows = try Row.fetchAll(db, request)
for row in rows {
    let note = try Note(row: row)
    let folder = try? Folder(row: row.scoped(on: "folder"))
}

We’ll cover this in detail in section 15. For now, know that including loads the related record alongside.

required vs optional:

  • including(required: Note.folder) — INNER JOIN; Notes without a folder are excluded.
  • including(optional: Note.folder) — LEFT JOIN; Notes with no folder still appear, with folder as nil.

Joining (not loading)

If you need to filter by association attributes without loading the related records:

// Notes whose folder name starts with "Work"
let workNotes = try Note
    .joining(required: Note.folder.filter(Column("name").like("Work%")))
    .fetchAll(db)

joining adds a JOIN for filtering but doesn’t pull the related record into the result. Faster than including if you don’t need the related data.

Operating on through associations

For many-to-many:

// All tags for a note
let note = try Note.fetchOne(db, key: 42)!
let tags = try note.request(for: Note.tags).fetchAll(db)

note.request(for: Note.tags) builds a query that fetches all tags belonging to this note. It’s a separate query — you load the note first, then load its tags.

For loading a note with all its tags in one go, see “eager loading” in section 15.

Filtering through associations

// Notes that have any tag named "important"
try Note
    .joining(required: Note.tags.filter(Column("name") == "important"))
    .fetchAll(db)

The filter applies inside the join. Only notes with an “important” tag come back.

Adding a tag to a note (operating on the join table)

let noteTag = NoteTag(noteId: noteId, tagId: tagId)
try noteTag.insert(db)

Or with conflict resolution (so re-tagging is idempotent):

try noteTag.upsert(db)  // requires a unique constraint on (noteId, tagId)

Removing a tag:

try NoteTag
    .filter(Column("noteId") == noteId && Column("tagId") == tagId)
    .deleteAll(db)

The join table is just a regular record. CRUD operations work normally.

Self-referential associations

A folder can have a parent folder (a tree structure):

struct Folder: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var parentId: Int64?
    var name: String

    static let parent = belongsTo(Folder.self, using: ForeignKey(["parentId"]))
    static let children = hasMany(Folder.self, using: ForeignKey(["parentId"]))
}

Now folder.request(for: Folder.parent).fetchOne(db) gets the parent. Same for children.

For deep traversal (the entire ancestry chain), you’d need recursive CTEs — beyond the query interface, into raw SQL.

Convenience accessors on the record

Often you want shortcuts:

extension Note {
    func folder(in db: Database) throws -> Folder? {
        try request(for: Note.folder).fetchOne(db)
    }

    func tags(in db: Database) throws -> [Tag] {
        try request(for: Note.tags).fetchAll(db)
    }
}

// Usage:
let folder = try note.folder(in: db)
let tags = try note.tags(in: db)

This makes the related-record access read like a property access. Each call is its own query — fine for occasional access but bad in tight loops (N+1). For loops, use eager loading (next section).

Pitfalls

Forgetting foreign-key declarations. GRDB infers the foreign key column from the related table name. If your column is parentFolderId instead of folderId, it won’t work without explicit using: ForeignKey([...]).

N+1 in loops. for note in notes { note.folder(in: db) } runs N folder queries. Use eager loading.

Cycles in self-referential. A folder pointing to itself as its own parent breaks queries. Validate at write time.

Unconventional column names. GRDB conventions assume things like folderId (foreign key) and lowercase singular table names. Override with explicit foreign key declarations when needed.

Forgetting to declare both sides. A belongsTo on Note doesn’t automatically give you hasMany on Folder. Declare both.

What to internalize

Associations declare relationships: belongsTo, hasMany, hasOne, hasMany through. By convention, GRDB infers foreign-key columns from naming. including(required:) is INNER JOIN with eager load; including(optional:) is LEFT JOIN. joining joins for filtering without loading. Use record.request(for: Type.relation).fetchAll(db) for ad-hoc access. Beware N+1 in loops — use eager loading.


15. Eager Loading: including, joining, having

The N+1 problem: you fetch a list of records, then for each one, you fetch related data. N+1 queries instead of 1 (or 2). For a list of 100 notes that each look up their folder, that’s 101 queries instead of 1 SQL query with a JOIN.

including is GRDB’s solution. It loads related records along with the primary records in a single (or few) queries. Used right, it eliminates N+1 at the cost of slightly more code structure.

including(required:)

The simplest form: load notes with their folders, requiring the folder to exist.

struct NoteWithFolder: Codable, FetchableRecord {
    var note: Note
    var folder: Folder
}

let results = try Note
    .including(required: Note.folder)
    .asRequest(of: NoteWithFolder.self)
    .fetchAll(db)

for item in results {
    print("\(item.note.title) in \(item.folder.name)")
}

The struct NoteWithFolder has nested structs: note and folder. GRDB decodes the joined query into both. Single SQL query: SELECT ... FROM note JOIN folder ON folder.id = note.folderId.

including(optional:)

When the relationship is optional:

struct NoteWithFolder: Codable, FetchableRecord {
    var note: Note
    var folder: Folder?
}

let results = try Note
    .including(optional: Note.folder)
    .asRequest(of: NoteWithFolder.self)
    .fetchAll(db)

Folder? (optional). Notes without a folder still come back, with folder = nil. Single query: SELECT ... FROM note LEFT JOIN folder ON folder.id = note.folderId.

Loading multiple associations

struct NoteFull: Codable, FetchableRecord {
    var note: Note
    var folder: Folder?
    var author: User
}

let results = try Note
    .including(optional: Note.folder)
    .including(required: Note.author)
    .asRequest(of: NoteFull.self)
    .fetchAll(db)

Two associations, both joined in the same query. asRequest(of: NoteFull.self) decodes into the multi-part struct.

Loading hasMany — to a list

For one-to-many, the eager load returns a list of related records per primary record:

struct FolderWithNotes: Codable, FetchableRecord {
    var folder: Folder
    var notes: [Note]
}

let results = try Folder
    .including(all: Folder.notes)
    .asRequest(of: FolderWithNotes.self)
    .fetchAll(db)

for folderInfo in results {
    print("\(folderInfo.folder.name): \(folderInfo.notes.count) notes")
}

including(all:) loads all related records. GRDB does this with two queries (one for folders, one for notes), and assembles the structure in memory.

Loading hasMany through

struct NoteWithTags: Codable, FetchableRecord {
    var note: Note
    var tags: [Tag]
}

let notesWithTags = try Note
    .including(all: Note.tags)
    .asRequest(of: NoteWithTags.self)
    .fetchAll(db)

For many-to-many, GRDB issues queries against the join table to load all related tags efficiently — typically one extra query for all the tags at once.

Filtered associations

You can filter the related records during loading:

let recentTagOnly = Note.tags.filter(Column("createdAt") > Date().addingTimeInterval(-86400))

let notesWithRecentTags = try Note
    .including(all: recentTagOnly)
    .asRequest(of: NoteWithTags.self)
    .fetchAll(db)

The filter applies during the related-records load — only “recent” tags come back.

Combining including, joining, filtering

A typical real-world query: “notes with their folder, where the folder name matches a search.”

let results = try Note
    .including(required: Note.folder.filter(Column("name").like("Work%")))
    .asRequest(of: NoteWithFolder.self)
    .fetchAll(db)

The filter is on the JOINed folder. Only notes whose folder name matches come back. Single query.

joining — filter without loading

If you need to filter by an association without loading it:

let workNotes = try Note
    .joining(required: Note.folder.filter(Column("name").like("Work%")))
    .fetchAll(db)

joining adds the JOIN for filtering but doesn’t include the folder in the result. Faster than including when you don’t need the related data.

Avoiding the N+1 in lists

A list view that shows note title and folder name:

struct NoteListItem: Codable, FetchableRecord {
    var note: Note
    var folder: Folder?
}

func notesForListView(in db: Database) throws -> [NoteListItem] {
    try Note
        .order(Column("createdAt").desc)
        .including(optional: Note.folder)
        .asRequest(of: NoteListItem.self)
        .fetchAll(db)
}

Single query. Each result row has both the note and (optionally) its folder. The list UI displays them without further queries.

Aggregation across associations

For “folders with their note count”:

struct FolderWithNoteCount: Codable, FetchableRecord {
    var folder: Folder
    var noteCount: Int
}

let request = Folder
    .annotated(with: Folder.notes.count.forKey("noteCount"))
    .asRequest(of: FolderWithNoteCount.self)

let results = try request.fetchAll(db)

annotated(with:) adds a computed column to the result. Folder.notes.count produces a COUNT(notes.id) over the joined notes. The result has the folder + the count.

Filtering by aggregate

To find folders with at least 5 notes:

let activeFolders = try Folder
    .having(Folder.notes.count >= 5)
    .fetchAll(db)

having on associations filters by aggregates. Generates HAVING COUNT(notes.id) >= 5.

Loading nested associations

struct DeepNote: Codable, FetchableRecord {
    var note: Note
    var folder: FolderWithParent?

    struct FolderWithParent: Codable, FetchableRecord {
        var folder: Folder
        var parent: Folder?
    }
}

let request = Note
    .including(optional: Note.folder.including(optional: Folder.parent))
    .asRequest(of: DeepNote.self)

You can chain including to traverse multiple levels. The result struct nests accordingly.

Performance considerations

including(required:) and including(optional:) produce a single SQL JOIN. Fast.

including(all:) produces an additional query per association (the GRDB engine batches them efficiently). For one note → many tags, that’s one query for the notes plus one query for all the tags across all notes. Not N+1; just N+M (where M is the number of associations loaded).

Always profile with EXPLAIN QUERY PLAN. If a JOIN is doing a full scan, ensure indexes exist on the foreign-key columns. Without an index on note.folderId, the JOIN can be slow.

Limit interactions

Be careful with limit and including(all:):

// This applies the limit to the OUTER query (notes), not the inner (tags)
Note.including(all: Note.tags).limit(10).fetchAll(db)
// Returns: 10 notes, each with all their tags. ✓

// To limit tags per note: would require window functions, beyond the interface

For “10 notes with their tags,” the above works. For “each note with at most 5 tags,” you need a more advanced query (CTE or post-processing).

Pitfalls

Decoding mismatches. If your NoteWithFolder struct doesn’t match the columns the join produces, decoding fails. Ensure the struct matches.

Forgetting asRequest(of:). Without it, the request is typed as Note and the join data is lost when fetched.

Required vs optional confusion. including(required:) excludes records without the relation. If you wanted them included, use optional.

Performance with large hasMany. Loading all 10,000 tags for 100 notes might be slow. Filter or paginate if the relation is potentially huge.

Circular relations. Folder → notes → folder → notes → ... doesn’t auto-load infinitely, but if you try to express it via nested includes, you’ll hit the depth limit (varies). Don’t go more than 2-3 levels.

What to internalize

including(required:) JOINs and includes related records. including(optional:) LEFT JOINs. including(all:) loads many-to-many or one-to-many lists. Filter associations (Note.folder.filter(...)) to constrain the join. joining filters without loading. annotated(with: relation.count) adds aggregate columns. Decode into multi-part structs via asRequest(of:). Always have indexes on foreign keys for fast JOINs.


16. Saving Records: insert, update, save, upsert

We’ve used insert, update, and save throughout. Now let’s slow down and look at the semantics of each — when to use which, what they do under the hood, and the hooks that let you customize behavior.

The four operations

A record conforming to MutablePersistableRecord has these instance methods:

  • insert(db) — INSERT a new row. The record’s primary key is populated (via didInsert) for autoincrement tables.
  • update(db) — UPDATE an existing row, looked up by primary key. Throws RecordError.recordNotFound if no row matches.
  • save(db) — Convenience: tries update; if it throws record-not-found, falls back to insert.
  • upsert(db) — INSERT, or UPDATE on conflict (using a unique constraint). Single SQL statement.

Pick by intent:

  • “I’m creating a new thing” → insert.
  • “I’m modifying an existing thing” → update.
  • “I might be creating or updating, just put it in the database” → save.
  • “Insert it, but if it conflicts on a unique key, update instead” → upsert.

insert(db)

var player = Player(id: nil, name: "Alice", score: 100, createdAt: Date())
try player.insert(db)
print(player.id)  // now populated, e.g. 1

The id was nil before; after insert, it’s the autoincrement value SQLite assigned. This works because Player implements didInsert to capture the rowID:

mutating func didInsert(_ inserted: InsertionSuccess) {
    id = inserted.rowID
}

Without that hook, the insert succeeds but the local struct’s id stays nil — so subsequent update calls would fail with no-key-to-look-up.

For records where you generate IDs yourself (UUIDs):

struct Note: Codable, FetchableRecord, PersistableRecord {
    var id: UUID
    var title: String

    init(title: String) {
        self.id = UUID()
        self.title = title
    }
}

let note = Note(title: "Hello")
try note.insert(db)
// id was already set; no didInsert needed

PersistableRecord (without Mutable) is for records whose insert doesn’t need to mutate. The trade-off: you can’t use autoincrement IDs.

update(db)

var player = try Player.fetchOne(db, key: 1)!
player.score = 200
try player.update(db)

update runs UPDATE player SET name = ?, score = ?, ... WHERE id = ?. Every column gets updated (whether you changed it or not) — there’s no “only changed columns” detection at this level.

If no row with that primary key exists:

do {
    try player.update(db)
} catch let error as RecordError where error.kind == .recordNotFound {
    // Row was deleted between fetch and update — handle accordingly
}

This is a TOCTOU situation: between fetching the player and updating, someone (another thread, another process) could have deleted it. For most apps this is rare; for high-contention scenarios, design for it.

save(db)

The “do whatever” form:

var player = Player(id: someId, name: "Alice", score: 100, createdAt: Date())
try player.save(db)  // updates if id exists, inserts if not

Use save when you don’t care about the distinction — for example, when syncing a record that the server says has a known ID, but your local DB might or might not have it yet.

save is a convenience that tries update first and falls back to insert. It’s not atomic in the strict sense — between the failed update and the insert, conditions could change. But within a transaction (the default for dbPool.write), the two queries run together and you’re safe.

upsert(db)

The single-statement insert-or-update:

let player = Player(id: 42, name: "Alice", score: 100, createdAt: Date())
try player.upsert(db)

Generates INSERT INTO player (...) VALUES (...) ON CONFLICT(id) DO UPDATE SET .... One round-trip to SQLite. If no row with id=42 exists, it’s inserted. If one does, it’s updated.

For this to work, the record’s primary key (or a unique column) must be the conflict target. SQLite figures this out from the schema.

upsert is preferred over save when you know the record always either matches or fits cleanly via a unique constraint. It’s atomic in a single statement and slightly faster.

Insert-and-fetch

After insert, you might want the just-inserted record (perhaps with default values or trigger-computed columns):

let inserted = try player.insertAndFetch(db)
// inserted is Player? — the freshly-fetched record after insert

insertAndFetch does the insert, then re-fetches by primary key. Useful when the database fills in fields the Swift side didn’t set.

Same for update:

let updated = try player.updateAndFetch(db)

For most records, you don’t need this — your in-memory copy is already accurate. But for records with computed columns, generated columns, or trigger-set values, insertAndFetch ensures your local copy reflects what’s in the database.

delete(db)

Removing a record:

try player.delete(db)

Returns Booltrue if a row was deleted, false if no row matched. Useful for “did I actually delete something?” checks.

For bulk deletes, use the query interface:

try Player.filter(Column("score") < 50).deleteAll(db)

Single SQL statement; faster than fetching and deleting one at a time.

Hooks: willInsert, didInsert, willUpdate, willSave

Records can hook into the persistence lifecycle:

struct Player: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var name: String
    var score: Int
    var createdAt: Date
    var updatedAt: Date

    mutating func willInsert(_ db: Database) throws {
        // Called just before the INSERT
        if createdAt == .distantPast {
            createdAt = Date()
        }
    }

    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }

    mutating func willUpdate(_ db: Database, columns: Set<String>) throws {
        // Called just before UPDATE
        updatedAt = Date()
    }

    mutating func willSave(_ db: Database) throws {
        // Called before insert OR update (whichever happens)
    }
}

These hooks let you add cross-cutting logic — auto-setting timestamps, validation, derived fields — close to the data definition.

willInsert and willUpdate can throw to abort the operation. didInsert runs after a successful insert and is your chance to capture the new ID.

Validating before save

Hooks can validate:

mutating func willSave(_ db: Database) throws {
    guard !name.isEmpty else {
        throw ValidationError.nameRequired
    }
    guard score >= 0 else {
        throw ValidationError.invalidScore
    }
}

If validation fails, the save throws and (because we’re in a transaction) any other changes in the same transaction roll back too.

For complex validation (cross-record checks, fetching related data), the hook receives a Database, so you can query.

Bulk operations

For inserting many records efficiently:

try await dbPool.write { db in
    for player in players {
        var player = player
        try player.insert(db)
    }
}

The whole loop is one transaction (one write closure = one transaction by default). All inserts commit together or roll back together. SQLite handles batches in a single transaction efficiently.

For very large bulk operations, see section 29 on performance — prepared statements, periodic checkpoints, etc.

Saving with explicit columns

Sometimes you only want to update specific columns:

try player.update(db, columns: ["score", "updatedAt"])

The other columns aren’t touched. Useful when:

  • You have stale data in your struct for some fields.
  • You want to avoid unintentionally overwriting other-process changes.

The query becomes UPDATE player SET score = ?, updatedAt = ? WHERE id = ?.

Pitfalls

Forgetting didInsert. Without it, the in-memory struct doesn’t have the new ID. Subsequent operations (update, fetch by id) fail because the id is nil.

Update hooks running on save-as-insert. willSave runs for both insert and update; willUpdate runs only for update. Pick the right hook.

Validating in willInsert only. Forgetting to also validate in willUpdate means updates skip validation.

save semantics. Save tries update first. For a brand-new record (id nil), update fails immediately, then insert succeeds. For records with known IDs, save works correctly. But if you have a record with an ID set to something that doesn’t exist in the DB, save will insert, which might create unexpected behavior.

Forgetting to capture inserted records. If you insert and don’t capture the result, you lose information (the new ID). Use var and let didInsert mutate.

Atomicity assumptions. Outside a transaction, save (which is two operations) isn’t atomic. Always wrap in write.

What to internalize

insert, update, save, upsert — pick by intent. didInsert populates the autoincrement ID. save is convenient; upsert is atomic in one statement. Hooks (willInsert, willUpdate, willSave, didInsert) attach logic to the persistence lifecycle. Use bulk operations within a single transaction for efficiency. Update specific columns when you don’t want to overwrite everything.


17. Conflict Resolution and onConflict

When you INSERT a row that violates a unique constraint, SQLite has to decide what to do. By default, it raises an error and the insert fails. But SQLite supports several conflict resolution strategies — REPLACE, IGNORE, ABORT, FAIL, ROLLBACK — that change this behavior. GRDB exposes them, and used right, they make certain patterns elegant.

The five strategies

When a UNIQUE, NOT NULL, CHECK, or PRIMARY KEY constraint is violated:

  • ABORT (default) — current statement is rolled back; the surrounding transaction continues. Throws an error.
  • ROLLBACK — the entire transaction is rolled back. Throws an error.
  • FAIL — the current statement stops at the offending row. Already-inserted rows in this statement remain. Throws an error.
  • IGNORE — the offending row is skipped silently. The statement continues with the next row. No error.
  • REPLACE — the offending row replaces the existing row. The statement continues. No error.

For most operations, ABORT (the default) is what you want. The error tells you something went wrong; the transaction can decide how to recover. The other strategies are useful for specific cases.

Strategy at the schema level

You can declare a default conflict strategy on a column:

try db.create(table: "tag") { t in
    t.autoIncrementedPrimaryKey("id")
    t.column("name", .text).notNull().unique(onConflict: .ignore)
}

Now INSERT INTO tag (name) VALUES ('foo') — if ‘foo’ already exists, the insert is silently ignored. The existing row remains.

This is sometimes useful for “insert if new” semantics. But note that the behavior is hidden in the schema; readers of the application code don’t see it. Document well.

Strategy at the statement level

You can also specify per-statement:

try db.execute(sql: "INSERT OR IGNORE INTO tag (name) VALUES (?)", arguments: ["foo"])
try db.execute(sql: "INSERT OR REPLACE INTO tag (name) VALUES (?)", arguments: ["foo"])

OR IGNORE and OR REPLACE are inline overrides. They beat the schema-level default for this statement.

IGNORE for “create if doesn’t exist”

A common pattern: insert a row only if it doesn’t already exist (based on a unique key).

try db.execute(sql: "INSERT OR IGNORE INTO tag (name) VALUES (?)", arguments: ["important"])

If “important” already exists, do nothing. If not, insert. Either way, no error, no transaction rollback.

After this, you might want to know whether you inserted or skipped:

let beforeCount = try Tag.fetchCount(db)
try db.execute(sql: "INSERT OR IGNORE INTO tag (name) VALUES (?)", arguments: ["important"])
let afterCount = try Tag.fetchCount(db)
let didInsert = afterCount > beforeCount

Or check db.changesCount1 if a row was inserted, 0 if ignored.

REPLACE for “insert or overwrite”

try db.execute(sql: """
    INSERT OR REPLACE INTO setting (key, value) VALUES (?, ?)
    """, arguments: ["theme", "dark"])

If a setting with key=’theme’ exists, it’s deleted and the new row inserted. If not, the new row is inserted.

REPLACE has a subtle gotcha: it deletes the conflicting row before inserting the new one. This triggers any DELETE triggers on the table, and ON DELETE CASCADE on related tables. If your “theme” setting has child rows pointing to it, those children get cascaded deletes — even though semantically you’re “updating” the theme.

For settings-style tables with no foreign-key dependents, REPLACE is fine. For records with relationships, prefer UPSERT.

UPSERT — the modern way

SQLite 3.24+ supports UPSERT syntax: INSERT ... ON CONFLICT ... DO UPDATE SET .... This is more powerful than REPLACE because it actually updates rather than delete-and-insert:

try db.execute(sql: """
    INSERT INTO setting (key, value)
    VALUES (?, ?)
    ON CONFLICT(key) DO UPDATE SET value = excluded.value
    """, arguments: ["theme", "dark"])

If a row with key=’theme’ exists, its value is updated to the new value. If not, a new row is inserted. Either way, no row is deleted. Triggers and cascades behave as you’d expect.

GRDB exposes this via upsert:

let setting = Setting(key: "theme", value: "dark")
try setting.upsert(db)

Behind the scenes, GRDB generates the appropriate INSERT … ON CONFLICT … DO UPDATE statement. By default, it updates all the non-primary-key columns to the new values.

Conditional UPSERT — only update some columns

try db.execute(sql: """
    INSERT INTO player (id, name, score)
    VALUES (?, ?, ?)
    ON CONFLICT(id) DO UPDATE SET score = MAX(score, excluded.score)
    """, arguments: [1, "Alice", 100])

If id=1 exists, score is set to the max of the current score and the proposed new score. The name is not updated. The excluded virtual table refers to “the row that would have been inserted.”

This is “only update if higher” semantics — useful for high-score updates, last-seen timestamps, etc.

Conflict on multiple columns

For composite unique constraints:

// Schema:
try db.create(table: "noteTag") { t in
    t.column("noteId", .integer).notNull()
    t.column("tagId", .integer).notNull()
    t.primaryKey(["noteId", "tagId"])
}

// Insert with conflict resolution:
try db.execute(sql: """
    INSERT OR IGNORE INTO noteTag (noteId, tagId) VALUES (?, ?)
    """, arguments: [noteId, tagId])

Adding a tag to a note becomes idempotent — re-running doesn’t error.

Trade-offs

  • IGNORE: silent. No way to distinguish “inserted” from “skipped” except by checking changeCount.
  • REPLACE: blunt. Deletes-then-inserts; cascades fire.
  • UPSERT: precise. Real updates; cascades don’t fire unless you explicitly delete; can update specific columns.

For new code, prefer UPSERT (or GRDB’s upsert). REPLACE and IGNORE are fine for specific patterns where their semantics fit.

Per-record conflict resolution

When using record.insert(db), the conflict policy is whatever’s declared in the schema or the record’s persistenceConflictPolicy:

struct Tag: Codable, FetchableRecord, MutablePersistableRecord {
    var id: Int64?
    var name: String

    static let persistenceConflictPolicy = PersistenceConflictPolicy(insert: .ignore)
}

Now tag.insert(db) uses INSERT OR IGNORE by default. Subsequent inserts of duplicate names are silently skipped.

For finer control, use raw SQL.

Inside a transaction

ABORT and FAIL roll back only the current statement. ROLLBACK rolls back the entire transaction. This is sometimes confusing:

try await dbPool.write { db in
    try player1.save(db)

    do {
        try player2.save(db)  // suppose this fails due to constraint violation (ABORT)
    } catch {
        // The error is caught here; player1 is still inserted.
        // The outer transaction commits when this closure returns.
    }
}

If the violation triggers ROLLBACK instead, the entire transaction would roll back — player1’s insert too. Plan accordingly.

When to use which

  • Default (ABORT): most operations. Errors propagate; transactions roll back; you handle.
  • IGNORE: idempotent inserts where duplicates are expected and harmless.
  • REPLACE: simple key-value tables with no foreign-key dependents. Use rarely; UPSERT is usually better.
  • UPSERT (via upsert or explicit ON CONFLICT): when you want true insert-or-update semantics on a unique key.
  • ROLLBACK: rarely. For invariant violations where you want the whole transaction to abort even if caller catches.
  • FAIL: rarely. Bulk-statement scenarios where you want to stop at the first error but keep prior rows.

Pitfalls

REPLACE deleting child rows. Cascades fire on the implicit delete. Use UPSERT instead.

IGNORE hiding bugs. A “duplicate insert that’s silently ignored” might be a sign of a bug. Don’t reflexively use IGNORE; understand why duplicates are happening.

Schema-level conflict policies hiding logic. Declaring unique(onConflict: .replace) makes inserts auto-replace, but readers of your code won’t know without checking the schema. Document explicitly.

Forgetting that ROLLBACK rolls back the whole transaction. A constraint violation under ROLLBACK aborts everything, even unrelated work. Check policy.

Conflict targets and composite keys. ON CONFLICT(col) requires a unique constraint on that column. For composite keys, ON CONFLICT(col1, col2) requires the composite unique constraint.

What to internalize

Conflict resolution strategies: ABORT (default), IGNORE, REPLACE, FAIL, ROLLBACK. Specify per-statement (INSERT OR IGNORE) or in the schema. UPSERT (INSERT … ON CONFLICT … DO UPDATE) is the modern, precise way. GRDB’s upsert(db) generates this. Prefer UPSERT over REPLACE because REPLACE deletes-and-inserts (firing cascades). IGNORE for idempotent inserts. Document when you use non-default policies.


18. Indexes: Single, Composite, Partial, Expression

Indexes are how SQLite (and any database) makes queries fast. Without indexes, a WHERE name = 'Alice' scans every row in the table. With an index on name, it’s a tree lookup — milliseconds even at millions of rows.

Knowing what to index and how is the difference between a fast app and a sluggish one.

Why indexes matter

A SQLite table is, conceptually, a heap of rows. Each query that filters has to either:

  • Scan every row (a “table scan”), OR
  • Use an index to jump directly to matching rows.

With no indexes, every WHERE clause is a scan. For a 100-row table, fast. For a 100,000-row table, terrible.

An index is a separate B-tree structure mapping values of one (or more) columns to row IDs. Lookup in a B-tree is O(log n) — extremely fast, even for millions of rows.

The trade-off: indexes take space and slow down writes. Each insert/update has to update the indexes. For most apps, the read speed gain dwarfs the write cost. Index columns you query frequently.

Creating an index

In a migration:

migrator.registerMigration("addPlayerNameIndex") { db in
    try db.create(index: "playerByName", on: "player", columns: ["name"])
}

Now WHERE name = 'Alice' on the player table uses the index. Fast.

The index name (playerByName) is just an identifier — SQLite uses it to refer to the index in error messages and EXPLAIN output. Pick a name that describes intent: <table>By<column> is a useful convention.

Inline indexes in column definitions

You can also declare an index when creating a column:

try db.create(table: "player") { t in
    t.autoIncrementedPrimaryKey("id")
    t.column("name", .text).notNull().indexed()  // creates index on name
    t.column("score", .integer).notNull()
}

indexed() creates an index named like index_player_on_name. Convenient for the common case.

Composite indexes

For queries that filter on multiple columns:

try db.create(index: "noteByFolderAndUpdatedAt", on: "note", columns: ["folderId", "updatedAt"])

This is a composite index on (folderId, updatedAt). It accelerates:

  • WHERE folderId = ? (uses index)
  • WHERE folderId = ? AND updatedAt > ? (uses index for both)
  • WHERE folderId = ? ORDER BY updatedAt DESC (uses index for filter and sort!)

But not:

  • WHERE updatedAt > ? (not the leading column — index isn’t used)

The order of columns in the index matters. The leading column (folderId) can be filtered alone. The trailing column (updatedAt) can only be used after filtering on the leading column.

Rule of thumb: put equality filters first, range filters last. Put high-cardinality columns first.

Indexes for ORDER BY

An index on a column allows ORDER BY on that column without sorting in memory:

CREATE INDEX playerByScore ON player(score);
SELECT * FROM player ORDER BY score DESC;  -- uses index

For descending order, SQLite walks the index backwards. For complex ORDER BY (ORDER BY col1, col2), a composite index (col1, col2) helps.

CREATE INDEX playerByTeamAndScore ON player(teamId, score);
SELECT * FROM player WHERE teamId = ? ORDER BY score DESC;
-- uses index for both filter and sort

Unique indexes

For uniqueness constraints that aren’t part of the primary key:

try db.create(index: "userByEmail", on: "user", columns: ["email"], options: .unique)

Or, if declared at column-creation time:

t.column("email", .text).notNull().unique()

Unique indexes both enforce uniqueness (failed insert on duplicate) and accelerate lookups.

Partial indexes

Sometimes you only care about a subset:

try db.create(
    index: "activePlayerByName",
    on: "player",
    columns: ["name"],
    condition: Column("status") == "active"
)

The index only contains rows where status = 'active'. Smaller than a full index; faster for queries that filter on status = 'active'.

SELECT * FROM player WHERE status = 'active' AND name LIKE 'A%';
-- uses activePlayerByName

Useful when most rows match a common filter (e.g., 90% of players are active, you almost always query active players, the index is much smaller).

Expression indexes

Index on a function of a column:

try db.create(
    index: "playerByLowercasedName",
    on: "player",
    expressions: [Column("name").lowercased]
)

Now WHERE LOWER(name) = 'alice' uses the index. Useful for case-insensitive searches.

For more complex expressions, raw SQL:

try db.execute(sql: """
    CREATE INDEX postByYear ON post(strftime('%Y', createdAt))
    """)
// Now WHERE strftime('%Y', createdAt) = '2024' uses the index

COLLATE NOCASE indexes

For case-insensitive equality (without lowercasing in queries):

try db.create(table: "player") { t in
    t.column("name", .text).notNull().collate(.nocase)
}
try db.create(index: "playerByName", on: "player", columns: ["name"])

The column has NOCASE collation. The index inherits it. WHERE name = 'alice' matches ‘Alice’, ‘ALICE’, etc., and uses the index.

When NOT to index

  • Tables with very few rows. A table scan on 50 rows is faster than an index lookup. SQLite’s optimizer often picks the scan anyway.
  • Columns with low cardinality. A boolean column (active/inactive) with 50/50 distribution doesn’t benefit much; the index doesn’t filter much. (Unless paired with a more selective column in a composite.)
  • Write-heavy tables. Each insert updates each index. For tables that are mostly written, fewer indexes is better.
  • Columns you never filter or sort on. Indexes you don’t use cost write performance for nothing.

EXPLAIN QUERY PLAN

Always verify your indexes are used:

let plan = try Row.fetchAll(db, sql: """
    EXPLAIN QUERY PLAN
    SELECT * FROM player WHERE name = ? AND score > ?
    """, arguments: ["Alice", 50])

for row in plan {
    print(row)
}

The output looks like:

SEARCH player USING INDEX playerByName (name=?)

SEARCH + USING INDEX is good. SCAN is a full table scan — bad for large tables.

If you see SCAN and you expected an index, check:

  • The index exists (run PRAGMA index_list(player)).
  • The query uses the leading column of the index (for composite indexes).
  • The query’s predicates are simple comparisons, not complex expressions.

Indexes and JOINs

JOINs use indexes too. For:

SELECT * FROM note JOIN folder ON note.folderId = folder.id

You want indexes on both note.folderId and folder.id. The latter is automatic (primary key). The former needs explicit indexing:

t.column("folderId", .integer).references("folder").indexed()

Without an index on folderId, the JOIN does a scan of note for each folder — disaster on large tables.

Inspecting existing indexes

PRAGMA index_list('player');
PRAGMA index_info('playerByName');

index_list shows all indexes on a table. index_info shows the columns in a specific index. Use these to debug “is the index I created actually there?”

Adding indexes after the fact

If you discover a slow query in production, you can ship a migration to add an index:

migrator.registerMigration("addNoteSearchIndex") { db in
    try db.create(index: "noteByTitleAndCreatedAt", on: "note",
                  columns: ["title", "createdAt"])
}

The migration runs once on each user’s device. Building the index can take time on large tables — but it happens once, then queries are fast forever after.

Pitfalls

Indexing every column “just in case.” Wastes space, slows writes, doesn’t necessarily help.

Composite index column order. (a, b) doesn’t help queries that filter only on b. Order matters.

Forgetting to index foreign keys. Especially for tables you JOIN frequently. The “child” side’s foreign key column should be indexed.

Indexes on small tables. Net negative if the table is tiny. SQLite’s optimizer might ignore the index anyway.

Functions in WHERE that defeat the index. WHERE LOWER(name) = ? doesn’t use an index on name (only on an expression index for LOWER(name)). Match the predicate to what’s indexed.

Indexes on heavily updated columns. A counter column updated thousands of times per minute, with an index, slows writes considerably. Index only for queries that need it.

What to internalize

Indexes are how queries become fast. Index columns you frequently filter on, sort by, or JOIN on. Composite indexes help multi-column predicates — leading column matters most. Partial indexes for “subset of rows we always query.” Expression indexes for computed predicates. Verify with EXPLAIN QUERY PLAN. Watch out for index cost in write-heavy tables.


19. Full-Text Search with FTS5

For real text search — “find documents matching this query” — LIKE '%word%' doesn’t scale and doesn’t rank results. SQLite’s FTS5 (Full-Text Search 5) is a sophisticated text-indexing extension built into SQLite, exposed by GRDB. It does tokenization, stemming, ranking with BM25, prefix searches, and phrase queries.

Setting up FTS5 is a few lines. Querying it is a few more. The feature is dramatic for any app with searchable user content.

What FTS5 provides

  • Tokenization — words are extracted from text using configurable tokenizers (Unicode-aware, Porter stemmer for English, custom tokenizers).
  • Inverted index — maps each token to the documents containing it. Lookups are O(log n) per token.
  • MATCH operator — replaces LIKE for full-text queries.
  • BM25 ranking — orders results by relevance to the query.
  • Phrase and prefix queries"quick brown" (phrase), quick* (prefix).
  • Boolean operators — AND, OR, NOT.
  • Column-restricted searches — search only specific columns.

If you’ve used Lucene or Elasticsearch, FTS5 is a smaller, embedded version of similar concepts.

Creating an FTS5 table

FTS5 tables are virtual tables — they’re indexed in a special way for text search. In a migration:

migrator.registerMigration("createNoteSearch") { db in
    try db.create(virtualTable: "noteSearch", using: FTS5()) { t in
        t.column("title")
        t.column("body")
    }
}

This creates a virtual table with two text columns. SQLite will tokenize the text and maintain the inverted index automatically.

You can also specify the tokenizer:

try db.create(virtualTable: "noteSearch", using: FTS5()) { t in
    t.tokenizer = .porter()  // Porter stemmer for English
    t.column("title")
    t.column("body")
}

Or unicode61 (Unicode-aware, no stemming), ascii (plain ASCII), or custom.

Inserting into an FTS5 table

You can insert directly:

try db.execute(sql: """
    INSERT INTO noteSearch (title, body) VALUES (?, ?)
    """, arguments: ["Hello world", "This is a test note"])

But this gives you a separate table from your main note table — duplicating data. For most apps, you want FTS5 to index an existing table without duplicating storage.

External-content FTS5

The recommended pattern: a “content table” approach where FTS5 only stores the index, not the text:

migrator.registerMigration("createNote") { db in
    try db.create(table: "note") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("title", .text).notNull()
        t.column("body", .text).notNull()
        t.column("createdAt", .datetime).notNull()
    }

    try db.create(virtualTable: "noteSearch", using: FTS5()) { t in
        t.tokenizer = .porter()
        t.synchronize(withTable: "note")  // FTS5 mirrors the note table
        t.column("title")
        t.column("body")
    }
}

t.synchronize(withTable: "note") sets up triggers so that any INSERT, UPDATE, or DELETE on note is reflected in noteSearch. You don’t have to maintain the FTS table manually.

After this, every note’s title and body is automatically searchable.

Searching with MATCH

Now query:

let results = try Row.fetchAll(db, sql: """
    SELECT note.* FROM note
    JOIN noteSearch ON noteSearch.rowid = note.id
    WHERE noteSearch MATCH ?
    """, arguments: ["hello world"])

The MATCH operator searches the FTS5 index. "hello world" finds rows containing both “hello” and “world” (default AND).

For phrase search:

"\"quick brown fox\""  // exact phrase (note the escaped quotes)

For prefix:

"qui*"  // matches quick, quiet, quill, etc.

For boolean:

"hello AND world"
"hello OR world"
"hello NOT world"
"(hello OR hi) AND world"

Ranking with BM25

FTS5 includes BM25 ranking — a state-of-the-art relevance algorithm:

let results = try Row.fetchAll(db, sql: """
    SELECT note.*, bm25(noteSearch) AS rank FROM note
    JOIN noteSearch ON noteSearch.rowid = note.id
    WHERE noteSearch MATCH ?
    ORDER BY rank
    """, arguments: ["search query"])

bm25(noteSearch) returns a relevance score (lower = more relevant). Sorting by rank gives results in relevance order.

You can weight columns:

"... ORDER BY bm25(noteSearch, 10.0, 1.0)"

Weights for (title, body). A title match counts 10x as much as a body match.

To search only the title:

"title:swift"

Only matches rows with “swift” in the title.

"title:swift body:programming"

“swift” in title AND “programming” in body.

Fetching matching records

Define a fetchable type:

struct Note: Codable, FetchableRecord {
    var id: Int64?
    var title: String
    var body: String
    var createdAt: Date
}

func searchNotes(_ query: String, in db: Database) throws -> [Note] {
    try Note.fetchAll(db, sql: """
        SELECT note.* FROM note
        JOIN noteSearch ON noteSearch.rowid = note.id
        WHERE noteSearch MATCH ?
        ORDER BY bm25(noteSearch)
        """, arguments: [query])
}

Returns notes ranked by relevance.

GRDB’s FTS5 helpers

GRDB has helpers to build FTS5 queries with Swift:

let pattern = FTS5Pattern(matchingAllPrefixesIn: "swft")
// Builds a pattern that matches any token starting with "swft"

let results = try Note.fetchAll(db, sql: """
    SELECT note.* FROM note
    JOIN noteSearch ON noteSearch.rowid = note.id
    WHERE noteSearch MATCH ?
    """, arguments: [pattern])

FTS5Pattern constructors:

  • matchingAnyTokenIn(_:) — matches if any token in the input is in the document.
  • matchingAllTokensIn(_:) — matches if all tokens are in the document (default AND).
  • matchingPhrase(_:) — exact phrase.
  • matchingAllPrefixesIn(_:) — prefix match for each token. Useful for “search-as-you-type.”

For an instant-search UI, matchingAllPrefixesIn is the natural choice — typing “swft” finds “swift”, “swifty”, “swiftui”, etc.

Tokenizers

Tokenizer choice affects search behavior:

  • unicode61 — default. Splits by Unicode word boundaries. Case-folding for ASCII; doesn’t stem.
  • porter — English stemmer. “running” matches “run”. Good for English content.
  • ascii — plain ASCII. Smaller, faster, but misses Unicode word boundaries.
  • Custom — implement FTS5CustomTokenizer for special needs (CJK languages, code search, etc.).

Pick based on language and use case. Porter for English search where stemming is wanted; unicode61 for general-purpose mixed content.

Highlighting matches

FTS5 supports highlighting:

let results = try Row.fetchAll(db, sql: """
    SELECT highlight(noteSearch, 0, '<b>', '</b>') as titleHighlighted,
           snippet(noteSearch, 1, '<b>', '</b>', '...', 30) as bodySnippet,
           note.id
    FROM note
    JOIN noteSearch ON noteSearch.rowid = note.id
    WHERE noteSearch MATCH ?
    """, arguments: [query])

highlight wraps matched terms in tags. snippet returns a short snippet of the body around matches with ... ellipsis. Useful for displaying search results with bolded terms.

The column index (0 for first FTS column, 1 for second) tells which column to operate on.

Updating the index

With synchronize(withTable:), you don’t have to update the FTS table manually — triggers keep it in sync. INSERT/UPDATE/DELETE on note propagate to noteSearch.

For a one-time rebuild (e.g., after changing the tokenizer):

try db.execute(sql: "INSERT INTO noteSearch(noteSearch) VALUES('rebuild')")

This is the FTS5 special “rebuild” command.

Performance

FTS5 is fast. A million-document index queries in milliseconds. The index size is roughly proportional to the unique-token count — typically a fraction of the source text size.

The build cost: adding FTS5 to an existing table requires building the index. For 100,000 rows, this is a few seconds during the migration. After, every insert/update incurs a small index update.

Pitfalls

Forgetting synchronize(withTable:). Without it, the FTS table is independent — you have to maintain it manually. Easy to forget and end up with stale results.

Tokenizer mismatch. If you create the FTS table with porter but query with terms that the porter tokenizer would stem, but you’re querying for unstemmed forms, you might miss results. Test with realistic queries.

MATCH syntax errors. A user-typed query like hello AND is invalid FTS5 syntax. Sanitize user input or use FTS5Pattern constructors that handle this.

Mixing MATCH and other predicates. SQLite can be picky about combining MATCH with regular WHERE clauses. Sometimes you need to restructure as a JOIN or sub-query.

Index size. FTS5 indexes can be substantial for large text. If your app has multi-megabyte indexes, that’s the trade-off for fast search.

What to internalize

FTS5 is SQLite’s full-text search. Create as a virtual table; use synchronize(withTable:) for automatic indexing. Query with MATCH; rank with bm25; sort by relevance. Use column-restricted, phrase, prefix, boolean queries. FTS5Pattern.matchingAllPrefixesIn(_:) for instant search. Choose tokenizer (porter for English, unicode61 general). Use highlight and snippet for UI.


20. Triggers, Views, and Generated Columns

SQLite supports several schema features beyond plain tables — triggers (run code on insert/update/delete), views (read-only virtual tables based on queries), and generated columns (computed values stored or virtual). GRDB exposes them. Used judiciously, they put logic where it belongs (close to the data) and remove some classes of bugs.

Triggers

A trigger runs SQL when a specified event happens on a table. Common uses:

  • Audit logging — every change to user is recorded in userAuditLog.
  • Maintaining denormalized data — when a lineItem is inserted, increment order.itemCount.
  • Validation — block inserts that violate business rules beyond what CHECK constraints can express.

A simple trigger:

migrator.registerMigration("addUserAuditTrigger") { db in
    try db.create(table: "userAudit") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("userId", .integer).notNull()
        t.column("action", .text).notNull()  // 'insert', 'update', 'delete'
        t.column("oldName", .text)
        t.column("newName", .text)
        t.column("at", .datetime).notNull()
    }

    try db.execute(sql: """
        CREATE TRIGGER user_audit_insert
        AFTER INSERT ON user
        BEGIN
            INSERT INTO userAudit (userId, action, newName, at)
            VALUES (NEW.id, 'insert', NEW.name, datetime('now'));
        END;
        """)

    try db.execute(sql: """
        CREATE TRIGGER user_audit_update
        AFTER UPDATE ON user
        BEGIN
            INSERT INTO userAudit (userId, action, oldName, newName, at)
            VALUES (NEW.id, 'update', OLD.name, NEW.name, datetime('now'));
        END;
        """)

    try db.execute(sql: """
        CREATE TRIGGER user_audit_delete
        AFTER DELETE ON user
        BEGIN
            INSERT INTO userAudit (userId, action, oldName, at)
            VALUES (OLD.id, 'delete', OLD.name, datetime('now'));
        END;
        """)
}

Now every change to user is logged in userAudit. The OLD and NEW keywords reference the row before/after the change. INSERT triggers see only NEW; DELETE sees only OLD; UPDATE sees both.

BEFORE vs AFTER triggers

  • AFTER — runs after the operation completes. Most common; you log or update related data.
  • BEFORE — runs before. Can modify NEW values (in INSERT/UPDATE) or block the operation by raising an error.

Example BEFORE trigger:

CREATE TRIGGER user_normalize_email
BEFORE INSERT ON user
FOR EACH ROW
BEGIN
    UPDATE NEW SET email = LOWER(NEW.email);
END;

(Note: SQLite has some limits on what BEFORE triggers can modify; consult the docs for specifics.)

Triggers for denormalization

A pattern: keep a count of related rows updated automatically.

CREATE TRIGGER folder_increment_count
AFTER INSERT ON note
WHEN NEW.folderId IS NOT NULL
BEGIN
    UPDATE folder SET noteCount = noteCount + 1 WHERE id = NEW.folderId;
END;

CREATE TRIGGER folder_decrement_count
AFTER DELETE ON note
WHEN OLD.folderId IS NOT NULL
BEGIN
    UPDATE folder SET noteCount = noteCount - 1 WHERE id = OLD.folderId;
END;

Now folder.noteCount always reflects the current number of notes in the folder. Reading the count is O(1) instead of running a COUNT(*) over notes.

The cost: each note insert/delete also updates the folder. For batch operations, this can add up. But for typical apps, the read speed is worth it.

Trigger gotchas

Performance: triggers add work to every operation. A trigger that does a heavy query on every row insert can dramatically slow inserts.

Recursion: triggers can fire other triggers. If trigger A fires trigger B which fires trigger A again, you have infinite recursion. SQLite has a recursive-trigger setting; GRDB defaults to OFF. Don’t enable unless you know what you’re doing.

Order: multiple triggers on the same event run in unspecified order (in SQLite). If you depend on order, consolidate into one trigger.

Bulk operations: DELETE FROM table with no WHERE clause may, under some optimizations, skip per-row triggers. Use DELETE FROM table WHERE 1=1 or check SQLite’s behavior.

Views

A view is a stored SELECT statement that you query like a table. It doesn’t store data — it computes results from underlying tables when queried.

migrator.registerMigration("addActivePlayerView") { db in
    try db.execute(sql: """
        CREATE VIEW activePlayer AS
        SELECT id, name, score
        FROM player
        WHERE status = 'active' AND score > 0
        """)
}

Now SELECT * FROM activePlayer is equivalent to running the view’s SELECT.

Useful for:

  • Encapsulating common joins or filters: activePlayer hides the WHERE clauses from callers.
  • Schema migration smoothing: a view can present old column names while the underlying table changes.
  • Permission boundaries (less applicable in iOS): views can expose subsets of data.

Querying a view via GRDB

A view is queryable just like a table:

struct ActivePlayer: Codable, FetchableRecord, TableRecord {
    static let databaseTableName = "activePlayer"  // matches the view name

    var id: Int64
    var name: String
    var score: Int
}

let active = try ActivePlayer.fetchAll(db)

The TableRecord conformance treats the view as the source. You can fetch from it; you can use the query interface.

For writing to views, SQLite supports it only for “simple” views (single underlying table, no aggregates). Most views are read-only.

Generated columns

A column whose value is computed from other columns:

try db.create(table: "rectangle") { t in
    t.autoIncrementedPrimaryKey("id")
    t.column("width", .real).notNull()
    t.column("height", .real).notNull()
    t.column("area", .real).generatedAs("width * height", .stored)
}

area is computed from width * height. Two modes:

  • STORED (.stored) — the value is computed and stored on insert/update. Reading is fast (no recomputation). Writing is slightly slower.
  • VIRTUAL (.virtual) — the value is computed at read time. No storage cost; computation cost on each read.

For values you read often, STORED. For values you read rarely or that change with derivations of expensive functions, VIRTUAL.

Generated columns with non-trivial expressions

t.column("fullName", .text).generatedAs("firstName || ' ' || lastName", .stored)
t.column("isVIP", .boolean).generatedAs("score > 1000", .virtual)

Generated columns can use any SQLite expression. Useful for:

  • Concatenated namesfirstName + lastName.
  • Computed flagsisVIP, isOverdue.
  • Normalized valuesLOWER(email) for case-insensitive lookups (paired with a unique index on the generated column).

Indexing generated columns

t.column("emailLower", .text).generatedAs("LOWER(email)", .stored)
// In a separate statement:
try db.create(index: "userByEmailLower", on: "user", columns: ["emailLower"])

Now SELECT * FROM user WHERE emailLower = ? uses the index. Faster than WHERE LOWER(email) = ? (which can’t use a regular index on email).

This is one of the most useful generated-column patterns: precomputed normalized values for fast lookup.

Generated columns vs computed properties

In Swift, you might define var fullName: String { firstName + " " + lastName } on a struct. That’s a computed property — never stored, computed in Swift.

Generated columns are computed in SQL, persisted in the database (STORED) or computed lazily (VIRTUAL). They’re queryable directly.

Use a generated column when:

  • You filter or sort by the computed value frequently.
  • You want the value visible in SQL queries (e.g., WHERE fullName LIKE ...).

Use a Swift computed property when:

  • You need the value only in Swift code.
  • The computation is straightforward.

Pitfalls

Triggers hidden in schema. A trigger that maintains denormalization is invisible in app code; reading the schema is the only way to see it. Document.

Trigger performance death spiral. A trigger that does a heavy query, with each main-table operation, slows the system dramatically. Profile with EXPLAIN QUERY PLAN for trigger bodies.

Views mistaken for tables. Writing to a view fails for non-simple views. The error message can be confusing.

Generated column expression validity. The expression must be deterministic (same inputs → same output). Functions like random() or current_timestamp generally aren’t allowed in generated columns.

Migration challenges. Adding a generated column to an existing table works (SQLite computes for existing rows). Modifying the expression requires dropping and re-adding the column.

What to internalize

Triggers run code on insert/update/delete. Use for audit logs, denormalization, business validation. AFTER triggers don’t block; BEFORE can. Views are saved queries — query like a table; mostly read-only. Generated columns compute from other columns; STORED for speed-on-read, VIRTUAL for low-storage. Index generated columns for fast lookups (e.g., normalized lowercase email). Be wary of trigger performance and schema invisibility.


21. Database Observation: ValueObservation

So far, all our queries have been one-shot: run them once, get a result, done. For UI, that’s not enough — we want the UI to update automatically when the data changes. GRDB’s solution is ValueObservation: register interest in the result of a query, and GRDB notifies you with fresh values whenever the underlying data changes.

This is the heart of reactive GRDB. Every modern integration (Combine, AsyncSequence, SwiftUI) is built on ValueObservation.

The basic pattern

import GRDB

let observation = ValueObservation.tracking { db in
    try Player.order(Column("score").desc).fetchAll(db)
}

let cancellable = observation.start(
    in: dbPool,
    onError: { error in print("Error: \(error)") },
    onChange: { players in
        print("Got \(players.count) players")
    }
)

What happens:

  • ValueObservation.tracking takes a closure that returns a value derived from the database.
  • start(in:onError:onChange:) begins the observation against a database pool.
  • onChange fires immediately with the current value, then again whenever the underlying data changes.
  • The cancellable controls the lifetime — keep it alive while you want the observation; release it to stop.

This is the foundation. Higher-level integrations (Combine, AsyncSequence) wrap this same primitive.

What “changes” means

ValueObservation observes the region of the database that your fetch closure touches. If your closure fetches from the player table, changes to player trigger a re-evaluation. Changes to other tables don’t.

GRDB tracks this automatically by analyzing the queries you run inside the closure. You don’t have to declare what you’re observing — the framework figures it out.

Multiple values from one observation

struct Dashboard {
    let topPlayers: [Player]
    let totalScore: Int
}

let observation = ValueObservation.tracking { db -> Dashboard in
    let top = try Player.order(Column("score").desc).limit(10).fetchAll(db)
    let total = try Int.fetchOne(db, sql: "SELECT SUM(score) FROM player") ?? 0
    return Dashboard(topPlayers: top, totalScore: total)
}

let cancellable = observation.start(in: dbPool,
    onError: { print($0) },
    onChange: { dashboard in
        // Use dashboard.topPlayers and dashboard.totalScore
    })

The closure can produce any value — a struct, an array of structs, a tuple, anything. GRDB calls the closure once per change and you get the fresh result.

The closure runs on a GRDB queue (typically a background reader). The onChange callback happens on whatever queue you specify (covered next).

Scheduling — main queue vs immediate

By default, onChange fires on the main queue:

observation.start(in: dbPool, ...)
// onChange runs on the main queue; safe to update UI

To get the first emission synchronously (during start) and subsequent emissions async:

observation
    .start(in: dbPool,
           scheduling: .immediate,  // first emission is sync
           onError: { ... },
           onChange: { ... })

.immediate scheduling: the first call to onChange is synchronous, before start returns. Useful when you need the initial value immediately (e.g., to populate a UI before the screen appears).

For a non-main scheduler:

observation
    .start(in: dbPool,
           scheduling: .async(onQueue: .global()),
           onError: { ... },
           onChange: { ... })

For most UI use cases, default (main queue, async) is right.

Lifetime: holding the cancellable

The observation is alive as long as the returned cancellable is retained. In a class:

import Observation

@Observable
final class PlayersViewModel {
    private(set) var players: [Player] = []

    @ObservationIgnored private var observationCancellable: AnyDatabaseCancellable?

    init(dbPool: DatabasePool) {
        observationCancellable = ValueObservation
            .tracking { db in try Player.fetchAll(db) }
            .start(in: dbPool, onError: { _ in }, onChange: { [weak self] players in
                self?.players = players
            })
    }

    deinit {
        observationCancellable?.cancel()  // optional; deinit's release does the same
    }
}

When the view model deinits, the cancellable is released, the observation stops. No leaks.

For SwiftUI, this lifecycle is typically managed via .task or @State (with @Observable types) — covered in sections 24-26.

Throttling and debouncing

By default, every database commit that affects the observed region triggers a re-evaluation. For high-frequency writes (a sync importing 1000 records), this means many rapid-fire onChange calls.

To throttle:

let observation = ValueObservation.tracking { db in
    try Player.fetchAll(db)
}
.print()  // for debugging — logs every tracking event

// Combined with throttling on the consumer side (e.g., via Combine or AsyncAlgorithms)

GRDB doesn’t have a built-in throttle on ValueObservation directly, but the Combine and AsyncSequence wrappers can be throttled via standard operators.

Filtering observations to specific changes

If you want to react only to certain kinds of changes:

let observation = ValueObservation
    .tracking { db in try Player.filter(Column("status") == "active").fetchAll(db) }

Even though all player changes trigger re-evaluation, the closure’s result reflects only active players. The downstream consumer sees changes only when the active-player set actually changes — GRDB de-duplicates emissions.

For this de-duplication to work, the result type must be Equatable. With Codable + Equatable records (which is automatic for value-type records), this is free.

Observing relationships

struct NoteWithFolder: Codable, FetchableRecord, Equatable {
    var note: Note
    var folder: Folder?
}

let observation = ValueObservation.tracking { db in
    try Note
        .including(optional: Note.folder)
        .asRequest(of: NoteWithFolder.self)
        .fetchAll(db)
}

The observation tracks both note and folder regions. Changes to either trigger re-evaluation. Powerful for showing related data that updates as anything changes.

valueObservation on a single record

let id: Int64 = 42
let observation = ValueObservation.tracking { db in
    try Player.fetchOne(db, key: id)
}

let cancellable = observation.start(in: dbPool, onError: { _ in }, onChange: { player in
    // player is Player? — nil if deleted
})

A single-record observation. The result is optional — if the record is deleted, you get nil.

Custom regions

For advanced cases, you can declare the observed region explicitly:

let observation = ValueObservation.trackingRegion(
    Note.all(),
    Folder.all()
) { db in
    // closure that uses note and folder data
}

Useful when the closure does something the auto-tracking doesn’t fully detect (e.g., raw SQL with computed table names).

Pitfalls

Closure errors silently retried. If the tracking closure throws, onError is called, and the observation continues — the closure will be tried again on the next change. For programming errors, you might want to abort instead.

Long-running tracking closures. The closure runs on every relevant change. If it takes 100ms, every change triggers 100ms of work. Keep the closure fast — it should mostly do queries, not heavy computation.

Memory leaks. If you don’t hold the cancellable, GRDB cancels the observation immediately. If you accidentally hold a strong reference cycle (closure capturing self → self holding cancellable), you leak. Use [weak self] in onChange.

Mismatched scheduling. Default scheduling delivers on main. If you change to async on a background queue and your onChange updates UI, you’ll have UI-from-background-queue bugs.

Forgetting Equatable. Without Equatable, GRDB can’t de-dupe emissions. Every database change triggers a callback even if the value didn’t change.

What to internalize

ValueObservation.tracking { db in ... } declares a query whose result you want to observe. start(in:onError:onChange:) begins the observation; the cancellable controls lifetime. onChange fires immediately and on every relevant database change. Default scheduling is main queue, async. Use .immediate for synchronous first emission. Equatable result types enable de-duping. Tracking closures should be fast — they run on every change.


22. Combine Integration

GRDB’s ValueObservation integrates with Combine via a publisher(in:scheduling:) method that returns an AnyPublisher<Value, Error>. If your app uses Combine, this is the natural way to consume database changes — chain operators like throttle, combineLatest, map, then sink into your @Observable model or bind to UIKit properties.

The publisher

import Combine
import GRDB

let publisher = ValueObservation
    .tracking { db in try Player.fetchAll(db) }
    .publisher(in: dbPool)

The result is AnyPublisher<[Player], Error>. It conforms to Combine’s Publisher protocol. Subscribe like any other publisher:

let cancellable = publisher
    .sink(
        receiveCompletion: { completion in
            if case .failure(let error) = completion {
                print("Failed: \(error)")
            }
        },
        receiveValue: { players in
            print("Players: \(players.count)")
        }
    )

The publisher emits initial value + every subsequent change. Cancel by releasing the cancellable.

Scheduling

By default, subscribers receive values on the main queue:

ValueObservation.tracking { db in try Player.fetchAll(db) }
    .publisher(in: dbPool, scheduling: .async(onQueue: .main))

The .async(onQueue:) parameter accepts any DispatchQueue. For a background subscriber:

.publisher(in: dbPool, scheduling: .async(onQueue: .global(qos: .userInitiated)))

For immediate-first-emission semantics:

.publisher(in: dbPool, scheduling: .immediate)

The first emission happens during subscribe. Useful for SwiftUI or other consumers that want the initial value present before view body runs.

Combine operators

Now you have a Combine publisher. Standard operators apply:

ValueObservation
    .tracking { db in try Player.fetchAll(db) }
    .publisher(in: dbPool)
    .throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
    .map { players in players.filter { $0.score > 50 } }
    .receive(on: DispatchQueue.main)
    .sink { _ in } receiveValue: { _ in }

Throttle prevents UI flicker during bursts of changes. Map and filter shape the data. receive(on:) switches to a specific queue.

Multiple publishers via combineLatest

A common need: react when either of two observations changes.

let playersPublisher = ValueObservation.tracking { db in
    try Player.fetchAll(db)
}.publisher(in: dbPool)

let teamsPublisher = ValueObservation.tracking { db in
    try Team.fetchAll(db)
}.publisher(in: dbPool)

let combined = playersPublisher
    .combineLatest(teamsPublisher)
    .map { (players, teams) in
        Dashboard(players: players, teams: teams)
    }

combineLatest emits whenever either side emits, with the latest values from both. Useful for views that depend on multiple data sources.

Binding to an @Observable model

In an @Observable view model:

import Combine
import GRDB
import Observation

@Observable
@MainActor
final class PlayersViewModel {
    private(set) var players: [Player] = []

    @ObservationIgnored private var cancellables = Set<AnyCancellable>()

    init(dbPool: DatabasePool) {
        ValueObservation
            .tracking { db in try Player.fetchAll(db) }
            .publisher(in: dbPool)
            .replaceError(with: [])  // swallow errors as empty array
            .receive(on: DispatchQueue.main)
            .sink { [weak self] players in
                self?.players = players
            }
            .store(in: &cancellables)
    }
}

A few @Observable-specific notes:

  • The @Observable macro replaces ObservableObject conformance. There’s no @Published — every stored property is observed automatically, and SwiftUI re-renders any view that read it when it changes.
  • Properties that are not part of the observable surface (like cancellables here — internal state that shouldn’t drive view updates) get @ObservationIgnored. Without it, every Combine bookkeeping change would be a “change” SwiftUI sees.
  • @Observable types don’t get a $projection for their properties, so the Combine convenience assign(to: &$players) doesn’t apply. Use .sink and assign explicitly. The result is one extra line.
  • @MainActor ensures the property assignments happen on main, which is where SwiftUI reads them.

replaceError(with:) is a common simplification — observation errors become empty results. For better error visibility, sink and handle errors explicitly.

Error handling

The publisher’s failure type is Error. Standard Combine error operators work:

publisher
    .catch { error -> AnyPublisher<[Player], Never> in
        print("Database error: \(error)")
        return Just([]).eraseToAnyPublisher()
    }
    .sink { _ in } receiveValue: { players in /* ... */ }

Once an error fires, the upstream publisher is done — it won’t emit further values. To recover and continue observing, you’d resubscribe.

For robustness, consider wrapping the database errors and re-establishing the observation:

private func observePlayers() {
    cancellables.insert(
        ValueObservation
            .tracking { db in try Player.fetchAll(db) }
            .publisher(in: dbPool)
            .sink(
                receiveCompletion: { [weak self] completion in
                    if case .failure(let error) = completion {
                        print("Observation failed: \(error)")
                        // Optionally re-establish after a delay
                        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
                            self?.observePlayers()
                        }
                    }
                },
                receiveValue: { [weak self] players in
                    self?.players = players
                }
            )
    )
}

For most apps, observation errors are rare (database is corrupt, disk is full) — a simple replaceError is fine.

Tracking specific writes via DatabaseRegionObservation

For lower-level control, DatabaseRegionObservation notifies you of transaction commits affecting a region, without re-running a query:

let observation = DatabaseRegionObservation(tracking: Player.all())

let cancellable = observation
    .publisher(in: dbPool)
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { db in
            // A transaction touching the player region just committed
            // No fetched value — it's a notification, not a result
        }
    )

This is rarely what you want — ValueObservation (which gives you the result) is more useful — but useful for “I just want to know something changed, I’ll handle the fetch separately.”

Custom equality

Sometimes the result is Equatable, but Equatable’s default behavior isn’t quite right (e.g., two structs are “equal” except for a timestamp that always differs). Customize:

let observation = ValueObservation
    .tracking { db in try Player.fetchAll(db) }
    .removeDuplicates(by: { lhs, rhs in
        lhs.map(\.id) == rhs.map(\.id) && lhs.map(\.score) == rhs.map(\.score)
        // Compare only IDs and scores; ignore timestamp differences
    })

removeDuplicates(by:) is a Combine operator on the publisher. The closure determines whether two consecutive emissions are “the same” — if so, the new one is dropped.

Lifecycle in views

For UIKit:

class PlayersViewController: UIViewController {
    let dbPool: DatabasePool
    var cancellables = Set<AnyCancellable>()
    var players: [Player] = []

    init(dbPool: DatabasePool) {
        self.dbPool = dbPool
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()

        ValueObservation
            .tracking { db in try Player.fetchAll(db) }
            .publisher(in: dbPool)
            .replaceError(with: [])
            .receive(on: DispatchQueue.main)
            .sink { [weak self] players in
                self?.players = players
                self?.tableView.reloadData()
            }
            .store(in: &cancellables)
    }
}

The cancellables set holds the subscription as long as the view controller exists. When deallocated, all cancellables are released, observations stop.

Pitfalls

Lost initial emission. If you subscribe inside an init that immediately returns, the publisher might emit before the consumer is ready. Use .immediate scheduling to force the first emission to be synchronous.

Nested publishers. Subscribing to a publisher inside a sink closure of another publisher can create complex pipelines that are hard to reason about. Use flatMap or switchToLatest for cleaner composition.

Forgetting to retain cancellables. A cancellable that’s not retained is immediately cancelled. The subscription does nothing.

Excessive emissions during sync/import. Bulk writes can trigger many observations rapidly. Throttle on the consumer side (throttle, debounce).

Combining observations with non-database publishers. combineLatest of a database publisher and a UI event might not behave as expected (orderings, race conditions). Test carefully.

What to internalize

ValueObservation.tracking { ... }.publisher(in: dbPool) returns a Combine publisher. Subscribe with sink, assign into @Observable model properties from [weak self] closures (no assign(to:&$foo) since @Observable has no projection). Configure scheduling: default is async on main; .immediate for synchronous first emission. Standard Combine operators apply: throttle, map, combineLatest, replaceError. Hold cancellables in @ObservationIgnored storage so they don’t drive view updates.


23. AsyncSequence and Swift Concurrency

For modern Swift Concurrency, ValueObservation exposes values(in:scheduling:) returning an AsyncSequence. This is the cleanest way to consume database changes from async/await code — no Combine, no sinks, just a for await loop.

The basic pattern

let stream = ValueObservation
    .tracking { db in try Player.fetchAll(db) }
    .values(in: dbPool)

for try await players in stream {
    print("Players: \(players.count)")
}

values(in:) returns an AsyncValueObservation<[Player]> — an AsyncSequence whose elements are the observation’s results.

The for await loop suspends until each new value arrives. The loop continues forever (or until the dbPool stops, or the task is cancelled).

In a view model

import Observation

@Observable
@MainActor
final class PlayersViewModel {
    private(set) var players: [Player] = []
    var error: Error?

    @ObservationIgnored private var observationTask: Task<Void, Never>?
    @ObservationIgnored let dbPool: DatabasePool

    init(dbPool: DatabasePool) {
        self.dbPool = dbPool
    }

    func startObserving() {
        observationTask?.cancel()
        observationTask = Task { [weak self] in
            guard let self else { return }

            do {
                let stream = ValueObservation
                    .tracking { db in try Player.fetchAll(db) }
                    .values(in: self.dbPool)

                for try await players in stream {
                    self.players = players
                }
            } catch {
                self.error = error
            }
        }
    }

    func stopObserving() {
        observationTask?.cancel()
        observationTask = nil
    }

    deinit {
        observationTask?.cancel()
    }
}

A Task drives the loop. The view model is @MainActor and @Observable. Each iteration of the for await updates players, which automatically notifies any SwiftUI view that read it. The task is cancelled when the view model is deallocated — clean lifecycle.

Properties like observationTask and dbPool are marked @ObservationIgnored because they’re internal plumbing — SwiftUI doesn’t care when they change, and observing them would just add noise.

Lifecycle in SwiftUI

The cleanest pattern uses SwiftUI’s .task:

struct PlayersView: View {
    let viewModel: PlayersViewModel

    var body: some View {
        List(viewModel.players) { player in
            Text(player.name)
        }
        .task {
            // Runs while the view is visible; cancelled on disappear
            viewModel.startObserving()
        }
    }
}

With @Observable, you don’t need a property wrapper to consume the model — just hold the reference and read its properties in body. SwiftUI tracks which properties were read and re-renders when they change. If the view itself owns the model (creating it on first appearance and keeping it across re-renders), use @State:

struct PlayersView: View {
    @State private var viewModel: PlayersViewModel

    init(dbPool: DatabasePool) {
        _viewModel = State(wrappedValue: PlayersViewModel(dbPool: dbPool))
    }

    var body: some View {
        List(viewModel.players) { player in
            Text(player.name)
        }
        .task {
            viewModel.startObserving()
        }
    }
}

@State with an @Observable reference type is the modern replacement for @StateObject. SwiftUI manages the model’s lifetime — created on first body, persisted across re-renders, released when the view leaves the hierarchy.

Or simpler — directly in the view:

struct PlayersView: View {
    let dbPool: DatabasePool
    @State private var players: [Player] = []

    var body: some View {
        List(players) { player in
            Text(player.name)
        }
        .task {
            do {
                let stream = ValueObservation
                    .tracking { db in try Player.fetchAll(db) }
                    .values(in: dbPool)

                for try await fresh in stream {
                    players = fresh
                }
            } catch {
                print("Observation error: \(error)")
            }
        }
    }
}

The .task modifier creates a task tied to the view’s lifecycle. When the view goes away, the task is cancelled, the for await exits, the observation stops.

Multiple streams

.task {
    async let players = playersStream()
    async let teams = teamsStream()

    // Wait for both initial values
    let initialPlayers = await players.first(where: { _ in true })
    let initialTeams = await teams.first(where: { _ in true })
}

For more sophisticated multi-stream coordination, AsyncAlgorithms (the apple/swift-async-algorithms package) provides operators like merge, combineLatest, zip.

Cancellation

When you cancel a task with active for await, the loop exits. If the database is being read at the moment, GRDB cooperatively cancels.

Manual cancellation:

let task = Task { ... }
// Later:
task.cancel()

After cancel, the task’s for await exits cleanly; the observation is torn down; resources are released.

Throttling with AsyncAlgorithms

import AsyncAlgorithms

for try await players in stream.throttle(for: .milliseconds(100), latest: true) {
    self.players = players
}

throttle(for:latest:) (from AsyncAlgorithms) batches rapid changes — useful during bulk imports.

If you don’t want the AsyncAlgorithms dependency, write your own:

extension AsyncSequence where Element: Sendable {
    func throttled(for nanoseconds: UInt64) -> AsyncStream<Element> {
        AsyncStream { continuation in
            let task = Task {
                var pending: Element?
                var lastEmit = ContinuousClock.Instant.now

                for try await element in self {
                    pending = element
                    let now = ContinuousClock.Instant.now
                    let elapsed = lastEmit.duration(to: now)
                    if elapsed.components.attoseconds + elapsed.components.seconds * 1_000_000_000_000_000_000 >= nanoseconds {
                        if let pending {
                            continuation.yield(pending)
                            lastEmit = now
                        }
                    }
                }
                continuation.finish()
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }
}

This is approximate; for a robust implementation, use AsyncAlgorithms.

Error handling

Errors propagate through the for await loop:

do {
    for try await players in stream {
        // ...
    }
} catch {
    // observation failed
}

Once an error is thrown, the loop exits. To re-establish, restart the task.

Hot vs cold streams

ValueObservation’s AsyncSequence is “hot” in the sense that it represents an ongoing observation. The first iteration starts the observation; subsequent iterations from the same task receive new values. Each for await call gets a fresh subscription:

let stream = ValueObservation
    .tracking { db in try Player.fetchAll(db) }
    .values(in: dbPool)

// First task subscribes:
Task {
    for try await p in stream { /* ... */ }
}

// Second task subscribes — separate observation:
Task {
    for try await p in stream { /* ... */ }
}

Each task triggers its own start underneath. Same database queries, separate state. Costs are usually negligible — you might run two reads instead of one — but be aware.

Combining with isolated actors

If you want database access wrapped in an actor:

actor PlayerService {
    let dbPool: DatabasePool

    init(dbPool: DatabasePool) { self.dbPool = dbPool }

    func playersStream() -> AsyncValueObservation<[Player]> {
        ValueObservation
            .tracking { db in try Player.fetchAll(db) }
            .values(in: dbPool)
    }
}

The playersStream() method is async (because actor methods are), but the returned AsyncSequence isn’t bound to the actor — it can be consumed from anywhere. The values are produced by GRDB’s queues, not by the actor.

Pitfalls

Forgetting try in for await. The stream throws; you need for try await. Without try, you get a compile error.

Tasks that outlive their owners. A Task that captures self strongly and runs forever leaks. Use [weak self] and break out of the loop on weak-self-nil.

Re-creating streams on every render. Don’t create the AsyncValueObservation inside body — it’s a new observation each time. Capture in a property or use .task(id:) to control re-creation.

Cancellation gaps. If your loop body is slow, the task might not check for cancellation between emissions. Add try Task.checkCancellation() if you need to be responsive.

Combining with synchronous code. for try await can’t run inside a non-async context. Either Task { ... } to bridge, or refactor.

What to internalize

ValueObservation.values(in: dbPool) returns an AsyncSequence emitting database changes. Consume with for try await. Tied to task lifecycle — cancel the task to stop the observation. Use SwiftUI .task for view-bound observations. AsyncAlgorithms for throttling/merging. Each for await iteration creates a separate underlying observation. Errors break the loop.


24. SwiftUI Integration: GRDBQuery

GRDB’s author maintains a separate package, GRDBQuery, that provides a SwiftUI-native integration layer. It defines a @Query property wrapper that reads from the SwiftUI environment and updates views automatically when the database changes — much like SwiftData’s @Query, but for GRDB.

Adding it is optional but highly recommended for SwiftUI apps. It eliminates much of the boilerplate of consuming ValueObservation in views.

Adding GRDBQuery

dependencies: [
    .package(url: "https://github.com/groue/GRDB.swift", from: "6.0.0"),
    .package(url: "https://github.com/groue/GRDBQuery", from: "0.7.0"),
]

(Check current versions on GitHub.)

Add GRDBQuery to your target.

The DatabaseContext

GRDBQuery introduces DatabaseContext — a SwiftUI environment value that holds your database. Set it up in your App:

import GRDB
import GRDBQuery
import SwiftUI

@main
struct PlayersApp: App {
    let appDatabase = AppDatabase.makeShared()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .databaseContext(.readWrite { appDatabase.dbWriter })
        }
    }
}

.databaseContext(.readWrite { ... }) puts a database context into the environment. Read-write because we want to allow writes from our views.

For read-only:

.databaseContext(.readOnly { appDatabase.dbWriter })

Defining a Queryable

Your queries are types that conform to Queryable:

import GRDB
import GRDBQuery

struct AllPlayersRequest: Queryable {
    static var defaultValue: [Player] { [] }

    func publisher(in dbContext: DatabaseContext) -> AnyPublisher<[Player], Error> {
        ValueObservation
            .tracking { db in try Player.fetchAll(db) }
            .publisher(in: dbContext.reader)  // Combine publisher
            .eraseToAnyPublisher()
    }
}

Wait — that’s a Combine publisher. For modern AsyncSequence-based queryables, GRDBQuery offers an async-friendly form:

struct AllPlayersRequest: ValueObservationQueryable {
    static var defaultValue: [Player] { [] }

    func fetch(_ db: Database) throws -> [Player] {
        try Player.fetchAll(db)
    }
}

ValueObservationQueryable is the higher-level form. You provide just the fetch closure; GRDBQuery wires up the observation.

defaultValue is what the view sees before the first fetch completes — usually an empty array or nil.

Using @Query in views

import SwiftUI
import GRDBQuery

struct PlayersListView: View {
    @Query(AllPlayersRequest()) var players: [Player]

    var body: some View {
        List(players) { player in
            Text(player.name)
        }
    }
}

That’s it. The @Query property wrapper:

  • Uses the request to build an observation.
  • Subscribes when the view appears.
  • Unsubscribes when the view disappears.
  • Updates players whenever the data changes — re-rendering the view.

Behind the scenes, it reads DatabaseContext from the environment, builds a ValueObservation, drives an AsyncStream, updates the view.

Parameterized requests

For requests that depend on view parameters:

struct PlayersInTeamRequest: ValueObservationQueryable {
    static var defaultValue: [Player] { [] }

    let teamId: Int64

    func fetch(_ db: Database) throws -> [Player] {
        try Player.filter(Column("teamId") == teamId).fetchAll(db)
    }
}

struct PlayersInTeamView: View {
    @Query<PlayersInTeamRequest> var players: [Player]

    init(teamId: Int64) {
        _players = Query(PlayersInTeamRequest(teamId: teamId))
    }

    var body: some View {
        List(players) { player in
            Text(player.name)
        }
    }
}

The view’s init constructs a Query with the specific request. When the view’s identity changes (different teamId), the Query re-initializes with a new request — the observation updates accordingly.

For dynamic parameters that change at runtime:

struct SearchView: View {
    @State private var search = ""

    var body: some View {
        VStack {
            TextField("Search", text: $search)
            SearchResultsView(search: search)
        }
    }
}

struct SearchResultsView: View {
    @Query<PlayerSearchRequest> var players: [Player]

    init(search: String) {
        _players = Query(PlayerSearchRequest(search: search))
    }

    var body: some View {
        List(players) { player in
            Text(player.name)
        }
    }
}

struct PlayerSearchRequest: ValueObservationQueryable {
    static var defaultValue: [Player] { [] }

    let search: String

    func fetch(_ db: Database) throws -> [Player] {
        if search.isEmpty {
            return try Player.fetchAll(db)
        } else {
            return try Player
                .filter(Column("name").like("%\(search)%"))
                .fetchAll(db)
        }
    }
}

Each keystroke causes the parent to re-create SearchResultsView with a new search string. The new request re-initializes @Query with new parameters. Live search.

For high-frequency input, debounce in the parent:

struct SearchView: View {
    @State private var search = ""
    @State private var debouncedSearch = ""

    var body: some View {
        VStack {
            TextField("Search", text: $search)
            SearchResultsView(search: debouncedSearch)
        }
        .task(id: search) {
            try? await Task.sleep(for: .milliseconds(200))
            debouncedSearch = search
        }
    }
}

The .task(id:) cancels and restarts when search changes; after 200ms of stability, debouncedSearch updates.

Writing from views

Views can write through DatabaseContext:

struct AddPlayerButton: View {
    @Environment(\.databaseContext) var dbContext

    var body: some View {
        Button("Add Player") {
            let player = Player(id: nil, name: "New", score: 0, createdAt: Date())
            do {
                try dbContext.writer?.write { db in
                    var player = player
                    try player.insert(db)
                }
            } catch {
                print("Insert failed: \(error)")
            }
        }
    }
}

The dbContext.writer is the DatabaseWriter (only available with .readWrite context). The query observation will pick up the change automatically — no manual refresh needed.

For async writes:

Button("Add Player") {
    Task {
        do {
            try await dbContext.writer?.write { db in
                var player = Player(id: nil, name: "New", score: 0, createdAt: Date())
                try player.insert(db)
            }
        } catch {
            print("Failed: \(error)")
        }
    }
}

Showcasing all pieces

A complete simple SwiftUI + GRDBQuery example:

@main
struct PlayersApp: App {
    let appDatabase = AppDatabase.makeShared()

    var body: some Scene {
        WindowGroup {
            NavigationStack {
                PlayersListView()
            }
            .databaseContext(.readWrite { appDatabase.dbWriter })
        }
    }
}

struct AllPlayersRequest: ValueObservationQueryable {
    static var defaultValue: [Player] { [] }

    func fetch(_ db: Database) throws -> [Player] {
        try Player.order(Column("score").desc).fetchAll(db)
    }
}

struct PlayersListView: View {
    @Query(AllPlayersRequest()) var players: [Player]
    @Environment(\.databaseContext) var dbContext

    var body: some View {
        List {
            ForEach(players) { player in
                NavigationLink(value: player.id ?? 0) {
                    HStack {
                        Text(player.name)
                        Spacer()
                        Text("\(player.score)")
                    }
                }
            }
            .onDelete { indexSet in
                Task {
                    do {
                        for index in indexSet {
                            try await dbContext.writer?.write { db in
                                _ = try players[index].delete(db)
                            }
                        }
                    } catch {
                        print("Delete failed: \(error)")
                    }
                }
            }
        }
        .toolbar {
            Button {
                Task {
                    try? await dbContext.writer?.write { db in
                        var player = Player(id: nil, name: "Random", score: Int.random(in: 0...100), createdAt: Date())
                        try player.insert(db)
                    }
                }
            } label: {
                Image(systemName: "plus")
            }
        }
        .navigationTitle("Players")
    }
}

@Query handles reads. dbContext.writer handles writes. Live-updating list with no glue code.

Testing

For previews and tests, inject an in-memory database:

#Preview {
    let database = try! AppDatabase.makeForTests()
    // Pre-populate
    try! database.dbWriter.write { db in
        try Player(id: nil, name: "Alice", score: 100, createdAt: Date()).insert(db)
        try Player(id: nil, name: "Bob", score: 80, createdAt: Date()).insert(db)
    }

    return NavigationStack {
        PlayersListView()
    }
    .databaseContext(.readWrite { database.dbWriter })
}

The view runs against an in-memory database. The preview shows real data.

Pitfalls

Forgetting .databaseContext(...). Without it, @Query has nothing to read from. The view shows the default value forever (silently). Always set the context at the App level.

Re-creating Query unnecessarily. Each view init creates a Query; if the view is re-instantiated frequently with different parameters, you pay observation setup costs. Use .id(...) thoughtfully.

Heavy fetch closures. The fetch runs on every change. For complex queries, profile.

Mismatched default values. The defaultValue shows during loading. If the view doesn’t handle “loading” gracefully, it might flash empty content. Consider showing a spinner or skeleton state if the default is misleading.

Forgetting to use the writer. The dbContext.reader is read-only; writes need dbContext.writer. With .readOnly context, no writer — writes fail.

What to internalize

GRDBQuery’s @Query for SwiftUI consumption of GRDB. Set up DatabaseContext at the App level. Define Queryable types (or ValueObservationQueryable for simpler cases). Use in views with @Query(MyRequest()). Parameterize requests via init. Write through dbContext.writer. Live updates happen automatically. Excellent for SwiftUI apps with GRDB; eliminates most observation boilerplate.


25. SwiftUI Without GRDBQuery: @Observable Stores

If you don’t want a third-party package or you need finer control, the next level down is a hand-rolled @Observable store that wraps ValueObservation and exposes plain stored properties. The view holds the store via @State and reads its properties directly in body — SwiftUI tracks the dependencies and re-renders on change. More code than GRDBQuery, but no extra dependency.

This is a good pattern for medium-complexity apps that want structure without a full repository.

The store pattern

import GRDB
import Observation

@Observable
@MainActor
final class PlayersStore {
    private(set) var players: [Player] = []
    private(set) var error: Error?

    @ObservationIgnored private let dbWriter: any DatabaseWriter
    @ObservationIgnored private var cancellable: AnyDatabaseCancellable?

    init(dbWriter: any DatabaseWriter) {
        self.dbWriter = dbWriter
        startObserving()
    }

    private func startObserving() {
        cancellable = ValueObservation
            .tracking { db in
                try Player.order(Column("score").desc).fetchAll(db)
            }
            .start(in: dbWriter,
                   onError: { [weak self] error in
                       self?.error = error
                   },
                   onChange: { [weak self] players in
                       self?.players = players
                   })
    }

    func addPlayer(name: String, score: Int) async throws {
        try await dbWriter.write { db in
            var player = Player(id: nil, name: name, score: score, createdAt: Date())
            try player.insert(db)
        }
    }

    func deletePlayer(_ player: Player) async throws {
        guard let id = player.id else { return }
        try await dbWriter.write { db in
            _ = try Player.deleteOne(db, key: id)
        }
    }
}

The store:

  • Is marked @Observable and @MainActor — SwiftUI views observing it re-render automatically when the public properties change, and assignments happen on main.
  • Uses plain stored properties (players, error) — no @Published needed. Anything not marked @ObservationIgnored is part of the observable surface.
  • Marks internal plumbing (dbWriter, cancellable) as @ObservationIgnored. These shouldn’t drive view updates; observing them would generate spurious change notifications.
  • Starts a ValueObservation in init.
  • Updates players on each change — SwiftUI views re-render automatically.
  • Provides write methods.

The cancellable is managed by the store — when the store is deallocated, the cancellable’s release stops the observation.

Using the store

struct PlayersListView: View {
    @State private var store: PlayersStore

    init(dbWriter: any DatabaseWriter) {
        _store = State(wrappedValue: PlayersStore(dbWriter: dbWriter))
    }

    var body: some View {
        List(store.players) { player in
            Text(player.name)
                .swipeActions {
                    Button(role: .destructive) {
                        Task { try? await store.deletePlayer(player) }
                    } label: { Image(systemName: "trash") }
                }
        }
        .toolbar {
            Button {
                Task {
                    try? await store.addPlayer(name: "New", score: 0)
                }
            } label: {
                Image(systemName: "plus")
            }
        }
    }
}

@State keeps the @Observable store alive across view re-renders. The view reads store.players directly — SwiftUI tracks the property access during body evaluation and re-renders only when that specific property changes. No property wrapper on the property access; just a regular read.

If store were also exposed via parent views or you needed to bind to a property of it, you’d use @Bindable:

struct PlayersFilterView: View {
    @Bindable var store: PlayersStore  // assumes a `var filterText: String` on the store

    var body: some View {
        TextField("Filter", text: $store.filterText)
    }
}

@Bindable projects bindings onto the @Observable properties. For pure read access (the more common case), no wrapper is needed.

Filtered stores

For filtered or parameterized stores:

@Observable
@MainActor
final class TeamPlayersStore {
    private(set) var players: [Player] = []

    @ObservationIgnored private let dbWriter: any DatabaseWriter
    @ObservationIgnored private var cancellable: AnyDatabaseCancellable?
    @ObservationIgnored private let teamId: Int64

    init(dbWriter: any DatabaseWriter, teamId: Int64) {
        self.dbWriter = dbWriter
        self.teamId = teamId
        startObserving()
    }

    private func startObserving() {
        cancellable?.cancel()
        let teamId = self.teamId
        cancellable = ValueObservation
            .tracking { db in
                try Player.filter(Column("teamId") == teamId).fetchAll(db)
            }
            .start(in: dbWriter,
                   onError: { _ in },
                   onChange: { [weak self] players in
                       self?.players = players
                   })
    }
}

A new store per filter context. The view creates the store with the relevant parameter.

If you want one store with mutable filters, expose a setter:

@Observable
@MainActor
final class PlayersStore {
    private(set) var players: [Player] = []

    var filterTeamId: Int64? {
        didSet { startObserving() }
    }

    @ObservationIgnored private let dbWriter: any DatabaseWriter
    @ObservationIgnored private var cancellable: AnyDatabaseCancellable?

    init(dbWriter: any DatabaseWriter) {
        self.dbWriter = dbWriter
        startObserving()
    }

    private func startObserving() {
        cancellable?.cancel()
        let teamId = filterTeamId
        cancellable = ValueObservation
            .tracking { db in
                if let teamId {
                    return try Player.filter(Column("teamId") == teamId).fetchAll(db)
                } else {
                    return try Player.fetchAll(db)
                }
            }
            .start(in: dbWriter,
                   onError: { _ in },
                   onChange: { [weak self] players in
                       self?.players = players
                   })
    }
}

Setting store.filterTeamId = 5 triggers didSet, which restarts the observation with the new filter. Because filterTeamId is part of the observable surface, any view that displays it (e.g., a filter chip) also re-renders.

Multiple data sets in one store

For a dashboard:

@Observable
@MainActor
final class DashboardStore {
    private(set) var topPlayers: [Player] = []
    private(set) var totalScore: Int = 0
    private(set) var teamCount: Int = 0

    @ObservationIgnored private var cancellable: AnyDatabaseCancellable?

    init(dbWriter: any DatabaseWriter) {
        cancellable = ValueObservation
            .tracking { db -> (top: [Player], total: Int, teams: Int) in
                let top = try Player.order(Column("score").desc).limit(10).fetchAll(db)
                let total = try Int.fetchOne(db, sql: "SELECT SUM(score) FROM player") ?? 0
                let teams = try Team.fetchCount(db)
                return (top, total, teams)
            }
            .start(in: dbWriter,
                   onError: { _ in },
                   onChange: { [weak self] (top, total, teams) in
                       self?.topPlayers = top
                       self?.totalScore = total
                       self?.teamCount = teams
                   })
    }
}

One observation that fetches everything; one transaction; updates all observable properties together. Atomic — the view always sees consistent values across all properties. SwiftUI batches the property changes within a single body evaluation cycle.

Throttling at the store

For high-frequency updates:

import Combine

@Observable
@MainActor
final class PlayersStore {
    private(set) var players: [Player] = []

    @ObservationIgnored private var cancellables = Set<AnyCancellable>()

    init(dbWriter: any DatabaseWriter) {
        ValueObservation
            .tracking { db in try Player.fetchAll(db) }
            .publisher(in: dbWriter)
            .throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
            .replaceError(with: [])
            .receive(on: DispatchQueue.main)
            .sink { [weak self] players in
                self?.players = players
            }
            .store(in: &cancellables)
    }
}

Combine version with throttle. During bulk writes, the UI updates at most every 100ms with the latest values. Note the manual .sink instead of assign(to: &$players)@Observable properties don’t have a $projection for Combine’s assign(to:).

Trade-offs vs GRDBQuery

vs GRDBQuery:

  • ✅ No external dependency.
  • ✅ More flexible (custom logic in store, multiple observable properties, throttling, error handling).
  • ❌ More boilerplate per use case.
  • ❌ More to test (each store is its own type).

For simple lists, GRDBQuery is more concise. For dashboards, complex stores, or apps where dependencies are minimized, hand-rolled stores work fine.

Tests

@MainActor
final class PlayersStoreTests: XCTestCase {
    var database: AppDatabase!
    var store: PlayersStore!

    override func setUp() async throws {
        database = try AppDatabase.makeForTests()
        store = PlayersStore(dbWriter: database.dbWriter)
    }

    func test_addPlayer_appearsInPlayers() async throws {
        try await store.addPlayer(name: "Alice", score: 100)

        // Wait briefly for observation to fire
        try await Task.sleep(for: .milliseconds(50))

        XCTAssertEqual(store.players.count, 1)
        XCTAssertEqual(store.players[0].name, "Alice")
    }
}

The async sleep is awkward but necessary — observations fire asynchronously. For more deterministic tests, use .immediate scheduling:

private func startObserving() {
    cancellable = ValueObservation
        .tracking { ... }
        .start(in: dbWriter,
               scheduling: .immediate,  // sync first emission
               onError: { ... },
               onChange: { ... })
}

The first emission is synchronous; the test sees the update immediately after the write. Subsequent emissions are still async.

Pitfalls

Forgetting [weak self]. Strong capture in the observation’s onChange creates a retain cycle: store → cancellable → closure → store. Use weak.

Forgetting @ObservationIgnored on internal state. Without it, every change to plumbing properties (cancellables, internal counters, the dbWriter reference if it could change) triggers SwiftUI re-renders of any view that touched the store in body. Mark anything that isn’t part of the public observable surface.

Multiple stores creating multiple observations. Each store is its own observation. Five different views with five different stores observing the same query is fine (cheap) but inefficient if they could share. Consider a single shared store.

Updating UI from non-main. The default scheduling is main, so this should be safe. If you change scheduling, dispatch to main — @Observable reads in body happen on main, so writes that drive them must too.

Re-creating stores on view re-render. Use @State (with the store created in init via _store = State(wrappedValue:)), not let, when the view creates the store. @State persists the instance across re-renders; let would make a fresh one each time.

What to internalize

A hand-rolled @Observable store wraps ValueObservation, exposes plain stored properties, provides write methods. Use @State to consume in SwiftUI views that own the store. Mark internal plumbing @ObservationIgnored. Cancellable controls observation lifetime — released with the store. Filtered stores via parameters; multi-data stores by combining queries in one observation. Throttle via Combine when needed. Less concise than GRDBQuery; more flexible.


26. SwiftUI Without GRDBQuery: View Model + AsyncSequence

The third path: a view model that owns a Task driving a for await loop over ValueObservation.values(in:). Modern Swift Concurrency idioms. No Combine dependency. Lifecycle tied to view via .task.

The view model

import GRDB
import Observation
import SwiftUI

@Observable
@MainActor
final class PlayersViewModel {
    private(set) var players: [Player] = []
    private(set) var error: Error?

    @ObservationIgnored private let dbWriter: any DatabaseWriter
    @ObservationIgnored private var observationTask: Task<Void, Never>?

    init(dbWriter: any DatabaseWriter) {
        self.dbWriter = dbWriter
    }

    func startObserving() {
        observationTask?.cancel()
        observationTask = Task { [weak self] in
            guard let self else { return }

            do {
                let stream = ValueObservation
                    .tracking { db in
                        try Player.order(Column("score").desc).fetchAll(db)
                    }
                    .values(in: self.dbWriter)

                for try await fresh in stream {
                    self.players = fresh
                }
            } catch {
                self.error = error
            }
        }
    }

    func stopObserving() {
        observationTask?.cancel()
        observationTask = nil
    }

    func addPlayer(name: String, score: Int) async throws {
        try await dbWriter.write { db in
            var player = Player(id: nil, name: name, score: score, createdAt: Date())
            try player.insert(db)
        }
    }

    deinit {
        observationTask?.cancel()
    }
}

The view model is @Observable and @MainActor. A Task drives the for await loop. Each iteration assigns to players, which automatically notifies any SwiftUI view that read it.

startObserving is called when the view appears; stopObserving (or deinit) cleans up. The dbWriter and observationTask are @ObservationIgnored — internal plumbing that shouldn’t drive view updates.

View lifecycle

struct PlayersListView: View {
    @State private var viewModel: PlayersViewModel

    init(dbWriter: any DatabaseWriter) {
        _viewModel = State(wrappedValue: PlayersViewModel(dbWriter: dbWriter))
    }

    var body: some View {
        List(viewModel.players) { player in
            Text(player.name)
        }
        .task {
            viewModel.startObserving()
        }
    }
}

@State with an @Observable reference type retains the view model across re-renders — the modern equivalent of @StateObject. The view reads viewModel.players directly; SwiftUI tracks the access and re-renders only when that property changes.

.task runs the closure when the view appears, cancels when it disappears. The view model’s observation is started/stopped accordingly.

For a simpler pattern, you can put the for-await loop directly in .task:

struct PlayersListView: View {
    let dbWriter: any DatabaseWriter
    @State private var players: [Player] = []

    var body: some View {
        List(players) { player in
            Text(player.name)
        }
        .task {
            do {
                let stream = ValueObservation
                    .tracking { db in try Player.fetchAll(db) }
                    .values(in: dbWriter)

                for try await fresh in stream {
                    players = fresh
                }
            } catch {
                print("Observation failed: \(error)")
            }
        }
    }
}

The view holds the data directly via @State. The .task drives the loop. Lifecycle is identical to the view model version, just less code for the simplest cases.

When to use the view model

Pure-.task works for simple views. Use a separate view model when:

  • The view has multiple data sources or complex state.
  • You want to test the view model in isolation.
  • Multiple views share the same view model (rare, but possible).
  • The view model has more responsibility than just “fetch and display.”

Multiple async sequences

@Observable
@MainActor
final class DashboardViewModel {
    private(set) var players: [Player] = []
    private(set) var teams: [Team] = []

    @ObservationIgnored private let dbWriter: any DatabaseWriter
    @ObservationIgnored private var playersTask: Task<Void, Never>?
    @ObservationIgnored private var teamsTask: Task<Void, Never>?

    init(dbWriter: any DatabaseWriter) {
        self.dbWriter = dbWriter
    }

    func startObserving() {
        playersTask = Task { [weak self] in
            guard let self else { return }
            for try await fresh in ValueObservation
                .tracking { db in try Player.fetchAll(db) }
                .values(in: self.dbWriter)
            {
                self.players = fresh
            }
        }

        teamsTask = Task { [weak self] in
            guard let self else { return }
            for try await fresh in ValueObservation
                .tracking { db in try Team.fetchAll(db) }
                .values(in: self.dbWriter)
            {
                self.teams = fresh
            }
        }
    }

    deinit {
        playersTask?.cancel()
        teamsTask?.cancel()
    }
}

Two separate observations, two separate tasks. Each one updates its own observable property independently. SwiftUI views reading either will re-render only on the relevant changes.

Or: combine into one observation

For atomic updates (both arrive together, view always sees a consistent pair):

func startObserving() {
    Task { [weak self] in
        guard let self else { return }
        for try await (players, teams) in ValueObservation
            .tracking { db -> ([Player], [Team]) in
                let p = try Player.fetchAll(db)
                let t = try Team.fetchAll(db)
                return (p, t)
            }
            .values(in: self.dbWriter)
        {
            self.players = players
            self.teams = teams
        }
    }
}

One observation, one task, atomic update. The view never sees “new players, old teams” — only one consistent state at a time.

Search with debounce

@Observable
@MainActor
final class SearchViewModel {
    var searchText = ""
    private(set) var results: [Player] = []

    @ObservationIgnored private let dbWriter: any DatabaseWriter
    @ObservationIgnored private var searchTask: Task<Void, Never>?

    init(dbWriter: any DatabaseWriter) {
        self.dbWriter = dbWriter
    }

    func startSearching() {
        searchTask?.cancel()
        searchTask = Task { [weak self] in
            guard let self else { return }

            // Debounce searchText changes
            // ... could observe searchText, debounce, then run query
            // For simplicity, here we let the view trigger search on each change with debounce
        }
    }

    func search(_ text: String) async {
        // Debounce: delay, check if cancelled, run if not
        do {
            try await Task.sleep(for: .milliseconds(200))
            guard !Task.isCancelled else { return }

            let fresh = try await dbWriter.read { db in
                if text.isEmpty {
                    return try Player.fetchAll(db)
                } else {
                    return try Player.filter(Column("name").like("%\(text)%")).fetchAll(db)
                }
            }
            results = fresh
        } catch {
            // ignore cancellation
        }
    }
}

Used with .task(id:) in the view:

struct SearchView: View {
    @State private var viewModel: SearchViewModel

    init(dbWriter: any DatabaseWriter) {
        _viewModel = State(wrappedValue: SearchViewModel(dbWriter: dbWriter))
    }

    var body: some View {
        @Bindable var bindable = viewModel

        VStack {
            TextField("Search", text: $bindable.searchText)
            List(viewModel.results) { player in
                Text(player.name)
            }
        }
        .task(id: viewModel.searchText) {
            await viewModel.search(viewModel.searchText)
        }
    }
}

Two @Observable-specific things to notice in the view:

  • The view holds the model with @State — same as @StateObject used to do, but for @Observable reference types.
  • For two-way binding (the TextField’s text: parameter), we need $bindable.searchText. Since @State doesn’t project bindings on properties of @Observable types, we make a local @Bindable shadow inside body. @Bindable var bindable = viewModel gives us the $ projection on viewModel’s properties for that body evaluation.

.task(id:) cancels and restarts when searchText changes. The debounce in search(_:) delays before fetching. If a new keystroke comes in within 200ms, the previous task is cancelled before it fetches.

Observation that produces a search-response stream

For continuously-observed search results (live updates while the search is active):

func startObserving(searchText: String) {
    searchTask?.cancel()
    searchTask = Task { [weak self] in
        guard let self else { return }

        do {
            let stream = ValueObservation
                .tracking { db in
                    if searchText.isEmpty {
                        return try Player.fetchAll(db)
                    } else {
                        return try Player.filter(Column("name").like("%\(searchText)%")).fetchAll(db)
                    }
                }
                .values(in: self.dbWriter)

            for try await fresh in stream {
                self.results = fresh
            }
        } catch {
            // handle
        }
    }
}

Now if you delete a player while the search is active, results update live. The stream is bound to the searchText at the time of starting; new searchText means restart.

Trade-offs

vs @Observable store:

  • ✅ Modern Swift Concurrency throughout.
  • ✅ No Combine.
  • ✅ Cancellation via Task is idiomatic.
  • ❌ Slightly more verbose for simple cases (the Task management).

vs GRDBQuery:

  • ✅ Full control.
  • ✅ No external dependency.
  • ✅ Easy to test (mock the dbWriter).
  • ❌ More code per view.

For modern apps targeting iOS 15+, the AsyncSequence pattern is increasingly the default.

Pitfalls

Forgetting [weak self]. Tasks holding strong self leak.

Cancelling and not restarting. Easy to add a cancel without remembering to start. The view goes blank.

Multiple observations of the same query. Each for await over the same values(in:) creates a separate underlying observation. Fine, but be aware.

Mixing Combine and AsyncSequence in the same view model. Pick one paradigm; mixing makes lifecycle reasoning harder.

Forgetting .task(id:) for parameter changes. Without id:, the task doesn’t restart when parameters change.

What to internalize

An @Observable @MainActor view model holds a Task driving for try await over ValueObservation.values(in:). Plain stored properties replace @Published; mark internal plumbing @ObservationIgnored. Lifecycle via SwiftUI’s .task. Use @State to own the model in views; @Bindable (often as a local shadow inside body) for two-way bindings. Combine multiple observations into one for atomic updates, or run them as separate tasks for independent flow. Debounce via try await Task.sleep. Modern, AsyncSequence-native, no Combine. The cleanest pattern for new SwiftUI + GRDB apps that don’t use GRDBQuery.


27. The Repository Pattern with GRDB

For larger apps, hiding GRDB behind a repository is the architectural move that enables clean tests, decoupled views, and pluggable data sources. The view model (or use case) depends on a Protocol, not on a DatabaseWriter directly. The production implementation is GRDB; tests use a mock; future versions could swap in remote data without changing the view layer.

Domain types

DTOs (Data Transfer Objects) at the boundary:

import Foundation

struct PlayerDTO: Identifiable, Equatable, Hashable, Sendable {
    let id: Int64
    let name: String
    let score: Int
    let teamId: Int64?
}

extension PlayerDTO {
    init(from record: Player) {
        self.id = record.id ?? 0
        self.name = record.name
        self.score = record.score
        self.teamId = record.teamId
    }
}

The DTO is a value type, Sendable, easily Equatable. It crosses actor boundaries. It doesn’t expose GRDB types.

The protocol

protocol PlayerRepository: Sendable {
    func players() -> AsyncThrowingStream<[PlayerDTO], Error>
    func player(id: Int64) async throws -> PlayerDTO?
    func search(_ query: String) async throws -> [PlayerDTO]

    func createPlayer(name: String, score: Int) async throws -> PlayerDTO
    func updatePlayer(id: Int64, name: String?, score: Int?) async throws -> PlayerDTO
    func deletePlayer(id: Int64) async throws
}

The contract: async functions for ad-hoc reads/writes; an AsyncThrowingStream for continuous observation. All return DTOs.

AsyncThrowingStream wraps any source — easy to bridge from GRDB, easy to mock.

The GRDB implementation

import GRDB

final class GRDBPlayerRepository: PlayerRepository, @unchecked Sendable {
    private let dbWriter: any DatabaseWriter

    init(dbWriter: any DatabaseWriter) {
        self.dbWriter = dbWriter
    }

    func players() -> AsyncThrowingStream<[PlayerDTO], Error> {
        AsyncThrowingStream { continuation in
            let cancellable = ValueObservation
                .tracking { db in
                    try Player.order(Column("score").desc).fetchAll(db)
                }
                .start(in: dbWriter,
                       onError: { error in
                           continuation.finish(throwing: error)
                       },
                       onChange: { players in
                           let dtos = players.map(PlayerDTO.init(from:))
                           continuation.yield(dtos)
                       })

            continuation.onTermination = { _ in
                cancellable.cancel()
            }
        }
    }

    func player(id: Int64) async throws -> PlayerDTO? {
        try await dbWriter.read { db in
            try Player.fetchOne(db, key: id).map(PlayerDTO.init(from:))
        }
    }

    func search(_ query: String) async throws -> [PlayerDTO] {
        try await dbWriter.read { db in
            let players = try Player
                .filter(Column("name").like("%\(query)%"))
                .order(Column("score").desc)
                .fetchAll(db)
            return players.map(PlayerDTO.init(from:))
        }
    }

    func createPlayer(name: String, score: Int) async throws -> PlayerDTO {
        try await dbWriter.write { db in
            var player = Player(id: nil, name: name, score: score, createdAt: Date())
            try player.insert(db)
            return PlayerDTO(from: player)
        }
    }

    func updatePlayer(id: Int64, name: String?, score: Int?) async throws -> PlayerDTO {
        try await dbWriter.write { db in
            guard var player = try Player.fetchOne(db, key: id) else {
                throw RepositoryError.notFound
            }
            if let name { player.name = name }
            if let score { player.score = score }
            try player.update(db)
            return PlayerDTO(from: player)
        }
    }

    func deletePlayer(id: Int64) async throws {
        try await dbWriter.write { db in
            _ = try Player.deleteOne(db, key: id)
        }
    }
}

enum RepositoryError: Error {
    case notFound
}

The repository converts records to DTOs at the boundary, hiding GRDB types.

The players() method returns an AsyncThrowingStream that wraps ValueObservation. Each emission converts to DTOs.

The view model

import Observation

@Observable
@MainActor
final class PlayersViewModel {
    private(set) var players: [PlayerDTO] = []
    private(set) var isLoading = true
    private(set) var error: Error?

    @ObservationIgnored private let repository: PlayerRepository
    @ObservationIgnored private var observationTask: Task<Void, Never>?

    init(repository: PlayerRepository) {
        self.repository = repository
    }

    func startObserving() {
        observationTask?.cancel()
        observationTask = Task { [weak self] in
            guard let self else { return }

            do {
                for try await dtos in repository.players() {
                    self.players = dtos
                    self.isLoading = false
                }
            } catch {
                self.error = error
                self.isLoading = false
            }
        }
    }

    func deletePlayer(_ player: PlayerDTO) async {
        do {
            try await repository.deletePlayer(id: player.id)
        } catch {
            self.error = error
        }
    }

    func createPlayer(name: String, score: Int) async {
        do {
            _ = try await repository.createPlayer(name: name, score: score)
        } catch {
            self.error = error
        }
    }

    deinit {
        observationTask?.cancel()
    }
}

The view model takes a PlayerRepository (the protocol). It doesn’t know GRDB. It’s @Observable so any view reading its properties re-renders on change; @MainActor so all the property assignments happen on main.

The view

struct PlayersListView: View {
    @State private var viewModel: PlayersViewModel

    init(viewModel: PlayersViewModel) {
        _viewModel = State(wrappedValue: viewModel)
    }

    var body: some View {
        List(viewModel.players) { player in
            HStack {
                Text(player.name)
                Spacer()
                Text("\(player.score)")
            }
            .swipeActions {
                Button(role: .destructive) {
                    Task { await viewModel.deletePlayer(player) }
                } label: { Image(systemName: "trash") }
            }
        }
        .toolbar {
            Button {
                Task {
                    await viewModel.createPlayer(name: "New", score: 0)
                }
            } label: {
                Image(systemName: "plus")
            }
        }
        .task {
            viewModel.startObserving()
        }
    }
}

The view consumes DTOs, calls view-model methods. No GRDB types anywhere in the view.

Wiring up

@main
struct PlayersApp: App {
    let appDatabase = AppDatabase.makeShared()
    let repository: PlayerRepository

    init() {
        repository = GRDBPlayerRepository(dbWriter: appDatabase.dbWriter)
    }

    var body: some Scene {
        WindowGroup {
            NavigationStack {
                PlayersListView(viewModel: PlayersViewModel(repository: repository))
            }
        }
    }
}

The repository is constructed at the app level with the production database. The view model receives it via init.

Tests with a mock repository

final class MockPlayerRepository: PlayerRepository, @unchecked Sendable {
    var stubbedPlayers: [PlayerDTO] = []
    var deleteCalls: [Int64] = []
    var createCalls: [(name: String, score: Int)] = []

    func players() -> AsyncThrowingStream<[PlayerDTO], Error> {
        AsyncThrowingStream { continuation in
            continuation.yield(stubbedPlayers)
            continuation.finish()
        }
    }

    func player(id: Int64) async throws -> PlayerDTO? {
        stubbedPlayers.first { $0.id == id }
    }

    func search(_ query: String) async throws -> [PlayerDTO] {
        stubbedPlayers.filter { $0.name.contains(query) }
    }

    func createPlayer(name: String, score: Int) async throws -> PlayerDTO {
        createCalls.append((name, score))
        let dto = PlayerDTO(id: Int64(stubbedPlayers.count + 1), name: name, score: score, teamId: nil)
        stubbedPlayers.append(dto)
        return dto
    }

    func updatePlayer(id: Int64, name: String?, score: Int?) async throws -> PlayerDTO {
        guard let idx = stubbedPlayers.firstIndex(where: { $0.id == id }) else {
            throw RepositoryError.notFound
        }
        var dto = stubbedPlayers[idx]
        if let name { dto = PlayerDTO(id: dto.id, name: name, score: dto.score, teamId: dto.teamId) }
        if let score { dto = PlayerDTO(id: dto.id, name: dto.name, score: score, teamId: dto.teamId) }
        stubbedPlayers[idx] = dto
        return dto
    }

    func deletePlayer(id: Int64) async throws {
        deleteCalls.append(id)
        stubbedPlayers.removeAll { $0.id == id }
    }
}

@MainActor
final class PlayersViewModelTests: XCTestCase {
    func test_startObserving_loadsPlayers() async {
        let mock = MockPlayerRepository()
        mock.stubbedPlayers = [
            PlayerDTO(id: 1, name: "Alice", score: 100, teamId: nil)
        ]
        let viewModel = PlayersViewModel(repository: mock)
        viewModel.startObserving()

        // Wait for the task to process
        try? await Task.sleep(for: .milliseconds(50))

        XCTAssertEqual(viewModel.players.count, 1)
        XCTAssertEqual(viewModel.players[0].name, "Alice")
    }

    func test_deletePlayer_callsRepository() async {
        let mock = MockPlayerRepository()
        let viewModel = PlayersViewModel(repository: mock)
        let player = PlayerDTO(id: 42, name: "Bob", score: 50, teamId: nil)

        await viewModel.deletePlayer(player)

        XCTAssertEqual(mock.deleteCalls, [42])
    }
}

Tests don’t touch GRDB. They run instantly. They’re focused on view-model behavior, not data layer details.

When to use the repository pattern

Worth the overhead when:

  • Your app has nontrivial business logic.
  • You need testability of the view layer in isolation.
  • You might swap data sources (local SQLite + remote API + cache).
  • Multiple developers work on the codebase.
  • You want clear architectural boundaries.

Skip when:

  • Your app is small and views can directly observe.
  • You’re prototyping.
  • The DTO mapping cost outweighs the benefit (rare, but possible for very simple cases).

Caching, retry, fallback in the repository

The repository is a natural place for cross-cutting data concerns:

final class CachingPlayerRepository: PlayerRepository, @unchecked Sendable {
    let local: PlayerRepository
    let remote: RemotePlayerService

    func players() -> AsyncThrowingStream<[PlayerDTO], Error> {
        local.players()  // local is the source of truth
    }

    func search(_ query: String) async throws -> [PlayerDTO] {
        // Try local first, fall back to remote
        let localResults = try await local.search(query)
        if localResults.isEmpty {
            let remoteResults = try await remote.search(query)
            // Cache locally
            for dto in remoteResults {
                _ = try await local.createPlayer(name: dto.name, score: dto.score)
            }
            return remoteResults
        }
        return localResults
    }

    // ... other methods
}

The view layer doesn’t change. The data layer composes additional behavior.

Pitfalls

DTO mapping cost. For huge result sets, mapping every record to a DTO has a cost. Profile if it shows up. Most apps it’s negligible.

Stale DTOs. A DTO is a snapshot. If the underlying record changes, the DTO doesn’t update. Always re-fetch via observation for fresh values.

Repository interface bloat. Adding methods for every micro-operation makes the protocol unwieldy. Group related operations; consider sub-protocols.

Mock complexity. Detailed mocks can grow large. For complex tests, consider using a real GRDB implementation with an in-memory database — sometimes the integration test is simpler than the mock.

Hiding too much. If the repository hides everything, you can’t take advantage of GRDB’s features (raw SQL for special queries, custom observations). Strike a balance.

What to internalize

The repository pattern: DTOs at the boundary, a Sendable protocol with async/throws methods, GRDB implementation maps records to DTOs. View models depend on the protocol, not on GRDB. Tests use mocks. Wire up at the App level. The cost is more code; the benefit is testable, layered architecture. Worth it for medium-to-large apps.


28. Concurrency Model: WAL, Readers, and the Writer

GRDB’s concurrency rules sit on top of SQLite’s. Understanding what’s happening — what locks exist, when reads block writes, what WAL does — helps you reason about contention, throughput, and the rare deadlock. This section is the deeper dive.

SQLite’s concurrency, briefly

SQLite is a single-file database with a process-shared lock. Default journaling mode is “rollback journal” — it serializes everything. Reads block during writes; writes block during reads. Adequate for low-contention scenarios.

WAL (Write-Ahead Logging) mode changes this:

  • Writes append to a separate .sqlite-wal file.
  • Readers read from the main file plus the WAL, getting a consistent snapshot.
  • Reads and writes don’t block each other.
  • The WAL is periodically checkpointed — its content merged into the main file.

DatabasePool enables WAL automatically. DatabaseQueue doesn’t (since it’s single-threaded anyway).

DatabasePool’s model

When you create a DatabasePool:

  • One writer connection is set up.
  • Several reader connections are set up (default 5, configurable via Configuration.maximumReaderCount).

When you call dbPool.read { ... }:

  • A reader connection is acquired (one of the available ones).
  • Your closure runs against that connection.
  • The connection is released when the closure returns.

When you call dbPool.write { ... }:

  • The writer connection is acquired.
  • Your closure runs against it, in a transaction.
  • On successful return, the transaction commits.
  • The connection is released.

Multiple concurrent reads use different reader connections — true parallelism. Multiple concurrent writes queue on the writer.

Snapshot isolation

When a read closure starts, it sees a snapshot of the database at the moment the transaction began. Any subsequent writes by the writer are invisible to this read.

This means within a read closure:

try dbPool.read { db in
    let count1 = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM player") ?? 0
    // ... writers might commit changes here ...
    let count2 = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM player") ?? 0
    // count1 == count2, even if a writer committed in between
}

The two queries see the same state. Snapshot isolation. Consistent multi-query reads.

Reader connections vs the writer

The writer is a single connection. All writes serialize through it. If you have ten dbPool.write calls happening concurrently from different tasks, they execute one after another.

async let writeA = dbPool.write { db in /* ... */ }
async let writeB = dbPool.write { db in /* ... */ }
async let writeC = dbPool.write { db in /* ... */ }
_ = try await (writeA, writeB, writeC)

These run sequentially on the writer, even though awaited concurrently. This is fine — just understand the throughput limit.

Reads, in contrast, parallelize:

async let readA = dbPool.read { db in /* ... */ }
async let readB = dbPool.read { db in /* ... */ }
async let readC = dbPool.read { db in /* ... */ }
let (a, b, c) = try await (readA, readB, readC)

Three reader connections (assuming maximumReaderCount >= 3) execute simultaneously.

What about DatabaseQueue?

A queue has just one connection. All read and write calls serialize through that single connection. No parallelism. Simpler model — no snapshot machinery, no checkpoint considerations.

For low-traffic situations (a small reference database), DatabaseQueue is simpler. For app data with active reads, DatabasePool is better.

Checkpoints

WAL mode accumulates writes in .sqlite-wal. Periodically, SQLite checkpoints — moves WAL data into the main .sqlite file. This:

  • Frees the WAL space.
  • Lets readers continue without growing memory needs.
  • Is largely automatic; SQLite picks moments when no readers are active.

You can force a checkpoint:

try dbPool.checkpoint()

Useful before backing up the file, or during low-activity periods to keep WAL small. SQLite’s auto-checkpointing handles most cases.

Locked database errors

In high-contention scenarios, you might see “database is locked” errors. Causes:

  • Another process has the writer lock and is taking too long.
  • The busy timeout was exceeded.

GRDB’s default busyMode is to retry briefly. For long-running locks (e.g., another process is doing a long write), set:

var config = Configuration()
config.busyMode = .timeout(30)  // wait up to 30 seconds

If that’s still not enough, the design likely needs revision — long write transactions are bad for many reasons.

Deadlocks

GRDB’s primitives prevent the most common deadlocks:

  • You can’t call dbPool.write from inside a dbPool.read (that would need both reader and writer simultaneously). GRDB throws.
  • You can’t acquire two writer locks (single writer).

Deadlocks can still happen with:

  • Multiple databases using nested closures.
  • External SQLite access (raw sqlite3_* calls outside GRDB).
  • Cross-process contention with extensions.

For most app code, follow the rules and you won’t deadlock.

Reentrant access

You can’t call read from inside read (or write from inside write):

try dbPool.read { db in
    try dbPool.read { db in   // throws!
        // ...
    }
}

GRDB detects this and throws. Use the existing db parameter — you’re already inside a read.

For more complex needs, unsafeRead and unsafeReentrantWrite exist but require careful use. Most code shouldn’t need them.

Sendable and structured concurrency

DatabaseWriter (and its subtypes) are Sendable. They can cross actor boundaries safely. The closures you pass to read/write execute on GRDB’s queues, not the calling actor’s executor.

Captured variables in the closure are subject to Sendable rules. For value types (Int, String, structs of values), no problem. For reference types (especially non-Sendable classes), you may get compiler warnings.

Within the closure, the Database parameter is not Sendable — it must stay inside the closure. Don’t hold or capture it.

WAL file persistence

The -wal and -shm files persist between launches. Closing the database doesn’t necessarily delete them. SQLite checkpoints when convenient, then truncates the WAL.

If you ship a database file as part of your app bundle, you typically ship just .sqlite (no WAL). When opened, it gets WAL files alongside. Don’t be surprised by their appearance.

Backing up while running

let backupURL = URL(fileURLWithPath: "/path/to/backup.sqlite")
try dbPool.backup(to: backupURL.path)

This creates a consistent snapshot, even while writers are active. Internally, SQLite uses its online backup API. The backup is a clean .sqlite file (the WAL is incorporated).

Pool sizing

maximumReaderCount controls the pool size. Larger means more parallelism but more connections (each consumes some memory and a file handle).

For typical iOS apps, the default 5 is fine. For apps with many concurrent reads (background sync + foreground UI + widgets), bump to 10. Beyond 20, diminishing returns.

You can profile actual usage: log when you wait for a reader, see if you’re running out.

Foreground/background transitions

When the app goes to the background, iOS may suspend it. GRDB’s observesSuspensionNotifications configuration option helps:

var config = Configuration()
config.observesSuspensionNotifications = true

With this, GRDB releases its connections when the app suspends, preventing corruption when iOS unmounts the file system (rare, but possible with URLFileProtection.complete).

For most app data with default protection, this isn’t strictly needed. For sensitive data with .complete protection, it is.

Cross-process access

iOS apps with extensions (widgets, share extensions) often share a database via App Groups. Multiple processes accessing the same SQLite file is supported, but each process has its own connections.

Each process should:

  • Open the database via containerURL(forSecurityApplicationGroupIdentifier:).
  • Use its own DatabasePool.
  • Be aware of busy errors during cross-process contention.

Schema migrations are tricky in this model — only one process should run them. Typically the main app does, and extensions assume the schema is already migrated.

Pitfalls

Long-running write transactions. Writes block other writes. A 5-second write means the next writer waits 5 seconds. Keep them short; do non-database work outside.

Synchronous reads on main thread. Even if reads parallelize, blocking the main thread while waiting for a connection is bad for UI. Use async or schedule on background.

Missing busy timeout configuration. Default is short; under contention, you’ll see locked errors. Tune for your app’s needs.

Holding readers too long. A read closure that does heavy work in Swift (not just queries) holds the reader connection. Other reads might wait for available connections. Keep closures fast.

Cross-process schema drift. Two processes running with different schemas (because one was updated, one wasn’t) corrupts data. Coordinate updates.

What to internalize

DatabasePool enables WAL: many readers, one writer, snapshot isolation. Reads parallelize; writes serialize. DatabaseQueue is single-connection. Long writes block other writes; long reads tie up reader connections. Default pool size 5; tune if needed. WAL files persist. Backup with dbPool.backup. Cross-process via App Groups; coordinate schema.


29. Performance: Statement Caching, Batches, Profiling

GRDB on top of SQLite is fast — but only if you use it well. The same database can be lightning-quick or punishingly slow depending on indexes, batching, and query construction. This section covers the performance tools and patterns.

Prepared statements

When you run the same query many times, parsing the SQL each time costs. SQLite supports prepared statements — parse once, execute many times.

GRDB caches parsed statements automatically. Running:

try Player.fetchAll(db)
try Player.fetchAll(db)
try Player.fetchAll(db)

Results in one SQL parse and three executions. The cache is per-Database connection.

For raw SQL, you can be explicit:

try dbPool.write { db in
    let statement = try db.makeStatement(sql: """
        INSERT INTO player (name, score) VALUES (?, ?)
        """)

    for (name, score) in playerData {
        try statement.execute(arguments: [name, score])
    }
}

Parsing happens once. Execution per row. Much faster than 1000 separate db.execute(sql:arguments:) calls.

Batch inserts

The classic optimization: insert many rows in one transaction.

// ❌ slow: N transactions
for player in players {
    try await dbPool.write { db in
        try player.insert(db)
    }
}

// ✅ fast: 1 transaction
try await dbPool.write { db in
    for player in players {
        var player = player
        try player.insert(db)
    }
}

The first version commits N times — each commit is fsync-expensive. The second commits once.

For 10,000 inserts, this can be 100x or more difference.

Batch with prepared statement

Combining batch + prepared statement:

try await dbPool.write { db in
    let statement = try db.makeStatement(sql: """
        INSERT INTO player (name, score, createdAt) VALUES (?, ?, ?)
        """)

    for player in players {
        try statement.execute(arguments: [player.name, player.score, player.createdAt])
    }
}

One transaction, one prepared statement, N executions. The fastest path for bulk inserts via raw SQL.

For records via the records API:

try await dbPool.write { db in
    for player in players {
        var player = player
        try player.insert(db)
    }
}

GRDB’s record-API also benefits from statement caching. Less explicit, similarly fast.

Read performance

For reads, the tools are:

  • Indexes. Cover your queries. See section 18.
  • relationshipKeyPathsForPrefetching equivalent. GRDB doesn’t have this exact API; instead use including(all: ...) to load related records efficiently.
  • fetchCount for counts. Don’t fetch and count.
  • fetchOne with LIMIT 1 for single results.
  • Cursors for huge results. fetchCursor instead of fetchAll when you process and discard.

A profile-and-fix loop:

  1. Identify a slow query (instrumentation, timing).
  2. Run EXPLAIN QUERY PLAN to see what SQLite does.
  3. Add indexes if needed.
  4. Restructure the query if needed.
  5. Re-measure.

EXPLAIN QUERY PLAN

try dbPool.read { db in
    let plan = try Row.fetchAll(db, sql: """
        EXPLAIN QUERY PLAN
        SELECT * FROM note WHERE folderId = ? ORDER BY updatedAt DESC LIMIT 20
        """, arguments: [42])
    for row in plan {
        print(row)
    }
}

Output:

SEARCH note USING INDEX noteByFolder (folderId=?)
USE TEMP B-TREE FOR ORDER BY

SEARCH note USING INDEX — good, the folderId index is used. But the ORDER BY uses a temporary B-tree (sorting in memory). For better performance, a composite index on (folderId, updatedAt DESC) would let SQLite read pre-sorted from the index.

After adding the composite index:

SEARCH note USING INDEX noteByFolderAndUpdatedAt (folderId=?)

No “USE TEMP B-TREE” — the order comes from the index. Faster.

SQL logging

In development, enable verbose logging:

var config = Configuration()
config.publicStatementArguments = true  // DEBUG only
let dbPool = try DatabasePool(path: path, configuration: config)

Now every executed SQL statement is logged with arguments. Useful for “what’s actually running?”

For production, leave this off — it logs sensitive data.

For programmatic logging, use GRDB’s tracing:

config.prepareDatabase { db in
    db.trace { event in
        // event.expandedDescription includes the SQL with arguments
        print("[SQL] \(event.expandedDescription)")
    }
}

This runs the closure on every query — useful for telemetry or specific debugging scenarios.

Profiling slow queries

A wrapper that logs slow queries:

extension DatabaseWriter {
    func readMonitored<T>(threshold: TimeInterval = 0.05,
                          _ block: (Database) throws -> T) async throws -> T {
        let start = ContinuousClock.now
        let result = try await self.read(block)
        let elapsed = start.duration(to: .now)
        let seconds = Double(elapsed.components.seconds) +
                      Double(elapsed.components.attoseconds) / 1e18
        if seconds > threshold {
            print("Slow read: \(seconds)s")
        }
        return result
    }
}

Replace dbPool.read with dbPool.readMonitored to log slow reads. In production, log to analytics instead of print.

Memory pressure

Long-running operations that hold many records in memory can trigger memory warnings. For huge result sets:

try await dbPool.read { db in
    let cursor = try Player.fetchCursor(db)
    while let player = try cursor.next() {
        // process one at a time, then release
    }
}

Cursors don’t materialize the entire result set. Memory stays low.

For paginated processing, see section 13’s keyset pagination — process one page at a time, release each before fetching the next.

Configuration tuning

A few Configuration settings for performance:

var config = Configuration()

// Increase the busy timeout for long writes (reduces "database locked" errors)
config.busyMode = .timeout(10)

// Set a larger reader pool for high-concurrency apps
config.maximumReaderCount = 10

Most defaults are well-tuned. Don’t over-tune unless you’ve measured a bottleneck.

PRAGMA tuning

SQLite has many PRAGMAs. A few useful ones:

config.prepareDatabase { db in
    try db.execute(sql: "PRAGMA cache_size = -2000")  // 2MB cache
    try db.execute(sql: "PRAGMA temp_store = MEMORY")  // temp tables in RAM
    try db.execute(sql: "PRAGMA synchronous = NORMAL")  // for WAL, NORMAL is safe and fast
}

cache_size = -2000 allocates 2MB of cache per connection (the “negative” syntax means kibibytes; positive would mean pages). For larger databases, a bigger cache helps repeat queries.

temp_store = MEMORY puts temp tables in RAM rather than disk — faster, but uses memory.

synchronous = NORMAL is the default for WAL and is generally optimal. Don’t reduce to OFF unless you accept data loss risk on crashes.

Vacuum

Over time, deletes and updates can leave the database file with empty space. VACUUM rebuilds the file, reclaiming space:

try dbPool.write { db in
    try db.execute(sql: "VACUUM")
}

Slow on large databases. Run rarely (e.g., before major version updates) or not at all (auto-vacuum is an alternative configurable in schema).

Profiling with Instruments

Xcode’s Instruments has a Time Profiler that, while not SQLite-specific, shows where your app spends time. Heavy SQL execution shows up as CPU time in the GRDB queue.

Run a typical workload, capture a profile, find hot spots. Often it’s a missing index or unnecessary work in a tight loop.

Performance recipe

When something’s slow:

  1. Reproduce in development with realistic data.
  2. Time the operation.
  3. Run EXPLAIN QUERY PLAN for any SQL that seems slow.
  4. Check indexes — are they used?
  5. Check batching — are you doing N queries when 1 would do?
  6. Check transactions — are you committing too often?
  7. Check Swift code — is the bottleneck actually Swift work, not SQL?
  8. Profile with Instruments if unclear.

Most performance fixes come from one of: missing index, unbatched writes, fetching too much data, or doing computation per-row that should be once-per-batch.

Pitfalls

N+1 in loops. for note in notes { fetch note's folder } is N queries. Use eager loading or pre-fetch.

Forgetting to batch writes. Each dbPool.write commits. Many small writes = many commits = many fsyncs.

Indexes on the wrong columns. Index column you query, not column you index “just in case.”

Heavy work inside read closures. The reader connection is held for the closure’s duration. Heavy Swift work inside delays releasing.

Profiling on debug builds. Debug SQLite is slower than release. Profile release builds for accurate numbers.

What to internalize

GRDB is fast on top of SQLite if you use it right. Indexes are the biggest single performance lever — see EXPLAIN QUERY PLAN. Batch inserts in single transactions; use prepared statements for repeated queries. fetchCount for counts; fetchOne with LIMIT 1; cursors for huge results. Profile with SQL logging and Instruments. Most slow code traces to a missing index or unbatched writes.


30. Encryption with SQLCipher

For sensitive data — health records, financial info, anything regulated — encrypted storage is sometimes required. GRDB integrates with SQLCipher, a SQLite extension that transparently encrypts the entire database file with AES-256.

Setting up SQLCipher with GRDB takes a few extra steps but no code changes once configured.

What SQLCipher provides

  • Full-database encryption. Every page of the database is encrypted on disk.
  • Transparent in use. Once you provide the passphrase, queries work normally.
  • AES-256 in CBC mode. Industry-standard cipher.
  • Key derivation via PBKDF2. Resistant to brute-forcing.

What it doesn’t do: protect data in memory. Once decrypted, query results are plaintext in your app’s RAM. Combine with iOS file protection for at-rest + at-runtime security.

Adding SQLCipher

Use the SQLCipher product instead of plain GRDB. In Package.swift dependencies:

.package(url: "https://github.com/groue/GRDB.swift", from: "6.0.0"),

Then in your target, use GRDB-SQLCipher:

.product(name: "GRDB-SQLCipher", package: "GRDB.swift")

(Confirm the exact product name from the current GRDB documentation.)

You can’t have both regular GRDB and GRDB-SQLCipher in the same target — they conflict.

Setting the passphrase

Configure the database to use a passphrase:

import GRDB

var config = Configuration()
config.prepareDatabase { db in
    try db.usePassphrase("my-secret-passphrase")
}

let dbPool = try DatabasePool(path: dbURL.path, configuration: config)

prepareDatabase runs once per connection (writer + each reader). The passphrase is set before any queries. SQLCipher uses it to decrypt pages on read and encrypt on write.

Without the correct passphrase, the file looks like random bytes — opening fails.

Where to store the passphrase

The passphrase is the security boundary. Anyone who has it can read the database.

Common storage:

  • Generated and stored in Keychain. The app generates a random passphrase, stores in Keychain (with appropriate access flags), uses on each launch. The passphrase is unique per install; stolen file without Keychain access can’t be decrypted.

  • Derived from user credentials. A password the user enters, run through PBKDF2, used as the passphrase. The user must enter the password on every cold launch. Strongest user-controlled security.

  • Hardcoded. Don’t. The passphrase is in the binary; trivially extractable.

A Keychain-based passphrase setup:

import Security

func getOrCreatePassphrase() throws -> String {
    let key = "com.example.MyApp.dbPassphrase"

    // Try to read existing passphrase
    var query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne,
    ]

    var result: AnyObject?
    let status = SecItemCopyMatching(query as CFDictionary, &result)

    if status == errSecSuccess, let data = result as? Data,
       let passphrase = String(data: data, encoding: .utf8) {
        return passphrase
    }

    // Generate new passphrase
    let bytes = (0..<32).map { _ in UInt8.random(in: 0...255) }
    let passphrase = Data(bytes).base64EncodedString()

    let addQuery: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecValueData as String: passphrase.data(using: .utf8)!,
        kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
    ]

    let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
    guard addStatus == errSecSuccess else {
        throw NSError(domain: "Keychain", code: Int(addStatus))
    }

    return passphrase
}

The kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly flag means the passphrase is accessible after the device is first unlocked since boot, but not before. This pairs with database file protection of URLFileProtection.completeUntilFirstUserAuthentication.

Use:

let passphrase = try getOrCreatePassphrase()

var config = Configuration()
config.prepareDatabase { db in
    try db.usePassphrase(passphrase)
}

let dbPool = try DatabasePool(path: dbURL.path, configuration: config)

Migrating an unencrypted database to encrypted

If you have an existing unencrypted database and want to encrypt it:

// Open the unencrypted source
let oldPool = try DatabasePool(path: oldPath)

// Create a new encrypted destination
var config = Configuration()
config.prepareDatabase { db in
    try db.usePassphrase(newPassphrase)
}
let newPool = try DatabasePool(path: newPath, configuration: config)

// Use SQLCipher's special EXPORT command
try oldPool.write { db in
    try db.execute(sql: "ATTACH DATABASE ? AS encrypted KEY ?",
                   arguments: [newPath, newPassphrase])
    try db.execute(sql: "SELECT sqlcipher_export('encrypted')")
    try db.execute(sql: "DETACH DATABASE encrypted")
}

// Now newPath is encrypted. Delete the old.

sqlcipher_export clones the schema and data into the attached database. The attached database is opened with the encryption key, so the result is encrypted.

Performance implications

Encryption has a cost. Every read decrypts; every write encrypts. Typical overhead:

  • 5-15% slower reads.
  • 5-15% slower writes.

For most iOS apps, this is unnoticeable. For high-throughput databases (heavy bulk import, complex analytics), the cost adds up.

Changing the passphrase

try dbPool.write { db in
    try db.changePassphrase("new-passphrase")
}

Re-encrypts the database with the new passphrase. The old passphrase is no longer valid. Useful if:

  • A user changes their password.
  • You suspect compromise of the old passphrase.

This rebuilds the database — slow on large files.

Combining with iOS file protection

For maximum security, combine SQLCipher with file-system encryption:

let dbURL = URL(fileURLWithPath: dbPath)
try (dbURL as NSURL).setResourceValue(
    URLFileProtection.completeUntilFirstUserAuthentication,
    forKey: .fileProtectionKey
)

Layered defense:

  • File system protection: data is encrypted on disk by iOS, key in Secure Enclave.
  • SQLCipher: data is also encrypted by your app, key in your Keychain.

If the device is fully compromised (root + Keychain), SQLCipher alone won’t help. But for typical threat models (lost device, file extracted from backup), the combination is strong.

Tests

For tests, you can use SQLCipher with an in-memory database:

extension AppDatabase {
    static func makeForTests() throws -> AppDatabase {
        var config = Configuration()
        config.prepareDatabase { db in
            try db.usePassphrase("test-passphrase")
        }
        let dbQueue = try DatabaseQueue(configuration: config)
        return try AppDatabase(dbQueue)
    }
}

Tests run with encryption enabled. Slightly slower than unencrypted but exercises the same code path.

For perf-sensitive tests, you can skip encryption with a separate test-only configuration.

Pitfalls

Forgetting prepareDatabase. Without setting the passphrase, queries fail with “file is not a database” (since the bytes are encrypted). Easy mistake.

Mismatched passphrases. If your reader connections (in a pool) somehow get different passphrases, queries fail unpredictably. The prepareDatabase runs for every connection — it must always set the same passphrase.

Storing passphrase insecurely. Hardcoded, in plain UserDefaults, in a config file in the bundle — all defeat the purpose. Use Keychain.

Backup considerations. An encrypted database backed up to iCloud (via Documents directory) is encrypted at rest in iCloud, but if you restore on a new device without the Keychain entry, you can’t open it. Plan for this.

Performance regressions. If you add SQLCipher and don’t measure, you might be surprised by slower writes. Profile.

What to internalize

SQLCipher provides full-database encryption for SQLite. Use the GRDB-SQLCipher product. Set the passphrase via prepareDatabase closure. Store the passphrase in Keychain with appropriate access flags. Combine with iOS file protection for layered defense. Migrate existing databases via sqlcipher_export. Performance overhead is typically 5-15%.


31. Common Gotchas and Anti-Patterns

A reference list of mistakes — things that bite real GRDB apps. When something’s off, check this list.

Schema-level mistakes

Forgetting foreign-key indexes. A references("folder") doesn’t auto-create an index. Without one, JOINs and cascade deletes are slow. Add .indexed() or a separate index.

Foreign keys pointing to the wrong column. Default is the referenced table’s primary key. If you mean a different column, specify explicitly via references("folder", column: "altKey").

Schema changes outside migrations. Tables created by db.execute(sql: "CREATE TABLE...") outside a migration aren’t tracked. Always use migrations.

Renaming a migration after release. Breaks tracking; existing users have it under the old name. Don’t.

Putting business logic in CHECK constraints. Validation is fine, but heavy logic in CHECK runs on every write. Keep them simple.

Missing UNIQUE on natural keys. A user.email column without UNIQUE allows duplicates. Most natural keys want uniqueness.

Concurrency mistakes

Calling write inside read. Throws — GRDB detects nesting. Don’t nest different access types.

Holding Database across closures. The db parameter is invalid outside its closure. Don’t capture and use later.

Long writes blocking other writers. Writes serialize; a 5-second write means the next writer waits. Keep writes short.

Synchronous operations on main thread. Even fast queries can hitch the UI. Use async forms or schedule on background.

Cross-process schema drift. Two processes (main + extension) running with different schemas corrupt data. Coordinate.

Record mistakes

Forgetting didInsert. The autoincrement ID isn’t captured; subsequent operations fail.

Mismatched CodingKeys. Property names that don’t match column names produce silent decode failures.

Wrong MutablePersistableRecord vs PersistableRecord. Use mutable for autoincrement; immutable when you set IDs explicitly.

Class-based records when struct would do. Adds complexity without proportional benefit. Prefer structs.

Forgetting databaseTableName for non-conventional names. Default is lowercased type name; override if your table is different.

Query mistakes

fetchAll().count instead of fetchCount(). Materializes everything for a count.

Missing ORDER BY when using LIMIT. Results are non-deterministic.

LIKE with user input not sanitized. % and _ from user input are wildcards. For search, use FTS5.

Predicates that defeat indexes. WHERE LOWER(name) = ? doesn’t use an index on name. Use a generated column with LOWER(name) and index that.

N+1 in loops. for note in notes { fetch note's folder } is N+1. Use eager loading.

Forgetting including for related data. Without it, you do separate queries for related records.

Migration mistakes

Not setting eraseDatabaseOnSchemaChange = false in release. If you accidentally ship with it on, users lose data.

Migration that throws from data corruption. Throws abort the migration; the database stays at the old version. Test with realistic data.

Backfilling that takes minutes. Long migrations freeze app launch. Show progress UI; consider deferred migration for large datasets.

Skipping versions. If V1 → V3 needs V1 → V2 → V3 stages, ensure all are registered.

Observation mistakes

Forgetting [weak self]. Strong capture creates retain cycles.

Not retaining the cancellable. Observation is immediately cancelled.

Long-running tracking closures. Each change re-runs the closure; if it’s slow, every change is slow.

Heavy work in onChange. The callback fires on every change; heavy work creates UI hitches.

Forgetting Equatable on result types. Without it, GRDB can’t de-dupe, every database change triggers a callback.

SwiftUI integration mistakes

Re-creating Query/observation per render. body runs many times; creating the observation in body creates a new one each time. Use property wrappers or @State.

Forgetting .databaseContext (with GRDBQuery). @Query has nothing to read from.

Passing DatabaseWriter directly to views. Couples views to GRDB. Use a repository or store.

Mixing direct @Query and repository in the same app. Inconsistent architecture. Pick one approach per layer.

Performance mistakes

No batch transactions. N small writes = N fsyncs. Wrap in a single transaction.

No prepared statements for repeated queries. Wastes parsing time.

Indexes on tiny tables. SQLite’s optimizer may ignore them; the index is overhead for nothing.

Indexes on every column “just in case”. Slows writes; doesn’t help reads not actually using them.

Heavy work inside read closures. Holds the reader connection longer than needed.

Reads on the main thread for non-trivial queries. Hitches UI.

Architecture mistakes

Singleton database accessed everywhere. Coupling. Use a wrapper passed via DI.

Views directly using dbPool.read/write. No abstraction; impossible to test views in isolation.

Repository protocol with SwiftData/GRDB types. Defeats the protocol’s purpose. Repository should expose DTOs.

Mock repositories that don’t match production semantics. Tests pass with mocks but fail with real DB. Keep them aligned.

Debugging mistakes

Not using SQL logging. Configuration.publicStatementArguments = true shows what’s running. Invaluable.

Not running EXPLAIN QUERY PLAN. For slow queries, this is the first diagnostic.

Profiling debug builds. Debug SQLite is slower. Profile release.

Ignoring console warnings. GRDB logs useful info. Read it.

What to internalize

Most bugs come from a small number of patterns: missing indexes, unbatched writes, foreign keys without indexes, observation closures that capture self strongly, schema changes outside migrations, renaming migrations after release. Check these first when something’s off.


32. Where to Go Deeper

You’ve worked through GRDB end to end. Here are the resources, ordered by impact.

Apple-adjacent

GRDB is third-party, but it’s compatible with SQLite, which Apple ships. So:

  • Apple’s SQLite documentation. sqlite.org has comprehensive docs.
  • iOS file system documentation. For paths, App Groups, file protection.

GRDB itself

  • The GitHub repo: github.com/groue/GRDB.swift. The README is excellent, the docs are thorough, the issue tracker is active.
  • GRDB’s own documentation. Detailed guides on every feature: schema, observations, FTS, etc.
  • GRDBQuery: github.com/groue/GRDBQuery. The SwiftUI companion.
  • The discussions and issues. Search before asking; many questions have been answered.

SQLite

GRDB exposes SQLite well, so understanding SQLite makes you better at GRDB:

  • sqlite.org documentation. The official site has comprehensive coverage of every feature.
  • “The Definitive Guide to SQLite” by Mike Owens. A book-length treatment.
  • Use The Index, Luke! (use-the-index-luke.com). Excellent free resource on database indexing in general.

Articles and blogs

  • Donny Wals’s blog. Has GRDB articles alongside SwiftData and Core Data coverage.
  • Gwendal Roué (GRDB’s author) on Medium and the Swift Forums. Direct guidance from the source.
  • iOS-specific GRDB tutorials on Hacking with Swift, Kodeco, and other tutorial sites.

When to use GRDB vs alternatives

A few scenarios where another tool fits better:

  • You want CloudKit sync with one line of code. Use SwiftData or Core Data. GRDB doesn’t have built-in CloudKit.
  • You want maximum SwiftUI ergonomics with zero boilerplate. SwiftData’s @Query is shorter than GRDB+GRDBQuery.
  • Your team strongly prefers visual schema editors. Core Data’s .xcdatamodeld is unmatched here.
  • You target iOS < 13. GRDB supports older iOS, but check current minimums.

A few where GRDB shines:

  • You want full SQL control. GRDB doesn’t hide it.
  • You need cross-platform compatibility. SQLite is everywhere.
  • You need testability. In-memory databases are trivial.
  • You want fine performance control. SQL is in your face.
  • You’re comfortable with SQL. GRDB rewards SQL fluency.
  • You need full-text search. FTS5 is excellent.
  • You need encryption. SQLCipher integration is straightforward.

Things to build

Reading is half. Building is the other half. Practice projects:

  • A note-taking app with folders, tags, search via FTS5. Hits most of the framework.
  • A budget tracker with categories, transactions, date ranges. Tests indexes and predicates.
  • A media library with thumbnails as Data, derived attributes, file paths. Exercises binary data and computed values.
  • A migration test app: ship v1, then v2 with a complex schema change. Verify users’ data survives.
  • A widget + extension setup with a shared encrypted store. Practices App Groups, SQLCipher, cross-process concerns.
  • A read-heavy reference app with bundled prepopulated data. Exercises read-only databases and bundled SQLite files.

Building forces you to confront trade-offs. The first GRDB app feels straightforward. The fifth one — with real concurrency, real testing, real migration, real encryption — is where the framework’s depth shows.

Final thoughts

GRDB occupies a distinctive position in the Swift database landscape. It doesn’t try to be Core Data; it doesn’t try to be SwiftData; it doesn’t try to be Realm. It’s a Swift wrapper around SQLite that respects what SQLite is — fast, reliable, embedded, sophisticated — and exposes it well.

The trade-off is that you bring SQL knowledge to the table, or learn it as you go. For developers who like control and transparency, this is a feature. For developers who want maximum hand-holding, Core Data or SwiftData fits better.

Both schools are valid. GRDB users tend to come from one of two backgrounds: long-time iOS developers tired of Core Data’s opacity, and back-end developers who already know SQL well. If you’re either, GRDB will feel like home.

The patterns we’ve covered — DatabasePool for parallelism, records for ergonomic CRUD, the query interface for type-safe queries, associations for relationships, ValueObservation for reactive UI, the repository pattern for testable architecture, FTS5 for search, SQLCipher for encryption — are the building blocks. Real apps combine them in many ways.

The first GRDB app might feel like more work than SwiftData. By the third, you’re moving fast — and you have full visibility into every query, every commit, every observation. That visibility is valuable.

Things will work. Then you’ll find a slow query. You’ll EXPLAIN QUERY PLAN it, add an index, and move on. Then a migration will surprise you. You’ll add a migration test, find the bug, fix it. Each cycle teaches you the framework deeper. Eventually GRDB feels like a tool you can pick up confidently for any persistence problem on Apple platforms — and SQL becomes part of your toolkit, not a foreign language.

Good luck, and have fun.

End of Document