Most engineers know three types of database indexes: B-tree (the default), hash, and maybe GIN for full-text search. They create an index on a column and assume the database will figure out the rest.
There are two index types that almost no one uses despite being available in Postgres since version 8.0. Both are strictly better than standard B-tree indexes in specific scenarios that are extremely common.
Partial Indexes
A partial index only indexes rows that satisfy a WHERE condition. The entire index is smaller, faster to scan, and faster to update.
The classic use case: a orders table with 50 million rows, but you almost always query for status = 'pending' and there are only 500 pending orders at any given time.
Standard index:
CREATE INDEX idx_orders_status ON orders(status);
-- Indexes all 50 million rows
-- Size: ~3GB
-- Scan time: proportional to all orders with a given status
Partial index:
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';
-- Indexes only 500 rows
-- Size: ~15KB
-- Scan time: microseconds
The query that benefits:
SELECT * FROM orders
WHERE status = 'pending'
ORDER BY created_at
LIMIT 20;
-- With partial index: index scan on 500 rows, sorted, done
-- Without: full index scan on potentially millions of rows
The partial index is also faster to maintain. Every insert, update, and delete that touches the status column requires updating the standard index for all rows. The partial index only updates when a row enters or leaves the pending status.
Where Partial Indexes Win
| Scenario | Standard index | Partial index | Savings |
|---|---|---|---|
Soft deletes (query deleted_at IS NULL) |
All rows | Only non-deleted | 90%+ if most rows are deleted |
| Status-based querying | All rows | Active/pending only | Proportional to selectivity |
| Error log search | All rows | Only error-level rows | Depends on error rate |
| Unprocessed queue items | All rows | Unprocessed only | Shrinks as queue clears |
The pattern is: if your important queries always filter on the same high-cardinality condition, make that condition part of the index definition, not a post-index filter.
Expression Indexes
Expression indexes index the result of a function applied to a column. They enable index scans on queries that use expressions or functions on indexed columns.
The common failure mode:
-- You have an index on email
CREATE INDEX idx_users_email ON users(email);
-- This query uses the index
SELECT * FROM users WHERE email = '[email protected]';
-- This query does NOT use the index - Postgres cannot use a B-tree
-- index when the column is wrapped in a function
SELECT * FROM users WHERE lower(email) = lower('[email protected]');
The second query does a sequential scan even though an index exists. The solution:
CREATE INDEX idx_users_email_lower ON users(lower(email));
-- Now this query uses the index
SELECT * FROM users WHERE lower(email) = lower('[email protected]');
Common Expression Index Patterns
Case-Insensitive Email Lookups
CREATE UNIQUE INDEX idx_users_email_ci ON users(lower(email));
-- Combined: unique constraint + case-insensitive lookup
Date-Based Queries on Timestamps
-- Index the date portion of a timestamp
CREATE INDEX idx_events_date ON events(date_trunc('day', created_at));
-- Query uses the index
SELECT * FROM events
WHERE date_trunc('day', created_at) = '2025-01-15';
JSONB Field Extraction
-- Index a specific JSON field
CREATE INDEX idx_configs_env ON configs((data->>'environment'));
-- Query uses the index
SELECT * FROM configs WHERE data->>'environment' = 'production';
Computed Status Flags
-- Index whether a subscription is expired
CREATE INDEX idx_subscriptions_expired
ON subscriptions((expires_at < NOW()))
WHERE expires_at < NOW();
-- Combines expression index with partial index
The Covering Index Pattern
One more underused type: covering indexes include extra columns to avoid a heap lookup entirely.
-- Standard index on user_id
CREATE INDEX idx_orders_user ON orders(user_id);
-- Query requires heap lookup to get status and total
SELECT status, total FROM orders WHERE user_id = 123;
-- Covering index - includes status and total
CREATE INDEX idx_orders_user_covering ON orders(user_id) INCLUDE (status, total);
-- Now the query is satisfied entirely from the index - no heap lookup
Covering indexes are most valuable for read-heavy, frequently-executed queries. The tradeoff is increased index size and write overhead.
How to Audit for Missing Partial Indexes
Run this query on your Postgres database to find tables with selective column filters that might benefit from partial indexes:
SELECT
schemaname,
tablename,
attname AS column_name,
n_distinct,
correlation
FROM pg_stats
WHERE n_distinct > 0 AND n_distinct < 20
AND tablename IN (
SELECT relname FROM pg_stat_user_tables
WHERE seq_scan > 1000 AND n_live_tup > 100000
)
ORDER BY n_distinct;
This finds large tables with sequential scans and columns with low cardinality - the classic signal that a partial index would help.
Bottom Line
Partial indexes and expression indexes are production-ready, battle-tested features of every major relational database. They are ignored because the default index works and the need for optimization is not obvious until queries are slow.
Audit your most-queried tables for soft delete columns, status fields with common values, and queries that wrap indexed columns in functions. In most production databases with significant query volume, you will find multiple opportunities where these index types cut query time by an order of magnitude.
Comments