<?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: Jishnu Saha</title>
    <description>The latest articles on DEV Community by Jishnu Saha (@jishnusaha89).</description>
    <link>https://dev.to/jishnusaha89</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%2F4080245%2F40bff0f7-05c0-492d-8f4a-caf42c039967.jpg</url>
      <title>DEV Community: Jishnu Saha</title>
      <link>https://dev.to/jishnusaha89</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jishnusaha89"/>
    <language>en</language>
    <item>
      <title>How Does a Database Let Everyone Read and Write at Once?</title>
      <dc:creator>Jishnu Saha</dc:creator>
      <pubDate>Sat, 19 Sep 2026 13:54:00 +0000</pubDate>
      <link>https://dev.to/jishnusaha89/how-does-a-database-let-everyone-read-and-write-at-once-lil</link>
      <guid>https://dev.to/jishnusaha89/how-does-a-database-let-everyone-read-and-write-at-once-lil</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ryd0630c8k63jory0l8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ryd0630c8k63jory0l8.png" alt="MVCC" width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://dev.to/jishnusaha89/how-does-a-database-actually-store-your-data-17f1"&gt;previous post&lt;/a&gt; we dug into how a database physically stores your rows — pages, slotted pages, heap files. Near the end, one small detail slipped by: &lt;strong&gt;when you delete a row, it doesn't actually disappear from disk right away.&lt;/strong&gt; It sits there, marked in a way that some transactions can still see it and others can't.&lt;/p&gt;

&lt;p&gt;That wasn't a quirk. It's the visible edge of one of the most important ideas in modern databases: &lt;strong&gt;MVCC — Multi-Version Concurrency Control.&lt;/strong&gt; It's the machinery that lets hundreds of transactions read and write the same table &lt;em&gt;at the same time&lt;/em&gt; without stepping on each other, and it's the reason &lt;code&gt;SELECT&lt;/code&gt; on a busy table doesn't grind to a halt just because someone else is mid-&lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In this post we'll build that picture from the ground up: the problem MVCC solves, the one rule that makes it work (&lt;code&gt;UPDATE&lt;/code&gt; is secretly a delete plus an insert), the tiny "snapshot" each transaction carries, and the arithmetic that decides which version of a row you're allowed to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: what happens when everyone touches the same row?
&lt;/h2&gt;

&lt;p&gt;Imagine a &lt;code&gt;balance&lt;/code&gt; row that a hundred transactions want to read while one transaction is busy changing it. How do you keep everyone's view consistent without chaos?&lt;/p&gt;

&lt;p&gt;The obvious answer is &lt;strong&gt;locking&lt;/strong&gt;: whoever touches a row locks the door, and everyone else waits their turn. It's safe — nobody ever sees a half-finished change — but it's slow. Readers wait for writers, writers wait for readers, and the database spends its life stuck in traffic jams.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzmd5q030s20zcqzy3h7w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzmd5q030s20zcqzy3h7w.png" alt="locking-vs-mvcc" width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MVCC makes a completely different bet: &lt;strong&gt;never let two people fight over the same copy.&lt;/strong&gt; Instead of one contended row that everyone queues for, the database keeps &lt;strong&gt;multiple versions&lt;/strong&gt; of the row alive at once, and hands each transaction the version that is correct &lt;em&gt;for its point in time&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The headline result is worth memorizing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Readers never block writers, and writers never block readers.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The one thing that &lt;em&gt;isn't&lt;/em&gt; free: &lt;strong&gt;two writers trying to change the &lt;em&gt;same&lt;/em&gt; row&lt;/strong&gt; still have to take turns. But that's a much narrower bottleneck than "everybody waits for everybody."&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: an append-only ledger
&lt;/h2&gt;

&lt;p&gt;The mental model to carry through the rest of this post is a &lt;strong&gt;ledger book with one iron rule: we never erase anything immediately.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;To &lt;em&gt;change&lt;/em&gt; a record, we don't overwrite it. We cross out the old line ("invalid as of transaction #200") and write a fresh copy below it ("valid as of transaction #200").&lt;/li&gt;
&lt;li&gt;Every visitor who walks in gets a numbered ticket and jots down a quick note: &lt;em&gt;which earlier visitors had already finished and left&lt;/em&gt; at the moment they arrived.&lt;/li&gt;
&lt;li&gt;"But won't the ledger fill with crossed-out lines forever?" Eventually a cleanup step removes the lines nobody can possibly still need — but that's a story worth its own post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's how the analogy maps onto real PostgreSQL terms — keep this table handy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Analogy&lt;/th&gt;
&lt;th&gt;Real PostgreSQL term&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ticket / badge number&lt;/td&gt;
&lt;td&gt;Transaction ID (&lt;strong&gt;XID&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid from #N&lt;/td&gt;
&lt;td&gt;Hidden column &lt;strong&gt;&lt;code&gt;xmin&lt;/code&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid as of #M&lt;/td&gt;
&lt;td&gt;Hidden column &lt;strong&gt;&lt;code&gt;xmax&lt;/code&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The note you jot on entry&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Visibility snapshot&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The janitor&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;VACUUM&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every transaction, on its first write, grabs the next number from an ever-increasing counter: 100, 101, 102, and so on. That number is its &lt;strong&gt;XID&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a row really is: tuples and hidden columns
&lt;/h2&gt;

&lt;p&gt;A single &lt;strong&gt;version&lt;/strong&gt; of a row is called a &lt;strong&gt;tuple&lt;/strong&gt;. And every tuple secretly carries a few &lt;strong&gt;hidden system columns&lt;/strong&gt; that you never created and never see in &lt;code&gt;SELECT *&lt;/code&gt; — but they're physically stored on disk, and you can ask for them by name:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvozbe446v6ib8z6fkxvu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvozbe446v6ib8z6fkxvu.png" alt="tuple-anatomy" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three of them do the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;xmin&lt;/code&gt;&lt;/strong&gt; — the XID of the transaction that &lt;strong&gt;created&lt;/strong&gt; this version. Think of it as the row's &lt;em&gt;birth certificate&lt;/em&gt;: "born from transaction N."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;xmax&lt;/code&gt;&lt;/strong&gt; — the XID of the transaction that &lt;strong&gt;killed&lt;/strong&gt; this version, via an update or a delete. &lt;code&gt;0&lt;/code&gt; means it's still alive. This is the &lt;em&gt;death certificate&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ctid&lt;/code&gt;&lt;/strong&gt; — the &lt;strong&gt;physical location&lt;/strong&gt; of the tuple as &lt;code&gt;(page, slot)&lt;/code&gt; — literally &lt;em&gt;where on disk&lt;/em&gt; it sits. This one is the odd member of the group, and the next section explains why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;xmin&lt;/code&gt; and &lt;code&gt;xmax&lt;/code&gt; are the birth and death certificates of a row version. Almost everything else in this post is just the rule that decides which certificates "count yet."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where those hidden columns actually live
&lt;/h2&gt;

&lt;p&gt;Calling them "hidden columns" invites a wrong mental picture: two separate filing cabinets, one where Postgres keeps its own bookkeeping and one where your data lives. It isn't like that at all, and the real layout quietly explains a lot.&lt;/p&gt;

&lt;h3&gt;
  
  
  There is one store, not two
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A tuple is a single contiguous record.&lt;/strong&gt; It sits inside one of the 8 KB pages we met in the &lt;a href="https://dev.to/jishnusaha89/how-does-a-database-actually-store-your-data-17f1"&gt;previous post&lt;/a&gt;, and it's laid out front to back: a &lt;strong&gt;tuple header&lt;/strong&gt; of roughly 23 bytes, then your own columns immediately behind it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozdspfavdp3hgeterluh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozdspfavdp3hgeterluh.png" alt="tuple-on-disk" width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;xmin&lt;/code&gt; and &lt;code&gt;xmax&lt;/code&gt; aren't looked up in some side structure — they're the &lt;strong&gt;first few bytes of the very same record&lt;/strong&gt; that holds &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;balance&lt;/code&gt;. The header carries a little more than the two we care about (a couple of command IDs, some status flags, a bitmap marking which of your columns are &lt;code&gt;NULL&lt;/code&gt;), but the shape is the point:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Hidden system columns" are not a separate system. They're the front of every row.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is also why the visibility check is so cheap. Deciding whether you're allowed to see a row needs no second lookup anywhere — the instant Postgres has the row in hand, it already has the certificates it needs to judge it. One record, one read.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ctid&lt;/code&gt; is an address, not a field
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ctid&lt;/code&gt; is the exception, and the distinction matters: &lt;strong&gt;it isn't stored in the tuple at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ctid&lt;/code&gt; is &lt;code&gt;(block number, slot number)&lt;/code&gt; — the tuple's &lt;em&gt;location&lt;/em&gt;. A thing's location doesn't need to be written inside it, the same way a house's address isn't painted on its living-room wall. When Postgres hands you a &lt;code&gt;ctid&lt;/code&gt;, it &lt;strong&gt;assembles&lt;/strong&gt; the value from context: it knows which block it's reading and which slot it followed to get there.&lt;/p&gt;

&lt;p&gt;To see where that slot number comes from, look inside a page. The first post sketched the slotted-page layout — a header, a slot array growing down, row data growing up. Here's that same page with the part that matters now:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumot4msprnd47dq94kb2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumot4msprnd47dq94kb2.png" alt="page-line-pointers" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That slot array is a &lt;strong&gt;line-pointer array&lt;/strong&gt;: real, physically-stored entries of 4 bytes each, packed near the top of the page just after its 24-byte header. Each line pointer holds the &lt;strong&gt;byte offset and length&lt;/strong&gt; of an actual tuple body further down.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;ctid = (0, 2)&lt;/code&gt; means: &lt;em&gt;block 0, second entry in that block's line-pointer array&lt;/em&gt; — follow that entry to find the bytes. The slot number is an &lt;strong&gt;index into a table of pointers&lt;/strong&gt;, not an offset into the page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the extra hop is worth it
&lt;/h3&gt;

&lt;p&gt;Pointing at a pointer looks like pure overhead. It buys something valuable: &lt;strong&gt;tuple bodies can move around inside a page without their addresses changing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzgihac07rb92gf0ykl5h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzgihac07rb92gf0ykl5h.png" alt="line-pointer-stability" width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When the cleanup process later compacts a page — sliding surviving tuples together to consolidate the free space left by dead ones — it moves the bodies and rewrites the offsets inside the line pointers. &lt;strong&gt;Slot 2 still means slot 2.&lt;/strong&gt; It just points somewhere new. Everything that referenced &lt;code&gt;(0,2)&lt;/code&gt; stays correct without being touched.&lt;/p&gt;

&lt;p&gt;Without that indirection, tidying up a single page would mean hunting down and rewriting every reference to every tuple in it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ctid&lt;/code&gt; is not a row identity
&lt;/h3&gt;

&lt;p&gt;One practical warning follows directly. A &lt;code&gt;ctid&lt;/code&gt; is a &lt;strong&gt;physical, right-now address&lt;/strong&gt;, and it changes whenever the tuple moves — which happens more than you'd think. An &lt;code&gt;UPDATE&lt;/code&gt; writes the new version at a different location entirely (we're about to see exactly that), and a full table rewrite relocates everything.&lt;/p&gt;

