How a table is stored on disk
Files, pages and row versions
The idea in 30 seconds
Every PostgreSQL table is a file of 8 kB pages. Each page keeps a small directory of pointers at the front and packs rows in from the back. A row on disk is a version: an update writes a new version and marks the old one with the transaction that replaced it, and a delete only marks. Nothing is erased in place, which is why tables do not shrink when you delete.
Helps to know first: Relational database concepts
On this page
From table to file
A table looks like rows and columns, but on disk it is a plain file. PostgreSQL can tell you which:
SELECT pg_relation_filepath('customers') AS file, pg_relation_size('customers') AS bytes;
file | bytes
-------------------+-------
base/16741/327683 | 8192
The path is relative to the server’s data directory: database number 16741, file number 327683. Yours will differ. The file is 8,192 bytes: exactly one page, because all eight customers fit on it. When a table passes 1 GB, PostgreSQL starts a second file, 327683.1, and so on. Each table also gets two small companion files, a free space map that remembers which pages have room, and a visibility map, which Heap access and the visibility map covers.
Inside a page
Every page is 8 kB and has the same shape:
| Part | Size | What it holds |
|---|---|---|
| Header | 24 bytes | Bookkeeping, including where free space starts and ends |
| Line pointers | 4 bytes each | A directory: for each row, its offset and length in the page |
| Free space | the rest | Shrinks from both sides as rows are added |
| Rows | variable | Packed in from the end of the page |
The line pointers grow forward from the front, the rows grow backward from the end, and the page is full when they meet. A row’s address, its ctid, is (page, line pointer): (0,3) means page 0, third pointer. Because indexes store ctids, a row can move within a page by updating its pointer, without touching the indexes.
Rows are versions
The pageinspect extension shows the raw contents of a page. Make a small table with three rows:
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE TABLE notes (id int PRIMARY KEY, body text);
INSERT INTO notes VALUES (1, 'buy milk'), (2, 'call Ben'), (3, 'fix the leaky tap');
SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('notes', 0));
lp | lp_off | lp_len | t_xmin | t_xmax | t_ctid
----+--------+--------+--------+--------+--------
1 | 8152 | 37 | 3106 | 0 | (0,1)
2 | 8112 | 37 | 3106 | 0 | (0,2)
3 | 8064 | 46 | 3106 | 0 | (0,3)
lp_off counts down from 8,192: the first row sits at the very end of the page. t_xmin is the transaction that inserted each row (yours will be different numbers), and t_xmax is 0: nothing has replaced them.
PredictWhat an UPDATE leaves behind
Now run UPDATE notes SET body = 'buy oat milk' WHERE id = 1;. How many row versions are on the page afterwards, and what happened to the original “buy milk”?
Four versions; the original is still there.
lp | lp_off | lp_len | t_xmin | t_xmax | t_ctid
----+--------+--------+--------+--------+--------
1 | 8152 | 37 | 3106 | 3107 | (0,4)
2 | 8112 | 37 | 3106 | 0 | (0,2)
3 | 8064 | 46 | 3106 | 0 | (0,3)
4 | 8016 | 41 | 3107 | 0 | (0,4)The update wrote a new version at line pointer 4, inserted by transaction 3107. The old version at pointer 1 was not overwritten: its t_xmax is now 3107, the transaction that replaced it, and its t_ctid points forward to (0,4). Asha’s note now lives at ctid (0,4).
Transactions that started before 3107 committed can still read the old version; everyone after sees the new one. That is how PostgreSQL lets readers and writers work at the same time, and Transactions, MVCC and concurrency builds on it.
A DELETE works the same way with nothing to replace it: it only sets t_xmax. Deleting note 2 leaves its row on the page with t_xmax set, and the table stays 8,192 bytes. Old and deleted versions stay until vacuum decides nobody can see them any more and marks their space as free for new rows.
Check yourself
What is a ctid, and why does it change when a row is updated?
Show answer
It is the row version’s physical address: page number and line pointer. An update writes a new version in a new slot, so the logical row’s current version has a new ctid. Use primary keys, not ctids, to identify rows.
Why does a page fill up from both ends?
Show answer
Line pointers are added from the front and row data from the back, so the free space in the middle shrinks from both sides. The page is full when they meet.
After
UPDATE notes SET body = ... WHERE id = 1, why is the old row version still on the page?Show answer
PostgreSQL never overwrites a row in place. It writes a new version and marks the old one with the updating transaction in
xmax, so transactions that should still see the old value can. Vacuum removes it later.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 66.1 Database File LayoutPrimary· §66.1 Database File Layout
Each table and index is stored in its own file named after its filenode; files are split into 1 GB segments; tables also have free space map and visibility map forks.
- 2.PostgreSQL documentation: 66.6 Database Page LayoutPrimary· §66.6 Database Page Layout
A page has a 24-byte header, an array of 4-byte item identifiers growing from the start, free space in the middle, and items added from the end; a row has a 23-byte header on most machines.
- 3.PostgreSQL documentation: 5.6 System ColumnsPrimary· §5.6 System Columns
ctid is a row version's physical location and changes when the row is updated; xmin is the inserting transaction and xmax the deleting one; each update creates a new row version.
- 4.PostgreSQL documentation: F.23 pageinspectPrimary· F.23 pageinspect: get_raw_page, page_header, heap_page_items
Functions that read a raw page and decode its header and line pointers.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 66.6 Database Page LayoutPrimary· §66.6
Two tables that describe every byte of a page and a row header. Keep it open while you do the lab.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.