<?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: Ujjwal Agarwal</title>
    <description>The latest articles on DEV Community by Ujjwal Agarwal (@ujjwal_011).</description>
    <link>https://dev.to/ujjwal_011</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%2F1201328%2F6fd17da3-1ba9-49d6-b3cd-992aa4581d38.jpg</url>
      <title>DEV Community: Ujjwal Agarwal</title>
      <link>https://dev.to/ujjwal_011</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ujjwal_011"/>
    <language>en</language>
    <item>
      <title>What Actually Happens When You UPDATE a Row in PostgreSQL?</title>
      <dc:creator>Ujjwal Agarwal</dc:creator>
      <pubDate>Sun, 16 Aug 2026 23:15:49 +0000</pubDate>
      <link>https://dev.to/ujjwal_011/what-actually-happens-when-you-update-a-row-in-postgresql-2nbk</link>
      <guid>https://dev.to/ujjwal_011/what-actually-happens-when-you-update-a-row-in-postgresql-2nbk</guid>
      <description>&lt;p&gt;Most developers think of a PostgreSQL table like this:&lt;/p&gt;

&lt;h2&gt;
  
  
  users
&lt;/h2&gt;

&lt;h2&gt;
  
  
  id | age
&lt;/h2&gt;

&lt;p&gt;1  | 25&lt;br&gt;
2  | 31&lt;br&gt;
3  | 42&lt;/p&gt;

&lt;p&gt;Then we run:&lt;/p&gt;

&lt;p&gt;UPDATE users&lt;br&gt;
SET age = 30&lt;br&gt;
WHERE id = 1;&lt;/p&gt;

&lt;p&gt;The obvious mental model is:&lt;/p&gt;

&lt;p&gt;25 → 30&lt;/p&gt;

&lt;p&gt;But PostgreSQL doesn't simply overwrite the old value.&lt;/p&gt;

&lt;p&gt;To understand what actually happens, we need to look at how PostgreSQL stores rows and how MVCC (Multi-Version Concurrency Control) works.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tables are stored as pages&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;PostgreSQL stores table data in fixed-size pages. The usual page size is 8 KB.&lt;/p&gt;

&lt;p&gt;You can roughly imagine a table as:&lt;/p&gt;

&lt;p&gt;users&lt;/p&gt;

&lt;p&gt;┌─────────────────┐&lt;br&gt;
│ Page 0 — 8 KB   │&lt;br&gt;
├─────────────────┤&lt;br&gt;
│ Page 1 — 8 KB   │&lt;br&gt;
├─────────────────┤&lt;br&gt;
│ Page 2 — 8 KB   │&lt;br&gt;
├─────────────────┤&lt;br&gt;
│ Page 3 — 8 KB   │&lt;br&gt;
├─────────────────┤&lt;br&gt;
│ ...             │&lt;br&gt;
└─────────────────┘&lt;/p&gt;

&lt;p&gt;Each page can contain multiple rows.&lt;/p&gt;

&lt;p&gt;Internally, PostgreSQL calls a row a tuple.&lt;/p&gt;

&lt;p&gt;So the simplified model is:&lt;/p&gt;

&lt;p&gt;Table&lt;br&gt;
  ↓&lt;br&gt;
Pages&lt;br&gt;
  ↓&lt;br&gt;
Tuples&lt;/p&gt;

&lt;p&gt;But a page isn't simply:&lt;/p&gt;

&lt;p&gt;[row][row][row]&lt;/p&gt;

&lt;p&gt;It has a structure.&lt;/p&gt;

&lt;p&gt;A simplified page looks like:&lt;/p&gt;

&lt;p&gt;┌──────────────────────────┐&lt;br&gt;
│ Page Header              │&lt;br&gt;
├──────────────────────────┤&lt;br&gt;
│ Line Pointers            │&lt;br&gt;
│ 1 → offset, length       │&lt;br&gt;
│ 2 → offset, length       │&lt;br&gt;
│ 3 → offset, length       │&lt;br&gt;
├──────────────────────────┤&lt;br&gt;
│ Free Space               │&lt;br&gt;
├──────────────────────────┤&lt;br&gt;
│ Tuple                    │&lt;br&gt;
│ Tuple                    │&lt;br&gt;
│ Tuple                    │&lt;br&gt;
└──────────────────────────┘&lt;/p&gt;

