PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 20: Architecture

Looking inside: buffer cache, visibility map, free space and WAL

Level 5: Internals30 min

Four contrib extensions let you look at what the server is doing internally: which tables occupy shared_buffers, which pages are all-visible or frozen, how much free space each page has, and what records are in the WAL. They turn internals diagrams into numbers you can query.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Measure which relations use shared_buffers and how hot they are with pg_buffercache
  • Check the visibility map and free space map that VACUUM maintains
  • Decode WAL records and see what generates WAL volume with pg_walinspect

conceptThe concept

ExtensionShowsKey functions
pg_buffercacheEvery buffer in shared_buffers: which relation and block, dirty or not, usage countpg_buffercache view, pg_buffercache_summary(), pg_buffercache_usage_counts() (16+), pg_buffercache_evict() (17+), pg_buffercache_evict_relation() (18+)
pg_visibilityThe visibility map: per page, all-visible and all-frozen bitspg_visibility_map_summary(), pg_visibility_map(), pg_check_visible(), pg_check_frozen()
pg_freespacemapThe free space map: how many bytes each page can still takepg_freespace(rel)
pg_walinspect (15+)Decoded WAL records between two LSNspg_get_wal_records_info(), pg_get_wal_stats(), pg_get_wal_block_info() (16+)

The visibility map matters twice: index-only scans skip the heap for all-visible pages, and VACUUM skips all-visible pages (and aggressive VACUUM skips all-frozen ones). The free space map is how INSERT and non-HOT UPDATE find a page with room. The buffer cache usage count (0 to 5) is the clock-sweep score: buffers with high counts survive eviction.

whyWhy it matters

"Is the hot table in memory?", "Why are index-only scans still fetching heap pages?", "Where did 40 GB of WAL per hour come from?" Each of these is a guess until you can query the structure directly. These extensions answer them in seconds and are safe to use on production for targeted relations.

Use caseReal-world scenario

An index-only scan reports Heap Fetches: 180000. pg_visibility_map_summary shows only 20% of pages all-visible because autovacuum rarely runs on that insert-heavy table; lowering autovacuum_vacuum_insert_scale_factor fixes it. Later, WAL volume doubles after a deploy; pg_get_wal_stats shows full-page images from a new index on a random UUID column.

Try itExamples

Run and edit these in the interactive lesson.

What is in shared_buffers
BEGIN;
CREATE EXTENSION pg_buffercache;
SELECT c.relname, count(*) AS buffers,
       pg_size_pretty(count(*) * 8192) AS cached,
       round(avg(b.usagecount), 2) AS avg_usage
FROM pg_buffercache b
JOIN pg_class c ON c.relfilenode = b.relfilenode
WHERE b.reldatabase = (SELECT oid FROM pg_database WHERE datname = current_database())
  AND c.relnamespace = 'public'::regnamespace
GROUP BY c.relname
ORDER BY buffers DESC
LIMIT 5;
SELECT buffers_used, buffers_unused, buffers_dirty, round(usagecount_avg::numeric, 2) AS usagecount_avg
FROM pg_buffercache_summary();
ROLLBACK;
The events table is the biggest, and the seed just read all of it, so it fills the most buffers. Join on relfilenode, not oid: they differ after TRUNCATE, CLUSTER or VACUUM FULL.
How hot the cache is
BEGIN;
CREATE EXTENSION pg_buffercache;
SELECT usage_count, buffers, dirty FROM pg_buffercache_usage_counts() ORDER BY usage_count;
ROLLBACK;
Many buffers at usage count 5 and few free ones means the working set fills the cache. Many at 0 and 1 means plenty of room, or a recent large scan.
Visibility map before and after an update
BEGIN;
CREATE EXTENSION pg_visibility;
SELECT 'before' AS at, * FROM pg_visibility_map_summary('orders');
UPDATE orders SET total = total WHERE id <= 300;
SELECT 'after' AS at, * FROM pg_visibility_map_summary('orders');
SELECT count(*) AS corrupt_tids FROM pg_check_visible('orders');
ROLLBACK;
Updating 300 rows cleared the all-visible bit on the pages they touched; the next VACUUM sets it again. pg_check_visible lists tuples on all-visible pages that are not really visible to everyone, which should never happen.
Free space per page
BEGIN;
CREATE EXTENSION pg_freespacemap;
SELECT count(*) AS pages, sum(avail) AS free_bytes, max(avail) AS max_on_one_page
FROM pg_freespace('orders');
ROLLBACK;
A freshly loaded table is packed, so almost no free space. After big deletes and a VACUUM, these numbers show how much room new rows can reuse before the table grows.
Decode the WAL an UPDATE writes
BEGIN;
CREATE EXTENSION pg_walinspect;
CREATE TEMP TABLE wal_window AS
  SELECT pg_logical_emit_message(false, 'course', 'start', true) AS from_lsn, NULL::pg_lsn AS to_lsn;
