@@ -80,25 +80,80 @@ EXPLAIN (COSTS OFF) SELECT * FROM places WHERE geom && ST_MakeEnvelope(0,0,10,10
8080
8181## Performance
8282
83- The benefit is at ** execution** : a selective predicate on a non-key column scans only the
84- matching partition instead of every partition. On 100 partitions × 30k rows = 3M rows
85- (` bench/planning_benchmark.sql ` , PostgreSQL 18, warm):
86-
87- | | Total query time (plan + exec) |
88- | ---| ---|
89- | pruning off (scans all 100 partitions) | ~ 125 ms |
90- | pruning on (scans 1 partition) | ~ 18 ms |
91-
92- Pruning is ** not** a free planning-time win: it adds a small per-plan overhead (loading
93- summaries once, then evaluating each partition — single-digit to low-tens of ms on
94- hundreds of partitions). It pays off when the partitions it eliminates are large enough
95- that avoiding their scan outweighs that overhead — so it helps most on ** large
96- partitions with a selective non-key predicate** , and can be a slight net cost on tiny
97- partitions. Use ` table_range.enable_pruning ` to measure both ways on your workload.
98-
99- Summaries are loaded ** once per plan** (not per partition); the
100- ` e2e_per_plan_cache_loads_once_regardless_of_partitions ` test asserts exactly one
101- catalog load for a 64-partition query.
83+ The deal is simple and worth stating plainly: ** table_range trades more planning time for
84+ much less execution time.** A selective predicate on a non-key column scans only the
85+ matching partition instead of every partition, which is a huge execution win — but the
86+ planner pays to evaluate each partition's summary, so planning gets slower.
87+
88+ The numbers below are reproducible with ` bench/benchmark.sql ` (` cargo pgrx run pg18 ` , then
89+ ` \i bench/benchmark.sql ` ); they report ` EXPLAIN (ANALYZE) ` planning and execution time
90+ separately, warm.
91+
92+ ** Faster execution.** 300 partitions × 8,000 rows (2.4M rows), `WHERE nk = <value in one
93+ partition>`, PostgreSQL 18, warm:
94+
95+ | | Planning | Execution | Total |
96+ | ---| ---| ---| ---|
97+ | pruning ** off** (scans all 300 partitions) | ~ 3 ms | ~ 100 ms | ~ 103 ms |
98+ | pruning ** on** (scans 1 partition) | ~ 12 ms | ~ 0.4 ms | ** ~ 12 ms** |
99+
100+ Planning is ~ 4× slower, execution is ~ 230× faster, and total time drops ~ 8×. The win
101+ grows with how much data the eliminated partitions hold, and shrinks as partitions get
102+ smaller — on tiny partitions the planning overhead can exceed the execution it saves, so
103+ measure your workload with ` table_range.enable_pruning ` .
104+
105+ ** Honest comparison to native pruning.** When a predicate is on the * partition key* ,
106+ PostgreSQL prunes natively — and that path is in a different league, because it eliminates
107+ partitions from a sorted bound array * before* they are ever locked or opened. The table
108+ below uses two identical columns on the same table: ` pk ` (the range partition key, pruned
109+ natively) and ` nk ` (the same values, not the key, pruned by table_range):
110+
111+ | Same ` = ` predicate, 2,000 partitions | Planning | Execution |
112+ | ---| ---| ---|
113+ | native pruning — column ** is** the partition key | ** ~ 0.1 ms** | ~ 0.02 ms |
114+ | table_range — column is ** not** the partition key | ~ 80 ms | ~ 0.06 ms |
115+ | no pruning — scans all 2,000 partitions | ~ 30 ms | ~ 26 ms |
116+
117+ Native pruning is * hundreds of times* cheaper to plan and is effectively constant in the
118+ partition count. table_range cannot match that (see
119+ [ Scaling] ( #scaling-and-partition-count ) ): its job is the case native pruning ** can't** do
120+ — eliminating partitions by a non-key column. Against the realistic alternative for that
121+ case (scanning every partition), it still wins on total time whenever the partitions are
122+ sizeable.
123+
124+ Each partition's summary is read from its own index page and cached for the duration of
125+ one plan; the per-column compare function and the query constant are resolved once per
126+ plan and reused across partitions (so the per-partition cost is a typed min/max compare,
127+ not repeated catalog lookups).
128+
129+ ## Scaling and partition count
130+
131+ table_range's planning cost is ** O(number of partitions)** : PostgreSQL builds a planner
132+ node for every partition of the table for a non-key predicate, and table_range evaluates
133+ each one's summary. This is fundamentally different from native partition pruning, which
134+ is ~ O(log n) because it prunes on the partition key before expansion. There is no public
135+ planner hook that prunes a non-key column before expansion, so this O(n) cost is inherent.
136+
137+ Two practical consequences and how to handle them:
138+
139+ - ** Lock table exhaustion (a hard wall, ~ 10k partitions on defaults).** Any query touching
140+ a non-key column must lock * every* partition (and its indexes) while planning. With the
141+ default ` max_locks_per_transaction = 64 ` , a query over ~ 10,000 partitions fails with
142+ ` ERROR: out of shared memory ` / ` You might need to increase "max_locks_per_transaction" ` .
143+ This is a PostgreSQL limit on wide non-key scans, not specific to this extension (native
144+ key pruning avoids it by never locking pruned partitions). ** Mitigation:** raise
145+ ` max_locks_per_transaction ` (e.g. to a few thousand) and restart — it preallocates
146+ shared memory for the lock table, pushing the wall out in proportion.
147+ - ** Planning time grows with partition count.** Even below the lock wall, planning scales
148+ linearly. ** Mitigations:** prefer ** fewer, larger partitions** (table_range's sweet spot
149+ — the execution win is biggest there anyway); use ** prepared statements** so a plan is
150+ reused across executions and the planning cost is amortized; and where you can,
151+ ** align the hot filter column with the partition key** so native pruning handles it.
152+
153+ In short, table_range targets ** hundreds to a few thousand sizeable partitions with a
154+ selective non-key predicate** . For tens of thousands of partitions, non-key pruning is not
155+ something an extension can make sub-linear today; that would require pre-expansion pruning
156+ support in PostgreSQL core.
102157
103158## Supported predicates
104159
@@ -154,8 +209,19 @@ range-type tests, which exercise the same code path.
154209
155210## Limitations
156211
212+ - ** Planning is O(partitions)** for non-key predicates, with a hard lock-table wall around
213+ ~ 10k partitions on default settings. See [ Scaling] ( #scaling-and-partition-count ) for the
214+ cause and mitigations (` max_locks_per_transaction ` , prepared statements, fewer/larger
215+ partitions). Native partition-key pruning does not have this limit; table_range is for
216+ the cases native pruning cannot handle.
217+ - Pruning is a ** planning-time cost / execution-time win** tradeoff: on small partitions
218+ the per-plan overhead can exceed the scan it saves. Measure with
219+ ` table_range.enable_pruning ` .
157220- ` NOT IN ` / ` <> ALL ` , ` NOT (...) ` , expression predicates, and parameterized
158221 prepared-statement plans are kept rather than pruned.
159222- Inserts keep summaries current incrementally, but deletes only relax them (the summary
160223 can stay wider than the live data until a ` VACUUM ` /` REINDEX ` re-tightens it) — always
161224 correct, just potentially less selective.
225+ - Pruning engages only while the index is ** valid** (` indisvalid ` ); the planner ignores
226+ invalid indexes, so anything that invalidates a table_range index silently disables its
227+ pruning until rebuilt.
0 commit comments