&lt;p&gt;The line pointer tells PostgreSQL where a tuple is located inside the page.&lt;/p&gt;

&lt;p&gt;This becomes important when we talk about ctid.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CTID: How does PostgreSQL locate a tuple?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;PostgreSQL exposes a system column called ctid.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;p&gt;SELECT id, age, ctid&lt;br&gt;
FROM users;&lt;/p&gt;

&lt;p&gt;You might see:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;age&lt;/th&gt;
&lt;th&gt;ctid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;(0,1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;(0,2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;(1,1)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A CTID is essentially:&lt;/p&gt;

&lt;p&gt;(block number, item identifier number)&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;p&gt;(0,1)&lt;br&gt;
 │ │&lt;br&gt;
 │ └── line pointer #1&lt;br&gt;
 └──── page/block #0&lt;/p&gt;

&lt;p&gt;Notice something important:&lt;/p&gt;

&lt;p&gt;(0,1) does NOT mean byte offset 1.&lt;/p&gt;

&lt;p&gt;The first value identifies the page/block.&lt;/p&gt;

&lt;p&gt;The second value identifies the line pointer.&lt;/p&gt;

&lt;p&gt;The line pointer then contains the actual byte offset and length of the tuple inside that page.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;p&gt;CTID&lt;br&gt;
(0,1)&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Line pointer #1&lt;br&gt;
  │&lt;br&gt;
  ├── offset&lt;br&gt;
  └── length&lt;br&gt;
       │&lt;br&gt;
       ▼&lt;br&gt;
     Tuple&lt;/p&gt;

&lt;p&gt;This is the important distinction when thinking about file offsets.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Where does the index come in?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now suppose we create an index:&lt;/p&gt;

&lt;p&gt;CREATE INDEX users_id_idx ON users(id);&lt;/p&gt;

&lt;p&gt;PostgreSQL will maintain a B-tree index.&lt;/p&gt;

&lt;p&gt;A simplified view:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;         B-Tree
            │
            ▼
         key = 1
            │
            ▼
         TID
        (0,1)
            │
            ▼
       Heap tuple
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The index doesn't contain the entire row.&lt;/p&gt;

&lt;p&gt;It helps PostgreSQL locate the corresponding heap tuple.&lt;/p&gt;

&lt;p&gt;So when we run:&lt;/p&gt;

&lt;p&gt;SELECT *&lt;br&gt;
FROM users&lt;br&gt;
WHERE id = 1;&lt;/p&gt;

&lt;p&gt;the simplified path is:&lt;/p&gt;

&lt;p&gt;id = 1&lt;br&gt;
  ↓&lt;br&gt;
B-tree index&lt;br&gt;
  ↓&lt;br&gt;
TID / CTID&lt;br&gt;
  ↓&lt;br&gt;
Heap page&lt;br&gt;
  ↓&lt;br&gt;
Line pointer&lt;br&gt;
  ↓&lt;br&gt;
Tuple&lt;/p&gt;

&lt;p&gt;Now we get to the interesting part.&lt;/p&gt;

&lt;p&gt;What happens when that tuple is updated?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;UPDATE doesn't simply overwrite the old tuple&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Suppose we start with:&lt;/p&gt;

&lt;p&gt;CTID = (0,1)&lt;/p&gt;

&lt;p&gt;id  = 1&lt;br&gt;
age = 25&lt;/p&gt;

&lt;p&gt;Now:&lt;/p&gt;

&lt;p&gt;UPDATE users&lt;br&gt;
SET age = 30&lt;br&gt;
WHERE id = 1;&lt;/p&gt;

&lt;p&gt;A simplified mental model is:&lt;/p&gt;

&lt;p&gt;Before:&lt;/p&gt;

&lt;p&gt;(0,1)&lt;br&gt;
id  = 1&lt;br&gt;
age = 25&lt;/p&gt;

&lt;p&gt;After:&lt;/p&gt;

&lt;p&gt;Old version              New version&lt;/p&gt;

&lt;p&gt;(0,1)                    (0,2)&lt;br&gt;
id  = 1                   id  = 1&lt;br&gt;
age = 25                  age = 30&lt;/p&gt;

&lt;p&gt;PostgreSQL has created a new tuple version.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because another transaction might still need to see the old version.&lt;/p&gt;

&lt;p&gt;This is the fundamental idea behind MVCC.&lt;/p&gt;

&lt;p&gt;Instead of thinking:&lt;/p&gt;

&lt;p&gt;UPDATE&lt;br&gt;
  ↓&lt;br&gt;
overwrite row&lt;/p&gt;

&lt;p&gt;think:&lt;/p&gt;

&lt;p&gt;UPDATE&lt;br&gt;
  ↓&lt;br&gt;
create new tuple version&lt;/p&gt;

&lt;p&gt;Now PostgreSQL can have:&lt;/p&gt;

&lt;p&gt;Old version&lt;br&gt;
age = 25&lt;br&gt;
    │&lt;br&gt;
    ▼&lt;br&gt;
New version&lt;br&gt;
age = 30&lt;/p&gt;

&lt;p&gt;and different transactions can potentially see different versions depending on their snapshots.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;So which version does SELECT return?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is where xmin and xmax come in.&lt;/p&gt;

&lt;p&gt;Tuple headers contain transaction metadata. Two important fields are:&lt;/p&gt;

&lt;p&gt;xmin&lt;br&gt;
xmax&lt;/p&gt;

&lt;p&gt;Very roughly:&lt;/p&gt;

&lt;p&gt;xmin = transaction that created the tuple&lt;/p&gt;

&lt;p&gt;xmax = transaction that deleted/replaced the tuple&lt;/p&gt;

&lt;p&gt;Imagine transaction 8 performs the update:&lt;/p&gt;

&lt;p&gt;Old tuple&lt;/p&gt;

&lt;p&gt;id   = 1&lt;br&gt;
age  = 25&lt;br&gt;
xmin = 5&lt;br&gt;
xmax = 8&lt;/p&gt;

&lt;p&gt;and the new version:&lt;/p&gt;

&lt;p&gt;New tuple&lt;/p&gt;

&lt;p&gt;id   = 1&lt;br&gt;
age  = 30&lt;br&gt;
xmin = 8&lt;/p&gt;

&lt;p&gt;The old version was created by transaction 5 and later replaced by transaction 8.&lt;/p&gt;

&lt;p&gt;Now another transaction runs:&lt;/p&gt;

&lt;p&gt;SELECT *&lt;br&gt;
FROM users&lt;br&gt;
WHERE id = 1;&lt;/p&gt;

&lt;p&gt;Which version should it get?&lt;/p&gt;

&lt;p&gt;Not necessarily the one with the newest CTID.&lt;/p&gt;

&lt;p&gt;PostgreSQL asks:&lt;/p&gt;

&lt;p&gt;Which tuple version is visible in my transaction snapshot?&lt;/p&gt;

&lt;p&gt;That's the key idea behind MVCC.&lt;/p&gt;

&lt;p&gt;A simplified view:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          SELECT
            │
            ▼
      Transaction snapshot
            │
            ▼
    Find candidate tuples
            │
            ▼
    Check visibility
         /      \
        /        \
   visible      invisible
      │             │
      ▼             ▼
   return       check another
   version         version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The exact visibility rules are more complicated than simply comparing xmin and xmax. PostgreSQL also considers transaction status and the snapshot's view of committed and in-progress transactions.&lt;/p&gt;

&lt;p&gt;That's why two concurrent transactions can see different versions of what logically looks like the same row.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What happens to the old version?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Eventually, the old tuple may no longer be needed by any active transaction.&lt;/p&gt;

&lt;p&gt;It becomes a dead tuple.&lt;/p&gt;

&lt;p&gt;Page&lt;/p&gt;

&lt;p&gt;┌─────────────────────┐&lt;br&gt;
│ Old tuple           │&lt;br&gt;
│ age = 25            │&lt;br&gt;
│ DEAD                │&lt;br&gt;
├─────────────────────┤&lt;br&gt;
│ New tuple           │&lt;br&gt;
│ age = 30            │&lt;br&gt;
└─────────────────────┘&lt;/p&gt;

&lt;p&gt;PostgreSQL can't immediately remove the old version because an older transaction might still need to see it.&lt;/p&gt;

&lt;p&gt;Once PostgreSQL knows that no active transaction needs it anymore, VACUUM can clean it up and make its space available for reuse.&lt;/p&gt;

&lt;p&gt;So the lifecycle is roughly:&lt;/p&gt;

&lt;p&gt;INSERT&lt;br&gt;
  ↓&lt;br&gt;
Tuple created&lt;br&gt;
  ↓&lt;br&gt;
UPDATE&lt;br&gt;
  ↓&lt;br&gt;
New tuple version created&lt;br&gt;
  ↓&lt;br&gt;
Old version becomes obsolete&lt;br&gt;
  ↓&lt;br&gt;
Dead tuple&lt;br&gt;
  ↓&lt;br&gt;
VACUUM&lt;br&gt;
  ↓&lt;br&gt;
Space can be reused&lt;/p&gt;

&lt;p&gt;And this is one of the reasons PostgreSQL needs autovacuum.&lt;/p&gt;

&lt;p&gt;One important optimization: HOT updates&lt;/p&gt;

&lt;p&gt;There's one more interesting detail.&lt;/p&gt;

&lt;p&gt;Suppose we have:&lt;/p&gt;

&lt;p&gt;CREATE INDEX users_id_idx ON users(id);&lt;/p&gt;

&lt;p&gt;and execute:&lt;/p&gt;

&lt;p&gt;UPDATE users&lt;br&gt;
SET age = 30&lt;br&gt;
WHERE id = 1;&lt;/p&gt;

&lt;p&gt;We're changing age, but the index is on id.&lt;/p&gt;

&lt;p&gt;If there is enough free space on the same page, PostgreSQL can perform a HOT (Heap-Only Tuple) update.&lt;/p&gt;

&lt;p&gt;In that case, it can create the new tuple version without creating another index entry.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;/p&gt;

&lt;p&gt;Index&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
(0,1)&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Old tuple&lt;br&gt;
age = 25&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
New tuple&lt;br&gt;
age = 30&lt;/p&gt;

&lt;p&gt;This can reduce the amount of index maintenance required by an UPDATE.&lt;/p&gt;

&lt;p&gt;HOT is possible when the UPDATE doesn't modify columns referenced by indexes and the new tuple can be placed appropriately on the same page.&lt;/p&gt;

&lt;p&gt;The mental model&lt;/p&gt;

&lt;p&gt;If you remember nothing else, remember this:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            TABLE
              │
              ▼
            PAGES
              │
              ▼
            TUPLES
              │
              ▼
          LINE POINTER
              │
              ▼
        BYTE OFFSET
              │
              ▼
           TUPLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;An index gives PostgreSQL a faster way to get there:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;         Index
           │
           ▼
          TID
           │
           ▼
       Heap page
           │
           ▼
     Line pointer
           │
           ▼
         Tuple
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;And an UPDATE looks more like:&lt;/p&gt;

&lt;p&gt;UPDATE&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Find old tuple&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Create new tuple version&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
MVCC decides which version&lt;br&gt;
each transaction can see&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Old version eventually becomes dead&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
VACUUM reclaims its space&lt;/p&gt;

&lt;p&gt;So PostgreSQL didn't simply do:&lt;/p&gt;

&lt;p&gt;25 → 30&lt;/p&gt;

&lt;p&gt;Under the hood, it did something closer to:&lt;/p&gt;

&lt;p&gt;25&lt;br&gt;
│&lt;br&gt;
│ UPDATE&lt;br&gt;
▼&lt;br&gt;
old tuple + new tuple&lt;br&gt;
│&lt;br&gt;
│&lt;br&gt;
├── xmin/xmax + snapshot&lt;br&gt;
│       ↓&lt;br&gt;
│   visibility&lt;br&gt;
│&lt;br&gt;
└── eventually&lt;br&gt;
        ↓&lt;br&gt;
      VACUUM&lt;/p&gt;

&lt;p&gt;And that's the interesting part of PostgreSQL: a simple SQL statement like UPDATE hides a surprisingly sophisticated storage and concurrency system underneath.&lt;/p&gt;

&lt;p&gt;Thanks for reading! More backend engineering deep dives coming soon!&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>programming</category>
      <category>backend</category>
      <category>database</category>
    </item>
  </channel>
</rss>
