The Page Layout: How PostgreSQL Stores Rows on Disk
PostgreSQL storage is organised into fixed-size blocks, 8KB by default (BLCKSZ). A table (the "main fork") is an array of these blocks in one or more 1GB segment files under the database directory. Every block — heap or index — shares the same general layout, defined in src/include/storage/bufpage.h.
Grounded in the source
Where this comes from in postgres/postgres
Each citation was checked against the REL_17_STABLE branch: real file path, symbol present, exact line number. Follow any link to read the code.
Primary symbol: PageHeaderData · line 154
The one thing to understand first
PostgreSQL storage is organised into fixed-size blocks, 8KB by default (BLCKSZ). A table (the "main fork") is an array of these blocks in one or more 1GB segment files under the database directory. Every block — heap or index — shares the same general layout, defined in src/include/storage/bufpage.h.
A page is a tiny 8KB filesystem: a header at the top, a directory of line pointers growing down, and tuples growing up from the bottom. That line-pointer indirection is the quiet hero — it lets rows move within a page, makes HOT updates possible, and explains why a row wider than ~2KB has to be pushed out to TOAST.
Four regions of a page
A heap page has four regions, growing toward each other from both ends:
- Page header (
PageHeaderData, 24 bytes) — the LSN of the last change (pd_lsn), checksum, flags, and three offsets:pd_lower,pd_upper,pd_special. - Line pointer array (
ItemIdData, 4 bytes each) — grows downward from just after the header. Each entry points to a tuple and stores its offset, length, and a state (used / dead / redirect). - Free space — the gap between
pd_lower(end of line pointers) andpd_upper(start of tuples). - Tuples — stored from the end of the page growing upward.
The "special space" at the very end is empty for heap pages but used by index access methods (e.g. btree stores its opaque data there).
Why the indirection of line pointers?
A tuple is addressed by a TID (tuple identifier): (block number, line pointer index), the ItemPointerData in itemptr.h. Indexes store TIDs. Because callers reference the line pointer rather than a raw offset, the tuple can be moved within the page during defragmentation without invalidating index entries — the line pointer is updated, the TID stays the same. This indirection is what makes intra-page compaction possible.
Line pointer states and HOT
An ItemId can be:
- LP_NORMAL — points to a live or recently-dead tuple.
- LP_DEAD — the tuple is dead; space reclaimable by vacuum (or page pruning).
- LP_REDIRECT — points to another line pointer, used by HOT chains so an index entry to the original TID still resolves to the current version.
- LP_UNUSED — free for reuse.
This is the storage-level reason HOT updates avoid index writes: the index keeps pointing at the root line pointer, which redirects to the newest heap-only tuple within the same page.
Layer 3 — Watch it happen on your own database
CREATE EXTENSION IF NOT EXISTS pageinspect;
-- Page header: see pd_lower / pd_upper / free space
SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('accounts', 0));
-- Line pointers and tuple lengths
SELECT lp, lp_off, lp_len, t_ctid
FROM heap_page_items(get_raw_page('accounts', 0));lower | upper | special | pagesize
-------+-------+---------+----------
568 | 576 | 8192 | 8192
(1 row)
lp | lp_off | lp_len | t_ctid
-----+--------+--------+---------
1 | 8136 | 56 | (0,1)
2 | 8080 | 56 | (0,2)
3 | 8024 | 56 | (0,3)
4 | 7968 | 56 | (0,4)
5 | 7912 | 56 | (0,5)
6 | 7856 | 56 | (0,6)
7 | 7800 | 56 | (0,7)
8 | 7744 | 56 | (0,8)
9 | 7688 | 56 | (0,9)
10 | 7632 | 56 | (0,10)
11 | 7576 | 56 | (0,11)
12 | 7520 | 56 | (0,12)
13 | 7464 | 56 | (0,13)
14 | 7408 | 56 | (0,14)
15 | 7352 | 56 | (0,15)
16 | 7296 | 56 | (0,16)
17 | 7240 | 56 | (0,17)
18 | 7184 | 56 | (0,18)
19 | 7128 | 56 | (0,19)
20 | 7072 | 56 | (0,20)
21 | 7016 | 56 | (0,21)
22 | 6960 | 56 | (0,22)
23 | 6904 | 56 | (0,23)
24 | 6848 | 56 | (0,24)
25 | 6792 | 56 | (0,25)
26 | 6736 | 56 | (0,26)
27 | 6680 | 56 | (0,27)
28 | 6624 | 56 | (0,28)
29 | 6568 | 56 | (0,29)
30 | 6512 | 56 | (0,30)
31 | 6456 | 56 | (0,31)
32 | 6400 | 56 | (0,32)
33 | 6344 | 56 | (0,33)
34 | 6288 | 56 | (0,34)
35 | 6232 | 56 | (0,35)
36 | 6176 | 56 | (0,36)
37 | 6120 | 56 | (0,37)
38 | 6064 | 56 | (0,38)
39 | 6008 | 56 | (0,39)
40 | 5952 | 56 | (0,40)
41 | 5896 | 56 | (0,41)
42 | 5840 | 56 | (0,42)
43 | 5784 | 56 | (0,43)
44 | 5728 | 56 | (0,44)
45 | 5672 | 56 | (0,45)
46 | 5616 | 56 | (0,46)
47 | 5560 | 56 | (0,47)
48 | 5504 | 56 | (0,48)
49 | 5448 | 56 | (0,49)
50 | 5392 | 56 | (0,50)
51 | 5336 | 56 | (0,51)
52 | 5280 | 56 | (0,52)
53 | 5224 | 56 | (0,53)
54 | 5168 | 56 | (0,54)
55 | 5112 | 56 | (0,55)
56Free space on the page is roughly upper - lower. Update a row and look again: a new lp appears and the old line pointer may become LP_REDIRECT (a HOT chain) or LP_DEAD. The cluster-wide Free Space Map (a separate _fsm fork) aggregates this so inserts can quickly find a page with room.
Layer 4 — The levers this hands you
The page budget is something you can tune. Leaving slack on each page lets future updates place the new tuple version on the same page, enabling HOT and reducing index churn. For update-heavy tables, lowering fillfactor trades some space for far less bloat:
ALTER TABLE hot_table SET (fillfactor = 80);
-- Newly written pages keep 20% free for in-page updates.- Row width matters. A row that exceeds ~2KB (a quarter page) triggers TOAST; understanding the page budget explains why and lets you design narrower hot rows.
- Bloat is measurable. Comparing live tuple bytes to relation size (via
pgstattuple) quantifies wasted space so you know when a rewrite is justified. - Checksums live in the header. Initialise the cluster with
data_checksumsto detect silent page-level corruption.
Layer 5 — What an Oracle DBA should expect vs what they get
If you know Oracle blocks, the shapes rhyme but the names and defaults differ:
- 8KB fixed, not a tablespace choice. Oracle lets you pick 2K–32K block sizes per tablespace; PostgreSQL fixes
BLCKSZat compile time (8KB almost everywhere). You design around 8KB rather than choosing it. - **
fillfactoris PCTFREE.** Oracle's PCTFREE/PCTUSED reserve in-block space for updates;fillfactoris the direct analogue, and it is the main knob for enabling HOT (Oracle has no HOT concept — it manages row migration/chaining instead). - Line pointers vs row directory. Oracle's block row directory plus ROWID maps closely to the
ItemIdarray plus TID. The big difference: Postgres uses the indirection to relocate tuples during in-page pruning without invalidating index TIDs. - TOAST instead of chained/migrated rows. When a row will not fit, Oracle chains or migrates it across blocks; PostgreSQL compresses and/or stores oversized attributes out-of-line in a TOAST table. Same problem, very different mechanism.
Key takeaway
Every heap page is a 24-byte header, a downward-growing array of 4-byte line pointers, free space, and tuples filling upward from the end. Indexes store TIDs that reference line pointers, not raw offsets — which is what makes intra-page compaction, HOT, and pruning possible without rewriting indexes. Knowing the 8KB budget explains TOAST, fillfactor, and bloat measurement, turning "why is my table bigger than its data" from a mystery into arithmetic.
Interview lens
How this shows up in interviews
What an interviewer is actually testing, the same mechanism seen from three roles, and the gap between a shallow answer and one that shows real depth.
What the interviewer is testing
Whether you know the physical unit Postgres actually reads and writes — the 8KB page — and how line pointers, tuples, and free space live inside it, the foundation for fillfactor, HOT, bloat, and TOAST.
Questions you should be able to answer
- ▸What's inside an 8KB heap page?
- ▸Why is there a line-pointer indirection between a ctid and the actual tuple?
- ▸How large can a single row be, and what happens beyond that?
Database engineer
You reason about bloat and fillfactor in terms of free space per page and line-pointer reuse, not a vague 'the table is big'.
Software engineer
You know a ctid is (block, line-pointer index), so it is not a stable row identifier and can move on UPDATE or VACUUM.
Feature builder
You size columns knowing a tuple must fit one page; anything larger is pushed to TOAST, which changes the feature's I/O profile.
Shallow answer
'Rows are just stored in the table file.' No notion of pages, so nothing downstream (fillfactor, HOT, TOAST) can be explained.
Answer that shows depth
A table's main fork is an array of 8KB blocks (BLCKSZ) split into 1GB segment files. Each block has a page header, then an array of ItemId line pointers growing from the front and tuples growing from the back, with free space between. A ctid is (block, line-pointer index) — that indirection lets VACUUM reclaim or move tuples without rewriting every index entry. Rows can't span pages, so oversized values are compressed and/or offloaded via TOAST.
How this was verified
This explanation is anchored to PostgreSQL's source tree — the files linked above, on the REL_17_STABLE branch — and cross-checked against the official documentation for versions 15 through 18. Every SQL query shown was executed and its output captured in a throwaway PostgreSQL 17.10 lab; where a query needed correcting to run, that correction is noted inline.
Official documentation: §73.6 Database Page Layout