B-tree versus inverted index paths over a query on dark navy
PostgreSQLindexingEXPLAINquery performancebtreegin

Postgres Indexing That Matches Your Queries

Indexes are not a checklist. Use EXPLAIN, pick btree vs gin for the predicates you actually run, and skip the covering index folklore startups copy from Twitter.

Swati ChaudharyFounder, Myrafy

A slow Postgres query is usually not "we need Redis." It is a sequential scan on a table that grew, a sort that spilled to disk, or an index that does not match the WHERE you wrote. Indexing that matches your queries is cheaper than a new datastore and more honest than "the database is the problem."

This is the order we use on product engineering when an API's p95 climbs. It pairs with PostgreSQL vs MongoDB (why you picked a relational store) and API rate limiting (so a hot key cannot melt the primary while you fix the plan).

EXPLAIN First, Indexes Second

EXPLAIN loop: capture SQL, read the plan, add or drop an index, measure p95
If you cannot show the plan, you are guessing

Copy the SQL from APM or logs. Run EXPLAIN (ANALYZE, BUFFERS) on a snapshot that resembles production size. Staging with 200 rows will lie.

Look for:

  • Seq Scan on a large table when you expected a point lookup
  • Sort + external merge (work_mem too small or missing index for ORDER BY)
  • Rows estimates off by 10-100ร— (statistics, or a predicate the planner cannot use)
  • Nested loops that made sense at 1k rows and do not at 10M

Do not add five indexes because a blog listed "always index foreign keys." Index the columns in the predicates and joins you measured. Then drop the ones whose idx_scan stays zero.

btree vs gin (The Decision You Actually Need)

Index Good at Skip when
btree (default) Equality, ranges, ORDER BY that matches the sort, unique constraints You need full-text or JSONB containment as the main filter
gin JSONB @>, arrays, full-text tsvector You thought it would speed up WHERE id =
brin Huge, naturally ordered time-series append Random UUID primary keys
hash Equality only, rare You wanted a unique constraint (use btree)

Most product apps are btree on (tenant_id, created_at DESC) and btree on the columns you filter in the admin UI. GIN appears when you search JSONB or implement search without Elasticsearch.

Partial indexes (WHERE deleted_at IS NULL) are how you keep a hot subset small. Expression indexes (LOWER(email)) are how you stop storing a second column "for search."

Composite order matters: (tenant_id, status, created_at) helps WHERE tenant_id = $1 AND status = $2 ORDER BY created_at DESC. It does not help a query that only filters status.

What Startups Skip

  1. No tenant_id leading column on a multi-tenant table, then wondering why "list my orders" seq-scans the world
  2. Indexing UUID id only and filtering on email in support tools
  3. SELECT * plus overlay networks so the index is never index-only and heap fetches dominate
  4. Creating indexes CONCURRENTLY in a migration that still holds a lock because someone wrapped it in a transaction
  5. Copying Mongo "flexible schema" into JSONB, then filtering with -> on an unindexed key

If you are on JSONB because the document shape changes weekly, index the keys you query now, and accept that new keys need new indexes. That is still cheaper than pretending Mongo will save you from modeling - the tradeoff is in Postgres vs Mongo.

Write Cost Is Real

Every index slows INSERT/UPDATE. A table with twelve overlapping btrees is a write amplifier. After a week in production, query pg_stat_user_indexes and drop the dead weight.

Reindex after a bulky bulk load. Autovacuum is not optional; if you turned it down to "go faster," you bought bloat.

Migrations belong in CI that actually runs against a copy of prod shape, not a hope that CREATE INDEX is instant on 80GB.

A Practical Sequence

Hour 1: Slowest query, EXPLAIN ANALYZE, note the filter. Hour 2: One matching btree or gin; deploy with CREATE INDEX CONCURRENTLY. Day 2: Watch p95 and index size. If the plan did not change, you indexed the wrong columns. Week 2: Remove unused indexes; add the tenant-leading composite you actually list on.

Do not start with a "covering index" that includes every column you select. Prove the lookup is the bottleneck first.

FAQ

Should every foreign key be indexed? If you JOIN or DELETE through it, yes. If it is a write-only audit pointer you never query, no.

Is uuid a bad primary key? Random UUIDs fragment btree inserts. Fine at small scale. At large insert volume, consider uuidv7 or a bigint identity and keep UUID as an external id.

When is Redis the right next step? When you have a hot computed key you can invalidate, not when SELECT lacks an index. Cache after the plan is sane.

The Bottom Line

Postgres indexing that matches your queries is EXPLAIN, then the smallest btree or gin that serves that predicate, then dropping the rest. Indexes are not a personality.

If p95 is high and the plan is a seq scan, fix the plan. A new database will seq-scan too.


Need a production Postgres plan in the SOW, not a rumour about Redis? See product engineering or get a free estimate.

Contact us