&lt;p&gt;So use your primary key when you need to identify a row across time. &lt;code&gt;id&lt;/code&gt; is a logical identity that survives every version; &lt;code&gt;ctid&lt;/code&gt; only tells you where one particular version happens to be sitting at this moment.&lt;/p&gt;

&lt;p&gt;One last wrinkle, because it's a fair thing to trip over: the tuple header &lt;em&gt;does&lt;/em&gt; contain a &lt;code&gt;ctid&lt;/code&gt;-shaped field. But its job isn't to record this tuple's own address — it's a &lt;strong&gt;forward pointer to the newer version&lt;/strong&gt; of the row, which is how Postgres walks old → new along a chain of versions. It's allowed inside the record precisely because it describes a &lt;em&gt;different&lt;/em&gt; tuple. (When there's no newer version yet, it simply points back at this one — which is why the diagram above shows a live tuple pointing at its own slot.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The golden rule: UPDATE = DELETE + INSERT
&lt;/h2&gt;

&lt;p&gt;Here's the single most important implementation fact, and the one that surprises people most:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Postgres never updates a row in place. An &lt;code&gt;UPDATE&lt;/code&gt; is internally an &lt;em&gt;invalidation&lt;/em&gt; of the old version plus an &lt;em&gt;insert&lt;/em&gt; of a new version.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When you run &lt;code&gt;UPDATE accounts SET balance = 90 WHERE id = 1&lt;/code&gt;, Postgres does &lt;strong&gt;not&lt;/strong&gt; find the &lt;code&gt;100&lt;/code&gt; on disk and overwrite it with &lt;code&gt;90&lt;/code&gt;. Instead it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stamps the &lt;strong&gt;old&lt;/strong&gt; tuple's &lt;code&gt;xmax&lt;/code&gt; with the current XID — "invalid as of #200."&lt;/li&gt;
&lt;li&gt;Writes a &lt;strong&gt;brand-new&lt;/strong&gt; tuple with the new balance, &lt;code&gt;xmin&lt;/code&gt; = the current XID ("born at #200") and &lt;code&gt;xmax&lt;/code&gt; = &lt;code&gt;0&lt;/code&gt; ("still alive").&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu4kz6m1ea27gzn81grc4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu4kz6m1ea27gzn81grc4.png" alt="update-delete-insert" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The old version physically stays on disk — it's just marked &lt;em&gt;ended&lt;/em&gt;. Any reader who was mid-transaction never notices; their point-in-time view still points at the old version. And notice the two tuples have different &lt;code&gt;ctid&lt;/code&gt;s: they are two distinct physical rows, not one row edited twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;DELETE&lt;/code&gt; is the same move, minus the insert.&lt;/strong&gt; It only stamps &lt;code&gt;xmax&lt;/code&gt; on the existing tuple and stops — no new row is written. The row is &lt;em&gt;marked dead now, physically removed later&lt;/em&gt;. This is exactly the behavior we saw in the last post: a deleted row isn't erased right away, it's handed a death certificate — and the cleanup that eventually reclaims it is a topic for a later post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4lswsp8zhxwm1q76s47t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4lswsp8zhxwm1q76s47t.png" alt="delete-operation" width="800" height="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Put the two side by side and the pattern is clear: an &lt;code&gt;UPDATE&lt;/code&gt; stamps the old tuple &lt;em&gt;and&lt;/em&gt; appends a new one; a &lt;code&gt;DELETE&lt;/code&gt; just stamps and stops. Neither one ever touches the original bytes in place — which is exactly why a reader mid-transaction keeps seeing the row as it was.&lt;/p&gt;

&lt;p&gt;One consequence worth internalizing: &lt;strong&gt;updating a single field copies the whole row.&lt;/strong&gt; &lt;code&gt;UPDATE users SET name = 'Bob' WHERE id = 1&lt;/code&gt; copies &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;bio&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt; — everything — into a fresh tuple, even though only &lt;code&gt;name&lt;/code&gt; changed. That's why updating a row with a big text column is expensive, and why a one-byte change still leaves behind a full-size dead tuple. &lt;em&gt;(There are mitigations — very large values live out-of-line in TOAST and can be shared; the HOT optimization skips extra index work when no indexed column changed — but the mental model holds: an update writes a whole new row.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reads create nothing
&lt;/h2&gt;

&lt;p&gt;If writes pile up versions, what about reads? This part is critical:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A &lt;code&gt;SELECT&lt;/code&gt; creates nothing. No copy, no version, no new row. Ever.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Versions are born &lt;em&gt;only&lt;/em&gt; from &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; — only when data actually changes. Ten transactions reading the same row produce &lt;strong&gt;zero&lt;/strong&gt; new copies. All ten look at the &lt;em&gt;same&lt;/em&gt; single physical tuple. It's a pointer situation — "everyone reads the same page of the same book" — not "everyone gets a photocopy."&lt;/p&gt;

&lt;p&gt;There's one nuance. That tuple lives on disk in an 8 KB page. To read it, Postgres loads that page into RAM (the &lt;strong&gt;shared buffer cache&lt;/strong&gt;) &lt;em&gt;once&lt;/em&gt;, and every reader shares that one cached copy. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Copies created by reading: &lt;strong&gt;zero.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Times the page is loaded from disk into RAM: &lt;strong&gt;once&lt;/strong&gt;, then shared by everyone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The takeaway: &lt;strong&gt;the memory and storage cost of MVCC comes entirely from the write side (dead versions), never from the read side.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That cache deserves more than a passing mention, though. It is where every read your database serves actually happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a read actually happens: the shared buffer cache
&lt;/h2&gt;

&lt;p&gt;Notice what hasn't appeared yet in this post: a disk read. Not for the tuple, not for the visibility check, not for the &lt;code&gt;SELECT&lt;/code&gt;. Every read PostgreSQL serves comes out of memory, and the shared buffer cache is the machinery that makes that true.&lt;/p&gt;

&lt;h3&gt;
  
  
  Postgres reads pages, not rows
&lt;/h3&gt;

&lt;p&gt;Start with the unit of work. There is no "fetch me one row" operation anywhere in the storage layer. The smallest thing Postgres will move is an &lt;strong&gt;8 KB page&lt;/strong&gt; — the same page from the &lt;a href="https://dev.to/jishnusaha89/how-does-a-database-actually-store-your-data-17f1"&gt;previous post&lt;/a&gt;. Asking for a single 60-byte tuple pulls in the entire 8 KB block that happens to contain it.&lt;/p&gt;

&lt;p&gt;That sounds wasteful, and it isn't. Storage devices and the operating system beneath them are built to move blocks, not bytes — fetching 8 KB costs very nearly what fetching 60 bytes would. And the neighbours that came along for free are usually the next thing you want, including &lt;strong&gt;other versions of the row you just read&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  One cache, shared by every connection
&lt;/h3&gt;

&lt;p&gt;So where does that page land? In the &lt;strong&gt;shared buffer cache&lt;/strong&gt;: one block of shared memory that Postgres carves out at startup and divides into slots exactly one page wide. A single slot is a &lt;strong&gt;buffer&lt;/strong&gt;, and how many exist is set by &lt;code&gt;shared_buffers&lt;/code&gt; — which ships deliberately small (128 MB) and is usually the first setting raised on a real server.&lt;/p&gt;

&lt;p&gt;The word &lt;em&gt;shared&lt;/em&gt; is doing literal work. Postgres runs &lt;strong&gt;one operating-system process per connection&lt;/strong&gt;, and those processes do not each keep a private cache. The buffer pool lives in memory mapped into all of them at once — a hundred connections, one pool.&lt;/p&gt;

&lt;p&gt;Beside it sits the &lt;strong&gt;buffer table&lt;/strong&gt;: a hash table whose key is &lt;em&gt;"which file, which block number"&lt;/em&gt; and whose value is &lt;em&gt;"which slot is holding it."&lt;/em&gt; That is the index that turns "I need block 5 of &lt;code&gt;accounts&lt;/code&gt;" into a memory address.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F59w6jtfckbp3yb59yf4o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F59w6jtfckbp3yb59yf4o.png" alt="shared-buffer-cache" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Two paths, and one of them is very fast
&lt;/h3&gt;

