Skip to main content
ValueObservation tracks database changes and notifies your app with fresh values whenever the database changes.

Overview

Create observations that automatically fetch fresh values when relevant database changes occur:
let observation = ValueObservation.tracking { db in
    try Player.fetchAll(db)
}

let cancellable = observation.start(
    in: dbQueue,
    onError: { error in print("Error: \(error)") },
    onChange: { players in print("Players: \(players)") }
)
ValueObservation is perfect for keeping your UI in sync with the database.

Creating Observations

tracking(_:)

Creates an observation that tracks database changes:
fetch
(Database) throws -> Value
required
A closure that fetches the observed value
let observation = ValueObservation.tracking { db in
    try Player.fetchAll(db)
}
The observation automatically detects which database tables and columns are accessed, and only notifies when those change.

trackingConstantRegion(_:)

Creates an optimized observation for a constant database region:
let observation = ValueObservation.trackingConstantRegion { db in
    try Player.fetchAll(db)
}
Use this when your query always accesses the same tables and columns. Provides better scheduling and performance.

tracking(regions:fetch:)

Explicitly specifies which database regions to observe:
let observation = ValueObservation.tracking(
    regions: [Player.all(), Team.all()]
) { db in
    // Fetch custom value
    let players = try Player.fetchAll(db)
    let teams = try Team.fetchAll(db)
    return (players, teams)
}

Starting Observations

start(in:scheduling:onError:onChange:)

Starts observing the database.
reader
DatabaseReader
required
The database queue or pool
scheduler
ValueObservationScheduler
default:".async(onQueue: .main)"
Where to deliver values
onError
(Error) -> Void
required
Error handler
onChange
(Value) -> Void
required
Value handler
let cancellable = observation.start(
    in: dbQueue,
    onError: { error in
        print("Database error: \(error)")
    },
    onChange: { players in
        print("Fresh players: \(players)")
    }
)

Scheduling Options

Main Actor

// Deliver on main actor (async)
let cancellable = observation.start(
    in: dbQueue,
    scheduling: .mainActor, // or just omit for default
    onError: { error in },
    onChange: { players in }
)

// Deliver on main actor (immediate first value)
let cancellable = observation.start(
    in: dbQueue,
    scheduling: .immediate,
    onError: { error in },
    onChange: { players in }
)

Custom Queue

let queue = DispatchQueue(label: "player.updates")
let cancellable = observation.start(
    in: dbQueue,
    scheduling: .async(onQueue: queue),
    onError: { error in },
    onChange: { players in }
)

Task

let cancellable = observation.start(
    in: dbQueue,
    scheduling: .task,
    onError: { error in },
    onChange: { players in }
)

Async/Await Support

values(in:scheduling:)

Returns an async sequence of observed values:
for try await players in observation.values(in: dbQueue) {
    print("Fresh players: \(players)")
}

Combine Support

publisher(in:scheduling:)

Returns a Combine publisher:
import Combine

let cancellable = observation
    .publisher(in: dbQueue)
    .sink(
        receiveCompletion: { completion in
            // Handle completion
        },
        receiveValue: { players in
            print("Fresh players: \(players)")
        }
    )

Transforming Observations

map(_:)

Transforms observed values:
let observation = ValueObservation
    .tracking { db in try Player.fetchAll(db) }
    .map { players in players.count }

// Notifies with Int instead of [Player]
let cancellable = observation.start(
    in: dbQueue,
    onError: { _ in },
    onChange: { count in
        print("Player count: \(count)")
    }
)

removeDuplicates()

Only notifies when values change:
let observation = ValueObservation
    .tracking { db in try Player.fetchCount(db) }
    .removeDuplicates()

// Only notifies when count actually changes

removeDuplicates(by:)

Custom duplicate detection:
struct PlayerStats {
    var count: Int
    var totalScore: Int
}

let observation = ValueObservation
    .tracking { db in
        PlayerStats(
            count: try Player.fetchCount(db),
            totalScore: try Player.select(sum(Column("score"))).fetchOne(db) ?? 0
        )
    }
    .removeDuplicates { $0.count == $1.count }

Observing Complex Queries

Multiple Tables

struct GameData {
    var players: [Player]
    var teams: [Team]
}

