Measure partition-count costs, skew, hot partitions, autovacuum/index burden, and planner memory so partition granularity is derived from workload evidence rather than folklore.

Too Many Partitions, Planner Overhead, Skew, Hot Partitions, and Maintenance Strategy

Measure partition-count costs, skew, hot partitions, autovacuum/index burden, and planner memory so partition granularity is derived from workload evidence rather than folklore.

Intermediate → Advanced180–240 minutesDeclarative partitioning and large-table operationsCurrent patched PostgreSQL 18.xCore PostgreSQL onlyServiceHub disposable schema: app.ch16_*Owner-equivalent lab role with CREATE in schema appLocal/free tooling; psql recommendedLast reviewed: August 2026

Learning outcomes

Partitioning shifts work rather than eliminating it. Every leaf is a relation with catalogs, statistics, indexes, autovacuum state, locks, and planner bookkeeping. PostgreSQL 18 improves planning efficiency for queries that access many partitions, but “more partitions” is still not free. This lesson builds two equivalent histories with daily versus monthly granularity and measures the costs instead of adopting “one partition per day” as doctrine.

01

Generate a controlled many-partition hierarchy and a coarser comparison hierarchy.

02

Measure planning and execution separately with EXPLAIN ANALYZE TIMING OFF.

03

Inspect skew, hot-leaf updates, dead tuples, index count, and per-partition statistics.

04

Connect partitionwise plan settings to memory/node multiplication.

05

Derive a partition-granularity decision record from retention, pruning, planning, and maintenance evidence.

1. Build 90 daily partitions and three monthly partitions

sql · daily hierarchy
DROP TABLE IF EXISTS app.ch16_many_daily CASCADE;CREATE TABLE app.ch16_many_daily (    event_id bigint NOT NULL,    occurred_on date NOT NULL,    customer_id bigint NOT NULL,    payload text NOT NULL) PARTITION BY RANGE (occurred_on);DO $$DECLARE  d date;  part_name text;BEGIN  FOR d IN    SELECT generate_series(DATE '2026-06-01',                           DATE '2026-08-29',                           INTERVAL '1 day')::date  LOOP    part_name := format('ch16_many_daily_%s', to_char(d,'YYYYMMDD'));    EXECUTE format(      'CREATE TABLE app.%I PARTITION OF app.ch16_many_daily       FOR VALUES FROM (%L) TO (%L)',      part_name, d, d + 1    );  END LOOP;END$$;INSERT INTO app.ch16_many_dailySELECT 2000000 + g,       DATE '2026-06-01' + (g % 90),       7000 + (g % 1000),       repeat('d',30)FROM generate_series(1,90000) AS g;ANALYZE app.ch16_many_daily;
sql · coarse monthly hierarchy with the same 90,000 logical rows
DROP TABLE IF EXISTS app.ch16_three_months CASCADE;CREATE TABLE app.ch16_three_months (    event_id bigint NOT NULL,    occurred_on date NOT NULL,    customer_id bigint NOT NULL,    payload text NOT NULL) PARTITION BY RANGE (occurred_on);CREATE TABLE app.ch16_three_months_jun PARTITION OF app.ch16_three_monthsFOR VALUES FROM ('2026-06-01') TO ('2026-07-01');CREATE TABLE app.ch16_three_months_jul PARTITION OF app.ch16_three_monthsFOR VALUES FROM ('2026-07-01') TO ('2026-08-01');CREATE TABLE app.ch16_three_months_aug PARTITION OF app.ch16_three_monthsFOR VALUES FROM ('2026-08-01') TO ('2026-08-30');INSERT INTO app.ch16_three_monthsSELECT * FROM app.ch16_many_daily;ANALYZE app.ch16_three_months;

2. Count the management surface