&lt;p&gt;When a query needs block 5 of &lt;code&gt;accounts&lt;/code&gt;, it hands the request to the buffer manager, and exactly one of two things happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache hit.&lt;/strong&gt; The buffer table already has an entry for that block. The backend bumps the buffer's &lt;strong&gt;pin&lt;/strong&gt; count — &lt;em&gt;"I'm using this, don't take it away"&lt;/em&gt; — and reads the bytes where they sit. No system call, no I/O, nothing. This is the path a healthy database is on the overwhelming majority of the time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache miss.&lt;/strong&gt; No entry for that block, so it has to be read in — and it needs a slot to be read &lt;em&gt;into&lt;/em&gt;. Here the &lt;em&gt;fixed&lt;/em&gt; part of &lt;code&gt;shared_buffers&lt;/code&gt; starts to matter: the number of slots is decided at startup and never changes. Postgres does not go ask the operating system for more memory when it wants to cache another page.&lt;/p&gt;

&lt;p&gt;So "isn't there free memory somewhere?" has two answers, depending on how long the server has been up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Just after startup, yes.&lt;/strong&gt; Slots nobody has used yet sit on a &lt;strong&gt;free list&lt;/strong&gt;, and the buffer manager simply takes one. That state lasts about as long as it takes your queries to touch &lt;code&gt;shared_buffers&lt;/code&gt; worth of data — often a few minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From then on, no.&lt;/strong&gt; Every slot holds some page. "Find a slot" now means "take one away from the page currently sitting in it." That is &lt;strong&gt;eviction&lt;/strong&gt;, and the page picked to be thrown out is called the &lt;strong&gt;victim&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choosing the victim is the &lt;strong&gt;clock sweep&lt;/strong&gt;. The buffer manager walks the pool in a circle, and each buffer it passes either has been used since the last time around — in which case its usage counter is decremented and it survives this pass — or has a counter already at zero, in which case it's the victim. It's a cheap approximation of "least recently useful" that costs nothing on the hit path.&lt;/p&gt;

&lt;p&gt;One step remains before the slot can be reused. If the victim is &lt;strong&gt;clean&lt;/strong&gt;, disk already holds an identical copy, so it can simply be overwritten and forgotten. If it's &lt;strong&gt;dirty&lt;/strong&gt; — carrying changes not yet written out — that page must be written to disk first, and the incoming read waits on it. Only then does the 8 KB read happen, the buffer table gets its new entry, and the page is finally in memory.&lt;/p&gt;

&lt;p&gt;That &lt;strong&gt;pin&lt;/strong&gt; from the hit path is worth separating from the locks this post has been discussing, because the two aren't the same kind of thing at all. They guard different things and answer different questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;What it guards&lt;/th&gt;
&lt;th&gt;The question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lock&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the row&lt;/td&gt;
&lt;td&gt;may I read or change this data?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the slot&lt;/td&gt;
&lt;td&gt;may the buffer manager reuse this memory?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A lock is concurrency control — it's about your data, and MVCC's whole achievement is that readers need almost none of them. A pin is memory management, and it excludes nobody: it's a counter, so ten backends can pin the same buffer at once, and the pin itself stops none of them from reading the tuples inside. All it tells the clock sweep is &lt;em&gt;this slot is in use, don't hand it to another page right now.&lt;/em&gt; Pins are held for as long as a scan is looking at a page and dropped immediately after.&lt;/p&gt;

&lt;p&gt;Think of a library. A lock decides who may write in the book. A pin only means the book is open on someone's desk, so the librarian mustn't reshelve it yet. (Stopping two people scribbling on the same page at the same moment is a third mechanism again — a very short-lived internal lock on the page's contents.)&lt;/p&gt;

&lt;p&gt;Three consequences worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;strong&gt;every&lt;/strong&gt; slot is pinned, the sweep gives up after a full lap and the query fails outright with &lt;code&gt;no unpinned buffers available&lt;/code&gt; — it never waits for one. That takes a pathologically small pool: pins last moments, so a real server always has unpinned slots to spare.&lt;/li&gt;
&lt;li&gt;A giant sequential scan does &lt;strong&gt;not&lt;/strong&gt; get to flush your cache. Postgres confines large scans to a small &lt;strong&gt;ring buffer&lt;/strong&gt; — a few hundred KB that it recycles — so one nightly report can't evict the pages your live traffic depends on.&lt;/li&gt;
&lt;li&gt;Underneath the buffer cache sits the operating system's own page cache. Even a "miss" is often served from RAM rather than from an actual device.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Writes go through the same door
&lt;/h3&gt;

&lt;p&gt;One loop worth closing, because it's tempting to picture an &lt;code&gt;UPDATE&lt;/code&gt; as "a write to disk." It isn't — not at the moment you run it.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;UPDATE&lt;/code&gt; reaches its page through the buffer cache exactly like a read does. It stamps the old tuple's &lt;code&gt;xmax&lt;/code&gt;, writes the new tuple into free space on that same page if it fits, and marks the buffer &lt;strong&gt;dirty&lt;/strong&gt;. The page itself is written out later, on a background process's schedule. What makes your commit durable in the meantime isn't that page write at all — it's the &lt;strong&gt;WAL&lt;/strong&gt; (write-ahead log), a compact sequential record of the change that is flushed before the transaction is told it succeeded. The heap page catches up in its own time.&lt;/p&gt;

&lt;p&gt;So a hot row can be read and updated thousands of times while its page never leaves memory — each commit sends a compact WAL record to disk, not eight kilobytes of page.&lt;/p&gt;

&lt;h3&gt;
  
  
  One copy, many answers
&lt;/h3&gt;

&lt;p&gt;Now the part that earns this section its place in an MVCC post.&lt;/p&gt;

&lt;p&gt;Ten transactions reading the same row hold ten pins on &lt;strong&gt;the same buffer&lt;/strong&gt;. Not ten copies — ten pointers into one set of bytes, and those bytes contain every version of that row that hasn't been cleaned up yet. Each of those transactions then applies &lt;strong&gt;its own snapshot&lt;/strong&gt; to the identical bytes, and they can legitimately reach different conclusions about which version they're allowed to see.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7z8bha9tdar37zhki9i0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7z8bha9tdar37zhki9i0.png" alt="one-page-many-readers" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The bytes are shared. The interpretation is per-transaction.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the whole trick, and it's why "readers never block writers" costs so little memory. There is no private copy to make and no per-transaction view to materialize — just one page in RAM holding all the versions, and a handful of numbers per transaction deciding which of them counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snapshots: what a transaction actually carries
&lt;/h2&gt;

&lt;p&gt;So the table is a jumble of versions — some alive, some dead, some created by transactions that haven't even committed yet. When your query reads a row and finds several versions of it, how does it pick the right one?&lt;/p&gt;

&lt;p&gt;It uses a &lt;strong&gt;snapshot&lt;/strong&gt;: the note you jotted down the instant your transaction began. And here's the beautiful part — &lt;strong&gt;a snapshot copies nothing.&lt;/strong&gt; It's essentially &lt;strong&gt;three numbers&lt;/strong&gt; capturing "who had finished at the moment I started":&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frg4er3pcc73mr10ikzk8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frg4er3pcc73mr10ikzk8.png" alt="snapshot" width="799" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;code&gt;xmin&lt;/code&gt;&lt;/strong&gt; (of snapshot)&lt;/td&gt;
&lt;td&gt;Oldest transaction still running when I started. Anything older is definitely finished.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;code&gt;xmax&lt;/code&gt;&lt;/strong&gt; (of snapshot)&lt;/td&gt;
&lt;td&gt;Next XID not yet handed out. Anything ≥ this hadn't started → can't be visible to me.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;in-progress list&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The exact XIDs that were &lt;em&gt;running&lt;/em&gt; at my start moment.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A snapshot for a 5-row table and a 5-billion-row table is the &lt;strong&gt;same size&lt;/strong&gt; — it doesn't scale with your data, because it's just three values. A billion transactions could fly by; your snapshot is still those three numbers, applied &lt;em&gt;lazily&lt;/em&gt; to each row your query actually touches, at the moment it touches it.&lt;/p&gt;

&lt;p&gt;To do the check, Postgres also consults the &lt;strong&gt;commit log&lt;/strong&gt; (&lt;code&gt;pg_xact&lt;/code&gt;) — a record of, for every XID, whether it committed, aborted, or is still in progress. And to avoid re-checking, it caches the answer right on the tuple as &lt;strong&gt;hint bits&lt;/strong&gt; the first time someone looks.&lt;/p&gt;

&lt;p&gt;That last detail has a surprising consequence, now that we know where a tuple sits while it's being read: the hint bit is written onto the page &lt;strong&gt;in the shared buffer cache&lt;/strong&gt;, which marks that page dirty and eventually sends it to disk. So a plain &lt;code&gt;SELECT&lt;/code&gt; really can cause writes. It still creates no new version — the same tuple just got a note stapled to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visibility rule
&lt;/h2&gt;

&lt;p&gt;Now the payoff. A tuple version is &lt;strong&gt;visible to your snapshot&lt;/strong&gt; when &lt;strong&gt;both&lt;/strong&gt; of these hold:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Its &lt;strong&gt;&lt;code&gt;xmin&lt;/code&gt;&lt;/strong&gt; (creator) committed before your snapshot — &lt;em&gt;or&lt;/em&gt; the creator is your own current transaction. &lt;strong&gt;AND&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Its &lt;strong&gt;&lt;code&gt;xmax&lt;/code&gt;&lt;/strong&gt; (deleter) is &lt;code&gt;0&lt;/code&gt;, &lt;em&gt;or&lt;/em&gt; belongs to a transaction that had &lt;strong&gt;not&lt;/strong&gt; committed as of your snapshot (or aborted). In other words: it hasn't been killed by anyone who already finished.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Said plainly: a version is visible if &lt;em&gt;"its creator has committed (or it's me)"&lt;/em&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;em&gt;"it hasn't been deleted by anyone who's already committed"&lt;/em&gt; — all relative to your frozen snapshot.&lt;/p&gt;

