In PostgreSQL, every tuple starts with 23-byte header, and the first eight bytes are two transaction IDs. t_xmin for the transaction that created the row and t_xmax for the one that deleted or updated it. That is the visibility story covered in PostgreSQL MVCC, Byte by Byte. For now we have discussed t_xmax acting as the delete marker.
t_xmax has a second job. When you run SELECT ... FOR UPDATE or an insert checks a foreign key, PostgreSQL has nowhere else to record the row lock. The shared memory lock table is limited by max_locks_per_transaction. Locking a million rows would exceed its capacity. PostgreSQL works around this by storing the locking transaction ID in t_xmax and marking the row as locked with flags in t_infomask, while readers can still see it, so every row lock in PostgreSQL ends up as a write to the page.
Setup
The setup is one parent table in the usual shape, plus a child table with a foreign key, since foreign key checks lock parent rows. Everything below was captured on a single PostgreSQL 18.6 cluster using the postgres:18 image. Transaction IDs will be different on your cluster; compare the bits instead.
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE TABLE lock_demo (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner text NOT NULL,
balance numeric(12,2)
);
CREATE TABLE lock_demo_tx (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id integer NOT NULL REFERENCES lock_demo (id),
amount numeric(12,2)
);
INSERT INTO lock_demo (owner, balance)
VALUES ('alice', 100.00), ('bob', 200.00), ('carol', 300.00);
SELECT count(*) FROM lock_demo;SELECT lp, t_xmin, t_xmax, t_ctid,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('lock_demo', 0)); lp | t_xmin | t_xmax | t_ctid | raw_flags
----+--------+--------+--------+----------------------------------------------------------
1 | 767 | 0 | (0,1) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
2 | 767 | 0 | (0,2) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
3 | 767 | 0 | (0,3) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
(3 rows)
The insert itself stamps HEAP_XMAX_INVALID. HEAP_XMIN_COMMITTED was set later by the count(*); a page read straight after the insert would still show it unset.
What FOR UPDATE writes
Call the original session A. Open a second psql session, B, lock the first row there and keep the transaction open:
-- session B
BEGIN;
SELECT pg_current_xact_id(); -- 768
SELECT id, owner FROM lock_demo WHERE id = 1 FOR UPDATE;
Back in session A, read the page:
-- session A
SELECT lp, t_xmin, t_xmax, t_ctid,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('lock_demo', 0)); lp | t_xmin | t_xmax | t_ctid | raw_flags
----+--------+--------+--------+--------------------------------------------------------------------------------------------------
1 | 767 | 768 | (0,1) | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_KEYS_UPDATED}
2 | 767 | 0 | (0,2) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
3 | 767 | 0 | (0,3) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
(3 rows)
Session B's xid sits in t_xmax exactly where a DELETE would put it. Three bits say what kind of lock it is, two of them in t_infomask:
HEAP_XMAX_LOCK_ONLYsays this xmax is a locker and the row isn't going anywhere.HEAP_XMAX_EXCL_LOCKgives the strength: exclusive.
HEAP_KEYS_UPDATED in t_infomask2 is set even though nothing was updated. A lock always sets at least one of the two t_infomask bits, which leaves three patterns for four strengths, so this bit does double duty: it separates FOR UPDATE from FOR NO KEY UPDATE. It is the same bit a key-column UPDATE or a DELETE sets, which is why FOR UPDATE conflicts with the key-share locks foreign keys take.
The HEAP_XMAX_INVALID hint no longer applies, yet the row is still readable from session A:
SELECT owner, balance FROM lock_demo WHERE id = 1; owner | balance
-------+---------
alice | 100.00
(1 row)
A reader checks visibility, sees HEAP_XMAX_LOCK_ONLY, and treats the row as live without even looking at whether 768 committed. Readers never wait for row locks in any case, because snapshot visibility decides what they see; what the bit does is keep the row alive after the locker commits. Without it, a committed lock-only xmax would read as a committed delete.
Now look at what the lock manager knows about all this, asked from session B itself:
-- session B, still inside the open transaction
SELECT locktype, relation::regclass AS relation, mode, granted
FROM pg_locks WHERE pid = pg_backend_pid() ORDER BY locktype; locktype | relation | mode | granted
---------------+----------------+-----------------+---------
relation | lock_demo | RowShareLock | t
relation | pg_locks | AccessShareLock | t
relation | lock_demo_pkey | RowShareLock | t
transactionid | | ExclusiveLock | t
virtualxid | | ExclusiveLock | t
(5 rows)transactionid lock is the queue. Every transaction that has an xid holds an ExclusiveLock on it until it ends. Anyone who needs to wait for that transaction, for any row it has locked, requests a ShareLock on the same xid and blocks.
There is no entry for row 1, because the row lock exists only in the tuple header. A second session that wants the row finds 768 in t_xmax, sees it is still in progress, and waits on the transaction ID lock instead.
The stamp outlives the lock
Commit session B and read the page again:
SELECT lp, t_xmin, t_xmax, t_ctid,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('lock_demo', 0)); lp | t_xmin | t_xmax | t_ctid | raw_flags
----+--------+--------+--------+--------------------------------------------------------------------------------------------------
1 | 767 | 768 | (0,1) | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_KEYS_UPDATED}
2 | 767 | 0 | (0,2) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
3 | 767 | 0 | (0,3) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
(3 rows)
Nothing changed. I'd assumed commit would tidy the tuple up, but COMMIT doesn't revisit the pages a transaction locked; that would mean remembering every one of them. The lock is released because 768 is no longer in progress, and anybody who looks at the row will work that out for themselves by checking the transaction's status. The stamp sits there until something that cares about xmax comes past:
VACUUM lock_demo;
SELECT lp, t_xmin, t_xmax, t_ctid,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('lock_demo', 0)); lp | t_xmin | t_xmax | t_ctid | raw_flags
----+--------+--------+--------+--------------------------------------------------------------------------------------------------------------------
1 | 767 | 768 | (0,1) | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_KEYS_UPDATED}
2 | 767 | 0 | (0,2) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
3 | 767 | 0 | (0,3) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
(3 rows)
VACUUM checks lock-only xmax values too, finds 768 finished, and sets the hint, which is enough to tell every future visitor to ignore the rest of the bits. The next locker overwrites them.
Four modes, four bit patterns
PostgreSQL has four row lock strengths, encoded in three bits across the two infomask fields. Repeat the experiment with each of the other three modes, one per row, each in its own transaction that session B holds open while session A reads the page, then commits before the next:
-- session B, one at a time, each in its own open transaction
SELECT id FROM lock_demo WHERE id = 2 FOR NO KEY UPDATE; -- xid 769
SELECT id FROM lock_demo WHERE id = 3 FOR SHARE; -- xid 770
SELECT id FROM lock_demo WHERE id = 1 FOR KEY SHARE; -- xid 771 lp | t_xmax | raw_flags
----+--------+--------------------------------------------------------------------------------
2 | 769 | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}
lp | t_xmax | raw_flags
----+--------+------------------------------------------------------------------------------------------------------
3 | 770 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}
lp | t_xmax | raw_flags
----+--------+----------------------------------------------------------------------------------
1 | 771 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}
The FOR KEY SHARE lock on row 1 overwrote the stale FOR UPDATE stamp completely. Side by side:
| SQL lock mode | KEYSHR_LOCK | EXCL_LOCK | KEYS_UPDATED | Taken implicitly by |
|---|---|---|---|---|
FOR KEY SHARE | 1 | 0 | 0 | foreign key checks |
FOR SHARE | 1 | 1 | 0 | nothing |
FOR NO KEY UPDATE | 0 | 1 | 0 | UPDATE that leaves key columns alone |
FOR UPDATE | 0 | 1 | 1 | UPDATE of a key column, DELETE |
The source calls the FOR SHARE combination HEAP_XMAX_SHR_LOCK, but it is not a separate bit. In practice, the last column explains most row-lock behaviour: every UPDATE that does not touch a key column (a column under a unique index that a foreign key could reference) takes FOR NO KEY UPDATE on the old row version, and every foreign key check takes FOR KEY SHARE. The two are compatible by design. That's what lets you update a customer's balance while someone else inserts an order for them.
Which modes block which:
| Requested, versus held | KEY SHARE | SHARE | NO KEY UPDATE | UPDATE |
|---|---|---|---|---|
FOR KEY SHARE | blocks | |||
FOR SHARE | blocks | blocks | ||
FOR NO KEY UPDATE | blocks | blocks | blocks | |
FOR UPDATE | blocks | blocks | blocks | blocks |
FOR UPDATE conflicts with everything, so a DELETE waits behind a foreign key check but a balance update doesn't. The same conflict applies to explicit locks: an application that runs SELECT ... FOR UPDATE on a parent row before changing a non-key column blocks every child insert that references it. FOR NO KEY UPDATE would have done the job. FOR UPDATE should rarely be anyone's default, and it mostly is only because it's the one everybody remembers. Laurenz Albe covers the consequences in SELECT FOR UPDATE considered harmful in PostgreSQL.
Two lockers: the MultiXactId
t_xmax is one 32-bit field. Two sessions can hold FOR SHARE on the same row at the same time, and PostgreSQL has to record both. With the three mode transactions committed, take the lock in session B, then in a third session C, and read the page from session A:
-- session B (xid 772) and session C (xid 773), both left open
SELECT id FROM lock_demo WHERE id = 3 FOR SHARE;-- session A
SELECT lp, t_xmin, t_xmax, t_ctid,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('lock_demo', 0)); lp | t_xmin | t_xmax | t_ctid | raw_flags
----+--------+--------+--------+-------------------------------------------------------------------------------------------------------------------------
1 | 767 | 771 | (0,1) | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}
2 | 767 | 769 | (0,2) | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}
3 | 767 | 1 | (0,3) | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_IS_MULTI}
(3 rows)
Row 3's t_xmax of 1 isn't a transaction ID. HEAP_XMAX_IS_MULTI marks it as a MultiXactId, the first this cluster has created, pointing at a member list stored outside the page:
SELECT * FROM pg_get_multixact_members('1'); xid | mode
-----+------
772 | sh
773 | sh
(2 rows)
The other mode values are keysh, fornokeyupd and forupd for the remaining lockers, and nokeyupd and upd for members that actually wrote a new row version. The infomask bits on the tuple summarise the strongest mode held by any member, so a compatible locker can be granted without walking the member list; deciding whether to wait still means reading it.
That structure is on disk, in two directories under the data directory:
$ ls -l $PGDATA/pg_multixact/offsets $PGDATA/pg_multixact/members
pg_multixact/members:
-rw------- 1 postgres postgres 8192 Sep 11 21:06 0000
pg_multixact/offsets:
-rw------- 1 postgres postgres 8192 Sep 11 21:06 0000
offsets maps a MultiXactId to a position in members; members is the flat list of (xid, mode) pairs. Both are SLRU files, the same family as the commit log. offsets grows by one entry per multixact, members by one entry per member.
Both lockers commit. As with the single-xid case, nothing on the page changes until VACUUM visits:
VACUUM lock_demo;
SELECT lp, t_xmin, t_xmax, t_ctid,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('lock_demo', 0)); lp | t_xmin | t_xmax | t_ctid | raw_flags
----+--------+--------+--------+----------------------------------------------------------------------------
1 | 767 | 0 | (0,1) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
2 | 767 | 0 | (0,2) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
3 | 767 | 0 | (0,3) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
(3 rows)
All members finished, none of them an updater, so the multixact carries no information any more. VACUUM replaced row 3's t_xmax with 0 instead of setting a hint. A multixact reference costs more to leave around than a plain xid: resolving it means a lookup in pg_multixact, and the multixact can't be recycled while any tuple points at it. HEAP_XMIN_INVALID next to HEAP_XMIN_COMMITTED means VACUUM also froze the page, which clearing an old multixact forces.
Foreign keys take FOR KEY SHARE
Inserting into lock_demo_tx locks a parent row without any FOR ... clause in the statement. The foreign key check has to make sure that alice's id still exists and does not change until the insert commits, and it does that by locking the parent row. Start an insert in session B and leave it open:
-- session B (xid 774)
BEGIN;
INSERT INTO lock_demo_tx (account_id, amount) VALUES (1, 25.00);-- session A
SELECT lp, t_xmin, t_xmax, t_ctid,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('lock_demo', 0)); lp | t_xmin | t_xmax | t_ctid | raw_flags
----+--------+--------+--------+----------------------------------------------------------------------------------------------------
1 | 767 | 774 | (0,1) | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID}
2 | 767 | 0 | (0,2) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
3 | 767 | 0 | (0,3) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
(3 rows)
The foreign key check took a FOR KEY SHARE lock on alice's row, in a table the statement never wrote to.
Now, still with session B open, update alice's balance from session A, in autocommit mode. Balance is not a key column, so the UPDATE wants FOR NO KEY UPDATE, which doesn't conflict with FOR KEY SHARE:
-- session A, autocommit (xid 775)
UPDATE lock_demo SET balance = 150.00 WHERE id = 1;UPDATE 1SELECT lp, t_xmin, t_xmax, t_ctid,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('lock_demo', 0)); lp | t_xmin | t_xmax | t_ctid | raw_flags
----+--------+--------+--------+------------------------------------------------------------------------------------------------------------------
1 | 767 | 2 | (0,4) | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_IS_MULTI,HEAP_HOT_UPDATED}
2 | 767 | 0 | (0,2) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
3 | 767 | 0 | (0,3) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
4 | 775 | 774 | (0,4) | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_UPDATED,HEAP_ONLY_TUPLE}
(4 rows)
The old version at line pointer 1 has to record session B's key-share lock and session A's update in one field, so t_xmax became MultiXactId 2:
SELECT * FROM pg_get_multixact_members('2'); xid | mode
-----+----------
774 | keysh
775 | nokeyupd
(2 rows)
With an updater among the members, HEAP_XMAX_LOCK_ONLY is gone and this version has been dead since 775 committed. HEAP_HOT_UPDATED is there because the new version landed on the same page with no index change, the subject of the next chapter.
The UPDATE carried session B's key-share lock forward to the new version at (0,4), because session B still relies on alice's id not changing and the current row now lives there.
Try, from session C, to delete alice while the child insert is still uncommitted:
-- session C
SET statement_timeout = '2s';
DELETE FROM lock_demo WHERE id = 1;ERROR: canceling statement due to statement timeout
CONTEXT: while deleting tuple (0,4) in relation "lock_demo"
DELETE needs FOR UPDATE strength, so it queued behind 774 on (0,4), the version carrying the inherited lock. While it was waiting, pg_locks did show a row, the one exception to it never showing row locks:
-- session A, while C is waiting
SELECT locktype, page, tuple, mode, granted FROM pg_locks WHERE locktype = 'tuple'; locktype | page | tuple | mode | granted
----------+------+-------+---------------------+---------
tuple | 0 | 4 | AccessExclusiveLock | t
(1 row)
pg_locks shows you what a blocked session is waiting on, never what a session already holds at row level. That's also the answer to "why can't I delete this row, nothing has it locked". Something inserted a child row and hasn't committed yet, and this tuple entry plus the transactionid it is waiting for are the only traces.
tuple lock on the row it is queued for before sleeping on the holder's transaction ID, so that later waiters line up behind it in order rather than racing for the row when the holder finishes. It disappears the moment the waiter gets the row or gives up.
Once session B commits, a delete goes through on a different row to show the last pattern:
-- session C
DELETE FROM lock_demo WHERE id = 2;
SELECT lp, t_xmin, t_xmax, t_ctid,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('lock_demo', 0)); lp | t_xmin | t_xmax | t_ctid | raw_flags
----+--------+--------+--------+------------------------------------------------------------------------------------------------------------------
1 | 767 | 2 | (0,4) | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_IS_MULTI,HEAP_HOT_UPDATED}
2 | 767 | 777 | (0,2) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_KEYS_UPDATED}
3 | 767 | 0 | (0,3) | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
4 | 775 | 774 | (0,4) | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_UPDATED,HEAP_ONLY_TUPLE}
(4 rows)
A delete sets HEAP_KEYS_UPDATED and no lock bits, because it has to conflict with every lock mode, key share included.
Multixacts on disk
Every multixact allocated is a permanent record until VACUUM says otherwise, so it matters how many a workload creates. Take a parent table with 20,000 rows and a child table referencing it, in the same shape as before:
CREATE TABLE mx_demo (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner text NOT NULL,
balance numeric(12,2)
);
CREATE TABLE mx_child (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
parent_id integer NOT NULL REFERENCES mx_demo (id)
);
INSERT INTO mx_demo (owner, balance)
SELECT 'user_' || i, i FROM generate_series(1, 20000) AS i;
SELECT relname, relminmxid FROM pg_class WHERE relname = 'mx_demo'; relname | relminmxid
---------+------------
mx_demo | 3
relminmxid is the oldest multixact any tuple in this table might still reference. A new table starts at the oldest multixact still in use, which on an idle cluster is the next one to be allocated; after the two created above, that's 3. Now have two transactions insert 5,000 child rows each for the same parents, overlapping in time:
-- session B (xid 781) and session C (xid 782), both left open
-- (778 to 780 went to the DDL above, 776 to the DELETE that timed out)
BEGIN;
INSERT INTO mx_child (parent_id) SELECT i FROM generate_series(1, 5000) AS i;-- session A
SELECT lp, t_xmax,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('mx_demo', 0)) LIMIT 3;
SELECT * FROM pg_get_multixact_members('3'); lp | t_xmax | raw_flags
----+--------+-----------------------------------------------------------------------------------------------------
1 | 3 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_IS_MULTI}
2 | 3 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_IS_MULTI}
3 | 3 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_IS_MULTI}
(3 rows)
xid | mode
-----+-------
781 | keysh
782 | keysh
(2 rows)
A backend caches the multixacts it created or read in the current transaction (up to 256 entries, dropped at transaction end), so transaction 782 built {781 keysh, 782 keysh} once and reused it for all 5,000 rows. The counters confirm it after a checkpoint:
CHECKPOINT;
SELECT next_multixact_id, next_multi_offset FROM pg_control_checkpoint(); next_multixact_id | next_multi_offset
-------------------+-------------------
4 | 7
Offsets start at 1, so six members leave next_multi_offset at 7.
The expensive shape is the opposite one: a single long transaction holding key-share locks, and many short transactions each adding their own member. Commit session C, keep session B's 5,000-row insert open, and from session C run 5,000 separate one-row inserts, each its own transaction:
-- session C, autocommit, 5,000 times
INSERT INTO mx_child (parent_id) VALUES (1);
INSERT INTO mx_child (parent_id) VALUES (2);
...SELECT * FROM pg_get_multixact_members('4');
SELECT * FROM pg_get_multixact_members('5'); xid | mode
-----+-------
781 | keysh
784 | keysh
(2 rows)
xid | mode
-----+-------
781 | keysh
785 | keysh
(2 rows)
Each short transaction forms a new member set with 781, so each row gets a new multixact (783 went to a stray statement):
CHECKPOINT;
SELECT next_multixact_id, next_multi_offset FROM pg_control_checkpoint();
SELECT pg_size_pretty(sum(size)) AS members_size
FROM pg_ls_dir('pg_multixact/members') f, pg_stat_file('pg_multixact/members/' || f) s; next_multixact_id | next_multi_offset
-------------------+-------------------
5004 | 10007
members_size
--------------
56 kB
A long-running transaction that touches many parent rows, alongside a steady stream of short inserts, produces exactly this. A common source is a nightly batch job holding key-share locks on a hot reference table while the application keeps inserting. At roughly five bytes per member, the hard limit of 232 members works out to about 20 GB.
Multixact IDs are 32-bit counters like transaction IDs, and they wrap around the same way:
SELECT relname, relminmxid, mxid_age(relminmxid)
FROM pg_class WHERE relname = 'mx_demo'; relname | relminmxid | mxid_age
---------+------------+----------
mx_demo | 3 | 5001
mxid_age is how far the counter has run ahead of the oldest multixact the table still references. VACUUM advances relminmxid the same way it advances relfrozenxid, by removing or replacing old multixacts in tuple headers, and it has its own set of thresholds for when to do so:
SELECT name, setting FROM pg_settings WHERE name LIKE '%multixact%age'; name | setting
-------------------------------------+------------
autovacuum_multixact_freeze_max_age | 400000000
vacuum_multixact_failsafe_age | 1600000000
vacuum_multixact_freeze_min_age | 5000000
vacuum_multixact_freeze_table_age | 150000000
These mirror the transaction ID settings but track a separate counter, so a database can be nowhere near xid wraparound and still hit autovacuum_multixact_freeze_max_age. Autovacuum then starts an anti-wraparound vacuum you didn't schedule, on a table whose n_dead_tup is zero, which is confusing the first time you see it. Once session B commits, freezing clears it:
VACUUM FREEZE mx_demo;
SELECT relname, relminmxid, mxid_age(relminmxid)
FROM pg_class WHERE relname = 'mx_demo';
SELECT lp, t_xmax,
(heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('mx_demo', 0)) LIMIT 3; relname | relminmxid | mxid_age
---------+------------+----------
mx_demo | 5004 | 0
lp | t_xmax | raw_flags
----+--------+----------------------------------------------------------------------------
1 | 0 | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
2 | 0 | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
3 | 0 | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
(3 rows)
The files in pg_multixact don't shrink yet. They are truncated once every database's datminmxid has moved past a segment. On this cluster that has not happened. datminmxid is the minimum relminmxid over a database's tables, and the system catalogs still carry the 1 they got from initdb, in every database including this one. Then there's template0, not connectable by default, whose value moves only when autovacuum's wraparound pass gets around to it.
When the 232 member slots run out, any statement that needs a new multixact fails with
multixact "members" limit exceeded. Autovacuum starts freezing multixacts more aggressively once half the space is used. On a system with many foreign key lockers, watch next_multi_offset in pg_control_checkpoint(), not just next_multixact_id.