PostgreSQL partition semantics, verified¶
Everything the 1.0 design relies on was measured against real servers rather than assumed:
postgres:17-alpine (17.11) and postgres:15-alpine (15.19) via testcontainers, on
2026-08-28. Both versions behaved identically in every case below. The measurement scripts
live with the development scratch files; the integration suite re-asserts the load-bearing
facts on every run, on PostgreSQL 15, 16 and 17 in CI.
Transactions¶
| Statement | Inside a transaction block |
|---|---|
ALTER TABLE … DETACH PARTITION … CONCURRENTLY |
refused: 25001 ALTER TABLE ... DETACH CONCURRENTLY cannot run inside a transaction block |
… DETACH PARTITION … CONCURRENTLY with a DEFAULT partition present |
refused even in autocommit: 55000 cannot detach partitions concurrently when a default partition exists |
plain DETACH, ATTACH, CREATE TABLE … PARTITION OF, DROP TABLE, COMMENT ON |
fine |
The executor therefore runs the concurrent form on an AUTOCOMMIT connection and,
in DetachMode.AUTO, falls back to the blocking form on 55000/0A000/42601.
The pending-detach state¶
A DETACH … CONCURRENTLY interrupted by statement_timeout (measured with 1.5 s against an
open transaction holding the partition) leaves pg_inherits.inhdetachpending = true:
- the partition still has
relispartition = true; - it is invisible through the parent (
SELECT count(*) FROM parent→ 0 rows from it); - rows that belong to it are rejected with
23514 no partition of relation … found for row; - a second
DETACH … CONCURRENTLYfails with55000 partition … already pending detach; - a plain
DETACHblocks on the lock; - only
ALTER TABLE … DETACH PARTITION … FINALIZEcompletes it (relispartition→ false); pg_partition_tree(parent)and queries through the parent omit the pending partition once the first transaction has committed (verified on 15–17);pg_inheritsstill lists it withinhdetachpending = true, which is why the tree is read frompg_inherits.
PartitionRemover.detach checks inhdetachpending first and finalizes; the planner reports
such a partition as DETACH_PENDING (INFO), plans its detach (DETACH_FINALIZE) and
otherwise treats its window as absent.
While the concurrent detach waits for an open transaction it holds no relation lock of its
own (wait_event = Lock/virtualxid), but inhdetachpending is already set — so a long
wait is itself a period of rejected writes for that partition.
Subtrees¶
- Detaching a subpartitioned branch keeps its subtree intact: the branch loses
relispartition, its children keep theirs and theirrelpartbound; the rows stay readable through the branch (100 rows before and after) and disappear from the root. DROP TABLE branchdrops the whole subtree withoutCASCADE, whether the branch is attached or detached. Nothing is left inpg_class.pg_partition_tree(detached_branch)works: the branch is level 0 of its own tree, which is how a half-built branch is inspected before it is attached.pg_partition_treeof a plain table or ofto_regclass(NULL)returns no rows.LIST (tier) → RANGE (created_at)nesting is accepted.- A partition may live in another schema than its parent;
pg_partition_treereports it asarch.cc_old.
Locks (measured from a second session via pg_locks)¶
| Statement | Locks held |
|---|---|
CREATE TABLE … PARTITION OF parent … |
ACCESS EXCLUSIVE on parent |
CREATE TABLE … (LIKE parent INCLUDING ALL) |
ACCESS SHARE on parent |
ALTER TABLE parent ATTACH PARTITION child … |
SHARE UPDATE EXCLUSIVE on parent, ACCESS EXCLUSIVE on child |
| … with a DEFAULT partition present | additionally ACCESS EXCLUSIVE on the DEFAULT partition |
ALTER TABLE parent DETACH PARTITION child (plain) |
ACCESS EXCLUSIVE on parent and child |
DROP TABLE detached_table |
ACCESS EXCLUSIVE on that table only |
DROP TABLE attached_partition |
ACCESS EXCLUSIVE on parent and partition |
COMMENT ON TABLE x |
SHARE UPDATE EXCLUSIVE on x |
ANALYZE x |
SHARE UPDATE EXCLUSIVE on x |
SELECT * FROM pg_partition_tree('parent') |
ACCESS SHARE on every member |
pg_get_expr(relpartbound, oid), pg_inherits, obj_description, pg_total_relation_size, reltuples |
no relation lock |
SELECT count(*) FROM x |
ACCESS SHARE on x |
Hence: create standalone with LIKE, attach last; never CREATE TABLE … PARTITION OF
against a live parent; a converged tree must issue no DDL at all.
A queued insert keeps the partition it chose (measured on 17, 2026-09-07)¶
An INSERT through the parent picks its target from the partition set it saw when it took
ROW EXCLUSIVE on the parent. ATTACH takes only SHARE UPDATE EXCLUSIVE there, which does
not conflict — so an insert can route to the DEFAULT partition, queue on the ACCESS
EXCLUSIVE the attach holds over it, and come out of that wait still aimed at DEFAULT. The
row is then rejected by the constraint the attach has just narrowed:
23514 new row for relation "events_legacy" violates partition constraint. Nothing re-routes
it; the write is lost to the caller.
Taking EXCLUSIVE on the parent instead — one level up, conflicting with ROW EXCLUSIVE but
not with ACCESS SHARE — makes the insert wait before it chooses: it re-plans against the
tree the attach left and lands in the new partition, its statement unchanged, while readers
of the other partitions carry on. That is what the move-and-attach of
DEFAULT reconciliation does; the
integration suite asserts it on PostgreSQL 15 through 18 with a writer inserting into the
window while the attach runs.
Foreign tables¶
CREATE FOREIGN TABLE … PARTITION OF parentandATTACH PARTITIONof a foreign table are refused with42809 cannot create foreign partition of partitioned table/cannot attach foreign table … as partitionwhen the parent has a unique index or primary key; both succeed on an index-free parent and on one with only non-unique indexes.- A foreign table attached as a partition must carry the parent's
NOT NULLconstraints (42804 column … in child table must be marked NOT NULLotherwise), which is whycreate_foreign_table_likecopiesattnotnullalong with the types. - In
pg_partition_treethe foreign leaf hasrelkind = 'f',isleaf = true, a normalrelpartbound;SELECT … FROM parentreads through it. DROP TABLE,COMMENT ON TABLEandLOCK TABLEon it fail with42809 "x" is not a table/not supported for foreign tables;COMMENT ON FOREIGN TABLEandDROP FOREIGN TABLEwork;pg_total_relation_sizeis 0.ATTACH,DETACHandDETACH … CONCURRENTLYall work with a foreign partition; a foreign DEFAULT partition is accepted.CREATE INDEXon a parent that has a foreign partition succeeds (non-unique indexes skip foreign children).
Under a LocalLeaves configuration the library never plans DDL for a foreign leaf and
reports it as FOREIGN_PARTITION; under ForeignLeaves it creates, comments, detaches and
drops foreign leaves with the statements above.
Storage parameters, tablespaces, privileges¶
WITH (...)on a partitioned table →42809 cannot specify storage parameters for a partitioned table; on a leaf it works.LocalLeavesapplies storage parameters to leaves only.TABLESPACE pg_defaulton a partitioned table →0A000 cannot specify default tablespace for partitioned relations; a real tablespace is accepted on branches and leaves alike.CREATE TABLE … (LIKE parent INCLUDING ALL)copies no grants and keeps the creator as owner: the new relation'srelaclis NULL while the parent's carries its grants.aclexplode(relacl)lists them (PUBLIC is grantee 0);LocalLeaves(inherit_privileges=True)replays owner and grants in the creating transaction.
Foreign keys (measured on 15.19 and 17.11, 98 scenarios, no difference)¶
- An FK on another table pointing at the partitioned parent is cloned onto every partition
(
conparentid ≠ 0,conislocal = false).DETACH PARTITION— plain andCONCURRENTLY— is refused with23503 removing partition "x" violates foreign key constraint "…"while a row of the referencing table points at a row of the partition;ON DELETE CASCADEchanges nothing and nothing is cascade-deleted. A failedCONCURRENTLYleaves noinhdetachpendingstate behind. Once the referencing rows are gone the detach succeeds, the detached table carries no FK-related constraint or trigger, and its rows are invisible to the FK (an insert into the referencing table pointing at them fails23503). Re-ATTACHrecreates the clone. DROP TABLEof an attached partition referenced by such an FK always fails (2BP01), with or without referencing rows: the DETAIL names the parent constraint.DROP … CASCADEsilently removes the whole FK from the referencing table. Detach first, then drop.- An FK from the partitioned table to another table is cloned onto every partition under the
same constraint name; it survives DETACH as a standalone constraint (still enforced),
cannot be dropped on an attached partition (
42P16), and can be dropped on the detached one — which is what the safe-drop path does beforeDROP TABLE. - An FK pointing at a partition directly does not block DETACH;
DROPof that partition needsCASCADE, which drops that FK. - Locks: plain
DETACHtakesACCESS EXCLUSIVEon the referencing table as well as on parent and partition (the documentedSHAREis what the referencing side needs; the measured lock isACCESS EXCLUSIVE, taken to drop the clone constraint);DETACH … CONCURRENTLYtakes it in its second transaction, whileinhdetachpending = true;ATTACHandCREATE … PARTITION OFtakeSHARE ROW EXCLUSIVEon the referencing table; a plainDETACHthat has to wait for the referencing table does so while already holdingACCESS EXCLUSIVEon the parent.
The library translates 23503 on detach into PartitionReferencedError, records it as an
issue and goes on; Unreferenced() keeps such partitions out of the plan.
Referential actions on single-statement moves (verified on 15, 16, 17)¶
Moving a row with WITH moved AS (DELETE … RETURNING …) INSERT … in one statement:
NO ACTION's trigger runs at the end of the statement and passes only when the key is still reachable through the referenced tree. A move into a detached table (apartition_datafill, DEFAULT reconciliation before an attach) or out of the tree (unpartition) leaves it unreachable:23503, atomic — so a referenced row cannot be moved at all, whatever the action.RESTRICTfires immediately and fails the statement — safe, everything rolls back.CASCADE,SET NULLandSET DEFAULTact on the DELETE alone: the referencing rows are deleted or rewritten even though the parent row is re-inserted in the same statement. The movers refuse to run when an incoming key declares one of these.
GENERATED ALWAYS AS … STORED columns refuse explicit values (428C9), so the movers
list only writable columns (attgenerated = '') and let the target recompute; a
GENERATED ALWAYS AS IDENTITY column on the target needs OVERRIDING SYSTEM VALUE for
the moved values to survive, which the movers add when one is present. OVERRIDING
leaves the backing sequence where it was, so after a move the movers advance every
identity sequence on the target past the moved ids (setval, in the move's transaction,
keeping a higher pre-existing position) — otherwise the target's next ordinary INSERT
would draw an id a moved row already owns (23505).
The first transaction of DETACH PARTITION … CONCURRENTLY requests ACCESS EXCLUSIVE
on the partition being detached (measured on 17: pg_locks shows the waiting AEL), so
any lock held on the partition blocks it — which is what the detach pin exploits, and
why the pin must be released while the statement is already queued behind it; the release
is scoped to the statement backend's own pid, so a bystander's queued lock cannot lift the
pin early. Unlike the CONCURRENTLY form, DETACH … FINALIZE may run inside a
transaction block (verified on 15–17), which is what makes a fully transactional
finalize — lock, identity checks, marker, statement — possible.
SET CONSTRAINTS ALL IMMEDIATE inside the move's transaction forces DEFERRABLE
INITIALLY DEFERRED foreign-key checks to fire at the statement, where the 23503
translation can catch them, instead of at commit. Identity sequences are read whole (pg_sequence plus
pg_sequence_last_value, which is NULL until the sequence has been called): the next
value it would issue is seqstart, or last_value + seqincrement. Only ids on that
arithmetic path, ahead of it, and inside [seqmin, seqmax] can ever be reissued, so only
those are chased — with MAX for an ascending sequence and MIN for a descending one.
setval past seqmax raises 22003, and a bounded sequence whose remaining path is
entirely taken raises 2200H on its next insert, which is why both are refused up front
instead.
seqcache > 1 is refused too, and for the whole allocated region — seqstart through
pg_sequence_last_value — rather than the newest block: the catalog publishes only the
latest allocation, so a session that drew an earlier block (before another session moved
the catalog on) holds values no query can see and no setval can take back. Values from
before seqstart were never issued and are left alone. Measured on 17: session A's
nextval returns 1 and keeps 2..5; session B's returns 6 and moves last_value to
10; 2 is still A's to issue.
A sequence is not transactional: setval stands whatever becomes of the transaction
around it. So every identity column of a destination is decided before any of its
sequences is moved — otherwise a refusal on the second column would leave the first one
spent on rows that rolled back, and a bounded one could be exhausted by a move that never
happened. What no amount of ordering can undo is a sequence that moves once the decision
is already made: a rollback after the move was accepted, or a failure partway through
issuing the setvals — a role holding UPDATE on one of a destination's sequences and
not the next fails the second with 42501, and the first stays where it was put. Either
way the rows go back and the sequence does not, leaving it past ids that are no longer
there. That direction is safe — a sequence too far ahead skips values, it does not repeat
them — and it is why the ordering is worth having even though it cannot cover this: what
it prevents is a semantic refusal spending a sequence, which is the case that recurs.
Which rows those questions are about is settled by the move statement itself. Its
INSERT returns the identity values it placed, and one enclosing INSERT parks them in a
temporary relation for the length of the transaction — one statement, so what it reports
is exactly what it moved, with no second look at the destination to confuse a moved id
with one the destination already held. An identity column the source does not carry is
skipped: the sequence fills it as it would in any ordinary insert, so the move takes
nothing from it. The relation is named per move (pg_partsmith_moved_ and a fresh
suffix): the temporary schema belongs to the session, and a caller may be holding names in
it of its own.
A cycling sequence is decided before any of that, and on its whole range: CYCLE restarts
it at seqmin (ascending) or seqmax (descending), which need not lie on the residue
class its increments were walking, so after a wrap it can issue values it previously
skipped. Measured on 17 with INCREMENT 3 MINVALUE 1 MAXVALUE 10 START 2 CYCLE CACHE 2:
2, 5, 8 then the wrap to 1, 4, … — a different class, and last_value moves behind
a block another session still holds.
Overlaps and gaps¶
- Overlapping RANGE siblings (
ATTACHorCREATE … PARTITION OF), overlapping LIST values, a hash bucket whose residue class overlaps an existing one →42P17(partition "x" would overlap partition "y"). - A hash modulus that is not a factor of the next larger modulus →
42P17 every hash partition modulus must be a factor of the next larger modulus. Mixed moduli are legal only when factor-chained (2 and 4; not 3 and 4) and their residue classes do not overlap. - A row falling into a range gap (or an uncovered hash residue) →
23514 no partition of relation "x" found for row. ATTACHof a partition when the DEFAULT partition holds rows for its range →23514 updated partition constraint for default partition "x_default" would be violated by some row.ATTACHof an already attached partition →42809 "x" is already a partition;CREATE TABLE … PARTITION OFwith an existing name →42P07;CREATE TABLE IF NOT EXISTS … PARTITION OFsucceeds silently against a same-named relation with different bounds — one reason the library never uses it.
Bound rendering (pg_get_expr(relpartbound, oid))¶
| Key type | Rendering |
|---|---|
timestamptz |
FOR VALUES FROM ('2026-08-31 00:00:00+00') TO ('2026-09-07 00:00:00+00') |
timestamp / date |
FROM ('2026-01-01 00:00:00') TO (…) / FROM ('2026-01-01') TO (…) (naive) |
bigint |
FROM ('0') TO ('100000'), FROM ('-100') TO ('0') — quoted |
int |
FROM (MINVALUE) TO (0) — bare keyword, unquoted literal |
numeric |
FROM ('0') TO (100000.5) — mixed |
uuid |
FROM ('019a0000-0000-7000-8000-000000000000') TO (…) |
text |
FROM ('a') TO ('m') |
| unbounded | MINVALUE / MAXVALUE bare |
parse_partition_bounds strips quotes and casts, so both spellings decode alike.
Sizes, rows, cursors, identity¶
pg_total_relation_size(partitioned_relation)is 0; sizes are summed over the leaves ofpg_partition_tree(188416for a 1 000-row branch with two buckets).pg_class.reltuplesis-1before the firstANALYZE;pg_stat_user_tables.n_live_tupreads0until the statistics collector flushes and535shortly after. Estimates only.pg_get_serial_sequenceworks forserialand identity columns;pg_sequence_last_valueisNULLfor an unused sequence and equalsmax(id)after inserts.- Dropping and recreating a table under the same name yields a different OID;
to_regclass('missing')isNULL. The executor revalidates OIDs before detach and drop. max_identifier_lengthis 63.
Query pruning (measured on 17, 2026-09-01)¶
Twelve leaves: a monthly RANGE (created_at) with four HASH (tenant_id) buckets per
month, three months created. EXPLAIN (COSTS OFF), counting the nodes under Append:
| Query shape | Leaves opened |
|---|---|
| no predicate | 12 |
created_at >= TIMESTAMPTZ '…' AND created_at < TIMESTAMPTZ '…' |
4 — one month |
the same plus tenant_id = 3 |
1, and no Append node at all |
tenant_id = 3 alone |
3 — one bucket per month |
date_trunc('month', created_at) = TIMESTAMPTZ '…' |
12 — an expression over the key prunes nothing |
created_at::date = DATE '…' |
12 |
created_at < now() + interval '3 days' |
run-time pruning: Subplans Removed: 8 |
a naive TIMESTAMP '…' literal against the timestamptz key |
run-time pruning: Subplans Removed: 8 |
On a weekly RANGE (id) over UUIDv7 with the UUIDv7BoundaryCodec, a predicate built from
min_uuid_for(start) / min_uuid_for(end) opens exactly one weekly partition and uses its
primary-key index. Written up for users in
Query a partitioned table.
Clock changes¶
A daily grid in Europe/Berlin follows the local calendar, not a fixed 24 hours:
| Local day | Bounds in UTC | Real length |
|---|---|---|
| 2026-03-29 (clocks go forward) | 2026-03-28 23:00+00 → 2026-03-29 22:00+00 |
23 h |
| 2026-10-25 (clocks go back) | 2026-10-24 22:00+00 → 2026-10-25 23:00+00 |
25 h |
Rows at 2026-03-29 21:30+00 and 22:30+00 — 23:30 and 00:30 in Berlin — route to the
29th and the 30th respectively. The same shift applies to encoded keys: the UUIDv7 codec
encodes those exact instants, and adjacent days stay contiguous across the transition.
Verified by unit tests and by an integration test that maintains such a table and checks
pg_get_expr and tableoid.
Note for readers of the code: subtracting two aware datetimes that share one tzinfo
compares wall clocks, not instants, so a 23-hour day still measures as 1 day unless both
ends are converted to UTC first.