&lt;p&gt;Let's run it. Take a row &lt;code&gt;id = 1&lt;/code&gt; with three versions on disk. Your snapshot was taken at a moment when &lt;strong&gt;transaction 90 has committed&lt;/strong&gt; and &lt;strong&gt;transaction 150 has started but not yet committed&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00g9nv8sino8ztsztpx3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00g9nv8sino8ztsztpx3.png" alt="visibility-check" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;v0&lt;/code&gt; (born 80, died 90) — NOT visible.&lt;/strong&gt; Its creator 80 committed, fine. But it was killed by 90, and 90 committed &lt;em&gt;before&lt;/em&gt; your snapshot. Rule 2 fails → it's gone for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;v1&lt;/code&gt; (born 90, died 150) — VISIBLE. ✅&lt;/strong&gt; Creator 90 committed (rule 1 ✓). Its killer is 150, which is still in-progress — so from your snapshot's point of view, that death &lt;em&gt;hasn't happened yet&lt;/em&gt; (rule 2 ✓). Still alive → visible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;v2&lt;/code&gt; (born 150) — NOT visible.&lt;/strong&gt; Created by 150, which hadn't committed when your snapshot was taken. Rule 1 fails → this version &lt;em&gt;doesn't exist yet&lt;/em&gt; from your point of view.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three physical versions, and exactly &lt;strong&gt;one&lt;/strong&gt; passes the lens. That's not luck. A row's versions form a &lt;strong&gt;chain&lt;/strong&gt; where each version's &lt;code&gt;xmax&lt;/code&gt; equals the next version's &lt;code&gt;xmin&lt;/code&gt; — the death of the old &lt;em&gt;is&lt;/em&gt; the birth of the new, a single atomic event by a single transaction. Committed transactions cleanly slice up the timeline, your snapshot draws one line across it, and exactly one version straddles that line.&lt;/p&gt;

&lt;p&gt;So the right way to think about it isn't "which version will my query grab?" It's: &lt;strong&gt;the snapshot is a fixed lens — three numbers — carried across the whole query, and for each row independently, exactly one version (or none) passes through it.&lt;/strong&gt; The same three numbers apply to every row you touch, whether you scan one row or a million.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same lens, applied to a whole table
&lt;/h3&gt;

&lt;p&gt;That single-row walk-through can make it look like the snapshot works one row at a time. It doesn't — it's &lt;strong&gt;one set of three numbers applied to every version your query touches.&lt;/strong&gt; So let's scale it up. Here's a whole heap on disk: five different &lt;code&gt;id&lt;/code&gt;s, seven physical tuples all jumbled together — some alive, some dead, some not yet born. We run a plain &lt;code&gt;SELECT id, value FROM accounts&lt;/code&gt; with no &lt;code&gt;WHERE&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;Same style of snapshot as before: transactions &lt;strong&gt;{60, 70, 80, 90} have committed&lt;/strong&gt;, and &lt;strong&gt;{100, 130, 150} are still running&lt;/strong&gt; (nothing numbered 151 or higher has started yet).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4nf7u1zmcpmnu1lr5pb7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4nf7u1zmcpmnu1lr5pb7.png" alt="visibility-many-rows" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Apply those exact same three numbers to every version, one row at a time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id = 1&lt;/code&gt;&lt;/strong&gt; has three versions. &lt;code&gt;(0,1)&lt;/code&gt; was killed by 90, which committed → dead. &lt;code&gt;(0,2)&lt;/code&gt; was born by 90 (committed) and killed by 150 (still running, so that death doesn't count yet) → &lt;strong&gt;visible: "A-mid."&lt;/strong&gt; &lt;code&gt;(0,3)&lt;/code&gt; was born by 150, still running → doesn't exist yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id = 2&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;(0,4)&lt;/code&gt; born by 70 (committed), never deleted → &lt;strong&gt;visible: "B."&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id = 3&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;(0,5)&lt;/code&gt; born by 100, still running → invisible, so this row doesn't appear at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id = 4&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;(0,6)&lt;/code&gt; born by 60 (committed), killed by 130 (still running — that death doesn't count) → &lt;strong&gt;visible: "D."&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id = 5&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;(0,7)&lt;/code&gt; born by 150, still running → invisible, so this row vanishes too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seven physical tuples collapse into a clean, consistent &lt;strong&gt;three-row&lt;/strong&gt; answer. Two of the five &lt;code&gt;id&lt;/code&gt;s disappear completely — simply because their only version isn't visible to this snapshot yet. That's the whole payoff: one tiny snapshot, applied uniformly, hands every transaction a coherent point-in-time view of the entire table — no locks, no coordination, no matter how much churn is happening around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one catch
&lt;/h2&gt;

&lt;p&gt;MVCC's gift — no locks between readers and writers — isn't free. Every update leaves a dead version behind, and on a heavily-updated table those pile up and quietly grow the table's file on disk — crowding live rows out of the buffer cache on the way. Reclaiming that space so it can be reused is a whole topic on its own, which we'll pick up in a later post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing it all together
&lt;/h2&gt;

