Room and SQLDelight both let us observe database queries as Kotlin Flows. When the underlying data changes, the Flow emits an update, allowing the UI to react without manually refreshing the data.

The result looks similar from the outside, but the two libraries handle change notifications differently.
Room relies on InvalidationTracker to monitor database tables. SQLDelight uses listeners attached to individual Query objects.
Let’s look at how these mechanisms work internally and what the differences mean in practice.
How Room 3.0 Monitors Database Changes
Room uses InvalidationTracker to keep track of changes to database tables. When a DAO query returns a Flow, Room uses this mechanism to determine when the query should run again.
The important detail is that Room tracks which tables a query depends on, rather than tracking changes to the query result itself.
What happens behind the scenes?
The following example illustrates the basic idea. It is simplified pseudocode, not Room’s actual implementation.
fun <T> createRoomQueryFlow(
tracker: InvalidationTracker,
tables: Array<String>,
queryBlock: suspend () -> T
): Flow<T> = flow {
emit(queryBlock())
tracker.createFlow(*tables).collect {
emit(queryBlock())
}
}
There are two things worth noting here.
The query runs before change monitoring begins.
When collection starts, queryBlock() fetches the current data and emits the result. The Flow then observes invalidation events from InvalidationTracker.
A table change can trigger another query execution.
Suppose a query reads from the users table. When Room detects a relevant change to that table, the Flow can emit another result by executing the query again.
Room does not need to compare the old and new query results. It uses table invalidation as a signal that the result may have changed.
This approach keeps the mechanism straightforward, but it has a trade-off: a change to a table can trigger re-execution even when the actual result of a particular query remains unchanged.
What happens when collection stops?
Room’s Flow integrates with Kotlin coroutines. When the collecting coroutine is cancelled, the collection is cancelled as well, allowing Room to clean up the associated observation.
This is particularly useful in Android applications, where UI collection often follows the lifecycle of a screen.
How SQLDelight 2.x Monitors Database Changes
SQLDelight takes a different approach. Instead of exposing table invalidation directly as the main abstraction, it lets us observe changes through listeners attached to a Query.
The relevant API is Query.asFlow(), provided by SQLDelight’s coroutine extensions.
From a query notification to a new result
The following pseudocode illustrates the basic mechanism:
fun <T : Any> Query<T>.asFlow(): Flow<Query<T>> = callbackFlow {
val listener = Query.Listener {
trySend(this@asFlow)
}
addListener(listener)
trySend(this@asFlow)
awaitClose {
removeListener(listener)
}
}
otice that this Flow emits a Query<T>, not the query result itself.
That distinction matters.
asFlow() reports that the query should be evaluated. It does not, by itself, fetch the latest rows.
To retrieve the data, we use an operator such as mapToList() or mapToOne().
For example:
query.asFlow()
.mapToList(Dispatchers.IO)
Conceptually, the pipeline works like this:
Database change
↓
Query.Listener
↓
Query.asFlow()
↓
mapToList()
↓
Execute SQL and read rows
↓
Emit the result
The notification and the database read are separate steps. This gives SQLDelight a clear division between observing changes and fetching data.
Listener registration and cleanup
The lifecycle is explicit:
- addListener() registers the listener.
- trySend() sends a notification to the Flow.
- awaitClose { removeListener(listener) }removes the listener when the channel closes or collection is cancelled.
This callback-based approach integrates an existing listener mechanism with Kotlin Flow.
The Dispatchers.IO argument in mapToList() controls the coroutine context used for query evaluation. It should not be confused with the notification mechanism itself.
Room vs. SQLDelight: What’s the Difference?
Both libraries provide reactive database queries, but they use different abstractions to detect changes.

Does SQLDelight always execute fewer queries?
Not necessarily.
Room’s table-based invalidation can cause a query to run again when a relevant table changes, even if the query’s result would be identical.
SQLDelight’s query-listener abstraction provides a more direct connection to an individual query. However, that alone does not prove that it always performs less work. The actual behavior depends on how listeners are notified, how queries are registered, and how the database driver implements change tracking.
In particular, query-level notification does not automatically mean that SQLDelight compares query results or guarantees that only genuinely affected queries are re-executed.
For a fair performance comparison, we need to examine the actual invalidation and notification paths in each library’s source code.
The Takeaway
Room and SQLDelight solve the same problem, but their designs expose different abstractions.
Room uses table invalidation to tell a Flow when its query may need to run again. SQLDelight exposes query notifications through listeners, leaving result evaluation to downstream Flow operators.
The distinction is easiest to remember this way:
- Room: A relevant table changed. Re-evaluate the query.
- SQLDelight: This query received a change notification. Evaluate it and emit the result.
Neither approach eliminates the need to execute SQL after a relevant change. The notification tells the application when to refresh the result; it does not replace the database read itself.
Understanding this distinction makes it easier to reason about query execution, Flow emissions, and the work each library performs when the underlying database changes.
References
- room/room-runtime/src/commonMain/kotlin/androidx/room/InvalidationTracker.kt — platform/frameworks/support — Git at Google
- sqldelight/runtime/src/commonMain/kotlin/app/cash/sqldelight/Query.kt at main · sqldelight/sqldelight
- sqldelight/extensions/coroutines-extensions/src/commonMain/kotlin/app/cash/sqldelight/coroutines/FlowExtensions.kt at main · sqldelight/sqldelight
Enjoyed this article? Here’s how you can support my work:
- 👏 Clap (up to 50 times!) if you found this insightful.
- 👤 Follow me and turn on notifications 🔔 so you never miss the next deep dive.
- 📧 Subscribe to get my latest stories delivered directly to your inbox.
You can find all my articles on Android architecture, Jetpack Compose, and Kotlin in this curated reading list.
Under the Hood: How Room 3.0 and SQLDelight 2.x Detect Database Changes was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.