<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>PostgreSQL Archives - Hécate Regnier</title>
	<atom:link href="https://hecate.neurozone.fr/category/postgresql/feed/" rel="self" type="application/rss+xml" />
	<link>https://hecate.neurozone.fr/category/postgresql/</link>
	<description>DBA, DevOps &#38; Sysadmin</description>
	<lastBuildDate>Sat, 24 Oct 2026 13:28:30 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>

<image>
	<url>https://hecate.neurozone.fr/wp-content/uploads/2026/10/cropped-avatar_Instagram-150x150.webp</url>
	<title>PostgreSQL Archives - Hécate Regnier</title>
	<link>https://hecate.neurozone.fr/category/postgresql/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">258172890</site>	<item>
		<title>Inside database data files (2/3): PostgreSQL</title>
		<link>https://hecate.neurozone.fr/2026/10/25/inside-database-data-files-2-3-postgresql/</link>
					<comments>https://hecate.neurozone.fr/2026/10/25/inside-database-data-files-2-3-postgresql/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Regnier]]></dc:creator>
		<pubDate>Sun, 25 Oct 2026 14:00:00 +0000</pubDate>
				<category><![CDATA[Databases]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Sysadmin notebook]]></category>
		<category><![CDATA[InnoDB]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[series: Inside database data files]]></category>
		<category><![CDATA[storage]]></category>
		<category><![CDATA[sysadmin notebook]]></category>
		<guid isPermaLink="false">https://hecate.neurozone.fr/?p=378</guid>

					<description><![CDATA[<p>After InnoDB, PostgreSQL. Both engines solve the same problems (store rows, find them fast, survive a crash, let people read while others write) but made almost opposite choices. Knowing them explains the need for VACUUM, table bloat, or why the first SELECT after a big load writes to disk. The data directory First habit to...</p>
<p>The post <a href="https://hecate.neurozone.fr/2026/10/25/inside-database-data-files-2-3-postgresql/">Inside database data files (2/3): PostgreSQL</a> appeared first on <a href="https://hecate.neurozone.fr">Hécate Regnier</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">After InnoDB, PostgreSQL. Both engines solve the same problems (store rows, find them fast, survive a crash, let people read while others write) but made almost opposite choices. Knowing them explains the need for <code>VACUUM</code>, table bloat, or why the first <code>SELECT</code> after a big load writes to disk.</p>



<h3 class="wp-block-heading">The data directory</h3>



<figure class="wp-block-image size-full"><a href="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_files_en.webp"><img fetchpriority="high" decoding="async" width="1800" height="1020" src="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_files_en.webp" alt="PGDATA directory tree" class="wp-image-372" srcset="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_files_en.webp 1800w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_files_en-300x170.webp 300w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_files_en-1024x580.webp 1024w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_files_en-768x435.webp 768w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_files_en-1536x870.webp 1536w" sizes="(max-width: 800px) 100vw, 760px" /></a><figcaption class="wp-element-caption">PGDATA: one directory per database, one file per table or index, named by number.</figcaption></figure>



<p class="wp-block-paragraph">First habit to lose: looking for tables by name. PostgreSQL refers to everything by number. Each database has an OID, which is its directory name under <code>base/</code>. Each table, index or sequence has a <code>relfilenode</code>, which is its file name. That number changes when the table is rewritten (<code>TRUNCATE</code>, <code>VACUUM FULL</code>, <code>CLUSTER</code>, some <code>ALTER TABLE</code>): PostgreSQL creates a new file and switches over, which makes those operations crash-safe.</p>



<pre class="wp-block-code"><code>SELECT oid, datname FROM pg_database;
SELECT pg_relation_filepath(&#x27;orders&#x27;);        -- base/16384/24576
SELECT pg_size_pretty(pg_total_relation_size(&#x27;orders&#x27;));   -- table + indexes + TOAST</code></pre>



<ul class="wp-block-list"><li><strong><code>global/</code></strong> holds the catalogs shared by every database (roles, database list) and <code>pg_control</code>, the small file that says where to resume after a crash;</li><li><strong><code>pg_wal/</code></strong> is the write-ahead log (WAL), in 16 MB segments: the equivalent of InnoDB&#8217;s redo;</li><li><strong><code>pg_xact/</code></strong> keeps each transaction&#8217;s status in two bits: in progress, committed, aborted. That&#8217;s what tells whether a row version is visible;</li><li><strong><code>pg_tblspc/</code></strong> holds symlinks to tablespaces created elsewhere. In PostgreSQL, a tablespace is just a directory on another disk.</li></ul>



<h3 class="wp-block-heading">A relation: forks and segments</h3>



<figure class="wp-block-image size-full"><a href="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_forks_en.webp"><img decoding="async" width="1800" height="600" src="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_forks_en.webp" alt="Forks of a PostgreSQL relation" class="wp-image-373" srcset="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_forks_en.webp 1800w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_forks_en-300x100.webp 300w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_forks_en-1024x341.webp 1024w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_forks_en-768x256.webp 768w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_forks_en-1536x512.webp 1536w" sizes="(max-width: 800px) 100vw, 760px" /></a><figcaption class="wp-element-caption">The main fork holds the data; fsm and vm are auxiliary files.</figcaption></figure>



<p class="wp-block-paragraph">Each table is really a small set of files. The <em>main fork</em> holds the data, split into files of at most 1 GB (<code>24576</code>, <code>24576.1</code>…). That limit dates from when not every filesystem handled large files, and it still makes backup and copying simpler. The <strong>free space map</strong> records each page&#8217;s approximate free space, so an <code>INSERT</code> quickly finds a page to write to. The <strong>visibility map</strong> keeps two bits per page: &#8220;every row is visible to everyone&#8221; and &#8220;every row is frozen&#8221;. It lets <code>VACUUM</code> skip pages that are already clean, and lets <em>index-only scans</em> avoid reading the table.</p>



<h3 class="wp-block-heading">Anatomy of a page</h3>



<figure class="wp-block-image size-full"><a href="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_page_en.webp"><img decoding="async" width="1720" height="980" src="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_page_en.webp" alt="Structure of an 8 KB PostgreSQL page" class="wp-image-375" srcset="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_page_en.webp 1720w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_page_en-300x171.webp 300w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_page_en-1024x583.webp 1024w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_page_en-768x438.webp 768w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_page_en-1536x875.webp 1536w" sizes="(max-width: 800px) 100vw, 760px" /></a><figcaption class="wp-element-caption">A heap page: header, line pointers, free space, tuples.</figcaption></figure>



<p class="wp-block-paragraph">Pages are 8 KB (a compile-time constant, <code>BLCKSZ</code>). Their structure is the same for tables and indexes:</p>



<ul class="wp-block-list"><li><strong>the 24-byte header</strong> holds <code>pd_lsn</code>, the WAL position of the last change. PostgreSQL uses it for a simple rule: a page can&#8217;t be written to disk before the WAL describing it is. It also holds the checksum (on by default at initdb since PostgreSQL 18, requested with <code>initdb -k</code> before), and two offsets, <code>pd_lower</code> and <code>pd_upper</code>, bounding the free space;</li><li><strong>line pointers</strong> form an array at the start of the page. Each holds a tuple&#8217;s offset and length. A row&#8217;s address, its <code>ctid</code>, is (page number, pointer number), e.g. <code>(0,2)</code>. That indirection lets a tuple move <em>within</em> the page for defragmentation without changing its address, so without touching the indexes;</li><li><strong>tuples</strong> are written from the end of the page, upwards. When <code>pd_lower</code> (end of the pointers) meets <code>pd_upper</code> (start of the tuples), the page is full;</li><li><strong>the special space</strong> at the end of the page is empty for a table. B-tree indexes keep links to neighbouring pages and the tree level there.</li></ul>



<p class="wp-block-paragraph">You can look at all of it with the <code>pageinspect</code> extension, on a test database:</p>



<pre class="wp-block-code"><code>CREATE EXTENSION pageinspect;
SELECT lower, upper, special, pagesize FROM page_header(get_raw_page(&#x27;orders&#x27;, 0));
SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page(&#x27;orders&#x27;, 0));</code></pre>



<h3 class="wp-block-heading">A tuple</h3>



<figure class="wp-block-image size-full"><a href="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_en.webp"><img loading="lazy" decoding="async" width="1800" height="444" src="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_en.webp" alt="PostgreSQL tuple header" class="wp-image-376" srcset="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_en.webp 1800w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_en-300x74.webp 300w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_en-1024x253.webp 1024w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_en-768x189.webp 768w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_en-1536x379.webp 1536w" sizes="auto, (max-width: 800px) 100vw, 760px" /></a><figcaption class="wp-element-caption">The 23 header bytes in front of every row version.</figcaption></figure>



<p class="wp-block-paragraph">Every row version starts with a 23-byte header, rounded up to 24 by alignment. The key fields are <code>t_xmin</code> (the transaction that created this version), <code>t_xmax</code> (the one that deleted or replaced it) and <code>t_ctid</code> (the address of the next version). Three practical details:</p>



<ul class="wp-block-list"><li><strong>alignment</strong>: each column is aligned to its type (8 bytes for <code>bigint</code> and <code>timestamp</code>). A <code>boolean</code> followed by a <code>bigint</code> wastes 7 bytes. Ordering columns from largest to smallest can shrink a big table by 10–20%;</li><li><strong>NULLs</strong> take only a bit in the bitmap, which is only present if the row has at least one NULL;</li><li><strong>TOAST</strong>: when a row exceeds about 2 KB, PostgreSQL first compresses large values (pglz, or lz4 if you ask), then, if that&#8217;s not enough, moves them to a side table, <code>pg_toast.pg_toast_&lt;oid&gt;</code>, in chunks. The row keeps only an 18-byte pointer. It&#8217;s transparent, but a <code>SELECT *</code> reading a TOASTed column fetches those chunks: select only the columns you need.</li></ul>



<h3 class="wp-block-heading">An UPDATE changes nothing in place</h3>



<figure class="wp-block-image size-full"><a href="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_en.webp"><img loading="lazy" decoding="async" width="1800" height="760" src="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_en.webp" alt="A PostgreSQL UPDATE creates a new version" class="wp-image-374" srcset="https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_en.webp 1800w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_en-300x127.webp 300w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_en-1024x432.webp 1024w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_en-768x324.webp 768w, https://hecate.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_en-1536x649.webp 1536w" sizes="auto, (max-width: 800px) 100vw, 760px" /></a><figcaption class="wp-element-caption">The old version stays in the page, marked by its xmax, until VACUUM.</figcaption></figure>



<p class="wp-block-paragraph">This is the fundamental difference from InnoDB and Oracle. In PostgreSQL, an <code>UPDATE</code> is a <code>DELETE</code> followed by an <code>INSERT</code>: the transaction writes a complete new version of the row, and records its number in the old one&#8217;s <code>xmax</code>. There&#8217;s no undo log. The old versions <em>are</em> the undo, and they live in the table.</p>



<p class="wp-block-paragraph">To decide whether a version is visible, a transaction compares <code>xmin</code> and <code>xmax</code> with its snapshot: the version is visible if its creation is committed and older than the snapshot, and its deletion didn&#8217;t happen, was aborted or is newer. Checking <code>pg_xact</code> every time would be expensive. So the first transaction that reads a version records the result in <strong>hint bits</strong> in the header (&#8220;xmin committed&#8221;, &#8220;xmax aborted&#8221;…). That explains something that often surprises people: the first <code>SELECT</code> after a big load <em>writes</em> to disk, because it sets those bits on every page.</p>



<h3 class="wp-block-heading">What it implies: VACUUM, HOT and freezing</h3>



<ul class="wp-block-list"><li><strong>VACUUM</strong> removes versions no transaction can see any more, makes their space reusable (updating the free space map) and updates the visibility map. It doesn&#8217;t give space back to the filesystem, except for empty pages at the end of the file. Only <code>VACUUM FULL</code> (or the <code>pg_repack</code> extension, without a long lock) rewrites the table;</li><li><strong>a transaction left open too long</strong> blocks cleanup of every version newer than it, across the whole database. It&#8217;s the first thing to check when a table bloats: <code>SELECT pid, xact_start, state FROM pg_stat_activity ORDER BY xact_start;</code>;</li><li><strong>HOT updates</strong> (Heap-Only Tuple): if no indexed column changes and the new version fits in the same page, PostgreSQL leaves the indexes alone. The index still points at the old line pointer, which leads to the new version through the <code>ctid</code> chain. Leaving room in pages improves the odds of HOT: <code>ALTER TABLE orders SET (fillfactor = 90);</code>;</li><li><strong>freezing</strong>: transaction IDs are 32 bits and eventually wrap around, after about two billion transactions. So that a very old row doesn&#8217;t suddenly look &#8220;in the future&#8221;, <code>VACUUM</code> marks it frozen, visible to all. If autovacuum falls too far behind, PostgreSQL eventually refuses writes to protect itself: that&#8217;s why you never disable autovacuum.</li></ul>



<h3 class="wp-block-heading">Indexes and the WAL</h3>



<p class="wp-block-paragraph">A B-tree index uses the same 8 KB pages. Its entries hold the key and the tuple&#8217;s <code>ctid</code>, i.e. a physical address, unlike InnoDB which stores the primary key. An index lookup is therefore one descent plus one page read. But the index knows nothing about visibility: the table must be read to know whether the version found is visible, unless the visibility map says the page is all-visible. That&#8217;s what makes <em>index-only scans</em> efficient on a well-vacuumed table.</p>



<p class="wp-block-paragraph">Finally, protection against torn pages: the problem is the same as InnoDB&#8217;s (8 KB written by a disk that guarantees 4 KB), the solution differs. With <code>full_page_writes = on</code>, the first change to a page after each checkpoint writes the full page image to the WAL. After a crash, PostgreSQL starts again from that intact image. It&#8217;s the equivalent of the doublewrite buffer, and the reason WAL volume jumps right after each checkpoint.</p>



<p class="wp-block-paragraph">Last post in the series: Oracle, which shares InnoDB&#8217;s separate undo but takes the logic further, with locks stored in the blocks themselves.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><em>This post is part of a series of sysadmin notes (tag &#8220;sysadmin notebook&#8221;).</em><br>Series &#8220;Inside database data files&#8221;, part 2/3. · <a href="https://hecate.neurozone.fr/?p=377">← Previous post</a> · <a href="https://hecate.neurozone.fr/?p=379">Next post →</a> · <a href="https://blog.neurozone.fr/?p=1788">Version française</a></p>

<p>The post <a href="https://hecate.neurozone.fr/2026/10/25/inside-database-data-files-2-3-postgresql/">Inside database data files (2/3): PostgreSQL</a> appeared first on <a href="https://hecate.neurozone.fr">Hécate Regnier</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://hecate.neurozone.fr/2026/10/25/inside-database-data-files-2-3-postgresql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">378</post-id>	</item>
	</channel>
</rss>