&lt;p&gt;Here's the whole picture, step by step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The naive way to stay consistent under concurrency is &lt;strong&gt;locking&lt;/strong&gt;, which makes readers and writers wait on each other. &lt;strong&gt;MVCC&lt;/strong&gt; avoids that by keeping &lt;strong&gt;multiple versions&lt;/strong&gt; of every row.&lt;/li&gt;
&lt;li&gt;A single version is a &lt;strong&gt;tuple&lt;/strong&gt;, carrying hidden columns: &lt;strong&gt;&lt;code&gt;xmin&lt;/code&gt;&lt;/strong&gt; (born from which transaction), &lt;strong&gt;&lt;code&gt;xmax&lt;/code&gt;&lt;/strong&gt; (killed by which transaction, &lt;code&gt;0&lt;/code&gt; if alive), and &lt;strong&gt;&lt;code&gt;ctid&lt;/code&gt;&lt;/strong&gt; (its physical &lt;code&gt;(page, slot)&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Physically, a tuple is &lt;strong&gt;one contiguous record&lt;/strong&gt; — a ~23-byte header holding &lt;code&gt;xmin&lt;/code&gt;/&lt;code&gt;xmax&lt;/code&gt; followed straight by your own columns, so the visibility check needs no second lookup. &lt;strong&gt;&lt;code&gt;ctid&lt;/code&gt; is the exception: an address, not a stored field.&lt;/strong&gt; Its slot number indexes the page's &lt;strong&gt;line-pointer array&lt;/strong&gt;, and that indirection is what lets tuples shift within a page without breaking anything that points at them.&lt;/li&gt;
&lt;li&gt;Postgres &lt;strong&gt;never updates in place&lt;/strong&gt;: an &lt;code&gt;UPDATE&lt;/code&gt; stamps the old tuple's &lt;code&gt;xmax&lt;/code&gt; and appends a brand-new tuple. A &lt;code&gt;DELETE&lt;/code&gt; is the same, minus the insert. Reads create nothing at all.&lt;/li&gt;
&lt;li&gt;Nothing is read from disk row by row. Whole &lt;strong&gt;8 KB pages&lt;/strong&gt; are pulled into the &lt;strong&gt;shared buffer cache&lt;/strong&gt; — one pool of memory that every connection shares — and all readers of a row work from that single cached copy. The bytes are shared; each transaction's snapshot decides what they mean.&lt;/li&gt;
&lt;li&gt;Each transaction carries a tiny &lt;strong&gt;snapshot&lt;/strong&gt; — three numbers describing who had finished when it started — and applies the &lt;strong&gt;visibility rule&lt;/strong&gt; to decide which single version of each row it's allowed to see.&lt;/li&gt;
&lt;li&gt;All those extra versions come at a cost — dead versions accumulate — and reclaiming that space is a story for a later post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is the promise we started with: on a busy table, your &lt;code&gt;SELECT&lt;/code&gt; and someone else's &lt;code&gt;UPDATE&lt;/code&gt; sail past each other, each looking at exactly the version that's true for its own moment in time — no locks, no waiting, no traffic jam.&lt;/p&gt;




</description>
      <category>database</category>
      <category>postgres</category>
      <category>concurrency</category>
      <category>jishnusaha</category>
    </item>
    <item>
      <title>What Is RAG, and Why Does Your LLM Need It?</title>
      <dc:creator>Jishnu Saha</dc:creator>
      <pubDate>Mon, 07 Sep 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/jishnusaha89/what-is-rag-and-why-does-your-llm-need-it-24dg</link>
      <guid>https://dev.to/jishnusaha89/what-is-rag-and-why-does-your-llm-need-it-24dg</guid>
      <description>&lt;p&gt;Ask an LLM about your company's refund policy and it will answer.&lt;/p&gt;

&lt;p&gt;Fluently. Confidently. And often wrongly — because it has never read your refund policy, and nothing in it can tell you that.&lt;/p&gt;

&lt;p&gt;RAG is the standard fix for that gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG stands for Retrieval-Augmented Generation&lt;/strong&gt;, which sounds heavier than it is. It isn't a model, a framework, or a product you install. It's one idea: before you ask the LLM a question, go and find the few paragraphs that actually answer it, and hand those paragraphs with the question to the LLM.&lt;/p&gt;

&lt;p&gt;That's the whole concept. Everything after this is detail about how you find those few paragraphs — and that detail is what decides whether the answers are any good.&lt;/p&gt;

&lt;p&gt;Here's the shape of the finished system. It'll make sense by the end of the post; there's nothing you need to work out from it now.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fenv6q4i7whwtcbmpv375.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fenv6q4i7whwtcbmpv375.png" alt="The two halves of a RAG system: an indexing pipeline that writes to a vector database, and an answering pipeline that reads from it" width="800" height="535"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We'll get there slowly. First, why the two obvious alternatives don't work. Then the pipeline one step at a time, from a PDF sitting on disk to a grounded answer coming back to a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a model that has never seen your data
&lt;/h2&gt;

&lt;p&gt;An LLM does exactly one thing: it predicts the next token, over and over. Everything it "knows" is a statistical residue of the text it was trained on. That gives it three specific blind spots.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Anything after its training cutoff.&lt;/strong&gt; It doesn't know what shipped last week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything private.&lt;/strong&gt; Your internal wiki, your policy handbook, this particular user's transaction history — none of it was in the training set, and none of it should be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fact that it doesn't know.&lt;/strong&gt; This is the one that hurts. A bare model can't check whether it actually knows something. There is no part of it that stops and says "I'm not sure about this one." So when a question arrives that it can't answer, it does what it always does: predicts the most plausible-sounding next token. The result is a &lt;strong&gt;hallucination&lt;/strong&gt; — a confident, well-formed, completely invented answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Say you're building a support assistant for a bank, and a customer asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does the platinum card waive the annual fee?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A bare LLM will happily produce an answer. It will sound authoritative. It may even be right, by luck. But nothing in the system connects that sentence to your actual fee schedule, and nobody — including the model — can tell the difference between the lucky case and the invented one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6xtfcu0e04vyf2wbx2v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6xtfcu0e04vyf2wbx2v.png" alt="The same question answered two ways: an LLM alone produces a confident guess, while an LLM given the retrieved policy passage produces a grounded, citable answer" width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two fixes that seem obvious, and why they fall short
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "Fine-tune the model on our documents"
&lt;/h3&gt;

&lt;p&gt;Fine-tuning continues training the model on your data, adjusting its weights. It's a real technique, but it's the wrong tool for this job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's slow and expensive&lt;/strong&gt;, and you have to redo it every time a document changes. Your policy handbook gets edited on a Tuesday afternoon; you are not retraining a model that afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It teaches behaviour better than it teaches facts.&lt;/strong&gt; Fine-tuning is excellent at "always answer in this tone, in this format, following these conventions." Getting a specific number to reliably come back out is a much shakier proposition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There are no sources.&lt;/strong&gt; Once a fact is baked into the weights, it's mixed in with everything else. You can't show the user where the answer came from, and you can't audit it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't take it back.&lt;/strong&gt; If a document is deleted, or a customer revokes consent, or one team's data should never be visible to another team — weights don't support deletion or access control.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  "Just paste all our documents into the prompt"
&lt;/h3&gt;

&lt;p&gt;More tempting, because context windows keep getting bigger. But the context window is a &lt;strong&gt;shared, finite budget&lt;/strong&gt;, and everything competes for it: your system prompt, the chat history so far, whatever documents you've pasted in, the user's message — &lt;em&gt;and the tokens the model still has to generate.&lt;/em&gt; The model's own answer is counted in the same window.&lt;/p&gt;

&lt;p&gt;Even setting the ceiling aside:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost and latency scale with tokens.&lt;/strong&gt; You pay for every token you send, on every single request, whether or not it was relevant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attention isn't free.&lt;/strong&gt; Transformer self-attention cost grows roughly with the square of the input length. A very long prompt isn't just more expensive — it's slower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Models lose things in the middle.&lt;/strong&gt; Give a model a huge block of text and it pays most attention to the beginning and the end. Facts buried in the middle get quietly under-weighted. This is common enough to have a name: the &lt;strong&gt;lost-in-the-middle&lt;/strong&gt; problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Almost all of it is noise.&lt;/strong&gt; For any one question, 99.9% of your documentation is irrelevant. You'd be paying — in money, latency, and attention — to show the model 500 pages so it can use one paragraph.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And there's the insight the whole pattern is built on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You don't need to give the model all of your documents. You need to give it the three paragraphs that answer &lt;em&gt;this&lt;/em&gt; question.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which reframes the problem entirely. "Find the few relevant passages in a big pile of text" isn't a language-model problem at all. It's a &lt;strong&gt;search&lt;/strong&gt; problem. RAG is what you get when you put a search engine in front of the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What RAG actually is
&lt;/h2&gt;

&lt;p&gt;Three steps, which is where the name comes from:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;R&lt;/strong&gt;etrieve&lt;/td&gt;
&lt;td&gt;Search your own data for the passages most relevant to this question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A&lt;/strong&gt;ugment&lt;/td&gt;
&lt;td&gt;Paste those passages into the prompt, alongside the question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;G&lt;/strong&gt;enerate&lt;/td&gt;
&lt;td&gt;Let the model answer &lt;em&gt;from the passages in front of it&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The useful way to think about it: RAG changes the model's job from &lt;strong&gt;"recall a fact"&lt;/strong&gt; to &lt;strong&gt;"read this passage and answer the question."&lt;/strong&gt; The first is unreliable and unverifiable. The second is something language models are genuinely excellent at.&lt;/p&gt;

&lt;p&gt;It's the difference between a closed-book and an open-book exam. You're not asking the student to have memorized the textbook. You're handing them the right page.&lt;/p&gt;

&lt;p&gt;Notice what falls out of that for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The answer is &lt;strong&gt;grounded&lt;/strong&gt; — you can cite the source, because you know exactly which chunks you passed in.&lt;/li&gt;
&lt;li&gt;Updating knowledge means &lt;strong&gt;updating a document&lt;/strong&gt;, not retraining a model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control is possible&lt;/strong&gt;, because retrieval is a query you can filter.&lt;/li&gt;
&lt;li&gt;"I don't know" becomes achievable: if retrieval comes back with nothing relevant, you can instruct the model to say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two halves of a RAG system
&lt;/h2&gt;

&lt;p&gt;Every RAG system is really two pipelines that meet at a database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Indexing&lt;/strong&gt; runs &lt;em&gt;offline&lt;/em&gt;. You do it once, ahead of time, and then again whenever your documents change. It turns a pile of documents into something searchable by meaning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answering&lt;/strong&gt; runs &lt;em&gt;online&lt;/em&gt;, on every single question. It searches that index, builds a prompt, and calls the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;People often conflate the two and end up confused about why some step exists. Keep them separate in your head. The rest of this post walks each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1 — Indexing: making documents searchable
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Extract the text
&lt;/h3&gt;

&lt;p&gt;PDFs, Word documents, HTML pages, Notion exports, database rows — whatever the source, you first need plain text out of it, ideally with some structure preserved (headings, page numbers, section titles). Hold onto that structure. You'll want it later, both for chunking and for citations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Chunk it
&lt;/h3&gt;

&lt;p&gt;You don't index whole documents. You split them into small pieces called &lt;strong&gt;chunks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One term first, since the next line leans on it: an &lt;strong&gt;embedding model&lt;/strong&gt; reads a piece of text and turns it into a list of numbers that captures what the text means. Those numbers — not the words themselves — are what you search. Step 3 covers it properly; for now, that's all you need.&lt;/p&gt;

&lt;p&gt;Two reasons to chunk:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Embedding models have token limits.&lt;/strong&gt; Like an LLM, they can only take in so much text at once. You can't hand a 200-page manual to one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval works much better on small, focused pieces.&lt;/strong&gt; A chunk about one topic produces a clean signal. A chunk covering nine topics is muddy — it's vaguely similar to everything and strongly similar to nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The naive approach is to cut every N tokens, and it goes wrong immediately, because meaning doesn't respect token counts:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Chunk 1:&lt;/strong&gt; …Premium customers can waive the annual&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunk 2:&lt;/strong&gt; credit card fee if they maintain…&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Neither chunk states the rule. Whichever one gets retrieved produces a wrong or useless answer. Good chunking is both &lt;strong&gt;semantic-aware&lt;/strong&gt; (split on natural boundaries — headings, sections, paragraphs, FAQ entries) and &lt;strong&gt;token-aware&lt;/strong&gt; (respect the embedding model's limit).&lt;/p&gt;

&lt;p&gt;And you add &lt;strong&gt;overlap&lt;/strong&gt;: consecutive chunks share a small region at the seam.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vh562dfci6kaob0acl8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vh562dfci6kaob0acl8.png" alt="Chunking without overlap severs a sentence across the boundary, while chunking with overlap lets the second chunk carry the whole sentence" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With chunk 1 covering tokens 0–500 and chunk 2 covering 450–950, chunk 2 starts &lt;em&gt;before&lt;/em&gt; chunk 1 ended. The sentence that got cut in half now appears whole inside chunk 2. The idea survives the boundary.&lt;/p&gt;

&lt;p&gt;A reasonable starting point:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Typical value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chunk size&lt;/td&gt;
&lt;td&gt;200–500 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overlap&lt;/td&gt;
&lt;td&gt;50–100 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both directions have a failure mode. Chunks that are too large give you noisy retrieval and expensive prompts. Chunks that are too small lose the surrounding context that made them meaningful. Between those, the sweet spot depends on your documents — this is one of the highest-leverage things to tune, and it gets &lt;a href="https://dev.to/blog/rag/how-text-splitter-chunks-documents"&gt;a post of its own&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Turn each chunk into an embedding
&lt;/h3&gt;

