TL;DR
PostgreSQL partial indexes are indexes built over only the rows that satisfy a WHERE clause on CREATE INDEX. They are smaller and cheaper to maintain than full indexes, and the query planner uses them automatically whenever your query's conditions imply their predicate.
Most production tables are not queried uniformly: a small, well-defined slice of rows absorbs most of the traffic, while the rest is historical data that is rarely touched. PostgreSQL partial indexes let you index only that slice. Instead of paying to index every row, you attach a predicate to CREATE INDEX and pay only for the rows your queries actually need. This guide explains how partial indexes work, when they beat full indexes, how the planner decides to use them, and the pitfalls to avoid.
What Is a Partial Index in PostgreSQL?

A partial index stores entries only for rows that satisfy its WHERE predicate.
PostgreSQL partial indexes store entries for only the rows that satisfy a predicate — a WHERE clause attached to the CREATE INDEX statement. Rows that do not match are simply absent from the index, which makes it smaller, faster to scan, and cheaper to update. Partial indexes are sometimes called conditional indexes for exactly this reason.
The table itself is unchanged: every row stays fully stored and queryable. A partial index is purely an access-path optimization for the subset of data your workload actually touches — a pattern the official documentation recommends for tables where most queries hit the same minority of rows.
How a partial index differs from a full index
A full index contains an entry for every row. A partial index contains entries only for rows matching its predicate, and that single difference drives everything else:
- Size: it scales with the number of matching rows, not the table size.
- Write overhead: index maintenance only happens for rows that satisfy the predicate.
- Maintenance: smaller indexes also give
VACUUMless work to do and speed up scans. - Applicability: the planner considers it only for queries whose conditions imply the predicate — more on that below.
Correctness is never in question: when a row starts or stops matching the predicate, PostgreSQL maintains the index automatically. Partial indexes never go stale with respect to the data.
CREATE INDEX ... WHERE syntax with a real example
Consider an orders table where a small slice of rows is active at any moment and the rest are historical:
CREATE INDEX idx_orders_active_created
ON orders (created_at)
WHERE status = 'active';
A query that targets active orders contains the predicate as one of its conditions, so the planner can use the index:
SELECT order_id, customer_id, created_at
FROM orders
WHERE status = 'active'
AND created_at > now() - interval '7 days';
The predicate may reference any columns and any immutable expressions, the index itself can be multi-column, and any access method works — including the default b-tree. The full grammar is in the CREATE INDEX reference.
When to Use a Partial Index
Indexing a hot subset of rows
The classic case: queries almost always target a small, well-defined slice — open carts, unshipped orders, pending jobs — while the bulk of rows are historical. Index only the hot rows:
CREATE INDEX idx_jobs_pending
ON jobs (scheduled_at)
WHERE status = 'pending';
Workers polling for due jobs scan a tiny index instead of a full one, and the constant churn of completed jobs never touches it.
Enforcing uniqueness on a subset of rows
A unique partial index enforces uniqueness only within the subset — ideal for one active X per Y rules and soft deletes:
CREATE UNIQUE INDEX idx_sessions_one_active
ON sessions (user_id)
WHERE ended_at IS NULL;
This guarantees at most one open session per user while allowing unlimited historical rows — something a plain unique constraint cannot express. The same trick frees an email address for reuse once a user is soft-deleted:
CREATE UNIQUE INDEX idx_users_email_alive
ON users (email)
WHERE deleted_at IS NULL;
Handling sparse data and NULL-heavy columns
When most rows hold NULL values in a column and queries only care about rows where a value exists, index just those rows. For very sparse data, this keeps the index a small fraction of the full size while still serving every query that filters on the column:
CREATE INDEX idx_invoices_po_number
ON invoices (po_number)
WHERE po_number IS NOT NULL;
How the Query Planner Matches Partial Indexes
A partial index only helps if the planner can prove from your query that only matching rows can be returned. That proof — and its limits — explains most cases of a partial index not being used.
Predicate-matching requirements
The planner uses a partial index only when the query's WHERE clause contains conditions that imply the index predicate. An exact match is the easy case: predicate status = 'active', query condition status = 'active'. The implication logic handles simple cases such as equality and range constraints and basic AND/OR combinations, but it is deliberately conservative. It will not, for example, infer status = 'active' from status <> 'delivered', even though a human can.
Practical rule: repeat the index predicate verbatim in your queries. A redundant extra condition costs nothing and makes the match unambiguous.
Immutable expressions only: why NOW() fails
Everything in the predicate must be immutable, because it is frozen into the index at creation time. This statement fails:
CREATE INDEX idx_orders_recent
ON orders (created_at)
WHERE created_at > now() - interval '30 days';
-- ERROR: functions in index predicate must be marked IMMUTABLE
now() returns a different value on every call, so PostgreSQL cannot guarantee the predicate stays true. For a rolling time window, use a constant cutoff that you rebuild on a schedule, or a full index on the column with the date filter left to the query.
Verifying usage with EXPLAIN and pg_stat_user_indexes
Confirm the planner picks the index:
EXPLAIN
SELECT order_id FROM orders
WHERE status = 'active'
AND created_at > now() - interval '7 days';
Look for Index Scan using idx_orders_active_created in the execution plan. For ongoing usage counts, check the statistics views:
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan ASC;
An idx_scan of 0 since the last statistics reset is a strong signal the index is dead weight. The guide to examining index usage covers this workflow in depth.
Partial Index vs. Full Index: Size and Trade-offs
| Aspect | Partial index | Full index |
|---|---|---|
| Rows indexed | Only rows matching the predicate | Every row in the table |
| Size | Scales with matching rows | Scales with table size |
| Write overhead | Only on rows matching the predicate | On every write to indexed columns |
| Usable by planner | Only queries implying the predicate | Any query on the indexed column(s) |
| Best for | Hot subsets, sparse data, conditional uniqueness | Broad or unpredictable query patterns |
When a full index is the better choice
- Query patterns vary. If queries filter the column in many different ways, a partial index serves only some of them and you end up maintaining both.
- The subset is most of the table. If the predicate matches the majority of rows, the savings are marginal and a full index is simpler.
- Many sibling subsets. Five partial indexes, one per
statusvalue, add up to roughly the cost of one full index plus extra catalog complexity.
A useful mental model: reach for a partial index when a clear, stable minority of rows carries most of the workload.
Common Pitfalls and Best Practices
- Predicate/query mismatch. The most common reason a partial index is ignored is a query that does not imply the predicate. Include the predicate condition explicitly and verify with
EXPLAIN. - Mutable predicates. Functions such as
now(), or any function not markedIMMUTABLE, are rejected in index predicates. - Overlapping partial indexes. Every extra index costs disk, memory, and planning time; consolidate subsets that overlap heavily.
- Orphaned predicates. Workloads drift. Review
pg_stat_user_indexesperiodically and drop indexes with near-zero scans. - Wrong expectations on size. Savings track the fraction of rows excluded, not the table's absolute size.
Managing Partial Indexes in DBO Studio
In DBO Studio's schema browser, each index shows its full definition — including the WHERE predicate — next to its size, which makes oversized or duplicated indexes easy to spot. Run EXPLAIN ANALYZE from the query editor to confirm the planner chooses the partial index, and compare plans before and after adding one.
You can download DBO Studio to work with PostgreSQL, MySQL, SQLite, SQL Server, and more; the release notes list recent improvements to the index and plan views.
FAQ
Use one when your queries consistently target a well-defined subset — such as active, pending, or non-deleted rows — when you need uniqueness only within a subset, or when a column is mostly NULL. If queries touch the column in unpredictable ways, use a full index.
Usually because the query's WHERE clause does not imply the index predicate. Add the exact predicate condition to the query and check the plan with EXPLAIN. On very small tables the planner may also prefer a sequential scan because it is genuinely cheaper.
Yes. CREATE UNIQUE INDEX ... WHERE ... enforces uniqueness only among rows matching the predicate. It is the standard way to implement one-active-row-per-user rules and uniqueness across soft-deleted tables.
No. Index predicates must be immutable, and now() is only stable. PostgreSQL rejects the statement with functions in index predicate must be marked IMMUTABLE. Use a constant cutoff that you rebuild periodically, or a full index.
It scales with the number of matching rows: index only a small fraction of the table and the index shrinks proportionally. Measure rather than guess — compare pg_relation_size('index_name') before and after.
Run EXPLAIN on a representative query and look for an index scan naming your index. For ongoing usage counts, query pg_stat_user_indexes and inspect the idx_scan column.
Key Takeaways
- A partial index stores entries only for rows matching a
WHEREpredicate onCREATE INDEX. - It wins on size and write overhead exactly to the degree that the predicate excludes rows.
- The planner uses it only when the query implies the predicate — repeat the predicate in your queries.
- Predicates must be immutable;
now()and friends are rejected at creation time. - Unique partial indexes express conditional constraints that plain unique constraints cannot.
Conclusion
Partial indexes are one of the highest-leverage tools in PostgreSQL performance tuning: a single well-chosen predicate can turn a bloated, write-hungry index into a small, focused one that serves your hottest queries. Start with the subsets your workload provably touches, verify with EXPLAIN, and let pg_stat_user_indexes tell you when a predicate has outlived its usefulness.
About the author
Founder & Lead Developer at DBO Studio
Full-stack developer and creator of DBO Studio, a modern database management tool. Passionate about PostgreSQL, MySQL, SQLite, database performance, developer productivity, and building tools that simplify database workflows.
Try this in DBO Studio
Explore PostgreSQL workflows visually in DBO Studio — schemas, queries, and plans in one place.
Our newsletter
Subscribe to DBO Studio’s newsletter for exclusive tutorials, tips & product updatesRelated posts








