---
title: "Partial Indexes in PostgreSQL: Cut Your API P99 by 80%"
published: true
description: "A hands-on workshop on PostgreSQL partial indexes, expression indexes, and composite column ordering — the patterns that dropped our mobile API P99 from 200ms to 12ms."
tags: postgresql, api, performance, architecture
canonical_url: https://blog.mvpfactory.co/partial-indexes-postgresql-api-p99-latency
---
## What We Will Build
By the end of this workshop, you will know how to replace generic B-tree indexes with surgical partial indexes, expression indexes for JSONB fields, and properly ordered composites. We will walk through the exact changes that dropped a critical mobile API endpoint from **200ms P99 to 12ms** — with real `EXPLAIN ANALYZE` output at each step.
## Prerequisites
- A running PostgreSQL instance (14+)
- Basic familiarity with `CREATE INDEX` and `EXPLAIN`
- A table with soft deletes or status filtering (most production apps qualify)
## Step 1: Audit Your Existing Indexes
Before adding anything, let me show you a pattern I use in every project. Run this against `pg_stat_user_indexes` to find dead weight:
sql
SELECT schemaname, relname, indexrelname,
idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND NOT indisunique
ORDER BY pg_relation_size(indexrelid) DESC;
On a recent production database serving a mobile app, this surfaced **11 unused indexes consuming 3.2 GB**. Dropping them reduced `INSERT` latency by 23% — every write was maintaining phantom indexes that nothing read.
## Step 2: Replace Broad Indexes With Partial Indexes
Consider an `orders` table where 92% of rows are soft-deleted or completed, but the API only queries active ones:
sql
-- Before: generic index scanning millions of dead rows
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- After: partial index on the 8% that matters
CREATE INDEX idx_orders_user_active ON orders (user_id, created_at DESC)
WHERE status = 'active' AND deleted_at IS NULL;
Here is the `EXPLAIN ANALYZE` comparison:
| Metric | Generic B-tree | Partial Index |
|---|---|---|
| Index size | 847 MB | 68 MB |
| Rows examined | ~240,000 | ~1,200 |
| Execution time (P99) | 189 ms | 11 ms |
| Heap fetches | 240,012 | 0 (index-only scan) |
The partial index is 12x smaller because it excludes rows the application never queries. Smaller means it lives in memory. Memory means no disk I/O. The gains compound from there.
## Step 3: Add Expression Indexes for JSONB
Mobile APIs love JSONB for flexible metadata. But querying JSONB without an expression index produces sequential scans that destroy latency at scale:
sql
CREATE INDEX idx_devices_platform ON devices ((metadata->>'platform'))
WHERE metadata->>'platform' IS NOT NULL;
-- Now this query hits the index
SELECT * FROM devices
WHERE metadata->>'platform' = 'ios' AND active = true;
The docs do not mention this, but a targeted expression index on the specific JSONB path is an order of magnitude smaller and faster than a GIN index on the entire column — which is what most teams reach for when they only ever query two or three keys.
## Step 4: Fix Composite Column Ordering
Column order in composite indexes is architectural. The rule: **equality columns first, range columns last, sort columns matching query order.**
sql
-- Wrong: range column first forces broader scan
CREATE INDEX idx_bad ON events (created_at, tenant_id, event_type);
-- Right: equality predicates first, range last
CREATE INDEX idx_good ON events (tenant_id, event_type, created_at DESC);
| Query Pattern | Wrong Order | Right Order |
|---|---|---|
| `tenant_id = X AND event_type = Y AND created_at > Z` | ~85,000 rows scanned | ~340 rows scanned |
| Index-only scan possible | No | Yes (with INCLUDE) |
## Step 5: The Covering Index Endgame
Once your partial index targets the right rows, add `INCLUDE` columns to eliminate heap fetches entirely:
sql
CREATE INDEX idx_orders_user_covering ON orders (user_id, created_at DESC)
INCLUDE (order_total, status)
WHERE status = 'active' AND deleted_at IS NULL;
This took the endpoint from 12ms to consistently single-digit. Zero heap fetches means the query never touches the table.
## Gotchas
- **Don't `EXPLAIN` — `EXPLAIN (ANALYZE, BUFFERS)`.** Estimated costs lie. Buffer counts reveal the actual I/O reality.
- **Partial index predicates must match your queries exactly.** If your `WHERE` clause says `deleted_at IS NULL` but the index uses `status != 'deleted'`, PostgreSQL will not use it.
- **A 12x smaller index does not give you 12x faster queries — it gives you 16x.** Memory residency, reduced I/O contention, and faster vacuum cycles all stack. The returns are superlinear.
- **Always run the `pg_stat_user_indexes` audit first.** Dropping unused indexes is free write performance you are leaving on the table.
- **Default to partial indexes** whenever your query filters out more than 50% of rows — soft deletes, status checks, tenant isolation.
## Conclusion
Here is the minimal setup to get this working: audit with `pg_stat_user_indexes`, drop what is unused, then replace your broadest indexes with partial indexes that match your actual query predicates. Index less, but index *precisely* — that is where the compounding returns live.
Top comments (0)