sql · relations and leaves per design
SELECT 'daily' AS design,       count(*) FILTER (WHERE isleaf) AS leaf_partitions,       count(*) AS tree_relationsFROM pg_partition_tree('app.ch16_many_daily'::regclass)UNION ALLSELECT 'monthly',       count(*) FILTER (WHERE isleaf),       count(*)FROM pg_partition_tree('app.ch16_three_months'::regclass);

The daily design has 90 leaves versus three. That means more catalog rows, statistics objects, potential indexes, autovacuum targets, lock entries, and DDL operations. Whether those costs are worthwhile depends on what daily granularity buys the workload.

3. Measure planning separately from execution

sql · same one-day query on both layouts
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT count(*)FROM app.ch16_many_dailyWHERE occurred_on = DATE '2026-08-15';EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT count(*)FROM app.ch16_three_monthsWHERE occurred_on = DATE '2026-08-15';

The daily hierarchy can prune to a smaller physical leaf, while the monthly hierarchy prunes to August but still filters within that leaf. Record both Planning Time and Execution Time. On this small lab the absolute numbers are machine-dependent and may not favor either design; the experiment teaches which cost each granularity changes.

sql · broad 60-day query exposes many relevant leaves
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT customer_id, count(*)FROM app.ch16_many_dailyWHERE occurred_on >= DATE '2026-06-15'  AND occurred_on <  DATE '2026-08-15'GROUP BY customer_id;EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT customer_id, count(*)FROM app.ch16_three_monthsWHERE occurred_on >= DATE '2026-06-15'  AND occurred_on <  DATE '2026-08-15'GROUP BY customer_id;

A broad analytical query may make a fine-grained hierarchy pay planning/executor-node overhead without much pruning benefit.

4. Index multiplication is write and maintenance multiplication

sql · add the same logical index to both parents
CREATE INDEX ch16_many_daily_customer_idxON app.ch16_many_daily (customer_id);CREATE INDEX ch16_three_months_customer_idxON app.ch16_three_months (customer_id);SELECT  CASE    WHEN tablename LIKE 'ch16_many_daily_%' THEN 'daily'    WHEN tablename LIKE 'ch16_three_months_%' THEN 'monthly'  END AS design,  count(*) AS physical_indexesFROM pg_indexesWHERE schemaname = 'app'  AND (    tablename LIKE 'ch16_many_daily_%'    OR tablename LIKE 'ch16_three_months_%'  )GROUP BY designORDER BY design;

One parent index becomes one physical child index per leaf. This expands CREATE/REINDEX/VACUUM/catalog work and write amplification. A partitioning scheme that requires many indexes on every tiny leaf can cost more than it saves.

5. Observe skew and the hot partition

Uniform generated data is convenient for planner comparisons but unrealistic for operations. Add a burst of recent activity to create a hot August 29 daily leaf.

sql · create a hot leaf and update churn
INSERT INTO app.ch16_many_dailySELECT 3000000 + g,       DATE '2026-08-29',       9000 + (g % 50),       repeat('h',40)FROM generate_series(1,20000) AS g;UPDATE app.ch16_many_dailySET payload = payload || 'u'WHERE occurred_on = DATE '2026-08-29'  AND event_id >= 3000001;ANALYZE app.ch16_many_daily;
sql · rank leaf size and table statistics
SELECT s.relname,       s.n_live_tup,       s.n_dead_tup,       s.n_tup_ins,       s.n_tup_upd,       pg_size_pretty(pg_total_relation_size(s.relid)) AS total_sizeFROM pg_stat_user_tables AS sWHERE s.relid IN (  SELECT relid FROM pg_partition_tree('app.ch16_many_daily'::regclass)  WHERE isleaf)ORDER BY pg_total_relation_size(s.relid) DESCLIMIT 10;

Cumulative statistics can lag and are estimates. They identify a maintenance pattern: one leaf can dominate inserts/updates/dead tuples while dozens of cold leaves barely change. Tune autovacuum/index strategy at the hot leaf where evidence supports it rather than copying one setting to all partitions.

