Marking Buffers Dirty: Exploring New pg_buffercache Capabilities

For years, pg_buffercache has been the DBA’s window into shared_buffers: which relations are cached, how hot they are, and which pages are dirty. Earlier releases introduced pg_buffercache_evict() for testing, and PostgreSQL 18 added pg_buffercache_evict_relation() and pg_buffercache_evict_all(). In PostgreSQL 19, the module gains the mirror image: the ability to mark buffers dirty on demand.

This post covers the three new functions, what they return, and why they exist. It also shows where they fit in the wider pg_buffercache toolkit, walks through a hands-on demo on PostgreSQL 19beta4, and ends with troubleshooting tips.

Related reading: PG19 Hacktober: pg_buffercache Under the Hood: Shared Memory Mapping Refactored covers the new pg_buffercache_os_pages view, so it is not repeated here.

What Is a Dirty Buffer?

When a backend modifies a page, it does so in a shared buffer first, not directly on disk. The buffer is flagged as dirty, meaning the in-memory copy is newer than the copy on disk. A dirty buffer is eventually written out by one of three actors:

  • The checkpointer, which flushes dirty buffers at each checkpoint.
  • The background writer, which trickles dirty buffers out between checkpoints.
  • A backend, when it needs a free buffer and the victim it picks is dirty. It must write the page out before reusing the slot.

The number of dirty buffers, and how quickly they are flushed, influences checkpoint duration, I/O spikes, and tail latency. That is why a controlled way to create dirty state is useful for testing.

Anatomy of a Buffer: What pg_buffercache Shows You

Every row in the pg_buffercache view describes one slot of the shared buffer pool. There is exactly one row per buffer, and unused buffers show all fields as null except bufferid.

ColumnTypeWhat it tells you
bufferidintegerSlot number, from 1 to shared_buffers
relfilenodeoidFilenode of the relation stored in this slot
reltablespaceoidTablespace of that relation
reldatabaseoidDatabase of that relation (0 for shared system catalogs)
relforknumbersmallintWhich fork: main data, free space map, visibility map, and so on
relblocknumberbigintPage number within the relation
isdirtybooleanWhether the in-memory page is newer than the on-disk copy
usagecountsmallintClock-sweep access count, used to pick eviction victims
pinning_backendsintegerHow many backends currently hold a pin on this buffer

Two of these columns matter most for this post. isdirty is the flag the new functions set. pinning_backends explains why some buffers cannot be dirtied or evicted: a buffer that another backend has pinned is skipped.

Because all databases share the pool, you will also see pages from relations outside your current database. When you join to pg_class, restrict the join to reldatabase equal to the current database’s OID or zero, as the documentation recommends.

What’s New in PostgreSQL 19

The PostgreSQL 19 documentation for pg_buffercache lists three new functions:

FunctionScopeArgumentReturns
pg_buffercache_mark_dirty()One bufferA buffer id (bufferid from pg_buffercache)buffer_dirtied, buffer_already_dirty
pg_buffercache_mark_dirty_relation()All unpinned buffers of a relation, across all forksA relation identifierbuffers_dirtied, buffers_already_dirty, buffers_skipped
pg_buffercache_mark_dirty_all()All unpinned buffers in the poolNonebuffers_dirtied, buffers_already_dirty, buffers_skipped

They mirror the existing eviction functions, so the module now offers a complete toolkit: dirty a buffer, inspect it, then flush or evict it.

Rules and Caveats

  • Superuser only. The read-only views are available to pg_monitor members, but the mark-dirty functions are restricted to superusers.
  • Developer testing only. These functions are intended for developer testing.
  • Unpinned buffers only. Buffers pinned by other backends are skipped.
  • Results go stale immediately. Concurrent activity can change buffer state the moment a function returns.
  • Inspection is lock-light. pg_buffercache and pg_buffercache_summary() do not take buffer manager locks, so counts under concurrent activity can be slightly inaccurate.

Where the New Functions Fit: The Full PG19 Toolkit

The mark-dirty functions are one part of a larger module. In PostgreSQL 19, pg_buffercache falls into five groups, and knowing which tool belongs to which job saves you from reaching for the heaviest one.

