Topic 26

Files on Disk — OIDs, relfilenode, Tablespaces

Data Directory

The data directory is not a black box. Every table is a numbered file inside a numbered directory, every file stops at 1 GB and continues in the next one, and two different catalogue numbers connect the name you type in SQL to the bytes on the volume. Knowing which number is which is the difference between an unexplained 96%-full volume and a five-minute answer.

Everything in this topic is diagnostic knowledge. It tells you what a backup has to copy, what a rewrite costs before it starts, and where the space went. It is emphatically not an invitation to edit anything under /var/lib/postgresql/18/main, and the last section explains why the first instinct a filling disk produces is the one that ends the cluster.

The Layout of PGDATA

Inside the data directory, base/ holds one subdirectory per database, named for the database's OID, and inside that sits a file per relation named for its filenode. global/ holds the cluster-wide catalogues such as pg_database, so they are readable from a connection to any database. pg_wal/ holds the write-ahead log, pg_tblspc/ holds a symlink per tablespace, and sorts that overflow work_mem spill into a pgsql_tmp directory inside base/. Any relation can be located from inside psql.

From a table name to a path, and from a path to a listing
SELECT oid FROM pg_database WHERE datname = 'cartwheel';   -- 16384
SELECT pg_relation_filepath('orders');                     -- base/16384/24576

$ ls -l /var/lib/postgresql/18/main/base/16384/24576*
1073741824  24576        -- segment 0, full
1073741824  24576.1
      ...
 962592768  24576.13     -- the tail of the table
   3850240  24576_fsm    -- free space map
    491520  24576_vm     -- visibility map

Read the path as two numbers. 16384 is the OID of the cartwheel database, so everything in that directory belongs to it and nothing outside it does. Fourteen data files and two maps is what "the orders table" means on the volume: 24576 is the filenode of orders, and the siblings with suffixes are the rest of the same relation — thirteen further gigabyte segments of data, plus the two map forks from the previous topic.

Reading base/16384/24576 as two numbers
The data directory
base/ · global/ · pg_wal/ · pg_tblspc/
base/16384
One subdirectory per database, named for its OID
16384 = cartwheel
24576
The filenode of orders — segment 0, one gigabyte
24576.1 … 24576.13 continue it
Forks
24576_fsm and 24576_vm sit beside the data

OID and relfilenode

A relation has two numbers and they answer different questions. The OID is its identity in the catalogue — the number every dependency, foreign key and statistics row refers to, and the number that never changes for the life of the object. The filenode is the name of its file. The two usually start out equal, which is why people conflate them, and they diverge the first time anything rewrites the relation: TRUNCATE, REINDEX, CLUSTER, VACUUM FULL and most forms of ALTER TABLE … ALTER COLUMN … TYPE all produce a new file and leave the OID untouched. For a handful of system catalogues pg_class.relfilenode reads zero, because their file names are tracked by low-level state instead; pg_relation_filenode() is the function that gives the right answer in every case.

One rewrite, one new file, the same identity
SELECT oid, relfilenode FROM pg_class WHERE relname = 'inventory';
  oid  | relfilenode
-------+-------------
 24601 |       24601

VACUUM FULL inventory;

  oid  | relfilenode
-------+-------------
 24601 |       31088

A rewrite writes a new file and does not release the old one until it finishes, so VACUUM FULL on the 900 MB inventory needs another 900 MB free before it starts — and the moment that command gets reached for is usually the moment the volume has nothing to spare. Chapter 7 takes that argument the rest of the way. The other consequence is less obvious: any script that caches a mapping from file names to tables is wrong from the next rewrite onward, reporting one table's growth under another table's name, with nothing anywhere logging that the mapping moved.

Two numbers on one relation, answering two different questions
OIDidentity in the catalogue
The number every dependency, foreign key and statistics row refers to, and the number that never changes for the life of the object.
relfilenodethe name of the file
Starts out equal to the OID, which is why people conflate them, and diverges the first time anything rewrites the relationTRUNCATE, REINDEX, CLUSTER, VACUUM FULL, most column type changes. Any script caching a file-name-to-table mapping is wrong from that moment on.

Segments and the 1 GB Rule

A relation's file grows to 1 GB and then continues as a second file with .1 appended, then .2, and so on for as long as the table keeps growing. The limit is set when the server is built and the server reports it as segment_size, which is 131,072 blocks of 8 KB. It applies to the maps as well, though a free space map would have to describe an implausible relation before it needed a second segment. The historical motive was file size limits on platforms nobody runs any more; the present-day benefit is that dropping or rewriting a huge relation moves in bounded pieces rather than as one enormous file operation.

The practical version is that "the file for orders" is never one file, so any hand-rolled size measurement that globs a single name is wrong by a factor of fourteen here and by an arbitrary factor elsewhere. Do the arithmetic in SQL instead: pg_total_relation_size() already sums the segments, the forks, the TOAST relation and the indexes. And pg_database_size() is not a provisioning number — the WAL, the temporary files and the space a backup needs are all outside it.

Tablespaces

A tablespace is a named directory outside the data directory that relations can be placed in, so a specific table or index can sit on different storage from the rest of the cluster. Postgres reaches it through a symlink in pg_tblspc named for the tablespace's OID, under which sits a version-specific subdirectory and then one directory per database. The two built-in tablespaces do not work that way: pg_default is base and pg_global is global, and neither goes through a link.

Neither of the two costs shows up in the syntax. Moving an existing relation into a tablespace rewrites it under an ACCESS EXCLUSIVE lock, with the same requirement for a second full copy as any other rewrite. The other cost is the restore — a physical backup of a cluster with tablespaces is a backup of a symlink whose target has to exist on the new host, and a host with no /mnt/fast-nvme fails at the worst possible moment, which Chapter 12 covers as a procedure rather than a surprise. temp_tablespaces is the one use that pays for itself with no drama: sort spill goes to a scratch volume, large sorts stay off the disk holding the data, and nothing needs restoring if that volume is lost.

