MVP Factory
ai startup development

SQLite Partial Indexes and Expression Indexes in Mobile Apps: The Query Optimization Techniques That Cut Our Room Database Read Times by 80%

KW
Krystian Wiewiór · · 5 min read

TL;DR

Most mobile teams slap a standard B-tree index on every filtered column and move on. But SQLite supports partial indexes (CREATE INDEX ... WHERE) and expression indexes that can shrink index size by 90%+ and make queries far faster. On a 500K-row table in Room, I measured an 80% reduction in read times for common patterns like unsynced item counts and paginated feeds, while also shrinking the database file. I’ll walk through exactly how, with EXPLAIN QUERY PLAN output to prove it.


Full indexes are wasteful on mobile

In the Room-backed production apps I’ve worked on, the typical database has columns like is_synced, is_deleted, and status where the interesting rows are a tiny minority. If only 2% of your 500K rows have is_synced = 0, a full index on that column wastes space indexing the 490K rows you never query.

Mobile makes this worse. Larger indexes mean more pages read from flash storage, more memory pressure, and slower INSERT/UPDATE operations as every write touches the index.

Partial indexes: index only what matters

SQLite has supported partial indexes since 3.8.0. Room exposes them via @Database’s execSQL in migrations or through RoomDatabase.Callback.

-- Instead of this:
CREATE INDEX idx_items_synced ON items(is_synced);

-- Do this:
CREATE INDEX idx_items_unsynced ON items(created_at) WHERE is_synced = 0;

The second index only contains rows where is_synced = 0. On our 500K-row table with 2% unsynced, that’s roughly 10K entries versus 500K, a 98% reduction in index size.

Benchmark: 500K-row table, unsynced item count

ApproachIndex SizeQuery Time (median, ms)EXPLAIN QUERY PLAN
Full table scan0 KB142 msSCAN items
Full index on is_synced3.8 MB28 msSEARCH items USING INDEX idx_items_synced (is_synced=?)
Partial index (WHERE is_synced=0)78 KB5.6 msSEARCH items USING INDEX idx_items_unsynced
Partial covering index94 KB3.1 msSEARCH items USING COVERING INDEX idx_items_unsynced_cover

The partial index is 5x faster than the full index and 25x faster than a scan, while using 2% of the storage. That’s a lot of free performance from one well-placed WHERE clause.

Expression indexes for computed filters

SQLite also supports indexes on expressions. This matters most for a pattern Room teams hit constantly: date range filtering on timestamps stored as epoch millis.

CREATE INDEX idx_items_date ON items(date(created_at / 1000, 'unixepoch'));

This lets queries like the following hit the index directly:

SELECT * FROM items
WHERE date(created_at / 1000, 'unixepoch') = '2026-05-15'
ORDER BY created_at DESC LIMIT 20;

The Room pitfall that defeats index usage

This is what most teams get wrong. Room’s generated SQL often wraps parameters in ways that break index selection. Consider this DAO:

@Query("SELECT * FROM items WHERE created_at > :since AND is_synced = 0")
fun getUnsyncedSince(since: Long): List<Item>

Room generates correct SQL here, but if you use @RawQuery or string concatenation, SQLite’s query planner may fail to match the expression in your index. Verify with EXPLAIN QUERY PLAN by running it via SupportSQLiteDatabase.query() in a debug build:

val cursor = db.query("EXPLAIN QUERY PLAN SELECT ...")
while (cursor.moveToNext()) {
    Log.d("QP", cursor.getString(3))
}

If you see SCAN instead of SEARCH USING INDEX, your index is being ignored.

Paginated feeds: covering indexes win

For cursor-based pagination (the dominant pattern in mobile feeds), a covering index eliminates table lookups entirely:

CREATE INDEX idx_feed_page ON items(created_at DESC, id, title, thumbnail_url)
WHERE is_deleted = 0;

Benchmark: paginated feed query (20 items, 500K rows)

StrategyCold Query (ms)Warm Query (ms)I/O Pages Read
No index1581344,812
Index on created_at124.248
Partial index (is_deleted=0)8.12.822
Partial covering index3.41.16

The covering index avoids all table lookups because every column the query needs lives in the index itself. Six page reads versus nearly five thousand. That’s the difference between a janky scroll and a smooth one.

When partial indexes won’t help

Partial indexes aren’t free. The query planner can only use them when the WHERE clause of your query logically implies the index’s WHERE clause. Parameterized predicates like WHERE is_synced = :value won’t match a partial index defined with WHERE is_synced = 0 because SQLite can’t prove at plan time that :value is always 0.

So your DAO queries must use literal values, or be structured so the partial condition is always satisfied. Design your DAO layer with this in mind.

What I’d actually do with this

If I inherited a Room database tomorrow, I’d start with three things:

  1. Look at your boolean and status columns. Any column where you only query one side of the distribution (unsynced items, non-deleted rows, pending uploads) is a candidate for a partial index. On typical mobile datasets, expect 5-25x query speedups and real storage savings.

  2. Use covering indexes for pagination queries. Include all selected columns in the index to eliminate table lookups, and combine with partial predicates to shrink it further. If EXPLAIN QUERY PLAN says COVERING INDEX, you’re good.

  3. Run EXPLAIN QUERY PLAN in debug builds for every query that matters. Room’s code generation is solid, but expression mismatches and parameterized predicates silently defeat index usage. You won’t notice until you’re dealing with real data at scale, and by then your users already have.


Share: Twitter LinkedIn