UPDATE orders SET status = status WHERE id <= 3;
UPDATE wal_window SET to_lsn = pg_logical_emit_message(false, 'course', 'end', true);
SELECT resource_manager, record_type, record_length, fpi_length
FROM wal_window, pg_get_wal_records_info(from_lsn, to_lsn)
WHERE resource_manager <> 'Heap2'
ORDER BY start_lsn;
SELECT "resource_manager/record_type" AS rmgr, count, round(record_size_percentage::numeric, 1) AS size_pct
FROM wal_window, pg_get_wal_stats(from_lsn, to_lsn)
WHERE count > 0
ORDER BY count DESC, rmgr;
ROLLBACK;
pg_walinspect reads only flushed WAL, so two non-transactional logical messages with flush = true bracket the UPDATE (temporary tables are not WAL-logged, so storing the bounds adds nothing). Each of the three rows wrote a row lock, the new row version and a new index entry: this update was not HOT because an indexed column was in the SET list. The WAL stays written even though the transaction rolls back.
Using them on a real server
-- needs pg_monitor (buffercache, visibility) or pg_read_server_files (walinspect)
GRANT pg_monitor TO ops_readonly;

-- cache share per table, biggest first
SELECT c.relname, pg_size_pretty(count(*) * 8192) AS cached,
       round(100.0 * count(*) / (SELECT setting::int FROM pg_settings WHERE name = 'shared_buffers'), 1) AS pct_of_cache
FROM pg_buffercache b JOIN pg_class c ON c.relfilenode = b.relfilenode
GROUP BY 1 ORDER BY count(*) DESC LIMIT 20;

-- WAL between two points in time: note the LSNs from pg_current_wal_lsn() before and after a job
SELECT * FROM pg_get_wal_stats('3F/A0000000', '3F/C0000000', per_record => true)
ORDER BY combined_size DESC LIMIT 10;

internalsUnder the hood

The visibility map and free space map are separate forks of each relation (_vm and _fsm files). The VM uses 2 bits per heap page; the FSM stores free space in 1/256 of a page (32-byte steps) in a small tree so a backend can find a page with enough room without scanning. pg_walinspect reads only WAL that is already flushed, which is why a range ending in an uncommitted transaction's records is rejected. Reading the pg_buffercache view takes each buffer header lock briefly; on servers with hundreds of GB of shared_buffers, prefer the summary functions.

Avoid / preferDo this, not that

Avoid
-- every minute, from monitoring
SELECT * FROM pg_buffercache;
Prefer
SELECT * FROM pg_buffercache_summary();
-- full per-relation breakdown only when investigating

The full view returns one row per buffer, millions on a large server, and touches every buffer header. The summary functions are cheap enough for regular collection.

Avoid
SELECT pg_buffercache_evict_relation('orders');  -- "to test cold cache" on production
Prefer
-- test cold-cache behaviour on a staging copy

Evicting a hot relation forces every following query to read from disk until the cache warms up again, which is an outage-shaped latency spike on a busy server.

PitfallsCommon mistakes

  • Joining pg_buffercache to pg_class on oid instead of relfilenode.
  • Expecting pg_walinspect to show records from a transaction that has not committed or flushed yet.
  • Reading low all-visible counts as a problem on a table that is updated constantly; it matters most for tables you expect index-only scans on.
  • Forgetting that these functions are per database: buffers of other databases have a different reldatabase.

LimitsWhen not to use it

Do not use these as routine monitoring for every table; cumulative statistics views (pg_statio_*, pg_stat_io, pg_stat_wal) give cache hit ratios and WAL volume far more cheaply. Reach for the extensions when a specific question needs page-level detail.

SecuritySecurity notes

pg_buffercache and pg_visibility reveal which tables are in use and how; pg_walinspect exposes decoded changes, including data in some record descriptions. Grant pg_monitor or pg_read_server_files only to operations roles, never to application users.

ChallengeTry it yourself

Find what fraction of shared_buffers the orders table and its indexes occupy, as a percentage.

Show one solution
One solution
BEGIN;
CREATE EXTENSION pg_buffercache;
SELECT count(*) AS buffers,
       round(100.0 * count(*) / (SELECT count(*) FROM pg_buffercache), 2) AS pct_of_cache
FROM pg_buffercache b
JOIN pg_class c ON c.relfilenode = b.relfilenode
WHERE c.oid = 'orders'::regclass
   OR c.oid IN (SELECT indexrelid FROM pg_index WHERE indrelid = 'orders'::regclass);
ROLLBACK;

InterviewQuestions you should be able to answer

Index-only scans show many heap fetches. How do you investigate?
Heap fetches happen for pages whose all-visible bit is not set. Check pg_visibility_map_summary for the table to see how many pages are all-visible, and pg_stat_user_tables for last_autovacuum and n_ins_since_vacuum. Insert-heavy tables often never trigger enough vacuuming; lower autovacuum_vacuum_insert_scale_factor or the per-table thresholds, or run VACUUM after bulk loads.
How would you find out what is generating a sudden increase in WAL?
Note two LSNs around the busy period (from pg_current_wal_lsn or the archive file names) and run pg_get_wal_stats between them, optionally per record type. A large fpi share points to full-page images: frequent checkpoints, a new index with random keys such as UUIDv4, or wal_compression off. A large Heap share points to bulk updates; Btree to index maintenance. Then match the time window to pg_stat_statements wal_bytes per query.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation