Most PostgreSQL operators learn about transaction ID wraparound eventually, usually from a scary monitoring alert or a scary blog post. Far fewer know there is a second, independent counter with its own wraparound emergency: MultiXact IDs. I have watched a database refuse writes over multixact exhaustion while its regular XID age was perfectly healthy, and the on-call runbook said nothing about it. This article is the runbook entry that was missing: what multixacts are, which workloads burn them, how to watch the counters, and how vacuum digs you out.
What a MultiXact actually is
A normal row lock is owned by one transaction, and the row's header has room for exactly one locking transaction id. But some locks are shared: multiple transactions can hold them on the same row at once. SELECT ... FOR SHARE, FOR KEY SHARE, and — most importantly — foreign key checks all take shared row locks. When a second transaction needs a shared lock on a row that already has one, PostgreSQL cannot store two transaction ids in the header, so it allocates a MultiXact ID: a separate counter whose value points into a data structure listing the member transactions. The row header then carries that multixact id.
The operational point: what consumes the counter is concurrent shared locking of the same row — a single locker just writes its own xid into the header, and it is the second transaction locking that row at the same time that allocates a multixact. The two classic burners are applications that use SELECT ... FOR SHARE (or FOR KEY SHARE) for application-level coordination, and foreign-key-heavy schemas where every insert or key update on a child table takes a FOR KEY SHARE lock on the referenced parent row. A hot parent row referenced constantly — think an accounts row touched by every transaction — is a multixact factory.
Offsets and members: two resources, two limits
Multixact storage has two parts, and exhaustion of either one is an emergency. The offset space is the multixact id counter itself, a 32-bit value stored in the pg_multixact/offsets SLRU. The members space is the list of member transactions those offsets point into, stored in pg_multixact/members, and it is the tighter limit in practice: the members area has a fixed 32-bit address space that wraps independently, and workloads whose multixacts contain many members fill it long before offsets wrap. When people say "multixact wraparound," ask which one they mean, because the symptoms and headroom differ.
Both limits end the same way when ignored: warnings in the log first, then the server protects itself by refusing to generate new multixacts until vacuum frees space — which from the application's side looks like writes starting to fail. Members space usually gets there first because it is the tighter limit. One error belongs in a different mental bucket: "MultiXactId N has already been removed" means a still-visible row header points at members that truncation already reclaimed, and on a correctly freezing system that should be impossible — treat it as a corruption signal or a known-bug hunt, not as a routine exhaustion symptom. Both flavors have caused real outages; both are preventable with monitoring that takes five minutes to set up.
The counter you should be watching: age of minmxid
PostgreSQL tracks the oldest unfrozen multixact per database and per table, and mxid_age translates that oldest point into how many multixacts have been consumed since — the bigger the age, the less headroom is left:
-- Per database: how close is each database to multixact wraparound?
SELECT datname,
datminmxid,
mxid_age(datminmxid) AS mxid_age
FROM pg_database
ORDER BY mxid_age(datminmxid) DESC;
-- Per table: which tables hold the oldest multixacts?
SELECT relname,
relminmxid,
mxid_age(relminmxid) AS mxid_age
FROM pg_class
WHERE relkind IN ('r', 'm', 't')
ORDER BY mxid_age(relminmxid) DESC
LIMIT 20;
Alert thresholds to start from: warn when mxid_age(datminmxid) passes half of autovacuum_multixact_freeze_max_age (default 400 million), and treat anything near that limit as urgent. Once a table's multixact age passes autovacuum_multixact_freeze_max_age, autovacuum forcibly vacuums that table — a worker connects to its database and goes after it even when the table is below its normal vacuum thresholds, exactly parallel to the XID freeze mechanism. If you have tuned autovacuum to be lazy, remember that wraparound vacuums override your laziness — they run whether you like the timing or not, and on a huge table they are heavy. The PostgreSQL vacuum guide covers the regular side of this machinery, and our XID wraparound prevention article is the sibling of this one for the main counter.
Watching the SLRU pressure
Multixact lookups go through the SLRU (simple least-recently-used) cache layers for offsets and members, and under a hot FOR SHARE workload these caches can thrash. PostgreSQL exposes per-SLRU statistics:
SELECT name,
blks_hit,
blks_read,
blks_written,
flushes,
truncates
FROM pg_stat_slru
ORDER BY blks_read DESC;
Watch the two multixact rows — they are named MultiXactOffset and MultiXactMember on PostgreSQL 16, and were renamed to lowercase multixact_offset and multixact_member on 17. Rising reads and flushes on these, especially paired with multixact buffer or SLRU wait events in pg_stat_activity, tell you the subsystem is working hard — often the first visible sign that a FK-heavy write pattern or a new FOR SHARE code path is burning counter and cache at an unusual rate. For inspection, pg_get_multixact_members(mxid) lists the members of a given multixact when you are digging into a specific row's lock history, though it is a debugging tool, not a dashboard metric.
How vacuum freezes multixacts
Freezing is the escape hatch, same idea as XID freezing. When vacuum visits a page and finds row headers carrying multixact ids older than vacuum_multixact_freeze_min_age (and subject to the other multixact freeze ages), it resolves them: if the members are all long-committed, the lock information is simply removed or collapsed to a plain committed state, and the table's relminmxid can advance. Once every table's relminmxid is old enough, the database's datminmxid advances, and old offsets and members can be truncated away, freeing the members space that is usually the real constraint.
The failure mode to avoid is a table that vacuum cannot finish — an hours-long vacuum of a giant FK-hot table, or one that keeps getting cancelled, or one blocked by an old prepared transaction or a forgotten replication slot holding back horizons. Then relminmxid never advances, the anti-wraparound vacuum launches, takes the same blockers, and the database eventually stops accepting writes to protect itself. The fix is unglamorous: clear the blockers, vacuum the worst tables manually at a quiet time, and keep it from recurring by fixing the shared-lock-heavy pattern that created the multixacts. Sometimes that pattern is a trigger doing FK checks you no longer need; sometimes it is application code taking FOR SHARE when FOR KEY SHARE would do — the weaker lock conflicts only with FOR UPDATE, so it blocks fewer writers — but any two transactions holding locks on the same row at once still allocate a multixact, so shortening lock lifetimes on hot rows matters as much as picking the lighter lock.
If you are already in the emergency
When the database logs that it is not accepting commands to avoid multixact wraparound, the recovery is the XID playbook adapted: stop writers, identify the tables with the highest mxid_age, remove anything blocking vacuum (old prepared transactions, idle-in-transaction sessions with snapshots, lagging slots), and VACUUM (VERBOSE) the offenders one at a time until datminmxid advances and the database lifts the protection. Plain VACUUM is the documented recovery here — at these ages every vacuum runs as an aggressive scan and freezes as it goes, and the FREEZE option mostly adds I/O you cannot afford mid-incident. It is slower and more stressful than prevention by about two orders of magnitude, which is the entire argument for the monitoring queries above.
How MonPG watches the second counter
The teams that get bitten by wraparound are rarely the ones without warnings — they are the ones without graphs. MonPG monitors PostgreSQL today, and both counters are first-class there: XID and multixact ages per database, autovacuum progress, and the blockers that stop freezing — long transactions, prepared transactions, lagging replication slots — so the trend is visible months out and the alert names the table to vacuum. The PostgreSQL monitoring overview shows what gets collected, and more wraparound war stories live on the MonPG blog. Two counters, two freeze ages, one vacuum system — watch both, and neither will ever stop your database.