min read

Building Offline-First iOS Apps with GRDB

Why I use SQLiteData + GRDB for Systemic, and the local-first patterns that keep queries fast, testable, and predictable.

Building Offline-First iOS Apps with GRDB

SQLiteData GRDB SwiftUI StructuredQueries

When building Systemic, I needed local persistence that was fast, explicit, and easy to reason about. I chose SQLiteData + GRDB so I could keep full control over queries, migrations, and performance without a backend.

Why SQLiteData + GRDB

GRDB gives a reliable SQLite core, and SQLiteData adds a clean Swift model layer on top:

  • Explicit queries with predictable performance
  • Type-safe models using @Table
  • Simple migrations with DatabaseMigrator
  • Local-first by design (sync can be layered in later)

Core Patterns

1) Type-Safe Models

@Table
struct HabitEntry: Identifiable, Equatable {
    let id: UUID
    var date: Date = Date()
    var isCompleted: Bool = false
    var notes: String?
    var completedAt: Date?
    var isAutoCompleted: Bool = false
    var autoTrackValue: Double?

    var habitID: Habit.ID
}

2) Query Helpers

I keep reusable queries in a dedicated file so views stay clean:

extension HabitEntry {
    nonisolated static func lastDays(_ count: Int) -> Where<HabitEntry> {
        let calendar = Calendar.current
        let today = calendar.startOfDay(for: Date())
        guard let startDate = calendar.date(byAdding: .day, value: -(count - 1), to: today) else {
            return Self.where { _ in false }
        }
        let start = calendar.startOfDay(for: startDate)
        return Self.where { $0.date >= start && $0.date <= today }
    }
}

@FetchAll(HabitEntry.lastDays(30)) private var habitEntries: [HabitEntry]

3) Database Access via Dependencies

Reads and writes go through a single injected database:

@Dependency(\.defaultDatabase) private var database

func loadHabits() async throws -> [Habit] {
    try await database.read { db in
        try Habit.activeOrdered.fetchAll(db)
    }
}

4) Explicit Migrations

Migrations are versioned and fully explicit (no magic):

var migrator = DatabaseMigrator()

migrator.registerMigration("v1_create_tables") { db in
    try #sql("""
        CREATE TABLE IF NOT EXISTS "habits" (
            "id" TEXT PRIMARY KEY NOT NULL,
            "name" TEXT NOT NULL DEFAULT '',
            "isActive" INTEGER NOT NULL DEFAULT 1
        ) STRICT
        """).execute(db)
}

Analytics Without Heavy Queries

Instead of running expensive queries on every view render, I compute analytics on pre-fetched entries:

func completionRate(
    days: Int,
    entries: [HabitEntry],
    calendar: Calendar = .current
) -> Double {
    guard days > 0 else { return 0 }
    let today = calendar.startOfDay(for: Date())
    guard let startDate = calendar.date(byAdding: .day, value: -(days - 1), to: today) else {
        return 0
    }

    let completedDays = Set(
        entries
            .filter { $0.habitID == id && $0.isCompleted && calendar.startOfDay(for: $0.date) >= startDate }
            .map { calendar.startOfDay(for: $0.date) }
    )

    return Double(completedDays.count) / Double(days)
}

Tradeoffs

  • No built-in sync: you have to add CloudKit/iCloud yourself later.
  • You own migrations: explicit and safe, but you must keep them disciplined.

When This Stack Works Best

  • Apps that must work offline
  • Local analytics and complex querying
  • Teams that want explicit SQL and predictable performance

Conclusion

SQLiteData + GRDB keeps Systemic fast, testable, and entirely local-first while leaving room for optional sync later. For apps where data ownership and offline reliability matter, it’s a solid, low-friction foundation.

J
Written by

Jack O'Shea

DevOps Engineer and Software Developer building reliable systems with clean code.