GroupObjectsPurposeWho can run it
Inspectpg_buffercache viewOne row per buffer, full detailSuperusers and pg_monitor members
Inspect (cheap)pg_buffercache_summary(), pg_buffercache_usage_counts()Aggregates: used, unused, dirty, pinned, usage count histogramSuperusers and pg_monitor members
Map to OS memorypg_buffercache_os_pages view, pg_buffercache_numa viewOS page of each buffer, and optionally its NUMA nodeSuperusers and pg_monitor members
Evictpg_buffercache_evict(), pg_buffercache_evict_relation(), pg_buffercache_evict_all()Push unpinned buffers out of the poolSuperusers only
Mark dirtypg_buffercache_mark_dirty(), pg_buffercache_mark_dirty_relation(), pg_buffercache_mark_dirty_all()Set the dirty flag on unpinned buffersSuperusers only

Access to the read-only functions can also be granted to other roles with GRANT, but the evict and mark-dirty functions are documented as superuser-only developer tools.

Choosing the Right Inspection Tool

The aggregate functions are significantly cheaper than the full view. A practical rule of thumb:

  • Use pg_buffercache_summary() for a quick “how many buffers are dirty right now” check. It returns a single row with buffers_used, buffers_unused, buffers_dirty, buffers_pinned and usagecount_avg.
  • Use pg_buffercache_usage_counts() when you want the distribution of usage counts, including how many buffers at each count are dirty or pinned.
  • Use the pg_buffercache view only when you need to know which relation or block is involved. It does not give a consistent snapshot across all buffers, although the information for each individual buffer is self-consistent.
  • Use pg_buffercache_os_pages when you care about how buffers line up with OS memory pages. The previous post in this series covers it in depth, so it is not repeated here.

Hands-On: Marking Buffers Dirty

This demo was run on PostgreSQL 19beta4 with pg_buffercache 1.7 and the default shared_buffers of 128 MB, which is 16,384 buffers. You need a test instance and a superuser psql session.

Step 1: Install the Extension and Create Test Data

CREATE EXTENSION IF NOT EXISTS pg_buffercache;

Confirm the extension version:

postgres=# \dx
                              List of installed extensions
      Name      | Version | Default version |   Schema   |           Description
----------------+---------+-----------------+------------+---------------------------------
pg_buffercache | 1.7     | 1.7             | public     | examine the shared buffer cache
plpgsql        | 1.0     | 1.0             | pg_catalog | PL/pgSQL procedural language
(2 rows)

Create a table with 100,000 rows, then checkpoint so we start from a clean state:

CREATE TABLE demo_dirty (
  id      int PRIMARY KEY,
  payload text
);
INSERT INTO demo_dirty
SELECT g, repeat('x', 100)
FROM generate_series(1, 100000) g;
CHECKPOINT;
CREATE TABLE
INSERT 0 100000
CHECKPOINT

Step 2: Take a Baseline

SELECT * FROM pg_buffercache_summary();

 buffers_used | buffers_unused | buffers_dirty | buffers_pinned |  usagecount_avg
--------------+----------------+---------------+----------------+-------------------
        2619 |          13765 |             0 |              0 | 4.639557082856052
(1 row)

buffers_dirty is zero. Right after a checkpoint it should be at or near zero. Background activity can dirty a few buffers, so if you see a small number, run CHECKPOINT again.

Now look at just our table:

SELECT count(*)                        AS cached_buffers,
      count(*) FILTER (WHERE isdirty) AS dirty_buffers
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('demo_dirty')
  AND reldatabase = (SELECT oid FROM pg_database
                    WHERE datname = current_database());

 cached_buffers | dirty_buffers
----------------+---------------
          1729 |             0
(1 row)

The table has 1,729 buffers in the pool and none of them is dirty.

Step 3: Dirty a Single Buffer

Find the buffer holding block 0 of the table’s main fork and store its id in a psql variable. Run this on its own:

SELECT bufferid
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('demo_dirty')
  AND reldatabase = (SELECT oid FROM pg_database
                    WHERE datname = current_database())
  AND relforknumber = 0
  AND relblocknumber = 0 \gset