&lt;p&gt;Here's the piece that makes semantic search possible.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;embedding&lt;/strong&gt; is a list of numbers — a vector — that represents the &lt;em&gt;meaning&lt;/em&gt; of a piece of text. &lt;strong&gt;Text with similar meaning gets similar numbers.&lt;/strong&gt; That's the entire trick.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;puppy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="mf"&gt;0.14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mf"&gt;0.82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# very close to "puppy"
&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.91&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mf"&gt;0.74&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# nowhere near either
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once meaning is expressed as coordinates, "how related are these two pieces of text?" becomes plain geometry: measure the angle between the two vectors. That measurement is &lt;strong&gt;cosine similarity&lt;/strong&gt; — a small angle means closely related, a wide angle means unrelated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyy27p1u3stntr83r6hxv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyy27p1u3stntr83r6hxv.png" alt="An embedding model converts text into a vector; related words land near each other in the vector space, and similarity is the angle between vectors" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;embedding model&lt;/strong&gt; is a different kind of model from the LLM you're chatting with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding model&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;a vector of numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Real embeddings typically have 768 to 3072 dimensions, not the three shown above. And like LLMs, embedding models have their own token limits — another reason chunks have to be small.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Store the vectors, with their metadata
&lt;/h3&gt;

&lt;p&gt;Each chunk goes into a &lt;strong&gt;vector database&lt;/strong&gt; as three things bundled together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;vector&lt;/strong&gt; — what you search by&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;original text&lt;/strong&gt; — what you'll actually put in the prompt&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;metadata&lt;/strong&gt; — everything else you know about it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That metadata is not an afterthought. Typical fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;document_id&lt;/span&gt;     &lt;span class="c1"&gt;# who is allowed to see this
&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;              &lt;span class="c1"&gt;# what it is
&lt;/span&gt;&lt;span class="n"&gt;page_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_index&lt;/span&gt;            &lt;span class="c1"&gt;# where it came from, for citations
&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;              &lt;span class="c1"&gt;# how fresh it is
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the storage layer itself you have options, and the right one usually comes down to what you're already running:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pgvector&lt;/strong&gt; — a Postgres extension. If your app is already on Postgres, start here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinecone, Weaviate, Qdrant, Milvus&lt;/strong&gt; — purpose-built vector databases, usually with hybrid search and filtering built in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FAISS&lt;/strong&gt; — Meta's library. In-memory and self-hosted; no server or persistence of its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And that's the whole indexing pipeline. Note that all of it happens &lt;em&gt;before&lt;/em&gt; any user asks anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2 — Answering: what happens on every question
&lt;/h2&gt;

&lt;p&gt;Now a question arrives. Here's what runs, in order.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ful750d2qehonjeqfg5rg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ful750d2qehonjeqfg5rg.png" alt="The query pipeline: rewrite the question, embed it, search with a metadata filter for twenty candidates, rerank down to the best four, and put those in the prompt" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Rewrite the query (optional, but it earns its keep)
&lt;/h3&gt;

&lt;p&gt;Real questions don't arrive on their own. They arrive partway through a conversation, and half their meaning is in the turns above them.&lt;/p&gt;

&lt;p&gt;Here's a support chat a few messages in:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User:&lt;/strong&gt; my new debit card arrived yesterday&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assistant:&lt;/strong&gt; Great — you can activate it in the app, under Cards → Activate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User:&lt;/strong&gt; done that. i'm at an ATM now, it's not my bank's&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assistant:&lt;/strong&gt; Thanks. What happens when you put the card in?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User:&lt;/strong&gt; my card is not working here&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Take that last message on its own and it barely says anything. Which card? Where is "here"? What does "not working" mean? Embed those six words and you'll search for something close to meaningless.&lt;/p&gt;

&lt;p&gt;Read the whole conversation, though, and nothing about it is vague. It's a debit card, at another bank's ATM, and it's being refused. The context was never missing — it just wasn't in the one sentence you were about to search with.&lt;/p&gt;

&lt;p&gt;So you make a quick call to a small, cheap model: &lt;em&gt;given this conversation, rewrite the user's last message as a standalone search query.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;debit card declined at another bank's ATM&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now retrieval has something to work with. Two details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The rewritten query is &lt;strong&gt;used for retrieval only&lt;/strong&gt;. It never goes into the prompt as the user's message.&lt;/li&gt;
&lt;li&gt;You &lt;strong&gt;store the original&lt;/strong&gt; in chat history. The user's own wording, tone, and phrasing stay intact.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: Embed the query
&lt;/h3&gt;

&lt;p&gt;Run the question through the exact same embedding model you used on your chunks. Every time.&lt;/p&gt;

&lt;p&gt;That "same model" part is the rule that matters more than any other in RAG. In a moment you're going to compare this one query vector against all your stored chunk vectors — and two vectors can only be compared if the same model produced them. Different embedding models arrange meaning differently, so the same sentence lands in a different spot.&lt;/p&gt;

&lt;p&gt;Get this wrong and nothing crashes. There's no error to catch. You just get back chunks that have nothing to do with the question, from a system that still looks like it's working. It's also why swapping embedding models later isn't a config change: you have to re-embed and re-index everything you've already stored.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Search — and filter first
&lt;/h3&gt;

&lt;p&gt;Now you find the nearest vectors. Two things are happening at once:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vector search&lt;/strong&gt; compares the query's vector against the stored chunk vectors and returns the top-k nearest — the passages closest in meaning. This is what people mean by &lt;em&gt;semantic search&lt;/em&gt;: matching on meaning rather than on keywords. Two texts can share no words at all and still be near-identical in vector space. (Semantic search is the concept; vector search is how it's implemented.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata filtering&lt;/strong&gt; constrains that search using the structured fields you stored alongside each vector. This is not optional polish — it does three jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security.&lt;/strong&gt; &lt;code&gt;WHERE user_id = :current_user&lt;/code&gt; is what stops one customer's documents from surfacing in another customer's answer. Pure similarity search has no notion of who is asking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Efficiency.&lt;/strong&gt; Shrinking the candidate set before the expensive similarity comparison makes the search faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relevance.&lt;/strong&gt; Similarity is not the same as correctness. A chunk can be semantically close and still be from the wrong document, the wrong product, or last year's version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4: Rerank
&lt;/h3&gt;

&lt;p&gt;Vector search is fast and approximate, so its top results might be good but noisy. A &lt;strong&gt;reranker&lt;/strong&gt; is a second, more precise model that reads the query and each candidate chunk &lt;em&gt;together&lt;/em&gt; and scores how well each one actually answers the question.&lt;/p&gt;

&lt;p&gt;The usual shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;retrieve&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;approximate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;rerank&lt;/span&gt; &lt;span class="nb"&gt;all&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slower&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;precise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;–&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're spending one extra cheap call to get fewer but better chunks. That pays for itself three ways: higher relevance, fewer tokens in the prompt, and less room for the model to be led astray by an irrelevant passage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Build the prompt
&lt;/h3&gt;

&lt;p&gt;Now assemble what you're actually sending. A typical RAG prompt has four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;System prompt&lt;/strong&gt; — the rules. This is where you make grounding explicit:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="n"&gt;You&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;banking&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt; &lt;span class="n"&gt;assistant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
   &lt;span class="n"&gt;Answer&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;provided&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
   &lt;span class="n"&gt;If&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="n"&gt;does&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;contain&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;say&lt;/span&gt; &lt;span class="n"&gt;you&lt;/span&gt; &lt;span class="n"&gt;do&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;know&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
   &lt;span class="n"&gt;Do&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;give&lt;/span&gt; &lt;span class="n"&gt;financial&lt;/span&gt; &lt;span class="n"&gt;advice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Chat history&lt;/strong&gt; — trimmed or summarized, so it doesn't grow without bound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieved chunks&lt;/strong&gt; — the passages you just fetched, clearly delimited so the model can tell them apart from the conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user's message&lt;/strong&gt; — the original one, not the rewritten one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And then the constraint that catches people out: &lt;strong&gt;you have to leave room for the answer.&lt;/strong&gt; The context window covers input &lt;em&gt;and&lt;/em&gt; output. If your model's window is 20,000 tokens and you want it to be able to write 3,000 tokens of answer, your input has to stop at 17,000.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzylkp0aj1t32m5s6kfbp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzylkp0aj1t32m5s6kfbp.png" alt="A context window shown as a shared budget: system prompt, chat history, retrieved chunks and user message all consume it, and space must be reserved for the model's answer" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The dangerous part is that chat history grows on its own. A conversation that fit comfortably on turn 3 can crowd out the answer entirely by turn 40. So you count tokens before you send, and when you're getting close, you trim: keep the last N messages verbatim and summarize everything older, drop to fewer retrieved chunks, or compress the chunks themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Generate
&lt;/h3&gt;

&lt;p&gt;Send it. The model reads the passages and answers from them.&lt;/p&gt;

&lt;p&gt;One thing worth being explicit about: &lt;strong&gt;retrieved chunks are not stored.&lt;/strong&gt; They exist for exactly one API call. The next message runs the whole retrieval pipeline again and gets whatever chunks that question needs. Chat history persists; retrieved context doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole thing, end to end
&lt;/h2&gt;

