<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pakeeza Khalid</title>
    <description>The latest articles on DEV Community by Pakeeza Khalid (@pakeezakhalid).</description>
    <link>https://dev.to/pakeezakhalid</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4158065%2Fbe3f2efb-a6ea-42c0-859a-2c7f821ea272.jpg</url>
      <title>DEV Community: Pakeeza Khalid</title>
      <link>https://dev.to/pakeezakhalid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pakeezakhalid"/>
    <language>en</language>
    <item>
      <title>Postgres Internals in Simple Words: One Example to Understand Tables, Pages, Tuples, and Indexes</title>
      <dc:creator>Pakeeza Khalid</dc:creator>
      <pubDate>Fri, 02 Oct 2026 16:43:01 +0000</pubDate>
      <link>https://dev.to/pakeezakhalid/postgres-internals-in-simple-words-one-example-to-understand-tables-pages-tuples-and-indexes-27m</link>
      <guid>https://dev.to/pakeezakhalid/postgres-internals-in-simple-words-one-example-to-understand-tables-pages-tuples-and-indexes-27m</guid>
      <description>&lt;h1&gt;
  
  
  Postgres Internals in Simple Words: One Example to Understand Tables, Pages, Tuples, and Indexes
&lt;/h1&gt;

&lt;p&gt;For the longest time, words like "heap," "tuple," "CTID," and "page" kept showing up whenever I read about Postgres — and they confused me every single time. So I sat down with one small example and traced the whole picture. Here it is, in the simplest words I could manage. No jargon without an explanation.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Start with a table
&lt;/h2&gt;

&lt;p&gt;Imagine a small shop. You create a table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row: item &lt;code&gt;100&lt;/code&gt; costs &lt;code&gt;$10&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When you create a table, Postgres creates &lt;strong&gt;one file on disk&lt;/strong&gt; for it. All of this table's data will live inside that file. That's it — a table is a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The file is made of pages
&lt;/h2&gt;

&lt;p&gt;Think of the file as a notebook. It is divided into fixed-size &lt;strong&gt;pages&lt;/strong&gt;. Every page is exactly &lt;strong&gt;8KB&lt;/strong&gt;, no more, no less. Pages are numbered: page 0, page 1, page 2, and so on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[file: items]
[ page 0 (8KB) ][ page 1 (8KB) ][ page 2 (8KB) ]...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Postgres needs a row, it doesn't read the whole file. It jumps straight to the right page number — like opening a notebook directly on page 5 instead of flipping from page 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Inside a page: tuples
&lt;/h2&gt;

&lt;p&gt;Each page holds rows. Postgres calls a row a &lt;strong&gt;tuple&lt;/strong&gt; — it's just a slightly more accurate word for "row," nothing scary.&lt;/p&gt;

&lt;p&gt;Each page also keeps a tiny list at the top, called &lt;strong&gt;line pointers&lt;/strong&gt;, which simply record where each tuple sits inside the page. Think of it as a table of contents for that one page.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Every tuple has an address: the CTID
&lt;/h2&gt;

&lt;p&gt;Every tuple's address is two numbers: &lt;strong&gt;(page number, line number)&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;(0, 0)&lt;/code&gt; → page 0, first row&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;(0, 2)&lt;/code&gt; → page 0, third row&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;(1, 5)&lt;/code&gt; → page 1, sixth row&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This address is called the &lt;strong&gt;CTID&lt;/strong&gt;. It's like a house address: street (page) + house number (line). Once Postgres knows the CTID, it jumps exactly to the row. Very fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. All the pages together = the heap
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Heap&lt;/strong&gt; is just the name for all the pages where the table's actual data lives — whether those pages are sitting on disk or loaded in memory.&lt;/p&gt;

&lt;p&gt;So remember this simple equation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;table's file = heap = where your real data is&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. The index is a separate shortcut
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;index&lt;/strong&gt; is a different file. You can think of it as a shortcut list: it maps a key to a CTID.&lt;/p&gt;

