Back to Devoured - October 12, 2026
DEVOURED
Postgres relation files, filed under base/

Postgres relation files, filed under base/

Data BoringSQL
Postgres relation files are identified by internal node IDs that can change during operations like VACUUM FULL, making direct filesystem manipulation dangerous.
What: The article explains how Postgres maps logical tables to physical files on disk, how 'forks' (FSM, VM) store metadata alongside data, and why standard tools should rely on `pg_relation_filepath()` rather than assuming a static relationship between OIDs and file names.
Why it matters: For engineers debugging database performance or corruption, understanding that logical objects and physical storage are loosely coupled is essential to prevent data loss.
Takeaway: If you are writing scripts to manage Postgres data files, never hardcode paths based on OIDs; always use the built-in catalog functions to resolve file locations.
Deep dive
  • Forks: PostgreSQL uses companion files (main, fsm, vm, init) for every relation to manage free space, visibility, and unlogged table state.
  • OID vs. Relfilenode: The OID is the static identity of a table, while the relfilenode is the file name which changes whenever the table is rewritten.
  • Checkpointing: Postgres holds onto deleted files until the next checkpoint to ensure transactional consistency for WAL replay.
  • Segmenting: Tables are automatically split into 1GB segments, a detail the storage engine handles transparently.
  • Checksums: Pages in shared buffers carry a zero checksum if modified in memory, while the on-disk file contains the computed checksum.
Decoder
  • FSM (Free Space Map): A fork that tracks which pages in a table have enough free space for new rows.
  • VM (Visibility Map): A fork that tracks which pages are 'all-visible' to speed up index-only scans and VACUUM operations.
  • TOAST (The Oversized-Attribute Storage Technique): A method Postgres uses to store large values (like long text) in separate physical tables.
Original article

The buffers article followed an 8KB page from disk into shared memory and back. What it didn't do is name the file. Every page that passes through the buffer pool is block number N of some file under the data directory, and the file has a naming scheme, a size limit, and companion files that share its name. Before the next article reads the inside of a page, this one finds it on disk with ls and od, and shows that the bytes are the same ones.

Nothing here needs more than a scratch database and shell access to its data directory. The examples were captured on PostgreSQL 18.6 with data checksums on, which is the default since 18.

Setup

CREATE EXTENSION IF NOT EXISTS pageinspect;

CREATE TABLE files_demo (
    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title text NOT NULL,
    value numeric(10,2)
);

INSERT INTO files_demo (title, value)
SELECT 'row_' || i, i * 1.5 FROM generate_series(1, 1000) AS i;

A thousand short rows and a primary key. The text column is there so a TOAST table gets created.

From table name to file name

The function that answers "where is this table on disk" ships with the server:

SELECT pg_relation_filepath('files_demo');
 pg_relation_filepath
----------------------
 base/5/16431
(1 row)

The path is relative to the data directory (SHOW data_directory prints it). Three parts: base is the directory for the default tablespace, 5 is the OID of the database, and 16431 is the file's name. That last number is the relfilenode, and it comes from pg_class:

SELECT oid, relfilenode, relname
FROM pg_class
WHERE relname LIKE 'files_demo%';
  oid  | relfilenode |      relname
-------+-------------+-------------------
 16431 |       16431 | files_demo
 16430 |       16430 | files_demo_id_seq
 16438 |       16438 | files_demo_pkey
(3 rows)

Three relations, three files. The identity column's sequence and the primary key index each get their own, because to the storage layer a sequence is one-page relation and an index is a relation like any other. The oid and relfilenode columns are equal for all three, which stays true only until the table is first rewritten.

The middle part of the path, 5, is the OID of the current database from pg_database. Each database gets its own subdirectory under base/, and a table's file lives in the directory of the database it belongs to. That is the whole reason the database OID is part of the path: relfilenodes are only unique within a tablespace and database, so two databases can each have a file called 16431 without colliding.

The files you didn't create

There is a function that goes the other way, from a file number back to a name. Point it at the numbers between and around ours:

SELECT n, pg_filenode_relation(0, n)
FROM unnest(ARRAY[16430, 16431, 16436, 16437, 16438]) AS n;
   n   |     pg_filenode_relation
-------+-------------------------------
 16430 | files_demo_id_seq
 16431 | files_demo
 16436 | pg_toast.pg_toast_16431
 16437 | pg_toast.pg_toast_16431_index
 16438 | files_demo_pkey
(5 rows)

The first argument is the tablespace OID, where 0 means the database's default. Two relations we didn't ask for appear: a TOAST table and its index. Because files_demo has a text column, PostgreSQL created a place to put values too large for a page. They're ordinary files, 16436 and 16437, sitting next to the main table, and nothing has been toasted yet, so the TOAST heap has no pages.

Forks

Each relation has a main file and up to three companion files that share its relfilenode and differ by a suffix. PostgreSQL calls them forks. The one with no suffix is the main fork, the table's actual pages.

16431_fsm is the free space map, a small tree that records roughly how much room each heap page has left so an INSERT can find a page without scanning them all. 16431_vm is the visibility map, two bits per heap page: all-visible, so a scan can skip visibility checks, and all-frozen, so VACUUM can skip the page for wraparound purposes.

The fourth fork, init, is empty for this table because files_demo is a normal, logged table. An unlogged table gets one. The init fork is the state the relation is reset to after a crash. An unlogged table skips the write-ahead log, so after unclean shutdown its contents can't be trusted and PostgreSQL replaces the main fork with a copy of the init fork.

Forks are per relation. The TOAST table, being a heap, can grow its own _fsm and _vm. The index can grow an _fsm, which B-trees use to track pages freed by deletion, but never a _vm, because visibility is a heap concept.

The same bytes, two ways

pageinspect's get_raw_page returns a page as it sits in shared buffers. The file on disk is supposed to contain the same page at the same offset.

PostgreSQL computes the checksum when it writes a page out, into the copy it hands to the kernel, and never touches the shared buffer. A page that was initialised in memory and has never been read from disk carries a zero checksum in the buffer; one that was read and then modified carries whatever checksum the file had when it was read. pageinspect never re-verifies a checksum; it reads the buffer, not the file. If you want the on-disk bytes from SQL, pg_read_binary_file on the relation path reads the file rather than the shared buffer.

OID versus relfilenode

The OID is the relation's identity, the number every catalog reference points at, and it never changes. The relfilenode is just a file name. Most of the time they match, because CREATE TABLE assigns the same number to both. Operations that rewrite the table break the tie. VACUUM FULL writes a compacted copy of the table into a brand new file and then swaps the catalog entry to point at it. TRUNCATE does the same swap, to an empty file.

Don't rely on oid = relfilenode. Tools and scripts that build paths by hand from pg_class.oid work on a fresh database and break after the first VACUUM FULL. Use pg_relation_filepath() to get the path and pg_filenode_relation() to get the name back.

Tablespaces

Everything so far lived under base/, the home of the pg_default tablespace. A tablespace is a directory somewhere else, with the same layout inside. pg_tblspc holds one symlink per tablespace, named by its OID. Under the link sits PG_version, the server and catalog version; below that it's <database OID>/<relfilenode> again.

Segment files

A single file is capped at 1GB. When a relation grows past that, PostgreSQL starts a second file with the same name and a .1 suffix, then .2, and so on. Each holds 131072 pages of 8192 bytes. pg_relation_filepath still returns the base name; the segment suffix is added by the storage manager when it computes which file holds a given block. pg_relation_size sums all segments.

Explore all 71 articles in today's digest

Fresh dev news, AI-digested daily. Never miss a trend at devoured.fyi