ATTACH PARTITION cost grows linearly with pg_constraint size (seqscan in CloneFkReferenced), much worse since not-null constraints are in pg_constraint (PG 18)

From: Bernhard Wonisch <bernhard(dot)wonisch(at)gmx(dot)at>
To: pgsql-hackers(at)lists(dot)postgresql(dot)org
Subject: ATTACH PARTITION cost grows linearly with pg_constraint size (seqscan in CloneFkReferenced), much worse since not-null constraints are in pg_constraint (PG 18)
Date: 2026-09-29 11:38:42
Message-ID: trinity-08d3329b-f6b2-4c11-91d7-2198e50bf4fc-1790681922354@trinity-msg-rest-gmx-gmx-live-58cc8f554d-c56lm
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

<html><body><div style="font-family: 'verdana'; font-size: 12px; color: #000;">
<div>
<pre>Hi hackers,<br><br>On PostgreSQL 18.6 we see ATTACH PARTITION and CREATE TABLE ... PARTITION<br>OF become steadily slower as the database grows. That happens even when<br>the partitioned table involved is empty, has only a handful of<br>partitions, and no foreign key references it or goes out from it. The<br>cost per attached partition is proportional to the total size of<br>pg_constraint.<br><br><br>1. Where the time goes<br>----------------------<br><br>Both ATExecAttachPartition() and DefineRelation() (for PARTITION OF)<br>call CloneForeignKeyConstraints() unconditionally, which calls<br>CloneFkReferenced(). That function looks for foreign keys referencing<br>the parent with a heap scan of pg_constraint, because there is no<br>index on confrelid (src/backend/commands/tablecmds.c, REL_18_STABLE):<br><br> ScanKeyInit(&amp;key[0],<br> Anum_pg_constraint_confrelid, BTEqualStrategyNumber,<br> F_OIDEQ, ObjectIdGetDatum(RelationGetRelid(parentRel)));<br> ScanKeyInit(&amp;key[1],<br> Anum_pg_constraint_contype, BTEqualStrategyNumber,<br> F_CHAREQ, CharGetDatum(CONSTRAINT_FOREIGN));<br> /* This is a seqscan, as we don't have a usable index ... */<br> scan = systable_beginscan(pg_constraint, InvalidOid, true,<br> NULL, 2, key);<br><br>So every partition added costs one full scan of pg_constraint. I<br>confirmed this with pg_stat_sys_tables.seq_scan on pg_constraint.<br>10 x CREATE TABLE ... (LIKE ...) added 0 seq scans, while 10 x ATTACH<br>PARTITION plus 10 x CREATE TABLE ... PARTITION OF added exactly 20.<br><br>This used to be cheap. Since commit 14e87ffa5 ("Add pg_constraint rows<br>for not-null constraints", PG 18), every NOT NULL column of every table<br>and every partition has a row in pg_constraint. Partitioned schemas in<br>particular now have far more rows there than before. In our database<br>97.5% of pg_constraint consists of rows belonging to partitions:<br><br> contype | count<br> --------+--------<br> n | 639630<br> p | 39054<br> c | 30035<br> f | 1001<br> u | 123<br><br> pg_constraint: 711k rows, 288 MB heap (469 MB total)<br><br> EXPLAIN (ANALYZE, BUFFERS) -- max_parallel_workers_per_gather = 0<br> SELECT * FROM pg_constraint WHERE confrelid = '&lt;parent&gt;'::regclass AND contype = 'f';<br> Seq Scan on pg_constraint (actual time=97.877..97.878 rows=0 loops=1)<br> Filter: ((confrelid = '1151806'::oid) AND (contype = 'f'::"char"))<br> Rows Removed by Filter: 709854<br> Buffers: shared hit=15015 read=21873<br> Execution Time: 97.895 ms<br><br>The same GetParentedForeignKeyRefs() pattern ("XXX This is a seqscan,<br>as we don't have a usable index") exists on the DETACH path.<br><br><br>2. Standalone reproducer<br>------------------------<br><br>Run this in an empty PG 18 database. It attaches 50 fresh, empty<br>partitions to a table without any FK and then grows pg_constraint with<br>unrelated tables that have NOT NULL columns. The nullable filler step is<br>a control: many more tables and columns, but no pg_constraint rows.<br><br> CREATE TABLE parent (k INTEGER NOT NULL, v TEXT) PARTITION BY LIST (k);<br> CREATE SEQUENCE part_seq;<br><br> CREATE FUNCTION time_attach(p_n INTEGER) RETURNS NUMERIC<br> LANGUAGE plpgsql AS $f$<br> DECLARE<br> t0 TIMESTAMPTZ;<br> v_first BIGINT := nextval('part_seq');<br> i BIGINT;<br> BEGIN<br> PERFORM setval('part_seq', v_first + p_n);<br> FOR i IN v_first .. v_first + p_n LOOP<br> EXECUTE format('CREATE TABLE parent_p%s (LIKE parent)', i);<br> END LOOP;<br> -- one warm-up ATTACH, not measured<br> EXECUTE format('ALTER TABLE parent ATTACH PARTITION parent_p%s FOR VALUES IN (%s)', v_first, v_first);<br> t0 := clock_timestamp();<br> FOR i IN v_first + 1 .. v_first + p_n LOOP<br> EXECUTE format('ALTER TABLE parent ATTACH PARTITION parent_p%s FOR VALUES IN (%s)', i, i);<br> END LOOP;<br> RETURN ROUND(EXTRACT(EPOCH FROM clock_timestamp() - t0) * 1000 / p_n, 2);<br> END;<br> $f$;<br><br> CREATE FUNCTION filler(p_prefix TEXT, p_tables INTEGER, p_cols INTEGER, p_not_null BOOLEAN) RETURNS VOID<br> LANGUAGE plpgsql AS $f$<br> DECLARE<br> v_cols TEXT;<br> BEGIN<br> SELECT string_agg(format('c%s INTEGER%s', c, CASE WHEN p_not_null THEN ' NOT NULL' ELSE '' END), ', ')<br> INTO v_cols<br> FROM generate_series(1, p_cols) c;<br> FOR t IN 1 .. p_tables LOOP<br> EXECUTE format('CREATE TABLE %s_%s (%s)', p_prefix, t, v_cols);<br> END LOOP;<br> END;<br> $f$;<br><br> CREATE TABLE results (step TEXT, pg_constraint_rows BIGINT, pg_constraint_mb NUMERIC,<br> ms_per_attach NUMERIC, ts TIMESTAMPTZ DEFAULT clock_timestamp());<br><br> CREATE FUNCTION measure(p_step TEXT) RETURNS VOID<br> LANGUAGE sql AS $f$<br> INSERT INTO results (step, pg_constraint_rows, pg_constraint_mb, ms_per_attach)<br> SELECT p_step, (SELECT COUNT(*) FROM pg_constraint),<br> ROUND(pg_relation_size('pg_constraint') / 1048576.0, 1), time_attach(50);<br> $f$;<br><br> SELECT measure('empty database');<br> BEGIN; SELECT filler('nullable', 500, 500, false); COMMIT;<br> SELECT measure('+500 tables x 500 nullable columns');<br> BEGIN; SELECT filler('nn1', 500, 500, true); COMMIT;<br> SELECT measure('+250k NOT NULL constraints');<br> BEGIN; SELECT filler('nn2', 500, 500, true); COMMIT;<br> SELECT measure('+500k NOT NULL constraints');<br> BEGIN; SELECT filler('nn3', 500, 500, true); COMMIT;<br> SELECT measure('+750k NOT NULL constraints');<br> BEGIN; SELECT filler('nn4', 500, 500, true); COMMIT;<br> SELECT measure('+1M NOT NULL constraints');<br><br> SELECT step, pg_constraint_rows, pg_constraint_mb, ms_per_attach FROM results ORDER BY ts;<br><br>Result on 18.6 (x86_64, Alpine/musl build, shared_buffers = 128MB, a<br>local developer machine):<br><br> step | pg_constraint_rows | pg_constraint_mb | ms_per_attach<br> -----------------------------------+--------------------+------------------+---------------<br> empty database | 195 | 0.0 | 0.78<br> +500 tables x 500 nullable columns | 246 | 0.1 | 1.05<br> +250k NOT NULL constraints | 250297 | 41.6 | 12.99<br> +500k NOT NULL constraints | 500348 | 83.2 | 29.42<br> +750k NOT NULL constraints | 750399 | 124.8 | 44.73<br> +1M NOT NULL constraints | 1000450 | 166.3 | 64.50<br><br>ATTACH of an empty partition into a table with no FKs goes from under<br>1 ms to 64 ms, linear in pg_constraint size. The nullable control shows<br>that the number of tables or columns doesn't matter; only the rows in<br>pg_constraint do. Before PG 18 the same filler tables would have added<br>no pg_constraint rows at all. I haven't measured the same script on<br>PG 17.<br><br><br>3. Real-world impact<br>--------------------<br><br>We use list partitioning for our data: 53 tables, all partitioned<br>by the same "partition number", each currently with 700 partitions<br>(38k leaf partitions in total). A maintenance job creates 50 new<br>partition numbers at a time, i.e. 53 x 50 = 2,650 ATTACH PARTITION per<br>run. Each new partition number adds ~990 pg_constraint rows (≈19 per<br>leaf, nearly all not-null constraints), so each run makes the next one<br>slower. Wall-clock times of consecutive runs:<br><br> 1:03, 1:09, 1:13, 1:21, 1:29, 1:31, 1:55, 2:10, 2:55, 3:46, 4:42, 6:16<br><br>Per-step timing shows ATTACH is ~95% of the runtime (~120 ms per<br>ATTACH at 700 partitions per table). CREATE TABLE ... LIKE, triggers,<br>grants and so on stay in the low milliseconds. Switching to CREATE<br>TABLE ... PARTITION OF doesn't help, since it goes through the same<br>CloneFkReferenced(). The curve bends upward once pg_constraint no longer<br>fits in shared_buffers.<br><br><br>4. Possible directions<br>----------------------<br><br>I'm not proposing a patch yet, but a few ideas:<br><br>a) Avoid the heap scan by finding the referencing FKs through an<br> existing index. An FK constraint records a dependency on the<br> referenced relation's columns, so pg_depend_reference_index<br> (refclassid, refobjid, refobjsubid) with refobjid = parent and<br> classid = pg_constraint yields exactly the candidate constraints.<br> The cost is then proportional to the number of dependents of the<br> parent, not the size of the catalog.<br><br>b) Cheap early exit: a table referenced by an FK carries the internal<br> RI action triggers (RI_FKey_*_del/_upd). If the parent has no such<br> triggers, CloneFkReferenced() could skip the scan entirely. That<br> covers the common case of partitioned tables that are never<br> referenced.<br><br>c) A catalog index on pg_constraint (confrelid). This is the most<br> direct fix, but it adds maintenance cost to every constraint insert,<br> and in PG 18 the vast majority of rows have confrelid = 0.<br><br>The same applies to GetParentedForeignKeyRefs() on the DETACH path.<br><br>Is this known, or worth a patch? I'm happy to test patches against our<br>workload.<br><br>Regards,<br>Bernhard Wonisch<br><br></pre>
</div>
</div></body></html>

Attachment Content-Type Size
unknown_filename text/html 9.5 KB

Responses

Browse pgsql-hackers by date

  From Date Subject
Next Message Manu 2026-09-29 11:45:06 Re: BUG #19686: Rolling back SET TABLESPACE
Previous Message Nisha Moond 2026-09-29 11:35:29 Re: Fix apply worker crash when subscriber table has only a deferrable primary key