let observation = ValueObservation.tracking { db in
    GameData(
        players: try Player.fetchAll(db),
        teams: try Team.fetchAll(db)
    )
}

Aggregates

let observation = ValueObservation.tracking { db in
    try Player.select(max(Column("score")), as: Int.self).fetchOne(db)
}

Joins

struct PlayerWithTeam {
    var player: Player
    var team: Team
}

let observation = ValueObservation.tracking { db in
    let request = Player.including(required: Player.team)
    return try PlayerWithTeam.fetchAll(db, request)
}

Performance Optimization

Constant Regions

Use trackingConstantRegion when the observed database region never changes:
// Always observes the same row
let observation = ValueObservation.trackingConstantRegion { db in
    try Player.fetchOne(db, key: 42)
}
Constant region observations can reduce database contention and avoid fetching from the main thread in DatabasePool.

Manual Region Specification

Explicitly declare observed regions to enable optimizations:
let observation = ValueObservation.tracking(
    regions: [Player.all()]
) { db in
    // Complex logic that always accesses Player table
    let config = try Config.fetchOne(db)
    return try Player.filter(Column("teamId") == config.favoriteTeamId).fetchAll(db)
}

Register Additional Regions

Extend tracked regions from within the fetch closure:
let observation = ValueObservation.trackingConstantRegion { db in
    // Also track Team table
    try db.registerAccess(to: Team.all())
    
    let config = try Config.fetchOne(db)
    if config.showPlayers {
        return try Player.fetchAll(db)
    } else {
        return try Team.fetchAll(db)
    }
}

Debugging

handleEvents

Inspect observation events:
let observation = ValueObservation
    .tracking { db in try Player.fetchAll(db) }
    .handleEvents(
        willStart: { print("Will start") },
        willFetch: { print("Will fetch") },
        willTrackRegion: { region in print("Tracking: \(region)") },
        databaseDidChange: { print("Database changed") },
        didReceiveValue: { value in print("Received: \(value)") },
        didFail: { error in print("Failed: \(error)") },
        didCancel: { print("Cancelled") }
    )

print

Log all events:
let observation = ValueObservation
    .tracking { db in try Player.fetchAll(db) }
    .print("Players observation")

// Prints:
// Players observation: start
// Players observation: fetch
// Players observation: tracked region: player(*)
// Players observation: value: [...]

Cancellation

Observations can be cancelled:
let cancellable = observation.start(
    in: dbQueue,
    onError: { _ in },
    onChange: { _ in }
)

// Stop observing
cancellable.cancel()
Observations are automatically cancelled when the returned cancellable is deallocated.

Error Handling

let cancellable = observation.start(
    in: dbQueue,
    onError: { error in
        if let dbError = error as? DatabaseError {
            print("Database error: \(dbError)")
        } else {
            print("Unexpected error: \(error)")
        }
    },
    onChange: { players in
        print("Players: \(players)")
    }
)
When an error occurs, the observation stops. You must create and start a new observation to resume.

SwiftUI Integration

import GRDB
import SwiftUI

struct PlayerList: View {
    @Query(PlayerRequest()) var players: [Player]
    
    var body: some View {
        List(players) { player in
            Text(player.name)
        }
    }
}

struct PlayerRequest: Queryable {
    static var defaultValue: [Player] { [] }
    
    func publisher(in database: DatabaseReader) -> DatabasePublishers.Value<[Player]> {
        ValueObservation
            .tracking { db in try Player.fetchAll(db) }
            .publisher(in: database)
    }
}

Best Practices

Keep observation closures fast. Heavy processing should happen after receiving values:
let observation = ValueObservation.tracking { db in
    try Player.fetchAll(db) // Fast database query
}

let cancellable = observation.start(
    in: dbQueue,
    onError: { _ in },
    onChange: { players in
        // Heavy processing happens here, off the database queue
        let processed = processPlayers(players)
        updateUI(with: processed)
    }
)
Use removeDuplicates() to avoid unnecessary UI updates:
ValueObservation
    .tracking { db in try Player.fetchCount(db) }
    .removeDuplicates() // Only notify when count changes
Avoid modifying the database from within the observation closure. This can cause infinite loops.

See Also

Build docs developers (and LLMs) love