&lt;p&gt;Following one question all the way through:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;What it produces&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;User sends a message&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"my card is not working here"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Query rewrite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"debit card declined at another bank's ATM"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Embed the query&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[0.18, -0.22, 0.91, …]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Vector search + metadata filter&lt;/td&gt;
&lt;td&gt;20 candidate chunks, scoped to this user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Rerank&lt;/td&gt;
&lt;td&gt;the best 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Build the prompt&lt;/td&gt;
&lt;td&gt;system rules + history + 4 chunks + original message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Token budget check&lt;/td&gt;
&lt;td&gt;trim history if the input is crowding the answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;LLM call&lt;/td&gt;
&lt;td&gt;a grounded answer, with a source you can cite&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything from step 2 to step 8 typically runs in a couple of seconds, and steps 2 through 5 are just plumbing — ordinary code, ordinary database queries. That's most of what a RAG system &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What RAG fixes, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;It's worth being clear about the boundary, because RAG is often oversold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it fixes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Private and current knowledge, without touching the model.&lt;/li&gt;
&lt;li&gt;Citations, because you know exactly which chunks you passed in.&lt;/li&gt;
&lt;li&gt;Cheap updates — edit the document, re-index that document.&lt;/li&gt;
&lt;li&gt;Access control, via metadata filtering.&lt;/li&gt;
&lt;li&gt;Far fewer hallucinations, because the answer is anchored to text in front of the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it doesn't fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bad retrieval.&lt;/strong&gt; If the right chunk isn't in the top results, the model can't use it properly — and it will often answer anyway, from whatever it did get. This is the single most common source of bad RAG answers, and it's a retrieval bug wearing a hallucination costume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bad chunking.&lt;/strong&gt; A rule severed across a boundary is unretrievable no matter how good the rest of your stack is — and retrieval hands over whole chunks, so nothing downstream can put it back together. &lt;a href="https://dev.to/blog/rag/how-text-splitter-chunks-documents"&gt;Where the cuts go&lt;/a&gt; is decided once, at indexing time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long, unfocused context.&lt;/strong&gt; Stuff 30 chunks in and lost-in-the-middle comes back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ambiguous questions.&lt;/strong&gt; If nobody can tell what the user meant, retrieval can't either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model's eagerness to be helpful.&lt;/strong&gt; Models would rather answer than say "I don't know." Your system prompt has to explicitly authorize the second option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeping the index in sync.&lt;/strong&gt; When a source document changes, its chunks and embeddings are stale until you do something about it. Doing that efficiently — without re-embedding an entire corpus over a one-line edit — is a genuinely interesting problem, and a topic on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which leaves one rule worth writing on the wall:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;RAG quality is retrieval quality.&lt;/strong&gt; The generation step is rarely the bottleneck. When a RAG system gives a bad answer, the bug is almost always upstream of the LLM.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An LLM predicts tokens. It has never seen your data, and it can't tell you when it doesn't know something.&lt;/li&gt;
&lt;li&gt;Fine-tuning is the wrong tool for facts. Pasting everything into the prompt doesn't scale, and buries the signal in noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RAG retrieves the few relevant passages, augments the prompt with them, and lets the model generate from what's in front of it.&lt;/strong&gt; It turns "recall a fact" into "read this and answer."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexing&lt;/strong&gt; (offline): extract text → chunk with overlap → embed → store vectors with metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answering&lt;/strong&gt; (per question): rewrite → embed → filter and search → rerank → build the prompt within the token budget → generate.&lt;/li&gt;
&lt;li&gt;The same embedding model must be used on both sides.&lt;/li&gt;
&lt;li&gt;Metadata filtering is a security boundary, not just a performance tweak.&lt;/li&gt;
&lt;li&gt;Always reserve context-window space for the answer.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>ai</category>
      <category>jishnusaha</category>
    </item>
    <item>
      <title>How Does a Database Actually Store Your Data?</title>
      <dc:creator>Jishnu Saha</dc:creator>
      <pubDate>Thu, 20 Aug 2026 10:00:32 +0000</pubDate>
      <link>https://dev.to/jishnusaha89/how-does-a-database-actually-store-your-data-17f1</link>
      <guid>https://dev.to/jishnusaha89/how-does-a-database-actually-store-your-data-17f1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5p6n2x4q2ofcjpzv6uqa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5p6n2x4q2ofcjpzv6uqa.png" alt="sequantial-scan" width="800" height="702"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every backend engineer has used queries like &lt;code&gt;SELECT * FROM users WHERE id = 42&lt;/code&gt; a thousand times without thinking twice. It just works. But have you ever wondered what actually happens between hitting enter and getting your row back?&lt;/p&gt;

&lt;p&gt;It's easy to picture a table as a neat spreadsheet sitting on disk, and the database as something that just "goes and grabs the row." In reality, there are a few layers of structure in between. Once you understand them, you'll actually know &lt;em&gt;how&lt;/em&gt; data is stored on disk, &lt;em&gt;how&lt;/em&gt; the database reads from disk, and &lt;em&gt;why&lt;/em&gt; adding an index makes a query faster — instead of just knowing that it does.&lt;/p&gt;

&lt;p&gt;In this post, we'll build that picture step by step: starting from a simple limitation of disks, moving to how rows are packed together, how those packs are organized into files, and finally why a query with no index has to do so much extra work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: disks can't read one row at a time
&lt;/h2&gt;

&lt;p&gt;You'd think a database could just say &lt;strong&gt;&lt;em&gt;"give me row 42"&lt;/em&gt;&lt;/strong&gt; and the disk would hand over exactly those bytes. But that's not how disks (or SSDs) work.&lt;/p&gt;

&lt;p&gt;Disks and the operating system move data in fixed-size chunks called &lt;strong&gt;blocks&lt;/strong&gt; — often 4KB at a time. There's no way to read just one byte, or just one row. If your row lives anywhere inside a 4KB block, the database has to read that whole block, whether it needed 5 bytes or 4000.&lt;/p&gt;

&lt;p&gt;Because of this, every database defines its own storage unit that matches this reality. That unit is called a &lt;strong&gt;page&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A page is the smallest chunk of data a database reads or writes in one go.&lt;/li&gt;
&lt;li&gt;PostgreSQL &amp;amp; SQL Server use 8KB pages, MySQL/InnoDB uses 16KB.&lt;/li&gt;
&lt;li&gt;Almost everything a database does — caching, locking, saving to disk — works in terms of pages, not individual rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcqrfksdqflfzm93kx9xy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcqrfksdqflfzm93kx9xy.png" alt="disk-blocks-to-page" width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So here's the first idea to hold onto: &lt;strong&gt;a database never reads just one row from disk. It reads the whole page that row lives in, then picks the row out of that page.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How rows are packed inside a page
&lt;/h2&gt;

&lt;p&gt;A page holds many rows, but rows aren't all the same size. A &lt;code&gt;users&lt;/code&gt; row with a short name takes less space than one with a long bio. So how do you fit rows of different sizes neatly into a fixed 8KB box?&lt;/p&gt;

&lt;p&gt;Most databases solve this with something called a &lt;strong&gt;slotted page&lt;/strong&gt;. Think of a page as split into three parts:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hr9cu5raltgsynlrt1s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hr9cu5raltgsynlrt1s.png" alt="slotted-page-layout" width="800" height="553"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A header&lt;/strong&gt; at the top. It's just a small note that says how many rows are in this page and where the free space currently starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A slot array&lt;/strong&gt; right after the header. Each "slot" is a tiny pointer that says: &lt;em&gt;the row for this slot starts at this exact spot in the page, and is this many bytes long.&lt;/em&gt; The slot array grows downward as more rows are added.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The actual row data&lt;/strong&gt;, packed in from the bottom of the page and growing upward.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As if two people filling a room from opposite ends: the slot array fills in from the top, the row data fills in from the bottom, and the free space in the middle keeps shrinking as both sides grow.&lt;/p&gt;

&lt;p&gt;Why are we going through this trouble instead of just listing rows one after another? Because it gives every row a short, stable address: &lt;code&gt;(page number, slot number)&lt;/code&gt;. Postgres calls this a &lt;strong&gt;CTID&lt;/strong&gt;. Oracle calls it a &lt;strong&gt;ROWID&lt;/strong&gt;. Later, when we build an index, this is exactly the address the index stores — not the row itself, just this small pointer to jump in.&lt;/p&gt;

&lt;p&gt;This setup also explains something that surprises a lot of people: &lt;strong&gt;when you delete a row, it doesn't actually disappear from the disk right away. It stays on the disk but not every transaction can see it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is really just a glimpse of MVCC, which is a deep topic on its own — and it's exactly what the next post digs into: &lt;a href="https://dev.to/jishnusaha89/how-does-a-database-let-everyone-read-and-write-at-once-lil"&gt;How Does a Database Let Everyone Read and Write at Once?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One more simple rule: &lt;strong&gt;a page always belongs to exactly one table.&lt;/strong&gt; A page will never hold some &lt;code&gt;users&lt;/code&gt; rows mixed with some &lt;code&gt;orders&lt;/code&gt; rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does the database decide where a new row goes?
&lt;/h3&gt;

&lt;p&gt;When you insert a row, the database doesn't just store it onto the newest page. It checks a small lookup table called the &lt;strong&gt;free space map&lt;/strong&gt;, which tracks which pages still have room, and drops the row into the first page that fits. Only when no page has enough space to store a row that we are trying to insert then database create a brand-new page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi560bpyn6vpq7tejfkjt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi560bpyn6vpq7tejfkjt.png" alt="free-space-map-insert" width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This can leave small gaps or some unused space in the existing pages — that's normal, and it's called &lt;strong&gt;internal fragmentation&lt;/strong&gt;. A page might not have room for a big row, but that same leftover space works fine to insert a smaller row later.&lt;/p&gt;

&lt;p&gt;Databases also purposely leave a little extra empty space(10% - 20%, configurable) on each page, controlled by a setting called &lt;code&gt;fillfactor&lt;/code&gt;. This spare room is used for HOT updates. This is also a separate topic worth covering when we will learn database indexing.&lt;/p&gt;

