Partial Indexes vs Partitioning for Hot Subsets #
An events table holds four years of history; every user-facing query touches the last seven days. Two proposals land in the same design review: a partial index on the recent window, and partitioning by month. They solve overlapping problems, cost very different amounts of work, and only one of them makes deleting old data instant.
Partial indexes are covered in partial index implementation, pruning in partition pruning analysis. This page compares them as alternatives.
The Condition Each Solves #
A partial index limits what is indexed. The table stays one relation; the index contains only rows matching its predicate, so it is small, fast to scan and cheap to maintain. It helps when queries can be proven to target the subset, and it does nothing for the table’s own size: scans of the full table, vacuum work, and the cost of deleting old rows are unchanged.
Partitioning splits the table. Each partition is a separate relation with its own indexes, statistics and storage. Queries with a predicate on the partition key touch only the matching partitions; old data is removed by detaching or dropping a partition, which is a catalog operation rather than a bulk delete; and each partition is vacuumed and analyzed independently.
Their strengths barely overlap:
| Need | Partial index | Partitioning |
|---|---|---|
| Small index for a hot subset | yes | yes, per partition |
| Cheap instant deletion of old data | no | yes, DROP PARTITION |
| Reduced vacuum scope | no | yes, per partition |
| Works with a moving time window | awkward: predicate must be rewritten | yes, new partitions |
| No planning overhead | yes | grows with partition count |
| Simple schema and tooling | yes | more moving parts |
The “moving window” row is the decisive one for time-series data. A predicate such as WHERE created_at >= '2026-09-10' becomes stale the next day, and PostgreSQL cannot use a partial index whose predicate references now(), because the predicate must be immutable. Keeping a recent-window partial index current means recreating it on a schedule.
Annotated EXPLAIN Evidence #
Partial index on a status subset:
CREATE INDEX CONCURRENTLY events_unprocessed_idx
ON events (occurred_at) WHERE processed = false;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM events WHERE processed = false ORDER BY occurred_at LIMIT 100;
Limit (actual time=0.03..0.06 rows=100 loops=1)
-> Index Scan using events_unprocessed_idx on events (actual rows=100 loops=1)
Buffers: shared hit=6
Execution Time: 0.08 ms
-- index holds ~40k unprocessed rows out of 1.4 billion
Partitioning for a time window:
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events WHERE occurred_at >= '2026-09-10';
Aggregate (actual time=412.6..412.6 rows=1 loops=1)
-> Append (actual rows=90412004 loops=1)
Subplans Removed: 46 -- 46 monthly partitions pruned
-> Seq Scan on events_2026_09 events_1 (actual rows=90412004 loops=1)
Execution Time: 412.9 ms
-- only the current month's partition is read
And what partitioning does that no index can:
-- retention: remove four-year-old data
ALTER TABLE events DETACH PARTITION events_2022_09 CONCURRENTLY;
DROP TABLE events_2022_09;
-- catalog operation + file removal; no row-by-row delete, no vacuum, no bloat
Step-by-Step Resolution #
-
Characterise the subset. Stable predicate (a status, a flag, a tenant class) or a moving range (recent time)?
-
Check whether queries carry the predicate. Neither structure helps a query that does not mention the defining column; that is a full-index problem.
-
For a stable subset, start with a partial index. It is one statement, has no schema implications and can be dropped as easily:
CREATE INDEX CONCURRENTLY events_unprocessed_idx ON events (occurred_at) WHERE processed = false; -
For a moving window with retention needs, partition. Size partitions so that a typical query touches one or two, and so that the total count stays modest — planning cost grows with partition count, as covered in planning overhead with many partitions.
-
Use both where they complement each other. Partitioning by month plus a partial index on
processed = falsewithin each partition gives pruning for reports and a tiny index for the worker queue. -
Do not partition to make one query faster. If retention and vacuum scope are not problems, a good index is simpler and often as fast.
-
Measure the migration cost honestly. Converting a large table to partitions requires a rewrite or a phased migration; a partial index is a background build.
Before and After #
-- BEFORE: one table, retention by DELETE
DELETE FROM events WHERE occurred_at < now() - interval '4 years'; 96 min, 1.4 billion dead rows to vacuum
-- AFTER: monthly partitions
ALTER TABLE events DETACH PARTITION events_2022_09 CONCURRENTLY; DROP TABLE events_2022_09; 2 s
The maintenance dimension #
Partitioning changes how autovacuum behaves. Each partition has its own thresholds, so an append-only month partition is vacuumed once and then left alone, while a single giant table keeps crossing its thresholds because of activity anywhere in it. Analyze behaves similarly, except for the partitioned parent, which needs explicit analysis — the trap in statistics on partitioned tables.
Partial indexes have their own maintenance quirk: rows moving into and out of the predicate are index inserts and deletes, so a predicate on a frequently flipping column costs more write work than a full index on the same column. A queue flag flipping once per row is fine; a boolean toggled repeatedly is not.
Neither structure fixes a missing predicate. If the application queries by a column that neither the partition key nor the index predicate mentions, both plans fall back to reading everything — which is why the first question in the workflow is about the query, not the table.
Common Pitfalls #
Partitioning for query speed alone. The complexity rarely pays without retention or vacuum-scope needs. Diagnostic signal: a partitioning proposal whose only benefit is one report. Fix: index first, measure.
A partial index with a date literal. It goes stale the next day. Diagnostic signal: index usage dropping over time. Fix: partition, or recreate the index on a schedule.
Too many partitions. Planning time grows with them. Diagnostic signal: planning time of milliseconds on simple queries. Fix: coarser partitions.
Queries without the key or predicate. Neither structure applies. Diagnostic signal: Append over all partitions, or the partial index unused. Fix: add the column to the query or use a full index.
Frequently Asked Questions #
Is a partial index a substitute for partitioning? #
For making queries on a stable subset fast, often yes. It does not reduce table size, vacuum scope or the cost of deleting old data, which are the reasons most teams partition.
Can a partial index use a moving window like the last seven days? #
No. The predicate must be immutable, so it cannot reference now(). A window predicate has to be written with a fixed date and recreated periodically, which is usually a sign that partitioning fits better.
Can I use partitioning and partial indexes together? #
Yes, and it is a common combination: partition by time for pruning and retention, and add a small partial index within each partition for a hot subset such as unprocessed rows.
Related #
- Partial Index Implementation — parent guide: partial index design
- Partition Pruning Analysis — how pruning appears in plans
- Partition Key Design for Pruning — choosing the key if you partition