Data Checksums

Since 18, initdb enables data checksums by default, and --no-data-checksums is the flag that turns them off. Each page carries a checksum that is verified when the page is read, so a page corrupted by the I/O system is reported rather than silently handed to a query, and failures are counted in pg_stat_database. SHOW data_checksums answers whether this particular cluster has them, which is a question worth asking of any cluster you did not build yourself — the setting is a property of the cluster from initdb onward, not something a config reload can change.

On a cluster created before 18, or one where that flag was passed at initdb, pg_checksums turns them on offline: the server must be shut down cleanly first, and enabling rewrites every relation block whose checksum changed, which on a multi-terabyte cluster is a long maintenance window to schedule rather than a command to try. There is a small running cost as well, and this chapter has already met it — with checksums on, hint-bit updates are always WAL-logged, because a hint bit changes the page and therefore changes its checksum.

Why You Never Touch the Files

When a volume fills, the instinct is to find the biggest file and move it aside. Inside the data directory that ends the cluster. Relations exist because catalogue rows say they do, WAL replay expects to find the files it wrote records about, and there is no supported way to inform the server that something it owns has disappeared. This holds for files that look obviously orphaned, for old segments in pg_wal — deleting those can break both recovery and the replica that had not read them yet — and for anything in pgsql_tmp while the server is running. The supported ways to free space are all SQL: delete rows, drop objects, run the right maintenance, and in the worst case add a volume and move a tablespace onto it.

What this knowledge is genuinely for is diagnosis, sizing, and understanding what a backup has to copy. Three functions do most of it: pg_relation_filepath() to find something, pg_total_relation_size() to measure it honestly, and pg_size_pretty() to make the answer readable. That closes the physical tour. Five topics ago a row was an abstraction; it is now 24 bytes of header and some columns, in a slot, on a page, in a segment, in a directory named for a database. The next chapter takes the two versions of the strawberries row that are sitting on page 91 and asks the question this one deliberately refused: which of them is a given transaction entitled to see?

Common Mistakes
  • Deleting a file from the data directory to reclaim space, including one that looks obviously orphaned — the next startup or the next WAL replay disagrees, and the table is gone with it.
  • Expecting VACUUM FULL to shrink a relation in place — it writes a new filenode and holds the old file until it finishes, so it needs room for a second complete copy of the table first.
  • Putting pg_wal on the same volume as the data on a write-heavy primary, and then investigating why checkpoint bursts and query I/O interfere with each other.
  • Creating a tablespace and leaving it out of the restore procedure — the restore fails on a symlink whose target does not exist on the new host, at the one moment nobody has spare attention.
  • Provisioning a volume from pg_database_size() — the WAL, the temporary files and the space the backup itself needs are all outside that number.
  • Caching a file-name-to-table mapping in a monitoring script — a TRUNCATE, CLUSTER or VACUUM FULL changes the filenode while the OID stays put, and the script silently reports the wrong relation from then on.
Best Practices
  • Learn pg_relation_filepath(), pg_total_relation_size() and pg_size_pretty() as one trio, and answer "what is using the disk" with a query rather than with du.
  • Give pg_wal its own volume wherever the write rate is significant, and alert on its free space separately from the data volume's.
  • Leave data checksums on — the default from 18 — and treat any checksum failure as failing hardware until something else is proven.
  • Record every tablespace in the backup and restore runbook, and restore a cluster that has one at least once before you need to.
  • Resolve the filenode fresh on every run in any script that maps files to relations, since a rewrite renames the file underneath it.
  • Free space with SQL — delete rows, drop objects, run the maintenance, add a volume — and treat editing the data directory as something only a restore is allowed to do.
Comparable toolsInnoDB file-per-table .ibd files plus a shared system tablespaceOracle tablespaces and datafiles, a far richer feature thereSQL Server filegroups over .mdf and .ndf filesSQLite the whole database in one file, plus its journal

Knowledge Check

A table's OID and its relfilenode were equal, and after a maintenance command they differ. What happened?

  • Something rewrote the relation into a new file, leaving the OID unchanged
  • The table was renamed, so the catalogue assigned it a fresh identity
  • The OID was reassigned while the file on disk kept its original name
  • The relation passed 1 GB, so a new filenode was allocated for segment one

The disk is 96% full and inventory is bloated at 900 MB. Why is VACUUM FULL the wrong reflex here?

  • It reclaims space only for reuse inside the table, not for the file system
  • It writes a second full copy of the table before it can free the first
  • It locks every table in the database for as long as the rewrite runs
  • It cannot be interrupted once started, which is the real risk on a full disk

Why does ls in a database directory show 24576, 24576.1, 24576.2 and so on?

  • They are successive versions of the table kept for point-in-time recovery
  • A relation continues into a new 1 GB segment each time it fills one
  • They are the partitions of a partitioned table sharing a base file name
  • They are the free space map, visibility map and initialization forks

What did enabling data checksums by default in 18 change for a new cluster?

  • Corrupted pages are repaired automatically when they are next read
  • Corruption is detected on read, and hint-bit updates are now WAL-logged
  • The write-ahead log gained per-record checksums it did not have before
  • Checksums can now be turned on and off with a configuration reload

What does adding a tablespace cost you at backup and restore time?

  • A second backup job, since its contents are excluded from the cluster's
  • The restore host must reproduce the symlink target the cluster expects
  • A logical dump becomes mandatory, because physical backups skip tablespaces
  • Its relations stop being WAL-logged, so they cannot be recovered at all

You got correct