Then mark that buffer dirty:
SELECT * FROM pg_buffercache_mark_dirty(:bufferid);
 buffer_dirtied | buffer_already_dirty
----------------+----------------------
t              | f
(1 row)

buffer_dirtied is true. Call it a second time:

SELECT * FROM pg_buffercache_mark_dirty(:bufferid);
buffer_dirtied | buffer_already_dirty
----------------+----------------------
f              | t
(1 row)

This time the buffer was already dirty, so buffer_already_dirty is true. Confirm in the view:

SELECT bufferid, relblocknumber, isdirty
FROM pg_buffercache
WHERE bufferid = :bufferid;
 bufferid | relblocknumber | isdirty
----------+----------------+---------
      613 |              0 | t
(1 row)

Step 4: Dirty a Whole Relation

SELECT * FROM pg_buffercache_mark_dirty_relation('demo_dirty'::regclass);
buffers_dirtied | buffers_already_dirty | buffers_skipped
-----------------+-----------------------+-----------------
            1728 |                     1 |               0
(1 row)

The function dirtied 1,728 buffers. One was already dirty (the buffer from Step 3), and none were skipped. Together, that is 1,729, exactly the number of cached buffers we saw for the table in Step 2. The function covers all forks of the relation, so cached free space map or visibility map buffers are included.

Step 5: Check the Dirty Buffer Count

Before running a checkpoint, record the checkpointer’s counter and the dirty buffer count:

SELECT buffers_written FROM pg_stat_checkpointer;
SELECT * FROM pg_buffercache_summary();
buffers_written
-----------------
          47935
(1 row)
buffers_used | buffers_unused | buffers_dirty | buffers_pinned |   usagecount_avg
--------------+----------------+---------------+----------------+--------------------
        2631 |          13753 |          1730 |              0 | 4.6484226529836565
(1 row)

There are 1,730 dirty buffers: the 1,729 from our table plus one more from other activity. Run CHECKPOINT and query both again, and buffers_dirty drops back to about zero while buffers_written rises. The background writer can contribute a few buffers, so treat the difference as approximate. If log_checkpoints is on, the server log also shows a checkpoint complete line with the number of buffers written.

Step 6: Go Big with pg_buffercache_mark_dirty_all()

Turn on timing, then mark every buffer in the pool dirty and checkpoint. Put \timing on on its own line:

\timing on
SELECT * FROM pg_buffercache_summary();
SELECT * FROM pg_buffercache_mark_dirty_all();
SELECT * FROM pg_buffercache_summary();
CHECKPOINT;
 buffers_used | buffers_unused | buffers_dirty | buffers_pinned |   usagecount_avg
--------------+----------------+---------------+----------------+--------------------
        2631 |          13753 |          1730 |              0 | 4.6484226529836565
(1 row)
buffers_dirtied | buffers_already_dirty | buffers_skipped
-----------------+-----------------------+-----------------
            901 |                  1730 |               0
(1 row)
buffers_used | buffers_unused | buffers_dirty | buffers_pinned |   usagecount_avg
--------------+----------------+---------------+----------------+--------------------
        2631 |          13753 |          2631 |              0 | 4.6484226529836565
(1 row)
CHECKPOINT
Time: 74.063 ms

pg_buffercache_mark_dirty_all() dirtied 901 more buffers on top of the 1,730 that were already dirty. Now buffers_dirty (2,631) equals buffers_used: every valid, unpinned buffer in the pool is dirty, whichever relation or database it belongs to. The checkpoint that had to write them all took 74 ms on this instance.

Step 7: Combine with Eviction

The mark-dirty and evict functions work well together. Dirty a buffer, then evict it:

SELECT * FROM pg_buffercache_mark_dirty(:bufferid);
SELECT * FROM pg_buffercache_evict(:bufferid);
 buffer_dirtied | buffer_already_dirty
----------------+----------------------
t              | f
buffer_evicted | buffer_flushed
----------------+----------------
t              | t

buffer_evicted is true when the buffer was removed from the pool. It is false if the buffer was not valid, was pinned, or became dirty again after the attempt to write it out. buffer_flushed is true if the buffer was written to disk, although not necessarily by your own session. Here both are true, which shows that a dirty buffer has to be written out before its slot can be reused.