6. Too many partitions can multiply planner and memory work

PostgreSQL 18 improves planning efficiency for many-partition queries, but its documentation still warns that excessive partition counts can increase planning CPU/memory. Partitionwise joins and aggregates can further multiply plan nodes whose memory is individually limited by work_mem.

sql · bounded partitionwise aggregate experiment
BEGIN;SET LOCAL enable_partitionwise_aggregate = on;EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON, SETTINGS)SELECT occurred_on, customer_id, count(*)FROM app.ch16_many_dailyGROUP BY occurred_on, customer_id;ROLLBACK;

Count aggregate/hash/sort nodes in the plan and consider concurrent sessions. A setting that is safe for three partitions can create much larger memory concurrency with hundreds.

7. Wrong rule: “one partition per day because pruning is faster”

Deliberately wrong approach

Daily partitions are not automatically better. If retention operates monthly, queries usually scan weeks/months, and each leaf carries several indexes, daily granularity can create dozens of extra relations and indexes without improving the dominant workload.

Build the decision from four dimensions:

Evidence Question
Retention/lifecycle What is the smallest unit we truly detach/archive/drop?
Pruning selectivity How many leaves do top queries normally need?
Hotspot/write pattern Does one leaf become a contention/autovacuum hotspot?
Management overhead How many physical indexes, stats objects, DDL jobs, locks, and plan nodes result?

8. Build a partition-granularity decision log

sql · evidence snapshot for the design review
SELECT now() AS observed_at,       current_setting('server_version') AS server_version,       current_setting('enable_partition_pruning') AS pruning,       current_setting('enable_partitionwise_join') AS pwise_join,       current_setting('enable_partitionwise_aggregate') AS pwise_aggregate,       current_setting('work_mem') AS work_mem;SELECT 'daily' AS design,       count(*) FILTER (WHERE isleaf) AS leavesFROM pg_partition_tree('app.ch16_many_daily'::regclass)UNION ALLSELECT 'monthly',       count(*) FILTER (WHERE isleaf)FROM pg_partition_tree('app.ch16_three_months'::regclass);

Store the EXPLAIN outputs, retention requirement, leaf/index counts, peak write distribution, and operational incidents with the schema decision. Revisit the partition granularity as workload and retention policy change.

9. Cleanup the Chapter 16 experiment

sql · safe cleanup of disposable ch16 objects
DROP TABLE IF EXISTS app.ch16_work_order_event CASCADE;DROP TABLE IF EXISTS app.ch16_work_orders CASCADE;DROP FUNCTION IF EXISTS app.ch16_audit_work_order();DROP TABLE IF EXISTS app.ch16_partition_audit CASCADE;DROP TABLE IF EXISTS app.ch16_many_daily CASCADE;DROP TABLE IF EXISTS app.ch16_three_months CASCADE;DROP TABLE IF EXISTS app.ch16_customer_bucket CASCADE;DROP TABLE IF EXISTS app.ch16_region_queue CASCADE;DROP TABLE IF EXISTS app.ch16_work_orders_unpartitioned CASCADE;

Check your understanding

  1. Why should planning time be recorded separately from execution time?
  2. How does one parent index affect a many-leaf hierarchy?
  3. Why can a single hot partition need different maintenance attention from cold leaves?
  4. Why are partitionwise planning settings a memory concern?
  5. What evidence should determine daily versus monthly partitioning?
Review the answers

Fine-grained partitioning can change planning overhead independently of runtime pruning. A parent index creates physical indexes on leaves, multiplying write/maintenance objects. Hot leaves accumulate churn while cold leaves do not. Partitionwise plans can multiply work_mem-bounded nodes. Granularity should follow retention units, query pruning, hotspot behavior, and operational/planner overhead.

Authoritative references

Partitioning behavior is planner-, lock-, constraint-, and version-sensitive. These PostgreSQL 18 primary sources define the mechanisms used in this lesson.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.