&lt;p&gt;Say you search for &lt;code&gt;item_id = 100&lt;/code&gt;. The index looks it up and says: &lt;em&gt;"it's at (0, 2)."&lt;/em&gt; Postgres jumps straight there. One quick jump — even if the table is 7GB big. &lt;strong&gt;That&lt;/strong&gt; is why searching with an index is fast: no full scan, just one jump.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The twist: Postgres never overwrites
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprised me most.&lt;/p&gt;

&lt;p&gt;You update the price from &lt;code&gt;$10&lt;/code&gt; to &lt;code&gt;$20&lt;/code&gt;. You'd expect Postgres to erase &lt;code&gt;$10&lt;/code&gt; and write &lt;code&gt;$20&lt;/code&gt; in the same spot. &lt;strong&gt;It doesn't.&lt;/strong&gt; It never updates in place.&lt;/p&gt;

&lt;p&gt;Instead, it writes a &lt;strong&gt;brand-new tuple&lt;/strong&gt; (a new version of the row) in some free space, with a new address like &lt;code&gt;(0, 3)&lt;/code&gt; — and adds a &lt;strong&gt;new entry in the index&lt;/strong&gt; pointing to it.&lt;/p&gt;

&lt;p&gt;Now the index has &lt;strong&gt;two&lt;/strong&gt; entries for item 100:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;100 → (0, 0)&lt;/code&gt; … the old &lt;code&gt;$10&lt;/code&gt; version&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;100 → (0, 3)&lt;/code&gt; … the new &lt;code&gt;$20&lt;/code&gt; version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The old row is still sitting there. Nothing was erased.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. So which version do you see?
&lt;/h2&gt;

&lt;p&gt;Good question — and this is the cleverest part.&lt;/p&gt;

&lt;p&gt;Every tuple carries two tiny numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;xmin&lt;/strong&gt; → which transaction &lt;em&gt;created&lt;/em&gt; this version&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;xmax&lt;/strong&gt; → which transaction &lt;em&gt;replaced or deleted&lt;/em&gt; this version (0 means "still alive")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your own query also runs inside a transaction, which has a number. Postgres compares the numbers and shows you the version that was alive when &lt;strong&gt;your&lt;/strong&gt; transaction started.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your query started &lt;strong&gt;after&lt;/strong&gt; the update → you see &lt;strong&gt;$20&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Your transaction started &lt;strong&gt;before&lt;/strong&gt; the update and is still running → you still see &lt;strong&gt;$10&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same table, two readers, two different answers — and both are correct. This system is called &lt;strong&gt;MVCC&lt;/strong&gt; (multi-version concurrency control). It's how Postgres lets many people read and write at the same time without blocking each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Old versions pile up — that's what vacuum is for
&lt;/h2&gt;

&lt;p&gt;The old &lt;code&gt;$10&lt;/code&gt; tuple doesn't vanish. It sits there as a &lt;strong&gt;dead tuple&lt;/strong&gt; until no running transaction could possibly still need it. Then a background cleaner called &lt;strong&gt;vacuum&lt;/strong&gt; sweeps it away.&lt;/p&gt;

&lt;p&gt;But if some transaction runs for a very long time, dead tuples keep piling up and the table gets &lt;strong&gt;bloated&lt;/strong&gt; — bigger on disk than the real data justifies. That's why database people care so much about vacuum.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. The takeaway
&lt;/h2&gt;

&lt;p&gt;The video I learned this from called Postgres's design &lt;em&gt;"fantastic and miserable"&lt;/em&gt; — and now I get it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fantastic&lt;/strong&gt;, because a CTID lookup is a single fast jump to the exact row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Miserable&lt;/strong&gt;, because every update secretly creates a new row plus a new index entry, which costs maintenance and cleanup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you remember one line, remember this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In Postgres, an UPDATE is really an INSERT of a new version — plus a quiet goodbye to the old one.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;That's the whole picture — file, pages, tuples, CTID, heap, index, and MVCC — through one little shop table. If any of these words scared you before, I hope they don't anymore.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>beginners</category>
      <category>sql</category>
    </item>
  </channel>
</rss>
