Build A Local-First Search Database With GRDB And SwiftUI
Remote search is useful when the server owns the truth, but plenty of app searches should feel instant even in airplane mode. Notes, saved recipes, downloaded documentation, journal entries, cached help articles, and offline project data all benefit from a local index.
SQLite’s FTS5 extension is a very good fit for that job. It gives you a local inverted index without turning your app into a search engine project. GRDB gives the app a Swift-friendly way to create the schema, run migrations, validate search patterns, and fetch typed results. SwiftUI only needs a small view model that debounces the text field and asks the database for fresh rows.
The examples below assume your SQLite build includes FTS5. GRDB’s full-text documentation calls out the SQLITE_ENABLE_FTS5 build option, which matters if you ship a custom SQLite build. If you use the system SQLite that comes with your target OS, still verify FTS5 on the platforms you support before committing the feature.
Add the packages
You only need GRDB for the implementation in this post. If you add the package through Xcode, use this repository URL:
https://github.com/groue/GRDB.swift.git
The snippets use GRDB 7 APIs. For a Package.swift, keep the lower bound boring and pin the exact resolved versions in Package.resolved after testing:
dependencies: [
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0"),
]
GRDBQuery is still worth knowing about because its @Query wrapper can observe database requests from SwiftUI views. It also has @EnvironmentStateObject for building observable models from the SwiftUI environment. For a search box, I usually keep a view model in the middle because the query string, debounce timing, and empty-state behavior are UI concerns. If you use GRDBQuery elsewhere in the same app, add https://github.com/groue/GRDBQuery.git as a separate package dependency and test the resolved version with your GRDB version.
Create a content table and FTS table
Start with a normal table that owns the durable data. Then add an FTS5 virtual table that indexes the searchable text. I like external-content FTS tables because the main table remains the source of truth:
import Foundation
import GRDB
struct AppDatabase {
private let dbQueue: DatabaseQueue
init(path: String) throws {
dbQueue = try DatabaseQueue(path: path)
try Self.migrator.migrate(dbQueue)
}
private static var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("createNotes") { db in
try db.execute(sql: """
CREATE TABLE note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
updatedAt DATETIME NOT NULL
)
""")
try db.create(virtualTable: "noteFTS", using: FTS5()) { table in
table.synchronize(withTable: "note")
table.tokenizer = .unicode61()
table.column("title")
table.column("body")
}
}
return migrator
}
}
synchronize(withTable:) is the important GRDB convenience here. It creates the triggers that keep noteFTS aligned when rows are inserted, updated, or deleted in note, and it indexes any existing rows when the virtual table is created. The id INTEGER PRIMARY KEY column aliases SQLite’s rowid, which is what the FTS table uses to point back at the content table.
The tokenizer defines what “same word” means. unicode61() is a practical default for user-facing search because it is Unicode case-insensitive and strips Latin diacritics by default. That means a plain ASCII query can match a Latin word with accents in many common cases. If your app needs English stemming, look at the Porter tokenizer. If your app needs domain-specific synonyms or stop words, GRDB also supports custom FTS5 tokenizers.
Write through the content table
After the migration, write to note, not to noteFTS. The synchronized virtual table follows along:
extension AppDatabase {
func createNote(title: String, body: String) async throws {
try await dbQueue.write { db in
try db.execute(
sql: """
INSERT INTO note (title, body, updatedAt)
VALUES (?, ?, ?)
""",
arguments: [title, body, Date()])
}
}
}
GRDB’s safe write access wraps the work in a transaction. If the insert fails, the FTS triggers do not leave a half-updated index behind.
Search with a validated pattern
Do not paste raw text from a search field into a MATCH expression. FTS has its own query grammar, so user input can be invalid even when it is not malicious. GRDB’s FTS5Pattern initializers build safe patterns from arbitrary strings and return nil when there is nothing searchable.
struct NoteSearchResult: Decodable, FetchableRecord, Identifiable, Sendable {
var id: Int64
var title: String
var snippet: String
}
extension AppDatabase {
func searchNotes(matching searchText: String) async throws -> [NoteSearchResult] {
let searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
return try await dbQueue.read { db in
guard let pattern = FTS5Pattern(matchingAllPrefixesIn: searchText) else {
return try NoteSearchResult.fetchAll(db, sql: """
SELECT id, title, substr(body, 1, 140) AS snippet
FROM note
ORDER BY updatedAt DESC
LIMIT 30
""")
}
return try NoteSearchResult.fetchAll(
db,
sql: """
SELECT note.id,
note.title,
snippet(noteFTS, 1, '', '', '...', 18) AS snippet
FROM note
JOIN noteFTS
ON noteFTS.rowid = note.id
WHERE noteFTS MATCH ?
ORDER BY rank
LIMIT 30
""",
arguments: [pattern])
}
}
}
matchingAllPrefixesIn makes search feel forgiving while the user is typing. A query like loc sea can match words that start with loc and sea. For a stricter search, use matchingAllTokensIn. For exact phrase search, use matchingPhrase. If your product intentionally exposes raw FTS5 operators, validate that grammar with db.makeFTS5Pattern(rawPattern:forTable:) instead of interpolating strings.
The query joins the FTS table back to the content table because the app wants stable model data from note, not just indexed text from the virtual table. ORDER BY rank uses FTS5 relevance ranking. snippet returns a short piece of the matched column, which is usually better than showing the first characters of a long body.
Debounce from SwiftUI
The view model stays on the main actor because it owns UI state. The database work uses GRDB’s async read, so the search does not block the current thread while SQLite runs:
import SwiftUI
@MainActor
final class NoteSearchModel: ObservableObject {
@Published var query = "" {
didSet { scheduleSearch() }
}
@Published private(set) var results: [NoteSearchResult] = []
@Published private(set) var errorMessage: String?
private let database: AppDatabase
private var searchTask: Task<Void, Never>?
init(database: AppDatabase) {
self.database = database
}
deinit {
searchTask?.cancel()
}
func scheduleSearch() {
searchTask?.cancel()
searchTask = Task {
do {
try await Task.sleep(for: .milliseconds(180))
try Task.checkCancellation()
let newResults = try await database.searchNotes(matching: query)
try Task.checkCancellation()
results = newResults
errorMessage = nil
} catch is CancellationError {
// A newer keystroke started a newer search.
} catch {
results = []
errorMessage = error.localizedDescription
}
}
}
}
The cancellation checks matter more than they look. Without them, an older slower search can finish after a newer search and replace the visible results with stale data.
The view is ordinary SwiftUI:
struct NoteSearchView: View {
@StateObject private var model: NoteSearchModel
init(database: AppDatabase) {
_model = StateObject(wrappedValue: NoteSearchModel(database: database))
}
var body: some View {
NavigationStack {
List(model.results) { result in
VStack(alignment: .leading, spacing: 4) {
Text(result.title)
.font(.headline)
Text(result.snippet)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.navigationTitle("Notes")
.searchable(text: $model.query, prompt: "Search notes")
.overlay {
if model.results.isEmpty && !model.query.isEmpty {
ContentUnavailableView.search(text: model.query)
}
}
}
}
}
That is the whole loop: migrate once, write to the content table, let GRDB synchronize the FTS table, validate the user’s pattern, and debounce the SwiftUI search field.
A few production notes
Use one DatabaseQueue or DatabasePool per database file and keep it alive for the app’s lifetime. GRDB’s concurrency guide is very clear about this because multiple unmanaged connections make observation harder and can create SQLITE_BUSY errors. The examples use DatabaseQueue because it is the simplest place to start; a read-heavy app can move to DatabasePool later.
If search results should update automatically when some other screen edits a note, add observation. You can use GRDB’s ValueObservation directly, or move the request into GRDBQuery when the result is a read-only SwiftUI view. For search, I still like the explicit view model because it gives you a natural place for debouncing, cancellation, and empty-query fallback.
If a later migration drops and recreates a synchronized FTS table, remember that GRDB creates synchronization triggers for you. Drop those triggers with dropFTS5SynchronizationTriggers(forTable:) as part of the migration cleanup.
Finally, test the language behavior you promise users. Tokenization, diacritics, prefix matching, and snippets are part of the product, not just implementation details.
Further reading:
- GRDB README: https://github.com/groue/GRDB.swift
- GRDB full-text search guide: https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md
- GRDB concurrency guide: https://github.com/groue/GRDB.swift/blob/master/GRDB/Documentation.docc/Concurrency.md
- GRDBQuery README: https://github.com/groue/GRDBQuery
- SQLite FTS5 documentation: https://www.sqlite.org/fts5.html