Topic 25

The Free Space Map and the Visibility Map

Relation Forks

Beside every table Postgres keeps two small side files. One remembers roughly how much room each page has, so an INSERT can find somewhere to put a row without searching. The other remembers which pages contain nothing but rows visible to everybody, so an index-only scan can answer from the index and a vacuum can skip work it does not need to do.

Each is tiny — a quarter of a megabyte of free space map and 32 KB of visibility map per gigabyte of table — and vacuum maintains both. That combination is why their failure mode always has the same shape: nothing errors, nothing appears in the query text, and a plan that used to be fast stops being fast without a line of it changing. Reading these two files directly is how you tell that story apart from the story where the query was wrong.

Forks: a Relation Is More Than One File

A relation on disk is a set of forks. The main fork holds the data. The _fsm fork is the free space map and the _vm fork is the visibility map, which tables have and indexes do not. A fourth, _init, exists only for unlogged tables and indexes: it holds the empty image the relation is reset to after a crash, and that is what "unlogged" means in practice. The suffixed files sit beside the main fork on disk, where the last topic of this chapter reads them.

One relation, three files, wildly different sizes
SELECT pg_size_pretty(pg_relation_size('orders', 'main')) AS main,
       pg_size_pretty(pg_relation_size('orders', 'fsm'))  AS fsm,
       pg_size_pretty(pg_relation_size('orders', 'vm'))   AS vm;

  main  |   fsm   |   vm
--------+---------+--------
 14 GB  | 3760 kB | 480 kB

Forty million orders occupy 14 GB and about 1.8 million pages. The free space map that indexes those pages is under 4 MB, and the visibility map is under half a megabyte. Their size is the point. Both are small enough to sit in shared_buffers permanently, so consulting them is a buffer hit where reading the heap they describe is 1.8 million page reads.

One relation, several files — 40 million orders and the two maps that describe them
main
The data itself
14 GB · about 1.8 million pages
_fsm
Free space map — roughly how much room each page has left
3760 kB
_vm
Visibility map — two bits per heap page
480 kB · tables have it, indexes do not
_init
The empty image an unlogged relation is reset to

The Free Space Map

The free space map is a tree. At the bottom level it stores one byte per heap page, recording approximately how much space that page has left — one byte, so the resolution is coarse on purpose. Above that, each node holds the larger of its two children's values, so the root of any FSM page carries the maximum free space available anywhere beneath it. An insert looking for a page with 200 bytes free descends that tree in a couple of lookups instead of walking the relation, and if the root says no page has room, it extends the relation instead of hunting. Every heap and index relation has one except hash indexes.

The map is only as good as its upkeep, and upkeep is vacuum's job: dead rows become reusable space when vacuum reclaims them, and the map is where that availability gets recorded. On a table where autovacuum has fallen behind — Chapter 7 makes that diagnosis properly — the map keeps saying there is no room, every insert extends the relation at the end, and the pages behind sit half empty holding tuples no transaction can see.

The Visibility Map, Bit One: All-Visible

The visibility map holds two bits per heap page. The first says the page contains only tuples visible to all active transactions, which also means there is nothing on it for vacuum to clean up. The map is deliberately conservative in one direction: when the bit is set the condition is guaranteed to be true, and when it is not set the condition may or may not hold. A stale visibility map therefore costs performance and never correctness, which is the property that makes it safe to treat as a hint.

The bits are set only by VACUUM and cleared by any operation that modifies the page. That asymmetry is the entire behaviour worth remembering: a single write clears the bit for a page instantly, and only a vacuum pass can put it back. Indexes carry no visibility map of their own; the bit always belongs to the heap page an index entry points at.

The Visibility Map, Bit Two: All-Frozen

The second bit records that every tuple on the page has been frozen, which allows an anti-wraparound vacuum to skip the page instead of reading it. Chapter 7 explains what freezing is and why transaction ID wraparound eventually forces every page to be visited; the storage-level consequence is worth stating on its own. With this bit, the pass reads what has changed. Without it, every wraparound vacuum on orders would have to read all 1.8 million pages of a mostly immutable table, forever, once per cycle.

Two bits per heap page, and what each one buys
All-visiblebit one
The page holds only tuples visible to every active transaction, so there is nothing on it for vacuum to clean up. Set only by VACUUM, cleared instantly by any operation that modifies the page. When the bit is not set the condition may still hold — the map is conservative in one direction, so a stale one costs performance and never correctness.
All-frozenbit two
Every tuple on the page has been frozen, so an anti-wraparound vacuum skips the page instead of reading it. The difference between maintenance that scales with your write rate and maintenance that scales with your table size.

Why an Index-Only Scan Stops Working

Index entries carry no visibility information, so a normal index scan has to fetch the heap tuple for every match just to find out whether the reader is entitled to see it. An index-only scan gets out of that by checking the visibility map bit for the heap page the entry points at. If the bit is set, the row is known visible and the data comes straight from the index. If it is not set, the heap tuple has to be visited after all, and there is no advantage left over a plain index scan. The trade pays because the map is four orders of magnitude smaller than the heap it describes and stays cached.

A plan that says Index Only Scan and behaves like an index scan
EXPLAIN ANALYZE
SELECT customer_id FROM orders WHERE placed_at >= DATE '2026-08-01';

 Index Only Scan using orders_placed_at_customer_idx on orders
   (actual time=0.048..1841.226 rows=412338 loops=1)
   Heap Fetches: 408112

