---
name: axiom-grdb
description: Use when writing raw SQL queries with GRDB, complex joins, ValueObservation for reactive queries, DatabaseMigrator patterns, query profiling under performance pressure, or dropping down from SQLiteData for performance - direct SQLite access for iOS/macOS
license: MIT
metadata:
  version: "1.1.0"
  last-updated: "TDD-tested with complex query performance scenarios"
---

# GRDB

## Overview

Direct SQLite access using [GRDB.swift](https://github.com/groue/GRDB.swift) — a toolkit for SQLite databases with type-safe queries, migrations, and reactive observation.

**Core principle** Type-safe Swift wrapper around raw SQL with full SQLite power when you need it.

**Requires** iOS 13+, Swift 5.7+
**License** MIT (free and open source)

## When to Use GRDB

#### Use raw GRDB when you need
- ✅ Complex SQL joins across 4+ tables
- ✅ Window functions (ROW_NUMBER, RANK, LAG/LEAD)
- ✅ Reactive queries with ValueObservation
- ✅ Full control over SQL for performance
- ✅ Advanced migration logic beyond schema changes

**Note:** SQLiteData now supports GROUP BY (`.group(by:)`) and HAVING (`.having()`) via the query builder — see the `axiom-sqlitedata-ref` skill.

#### Use SQLiteData instead when
- Type-safe `@Table` models are sufficient
- CloudKit sync needed
- Prefer declarative queries over SQL

#### Use SwiftData when
- Simple CRUD with native Apple integration
- Don't need raw SQL control

**For migrations** See the `axiom-database-migration` skill for safe schema evolution patterns.

## Example Prompts

These are real questions developers ask that this skill is designed to answer:

#### 1. "I need to query messages with their authors and count of reactions in one query. How do I write the JOIN?"
→ The skill shows complex JOIN queries with multiple tables and aggregations

#### 2. "I want to observe a filtered list and update the UI whenever notes with a specific tag change."
→ The skill covers ValueObservation patterns for reactive query updates

#### 3. "I'm importing thousands of chat records and need custom migration logic. How do I use DatabaseMigrator?"
→ The skill explains migration registration, data transforms, and safe rollback patterns

#### 4. "My query is slow (takes 10+ seconds). How do I profile and optimize it?"
→ The skill covers EXPLAIN QUERY PLAN, database.trace for profiling, and index creation

#### 5. "I need to fetch tasks grouped by due date with completion counts, ordered by priority. Raw SQL seems easier than type-safe queries."
→ The skill demonstrates when GRDB's raw SQL is clearer than type-safe wrappers

---

## Database Setup

### DatabaseQueue (Single Connection)

```swift
import GRDB

// File-based database
let dbPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
let dbQueue = try DatabaseQueue(path: "\(dbPath)/db.sqlite")

// In-memory database (tests)
let dbQueue = try DatabaseQueue()
```

### DatabasePool (Connection Pool)

```swift
// For apps with heavy concurrent access
let dbPool = try DatabasePool(path: dbPath)
```

**Use Queue for** Most apps (simpler, sufficient)
**Use Pool for** Heavy concurrent writes from multiple threads

## Record Types

### Using Codable

```swift
struct Track: Codable {
    var id: String
    var title: String
    var artist: String
    var duration: TimeInterval
}

// Fetch
let tracks = try dbQueue.read { db in
    try Track.fetchAll(db, sql: "SELECT * FROM tracks")
}

// Insert
try dbQueue.write { db in
    try track.insert(db)  // Codable conformance provides insert
}
```

### FetchableRecord (Read-Only)

```swift
struct TrackInfo: FetchableRecord {
    var title: String
    var artist: String
    var albumTitle: String

    init(row: Row) {
        title = row["title"]
        artist = row["artist"]
        albumTitle = row["album_title"]
    }
}

let results = try dbQueue.read { db in
    try TrackInfo.fetchAll(db, sql: """
        SELECT tracks.title, tracks.artist, albums.title as album_title
        FROM tracks
        JOIN albums ON tracks.albumId = albums.id
        """)
}
```

### PersistableRecord (Write)

```swift
struct Track: Codable, PersistableRecord {
    var id: String
    var title: String

    // Customize table name
    static let databaseTableName = "tracks"
}

try dbQueue.write { db in
    var track = Track(id: "1", title: "Song")
    try track.insert(db)

    track.title = "Updated"
    try track.update(db)

    try track.delete(db)
}
```

## Raw SQL Queries

### Reading Data

```swift
// Fetch all rows
let rows = try dbQueue.read { db in
    try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"])
}

// Fetch single value
let count = try dbQueue.read { db in
    try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tracks")
}

// Fetch into Codable
let tracks = try dbQueue.read { db in
    try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY title")
}
```

### Writing Data

```swift
try dbQueue.write { db in
    try db.execute(sql: """
        INSERT INTO tracks (id, title, artist, duration)
        VALUES (?, ?, ?, ?)
        """, arguments: ["1", "Song", "Artist", 240])
}
```

### Transactions

```swift
try dbQueue.write { db in
    // Automatic transaction - all or nothing
    for track in tracks {
        try track.insert(db)
    }
    // Commits automatically on success, rolls back on error
}
```

## Type-Safe Query Interface

### Filtering

```swift
let request = Track
    .filter(Column("genre") == "Rock")
    .filter(Column("duration") > 180)

let tracks = try dbQueue.read { db in
    try request.fetchAll(db)
}
```

### Sorting

```swift
let request = Track
    .order(Column("title").asc)
    .limit(10)
```

### Joins

```swift
struct TrackWithAlbum: FetchableRecord {
    var trackTitle: String
    var albumTitle: String
}

let request = Track
    .joining(required: Track.belongsTo(Album.self))
    .select(Column("title").forKey("trackTitle"), Column("album_title").forKey("albumTitle"))

let results = try dbQueue.read { db in
    try TrackWithAlbum.fetchAll(db, request)
}
```

## Complex Joins

```swift
let sql = """
    SELECT
        tracks.title as track_title,
        albums.title as album_title,
        artists.name as artist_name,
        COUNT(plays.id) as play_count
    FROM tracks
    JOIN albums ON tracks.albumId = albums.id
    JOIN artists ON albums.artistId = artists.id
    LEFT JOIN plays ON plays.trackId = tracks.id
    WHERE artists.genre = ?
    GROUP BY tracks.id
    HAVING play_count > 10
    ORDER BY play_count DESC
    LIMIT 50
    """

struct TrackStats: FetchableRecord {
    var trackTitle: String
    var albumTitle: String
    var artistName: String
    var playCount: Int

    init(row: Row) {
        trackTitle = row["track_title"]
        albumTitle = row["album_title"]
        artistName = row["artist_name"]
        playCount = row["play_count"]
    }
}

let stats = try dbQueue.read { db in
    try TrackStats.fetchAll(db, sql: sql, arguments: ["Rock"])
}
```

## ValueObservation (Reactive Queries)

### Basic Observation

```swift
import GRDB
import Combine

let observation = ValueObservation.tracking { db in
    try Track.fetchAll(db)
}

// Start observing with Combine
let cancellable = observation.publisher(in: dbQueue)
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { tracks in
            print("Tracks updated: \(tracks.count)")
        }
    )
```

### SwiftUI Integration

```swift
import GRDB
import GRDBQuery  // https://github.com/groue/GRDBQuery

@Query(Tracks())
var tracks: [Track]

struct Tracks: Queryable {
    static var defaultValue: [Track] { [] }

    func publisher(in dbQueue: DatabaseQueue) -> AnyPublisher<[Track], Error> {
        ValueObservation
            .tracking { db in try Track.fetchAll(db) }
            .publisher(in: dbQueue)
            .eraseToAnyPublisher()
    }
}
```

**See** [GRDBQuery documentation](https://github.com/groue/GRDBQuery) for SwiftUI reactive bindings.

### Filtered Observation

```swift
func observeGenre(_ genre: String) -> ValueObservation<[Track]> {
    ValueObservation.tracking { db in
        try Track
            .filter(Column("genre") == genre)
            .fetchAll(db)
    }
}

let cancellable = observeGenre("Rock")
    .publisher(in: dbQueue)
    .sink { tracks in
        print("Rock tracks: \(tracks.count)")
    }
```

## Migrations

### DatabaseMigrator

```swift
var migrator = DatabaseMigrator()

// Migration 1: Create tables
migrator.registerMigration("v1") { db in
    try db.create(table: "tracks") { t in
        t.column("id", .text).primaryKey()
        t.column("title", .text).notNull()
        t.column("artist", .text).notNull()
        t.column("duration", .real).notNull()
    }
}

// Migration 2: Add column
migrator.registerMigration("v2_add_genre") { db in
    try db.alter(table: "tracks") { t in
        t.add(column: "genre", .text)
    }
}

// Migration 3: Add index
migrator.registerMigration("v3_add_indexes") { db in
    try db.create(index: "idx_genre", on: "tracks", columns: ["genre"])
}

// Run migrations
try migrator.migrate(dbQueue)
```

**For migration safety patterns** See the `axiom-database-migration` skill.

### Migration with Data Transform

```swift
migrator.registerMigration("v4_normalize_artists") { db in
    // 1. Create new table
    try db.create(table: "artists") { t in
        t.column("id", .text).primaryKey()
        t.column("name", .text).notNull()
    }

    // 2. Extract unique artists
    try db.execute(sql: """
        INSERT INTO artists (id, name)
        SELECT DISTINCT
            lower(replace(artist, ' ', '_')) as id,
            artist as name
        FROM tracks
        """)

    // 3. Add foreign key to tracks
    try db.alter(table: "tracks") { t in
        t.add(column: "artistId", .text)
            .references("artists", onDelete: .cascade)
    }

    // 4. Populate foreign keys
    try db.execute(sql: """
        UPDATE tracks
        SET artistId = (
            SELECT id FROM artists
            WHERE artists.name = tracks.artist
        )
        """)
}
```

## Performance Patterns

### Batch Writes

```swift
try dbQueue.write { db in
    for batch in tracks.chunked(into: 500) {
        for track in batch {
            try track.insert(db)
        }
    }
}
```

### Prepared Statements

```swift
try dbQueue.write { db in
    let statement = try db.makeStatement(sql: """
        INSERT INTO tracks (id, title, artist, duration)
        VALUES (?, ?, ?, ?)
        """)

    for track in tracks {
        try statement.execute(arguments: [track.id, track.title, track.artist, track.duration])
    }
}
```

### Indexes

```swift
try db.create(index: "idx_tracks_artist", on: "tracks", columns: ["artist"])
try db.create(index: "idx_tracks_genre_duration", on: "tracks", columns: ["genre", "duration"])

// Unique index
try db.create(index: "idx_tracks_unique_title", on: "tracks", columns: ["title"], unique: true)
```

### Query Planning

```swift
// Analyze query performance
let explanation = try dbQueue.read { db in
    try String.fetchOne(db, sql: "EXPLAIN QUERY PLAN SELECT * FROM tracks WHERE artist = ?", arguments: ["Artist"])
}
print(explanation)
```

## Dropping Down from SQLiteData

When using SQLiteData but need GRDB for specific operations:

```swift
import SQLiteData
import GRDB

@Dependency(\.database) var database  // SQLiteData Database

// Access underlying GRDB DatabaseQueue
try await database.database.write { db in
    // Full GRDB power here
    try db.execute(sql: "CREATE INDEX idx_genre ON tracks(genre)")
}
```

#### Common scenarios
- Complex JOIN queries
- Custom migrations
- Bulk SQL operations
- ValueObservation setup

## Quick Reference

### Common Operations

```swift
// Read single value
let count = try db.fetchOne(Int.self, sql: "SELECT COUNT(*) FROM tracks")

// Read all rows
let rows = try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"])

// Write
try db.execute(sql: "INSERT INTO tracks VALUES (?, ?, ?)", arguments: [id, title, artist])

// Transaction
try dbQueue.write { db in
    // All or nothing
}

// Observe changes
ValueObservation.tracking { db in
    try Track.fetchAll(db)
}.publisher(in: dbQueue)
```

## Resources

**GitHub**: groue/GRDB.swift, groue/GRDBQuery

**Docs**: sqlite.org/docs.html

**Skills**: axiom-database-migration, axiom-sqlitedata, axiom-swiftdata

## Production Performance: Query Optimization Under Pressure

### Red Flags — When GRDB Queries Slow Down

If you see ANY of these symptoms:
- ❌ Complex JOIN query takes 10+ seconds
- ❌ ValueObservation runs on every single change (battery drain)
- ❌ Can't explain why migration ran twice on old version

#### DO NOT
1. Blindly add indexes (don't know which columns help)
2. Move logic to Swift (premature escape from database)
3. Over-engineer migrations (distrust the system)

#### DO
1. Profile with `database.trace`
2. Use `EXPLAIN QUERY PLAN` to understand execution
3. Trust GRDB's migration versioning system

### Profiling Complex Queries

#### When query is slow (10+ seconds)

```swift
var database = try DatabaseQueue(path: dbPath)

// Enable tracing to see SQL execution
database.trace { print($0) }

// Run the slow query
try database.read { db in
    let results = try Track.fetchAll(db)  // Watch output for execution time
}

// Use EXPLAIN QUERY PLAN to understand execution:
try database.read { db in
    let plan = try String(fetching: db, sql: "EXPLAIN QUERY PLAN SELECT ...")
    print(plan)
    // Look for SCAN (slow, full table) vs SEARCH (fast, indexed)
}
```

#### Add indexes strategically

```swift
// Add index on frequently queried column
try database.write { db in
    try db.execute(sql: "CREATE INDEX idx_plays_track_id ON plays(track_id)")
}
```

#### Time cost
- Profile: 10 min (enable trace, run query, read output)
- Understand: 5 min (interpret EXPLAIN QUERY PLAN)
- Fix: 5 min (add index)
- **Total: 20 minutes** (vs 30+ min blindly trying solutions)

### ValueObservation Performance

#### When using reactive queries, know the costs

```swift
// Re-evaluates query on ANY write to database
ValueObservation.tracking { db in
    try Track.fetchAll(db)
}.start(in: database, onError: { }, onChange: { tracks in
    // Called for every change — CPU spike!
})
```

#### Optimization patterns

```swift
// Coalesce rapid updates (recommended)
ValueObservation.tracking { db in
    try Track.fetchAll(db)
}.removeDuplicates()  // Skip duplicate results
 .debounce(for: 0.5, scheduler: DispatchQueue.main)  // Batch updates
 .start(in: database, ...)
```

#### Decision framework
- Small datasets (<1000 records): Use plain `.tracking`
- Medium datasets (1-10k records): Add `.removeDuplicates()` + `.debounce()`
- Large datasets (10k+ records): Use explicit table dependencies or predicates

### Migration Versioning Guarantees

#### Trust GRDB's DatabaseMigrator - it prevents re-running migrations

```swift
var migrator = DatabaseMigrator()

migrator.registerMigration("v1_initial") { db in
    try db.execute(sql: "CREATE TABLE tracks (...)")
}

migrator.registerMigration("v2_add_plays") { db in
    try db.execute(sql: "CREATE TABLE plays (...)")
}

// GRDB guarantees:
// - Each migration runs exactly ONCE
// - In order (v1, then v2)
// - Safe to call migrate() multiple times
try migrator.migrate(dbQueue)
```

#### You don't need defensive SQL (IF NOT EXISTS)
- GRDB tracks which migrations have run
- Running `migrate()` twice only executes new ones
- Over-engineering adds complexity without benefit

#### Trust it.

---

## Common Mistakes

### ❌ Not using transactions for batch writes
```swift
for track in 50000Tracks {
    try dbQueue.write { db in try track.insert(db) }  // 50k transactions!
}
```
**Fix** Single transaction with batches

### ❌ Synchronous database access on main thread
```swift
let tracks = try dbQueue.read { db in try Track.fetchAll(db) }  // Blocks UI
```
**Fix** Use async/await or dispatch to background queue

### ❌ Forgetting to add indexes
```swift
// Slow query without index
try Track.filter(Column("genre") == "Rock").fetchAll(db)
```
**Fix** Create indexes on frequently queried columns

### ❌ N+1 queries
```swift
for track in tracks {
    let album = try Album.fetchOne(db, key: track.albumId)  // N queries!
}
```
**Fix** Use JOIN or batch fetch

## tvOS

**Local GRDB databases are not persistent on tvOS.** The system deletes Caches (including Application Support) under storage pressure. A local-only GRDB database will lose all data between app launches.

**If targeting tvOS**, pair GRDB with CloudKit sync (via CKSyncEngine or SQLiteData's SyncEngine) so iCloud is the persistent store and the local database is a rebuildable cache. See `axiom-tvos` for full tvOS storage constraints.

---

**Targets:** iOS 13+, Swift 5.7+
**Framework:** GRDB.swift 6.0+
**History:** See git log for changes
