Partial indexes in PostgreSQL: wins your mobile backend misses
Meta description: PostgreSQL partial indexes, expression indexes, and covering indexes can dramatically cut query times for mobile APIs. Real EXPLAIN ANALYZE benchmarks included.
Tags: kotlin, backend, api, architecture, mobile
TL;DR
Most mobile backend teams index entire columns when their queries only touch a fraction of the rows. PostgreSQL partial indexes (CREATE INDEX ... WHERE), expression indexes, and covering indexes (INCLUDE) let you build small, focused indexes that match your actual API query patterns. On a 50M-row notifications table, switching from a standard B-tree to a partial index dropped query time from 47ms to 0.8ms, cut index size by 94%, and reduced write amplification. If your mobile API serves queries like “active subscriptions for user X” or “unread notifications since timestamp Y,” you’re leaving real performance on the table.

The problem: your indexes don’t match your queries
In my experience building backends for mobile apps, the pattern is almost always the same. A team has a notifications table with 50 million rows. 96% of those rows have read = true. Every API call from the mobile client asks for unread notifications for a specific user since a given timestamp.
The team creates a standard composite index:
CREATE INDEX idx_notifications_user_created
ON notifications (user_id, created_at);
PostgreSQL dutifully indexes all 50 million rows. But your mobile client never queries read notifications through this endpoint. You’re paying storage and write costs to index rows you’ll never touch.
Here’s the thing most teams get wrong: they think more indexed rows means better coverage. It doesn’t. It means more bloat, slower writes, and a planner sifting through a bigger haystack to find your needle.
The fix: targeted indexing strategies
Partial indexes
A partial index includes only rows matching a WHERE predicate:
CREATE INDEX idx_unread_notifications
ON notifications (user_id, created_at DESC)
WHERE read = false;
This index covers only the ~4% of rows that are unread. When your mobile API fires SELECT * FROM notifications WHERE user_id = $1 AND read = false AND created_at > $2 ORDER BY created_at DESC LIMIT 20, PostgreSQL matches the index predicate exactly and scans a tiny structure instead of the full B-tree.
Expression indexes
For the common “active subscriptions” pattern, you often need to evaluate expressions at query time:
-- Mobile API: "give me active subscriptions for this user"
CREATE INDEX idx_active_subs
ON subscriptions (user_id)
WHERE status = 'active' AND (expires_at IS NULL OR expires_at > now());
Or use an expression index when your queries normalize data on the fly:
CREATE INDEX idx_lower_email ON users (lower(email));
The planner picks these up when your WHERE clause matches the indexed expression exactly.
Covering indexes with INCLUDE
PostgreSQL 11+ lets you attach non-key columns to avoid heap fetches entirely, giving you an index-only scan:
CREATE INDEX idx_unread_notif_covering
ON notifications (user_id, created_at DESC)
INCLUDE (title, body)
WHERE read = false;
Now your “fetch unread notification previews” endpoint never touches the heap. The difference shows up immediately in the benchmarks.
Benchmark: real EXPLAIN ANALYZE comparison
Test setup: 50M rows in notifications, ~2M unread (4%), PostgreSQL 16, 4GB shared_buffers, warm cache. Query: fetch 20 most recent unread notifications for a given user.
| Metric | Standard Composite Index | Partial Index | Partial + Covering |
|---|---|---|---|
| Index size | 1.1 GB | 68 MB | 94 MB |
| Query time (avg) | 47.2 ms | 0.82 ms | 0.41 ms |
| Buffers hit | 1,847 | 12 | 6 |
| Scan type | Index Scan + Filter | Index Scan | Index Only Scan |
| Heap fetches | 1,200+ | 18 | 0 |
| INSERT overhead | +12.4 µs/row | +0.5 µs/row* | +0.7 µs/row* |
Partial indexes only update when the inserted row matches the predicate. Reads of already-read notifications (the vast majority of inserts after initial creation) skip index maintenance entirely.
57x faster at query time. 94% smaller on disk. Almost zero write overhead for the dominant insert path. The covering variant cuts query time in half again by eliminating heap access.
When Paul Graham’s “superlinear returns” applies to indexing
Graham’s essay argues that in many domains, output scales superlinearly with input quality. Indexing works like this. A marginally smarter index doesn’t yield a marginal improvement; it yields an order-of-magnitude one. Going from “index everything” to “index what you actually query” is a small intellectual investment with outsized payoff in latency, storage, and write throughput. The teams that internalize this build mobile backends that stay fast at scale without throwing hardware at the problem.
Tradeoffs
Partial indexes aren’t free. Your WHERE clause in the query must match the index predicate for the planner to consider it. This means:
- Query discipline matters. If a developer writes
AND read IS NOT TRUEinstead ofAND read = false, the index is invisible to the planner. - Predicate drift is real. If your business logic changes what “active” means, you need to rebuild the index.
pg_stat_user_indexesis your friend. Monitoridx_scancounts. If a partial index shows zero scans, it’s dead weight.
What to do with this
-
Audit your top 5 mobile API queries with
EXPLAIN (ANALYZE, BUFFERS). Find the ones that filter on a status column where one value dominates. Those are immediate candidates for partial indexes, and you’ll likely see 10-50x query time reductions. -
Add
INCLUDEcolumns for endpoints that return preview data (notification titles, subscription plan names). Eliminating heap fetches converts Index Scans to Index Only Scans and cuts buffer hits, which matters most under mobile traffic concurrency. -
Set up monitoring on
pg_stat_user_indexesfrom day one. Trackidx_scanandidx_tup_readfor every partial index. If a partial index isn’t being scanned, either the query pattern changed or the predicate doesn’t match. Both are bugs you want to catch early.