Heap Fetches is the number of times the map said "not sure" and the scan went to the heap anyway. Here it is 408,112 out of 412,338 rows, so the index-only scan is doing the work of an index scan plus the cost of asking. The reason is not the index: these are August rows, they have been written since the last vacuum touched that end of the table, and their pages have no all-visible bit. Vacuuming orders fixes it. Adding columns to the index does not, and is the more common response because the plan node's name suggests the index is at fault.

The planner sees this through pg_class.relallvisible, the number of pages currently marked all-visible, refreshed by VACUUM, ANALYZE and a few DDL commands — Chapter 9 takes estimates apart in general. That count is what the planner uses to guess how many heap fetches an index-only scan would need, so a stale one distorts the choice of plan as well as the speed of the plan chosen.

Hints, Not Treasure

Give both files the right mental weight. They are derived data: vacuum rewrites them, and pg_visibility even ships pg_truncate_visibility_map() to throw one away deliberately so the next vacuum rebuilds it from scratch. They are not where your data lives, they are not a thing to tune, and they are too small to matter in a capacity plan — though a physical backup still has to copy them along with everything else in the directory.

Reading the map directly instead of inferring it from a plan
SELECT * FROM pg_visibility_map_summary('orders');

 all_visible | all_frozen
-------------+------------
     1782940 |    1638400

Of roughly 1.83 million pages, 1,782,940 are marked all-visible and 1,638,400 all-frozen. When the first number sits well below the page count on a table you expected to be static, you have found either a write pattern nobody documented or a vacuum that is not keeping up. That is the direct answer to a question the plan only hints at: index-only scans over the older 97% of orders will be genuinely index-only, and over the recently written tail they will not.

Common Mistakes
  • Investigating a table that "only grows" without checking whether autovacuum is running — with no free space being recorded, every insert extends the relation while the pages behind it sit half empty.
  • Reading a high Heap Fetches count as proof that an index-only scan is impossible, and widening the index instead of vacuuming the table that has no all-visible bits.
  • Disabling autovacuum on a large append-mostly table to save I/O — the visibility map stops being maintained, index-only scans degrade to ordinary index scans, and the eventual anti-wraparound pass has to read every page.
  • Copying only the main fork when moving relation files by hand — the maps are separate files, and losing them changes behaviour with no error anywhere until the next vacuum rebuilds them.
  • Treating an unset visibility bit as evidence that the page holds dead rows — the map guarantees the positive case only, so "not set" means unknown, not dirty.
  • Assuming an index has a visibility map of its own — the bit belongs to the heap page, which is why index-only scans depend on the state of the table rather than the state of the index.
Best Practices
  • Let autovacuum run on every table including append-only ones, precisely so the visibility map stays current and the wraparound pass stays cheap.
  • Read Heap Fetches from EXPLAIN (ANALYZE) whenever an index-only scan disappoints, and vacuum the table before changing the index definition.
  • Query pg_visibility_map_summary() when you want the map's actual state rather than an inference drawn from plan behaviour.
  • Compare relallvisible with relpages on any table whose plan choice looks wrong, since that ratio is the input the planner is reasoning from.
  • Measure the fsm and vm forks with pg_relation_size() once per large table, so their size stops being a question in capacity discussions.
  • Treat a relation that keeps extending while its live row count stays flat as a vacuum question first and a data-growth question second.
Comparable toolsInnoDB free-space management inside tablespace segmentsOracle freelists and automatic segment space managementSQL Server PFS pages tracking free space per data pageNo direct equivalent undo-based engines need no visibility map

Knowledge Check

What does each of the visibility map's two bits buy?

  • All-visible enables index-only scans, all-frozen lets vacuum skip the page
  • All-visible counts dead tuples, all-frozen records the page's free space
  • All-visible stores the page's oldest xmin, all-frozen stores its newest xmax
  • All-visible drives WAL replay, all-frozen drives the page checksum check

An EXPLAIN (ANALYZE) shows an Index Only Scan with Heap Fetches equal to nearly every row returned. What is the right first response?

  • Add the fetched columns to the index so it covers the whole query
  • Vacuum the table, since those recent pages have no all-visible bit set
  • Force a different plan, because an index-only scan is wrong for this query
  • Reindex the table so the index entries point at up-to-date heap pages

Autovacuum has been off on a busy table for a month. Why does the relation keep extending even though rows are being deleted?

  • Inserts always append to the end of a relation, whatever space exists
  • No vacuum means no reclaimed space for the free space map to offer
  • Deleted tuples are relocated to the end of the relation before removal
  • The free space map holds a fixed number of pages and overflowed

delivery_events is append-only and never updated. Why does it still need vacuum?

  • To reclaim the dead tuples that each insert leaves behind on its page
  • Only vacuum sets the visibility map bits its scans and freezing depend on
  • To rebuild the column statistics that the planner samples for estimates
  • To compact the free space map, which grows with every appended page

A page's all-visible bit is not set. What can you conclude about that page?

  • It definitely contains at least one tuple that is not visible to everyone
  • Nothing definite, since the map only ever guarantees the set case of it
  • It has failed its page checksum and will be reported as corrupt on read
  • It is frozen, since the two bits are set as mutually exclusive states

You got correct