If concurrent activity reuses the buffer id between the two calls, the result can differ. That is why the documentation describes these results as immediately out of date.

Step 8: Clean Up

DROP TABLE demo_dirty;

Reading the Results: What Every Return Value Means

When a buffer cannot be dirtied, the behavior is to report that in the result, so reading the result correctly matters.

FunctionResultMeaning
pg_buffercache_mark_dirty(bufferid)buffer_dirtied = trueThe buffer was valid, unpinned and clean, and is now dirty
pg_buffercache_mark_dirty(bufferid)buffer_already_dirty = trueNothing changed because the buffer was already dirty
pg_buffercache_mark_dirty(bufferid)both falseThe buffer was not valid, or it was pinned and could not be dirtied
pg_buffercache_mark_dirty_relation(rel)three countsbuffers_dirtied, buffers_already_dirty, and buffers_skipped (pinned or invalid)
pg_buffercache_mark_dirty_all()three countsThe same three counts, across the whole pool

A few edge cases are worth knowing before you build a test on top of these functions:

  • Counts cover only cached buffers. A relation that is not fully in shared_buffers, or one that was never read, will only partly appear.
  • Pinned buffers are skipped, not waited for. On a busy system, a few skipped buffers are normal.
  • The answer is stale the moment you read it. Do not use a returned count as a guarantee about what the pool looks like a second later.
  • Unused slots are never dirtied. Right after a restart, pg_buffercache_mark_dirty_all() has little to do because most of the pool is empty.

Where This Is Useful

  • Checkpoint and background writer testing: build a repeatable dirty state, then measure checkpoint duration and write behavior.
  • Extension and tooling development: check how monitoring queries, dashboards or custom extensions handle dirty buffers without staging a write workload.
  • Regression and TAP tests: a deterministic dirty-buffer setup makes buffer manager tests much simpler.
  • Teaching and demos: the full life cycle of a buffer (clean, dirty, flushed, evicted) can be shown live in a handful of queries.

Pitfalls and Troubleshooting

Most surprises with these functions come from the same few causes. This table maps what you see to what is probably happening.

What you seeLikely causeWhat to do
A permission error when calling a mark-dirty functionThe functions are superuser-onlyConnect as a superuser on a test instance
function pg_buffercache_mark_dirty does not existThe extension is missing, or an older version is installedRun CREATE EXTENSION pg_buffercache, or ALTER EXTENSION pg_buffercache UPDATE after upgrading to PostgreSQL 19
buffer_dirtied and buffer_already_dirty are both falseThe buffer was invalid or pinnedPick another buffer, or retry after concurrent activity settles
Relation call dirties fewer buffers than the table has pagesOnly cached pages count, and some may be pinnedRead the table first, for example with a sequential scan, then retry
Dirty count drops before you run CHECKPOINTThe background writer or a checkpoint wrote buffers in betweenRun the test on an otherwise idle instance, or lower bgwriter_lru_maxpages on a test system
pg_buffercache_mark_dirty_all() runs but the pool looks mostly emptyMost slots are unused right after a restartWarm the cache first by reading the tables you care about

Good Habits

  • Test instances only. Keep these functions away from production, and never grant superuser access just to run them.
  • Start small. Try the single-buffer and relation variants before pg_buffercache_mark_dirty_all().
  • Checkpoint first. Begin each test from a clean pool so your numbers mean something.
  • Record the environment. Note shared_buffers, storage type and PostgreSQL build when you publish results, because checkpoint timings do not transfer between systems.
  • Prefer the cheap views. Use pg_buffercache_summary() and pg_buffercache_usage_counts() in loops and scripts, and reserve the full view for targeted questions.

Conclusion

PostgreSQL 19 rounds out the pg_buffercache testing toolkit. With pg_buffercache_mark_dirty(), pg_buffercache_mark_dirty_relation() and pg_buffercache_mark_dirty_all(), alongside the evict functions and the lightweight pg_buffercache_summary(), you can set up clean, dirty, flushed or evicted states on demand and see how PostgreSQL responds.

The key takeaway: you no longer need a real write workload to study how PostgreSQL handles dirty buffers.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top