&lt;p&gt;This is one reason a table's file on disk is usually bigger than the raw size of its data — you're seeing the data plus some intentional extra space in the disk.&lt;/p&gt;

&lt;p&gt;There's one exception worth knowing: what if a single value — like a huge block of text — is too big to fit on any page at all? In that case, the database stores that oversized value separately, in what's called an overflow page, and just leaves a small pointer to it in the original row. PostgreSQL calls this &lt;strong&gt;TOAST&lt;/strong&gt;. Other databases have their own version of the same idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  Heap files: a pile of pages
&lt;/h2&gt;

&lt;p&gt;Zoom out one level. A table isn't just one page — it can be made of millions of them. How does the database keep track of all those pages?&lt;/p&gt;

&lt;p&gt;The simplest approach, and the default one most databases use, is called a &lt;strong&gt;heap file&lt;/strong&gt;. Think of it as an unordered pile of pages, numbered in the order they were created: page 0, page 1, page 2, and so on.&lt;/p&gt;

&lt;p&gt;Here's how the pile grows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A brand-new, empty table starts as just page 0, with nothing in it.&lt;/li&gt;
&lt;li&gt;As rows come in, page 0 fills up.&lt;/li&gt;
&lt;li&gt;Once page 0 has no more room or can store a new insert, the database adds page 1.&lt;/li&gt;
&lt;li&gt;Once page 1 fills up too, it adds page 2. And so on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;strong&gt;full table scan&lt;/strong&gt; is exactly this: reading page 0, then page 1, then page 2, all the way through the last page, checking every row along the way.&lt;/p&gt;

&lt;h3&gt;
  
  
  The page numbers don't tell you where the pages physically are
&lt;/h3&gt;

&lt;p&gt;Here's a small but important detail. The database numbers its pages neatly — 0, 1, 2, 3 — but &lt;em&gt;where those pages actually sit on the physical disk&lt;/em&gt; is decided by the operating system, not the database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwmyk2cv4gj0liddfycxm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwmyk2cv4gj0liddfycxm.png" alt="logical-page-order-vs-physical-blocks" width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When the database asks for one more page, the OS just hands back whatever free space it currently has. Sometimes that's right next to the last page. Sometimes it's in a totally different spot on the disk. So "page 5 comes after page 4" is true in the database's bookkeeping, but it says nothing about them being physically next to each other on the disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Each table gets its own separate pile
&lt;/h3&gt;

&lt;p&gt;Just like a single page can't mix two tables, one heap file never mixes pages from two tables either. Each table has its own pile, and its own numbering, starting from 0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;users&lt;/code&gt; table → its own page 0, page 1, page 2...&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;menus&lt;/code&gt; table → a completely separate page 0, page 1, page 2...&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr9id15t17ottp5nmjxoe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr9id15t17ottp5nmjxoe.png" alt="separate-heap-file-per-table" width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These piles never merge, and they grow independently. If you scan the &lt;code&gt;users&lt;/code&gt; table, the database only touches &lt;code&gt;users&lt;/code&gt; pages — it never even looks at &lt;code&gt;menus&lt;/code&gt; pages.&lt;/p&gt;

&lt;h3&gt;
  
  
  How inserts really pick a page
&lt;/h3&gt;

&lt;p&gt;Tying this back together: when you insert a row, the database looks at the free space map for &lt;em&gt;any&lt;/em&gt; page with room — not necessarily the latest one — and drops the row into an open slot there. Find a spot, insert. That's it.&lt;/p&gt;

&lt;p&gt;This simple rule is what makes inserts fast. And it's not permanent filling — once rows are deleted from a page and then cleaned up by vacuum, that space shows up again in the free space map, available for the next insert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "no index" means "check every single page"
&lt;/h2&gt;

&lt;p&gt;This simplicity has a real cost when it's time to &lt;em&gt;find&lt;/em&gt; a row. Say you run:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;user&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no index, the database has no idea which page holds the row &lt;code&gt;id = 42&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Remember — when we insert new rows, these land wherever there's free space, not in any particular order. So the only way to be sure it finds the right row is to check every page, one by one. This is a full table scan, and the amount of work grows directly with the number of pages in the table.&lt;/p&gt;

&lt;p&gt;This is exactly the problem an &lt;strong&gt;index&lt;/strong&gt; solves. An index (usually a structure called a B+tree) is a separate, sorted list that maps values — like &lt;code&gt;id = 42&lt;/code&gt; — directly to a &lt;code&gt;(page, slot)&lt;/code&gt; address. Instead of checking every page, the database can jump straight to the right one. The actual row data still lives in the heap, in one place; the index is just a fast shortcut pointing into it. A table can have several indexes, each built for a different kind of lookup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv9cb0cvk6rm5gn7qizm6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv9cb0cvk6rm5gn7qizm6.png" alt="full-scan-vs-index" width="800" height="488"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A table with &lt;em&gt;no&lt;/em&gt; indexes at all means every single lookup, no matter how specific, has to scan the whole thing.&lt;/p&gt;

&lt;p&gt;And also worth mentioning here, an having index does not mean the query will always use the index; database query optimizer plans how it will run the query and will use the index or not. We will discuss this in database indexing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Walking through a scan, step by step
&lt;/h3&gt;

&lt;p&gt;Take a query like &lt;code&gt;SELECT * FROM users WHERE age = 30&lt;/code&gt;, with no faster way to find matching rows. Here's what the database does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read page 0. Check every row on it for &lt;code&gt;age = 30&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Read page 1. Check every row on it.&lt;/li&gt;
&lt;li&gt;Repeat, page after page, all the way to the last page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice it can't stop early just because it found one match on page 3 — there might be more matching rows on page 50. So it has to check &lt;em&gt;every&lt;/em&gt; page to be sure it hasn't missed any.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this gets slower as the table grows
&lt;/h3&gt;

&lt;p&gt;The amount of work scales directly with the size of the table. Double the number of rows, and you roughly double the number of pages — and roughly double the time the scan takes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A small table, with only a few pages, scans almost instantly.&lt;/li&gt;
&lt;li&gt;A huge table, with millions of pages, means reading that much data off disk. A 20GB table means the scan reads 20GB. There's no shortcut hiding inside the heap itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real cost is best measured in pages read, not rows checked, since many rows share one page:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;cost ≈ number of pages × cost of reading one page&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that's easy to get wrong
&lt;/h3&gt;

&lt;p&gt;Here's the key thing to understand: a full table scan is slow on a big table, &lt;em&gt;not&lt;/em&gt; because the reading itself is done badly. It's slow simply because there's so much to read.&lt;/p&gt;

&lt;p&gt;Imagine flipping through every page of a book to find one specific word.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flipping the pages &lt;strong&gt;in order&lt;/strong&gt; — page 1, then 2, then 3 — is actually the fastest possible way to flip through a book.&lt;/li&gt;
&lt;li&gt;What makes the search slow isn't a bad way of flipping. It's that the book has a million pages, and you have to look at all of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A full table scan works the exact same way. The database reads pages in order — 0, 1, 2, 3 — which is also the fastest possible pattern for reading off disk, since the disk can just hand over one page after another without jumping around. So the &lt;em&gt;way&lt;/em&gt; it reads isn't the problem. The problem is simply the &lt;em&gt;amount&lt;/em&gt; it has to read.&lt;/p&gt;

&lt;p&gt;Now add in the fact that disks are much slower than memory: a full scan means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go through page by page

&lt;ul&gt;
&lt;li&gt;Read all data in the page(page read means disk read) and move the data from disk to memory&lt;/li&gt;
&lt;li&gt;This moving data from disk to memory is the slowest part of this flow&lt;/li&gt;
&lt;li&gt;Disks are slow(SSDs are faster than hard drives, but still take some time to read; not as fast as memory)&lt;/li&gt;
&lt;li&gt;In memory than it checks all the rows of that page&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Small table → small amount to move → fast.&lt;/li&gt;
&lt;li&gt;Big table → large amount to move → slow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No matter how neatly the pages are read, reading a lot of pages always takes a lot of time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing it all together
&lt;/h2&gt;

&lt;p&gt;Here's the full picture, step by step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disks read and write in fixed-size blocks, so a database defines its own matching unit: the &lt;strong&gt;page&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Rows of different sizes are packed into fixed-size pages using a &lt;strong&gt;slotted page&lt;/strong&gt; layout — a header, a slot array that grows down, and row data that grows up. This gives every row a stable &lt;code&gt;(page, slot)&lt;/code&gt; address.&lt;/li&gt;
&lt;li&gt;A table's pages are collected into a &lt;strong&gt;heap file&lt;/strong&gt;: an unordered, ever-growing pile, numbered in order but not necessarily sitting next to each other on the physical disk.&lt;/li&gt;
&lt;li&gt;Inserts are simple on purpose — find any page with room, drop the row in — which keeps writes fast, but scatters a table's rows across the pile with no particular order.&lt;/li&gt;
&lt;li&gt;That scattering is exactly why a lookup with no index has no choice but to check every page — a &lt;strong&gt;full table scan&lt;/strong&gt;. It reads pages in the most efficient order possible, but it still has to read all of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7bw8kq4933b1foizok0j.png" alt="storage-hierarchy" width="800" height="400"&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; we saw that a deleted row lingers on disk, and that inserts drop rows wherever there's space. Both are fingerprints of &lt;strong&gt;MVCC&lt;/strong&gt; — the mechanism that lets many transactions read and write the same table at once without blocking each other. That's the whole subject of the next post: &lt;a href="https://dev.to/jishnusaha89/how-does-a-database-let-everyone-read-and-write-at-once-lil"&gt;How Does a Database Let Everyone Read and Write at Once?&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>databaseinternals</category>
      <category>jishnusaha</category>
    </item>
  </channel>
</rss>
