MVP Factory
ai startup development

PostgreSQL partial & expression indexes: skip the ORM

KW
Krystian Wiewiór · · 5 min read

SEO Meta Description: PostgreSQL partial and expression indexes can cut index size by 90% and turn sequential scans into sub-millisecond seeks. Here’s how to write them.

Tags: postgresql database performance backend sql


TL;DR

Your ORM generates naive full-table indexes. PostgreSQL supports partial indexes (with WHERE clauses) and expression indexes (on computed values) that can reduce index size by 80–95% and turn sequential scans into sub-millisecond index seeks. If you’re not writing these by hand, you’re leaving serious performance on the table.


The problem with ORM-generated indexes

In my experience building production systems, the first thing I audit in a struggling PostgreSQL deployment is the index list. What I find, almost universally, is a graveyard of bloated, full-column indexes generated by ActiveRecord, SQLAlchemy, or Hibernate — indexes that cover every row, including the 97% your queries will never touch.

The common mistake is assuming more indexing equals better performance. An index on deleted_at or status that covers every row in a 50M-row table is often worse than no index at all — the planner may choose it, read a massive index, and still return millions of rows to filter.

PostgreSQL has had the answer since version 7.2. We just stopped writing SQL long enough to forget it.


Partial indexes: index only what you query

A partial index includes a WHERE clause that restricts which rows are indexed.

Pattern 1: soft-delete filtering

-- Naive ORM index (indexes all 50M rows)
CREATE INDEX idx_users_email ON users(email);

-- Partial index (indexes only ~1M active users)
CREATE INDEX idx_users_email_active ON users(email)
WHERE deleted_at IS NULL;

Note: EXPLAIN ANALYZE output below is simplified for readability. Actual output includes additional buffer and planning metadata.

Before:

Seq Scan on users  (cost=0.00..142000.00 rows=980000 width=200)
                   (actual time=0.042..2831.445 rows=980000 loops=1)
  Filter: ((deleted_at IS NULL) AND ((email)::text = $1))
  Rows Removed by Filter: 49020000
 Planning Time: 0.8 ms
 Execution Time: 2840.112 ms

After:

Index Scan using idx_users_email_active on users
               (cost=0.43..8.45 rows=1 width=200)
               (actual time=0.023..0.091 rows=1 loops=1)
  Index Cond: ((email)::text = $1)
 Planning Time: 0.3 ms
 Execution Time: 0.091 ms

Index size drops from ~2.1 GB to ~42 MB.

Pattern 2: multi-tenant row isolation

In a SaaS schema where every query includes tenant_id, a composite partial index can isolate a tenant’s working set:

CREATE INDEX idx_orders_tenant_42_pending
  ON orders(created_at DESC)
  WHERE tenant_id = 42 AND status = 'pending';

This creates one index per tenant. It’s only viable for a small number of high-volume tenants known at schema design time — not a general-purpose multi-tenancy strategy.


Expression indexes: index computed values

Expression indexes store the result of a function, letting the planner use the index when the same expression appears in a query predicate.

-- This never uses a plain btree index on email
WHERE LOWER(email) = LOWER($1)

-- Expression index fixes this
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

The query must use LOWER(email) exactly — the planner matches the expression, not the column.

Pattern 4: JSONB field indexing

-- Index a nested JSONB key
CREATE INDEX idx_events_user_id
  ON events((payload->>'user_id'));

-- Query hits the index
SELECT * FROM events
WHERE payload->>'user_id' = '10034';

Without this, every JSONB predicate is a full sequential scan with per-row extraction cost.


Side-by-side comparison

ScenarioNaive index sizeOptimized index sizeQuery time beforeQuery time after
Soft-delete email lookup2.1 GB42 MB2840 ms0.09 ms
Case-insensitive loginN/A (unused)310 MB1200 ms0.4 ms
JSONB field filterN/A (full scan)890 MB3100 ms1.1 ms
Pending orders by tenant1.4 GB18 MB640 ms0.3 ms

Planner statistics: why expression indexes need help

PostgreSQL’s query planner relies on pg_statistic to estimate row counts. For expression indexes, statistics are only collected if you run:

ANALYZE users;

…after creating the index, or before autovacuum has run. A fresh expression index with no statistics causes the planner to guess — and it’s often catastrophically wrong. Always run ANALYZE manually after creating expression or partial indexes in production.


Tradeoffs: write overhead

Partial and expression indexes aren’t free. Every additional index adds overhead to INSERT, UPDATE, and DELETE — PostgreSQL must maintain each index on every write to an affected row. On high-write tables like event streams, audit logs, or order pipelines, this cost compounds.

Before deploying expression indexes to production, benchmark write throughput under realistic load. A targeted pgbench run with and without the index on your write-heavy table will surface the tradeoff quickly. The read gains are usually worth it, but measure them — don’t assume.


What to do now

  1. Audit your soft-delete columns. Any table with deleted_at IS NULL as a near-universal query predicate is a candidate for partial indexes. This single change has cut index storage by 80%+ in production systems I’ve managed.

  2. Never index a column you query through a function. LOWER(), DATE_TRUNC(), JSONB operators — all require expression indexes. If your ORM doesn’t generate them, write the migration by hand.

  3. Run EXPLAIN (ANALYZE, BUFFERS) before and after, and benchmark writes. The planner’s decision is ground truth for read performance. Tuning indexes on write-heavy tables without measuring INSERT and UPDATE throughput is only solving half the problem.


Share: Twitter LinkedIn