<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Vedant Thakkar on Medium]]></title>
        <description><![CDATA[Stories by Vedant Thakkar on Medium]]></description>
        <link>https://medium.com/@vedantthakkar1003?source=rss-a728a5a0f4a6------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*kCi6yEjcCQedeYp7</url>
            <title>Stories by Vedant Thakkar on Medium</title>
            <link>https://medium.com/@vedantthakkar1003?source=rss-a728a5a0f4a6------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 23 May 2026 12:24:46 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@vedantthakkar1003/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[The One Index to Rule Them All: How GiST Made PostgreSQL Extensible]]></title>
            <link>https://medium.com/@vedantthakkar1003/the-one-index-to-rule-them-all-how-gist-made-postgresql-extensible-9376356d2f58?source=rss-a728a5a0f4a6------2</link>
            <guid isPermaLink="false">https://medium.com/p/9376356d2f58</guid>
            <category><![CDATA[relational-databases]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[database-internals]]></category>
            <category><![CDATA[indexing]]></category>
            <dc:creator><![CDATA[Vedant Thakkar]]></dc:creator>
            <pubDate>Tue, 28 Oct 2025 08:01:54 GMT</pubDate>
            <atom:updated>2025-10-28T08:01:54.049Z</atom:updated>
            <content:encoded><![CDATA[<p>If you’ve worked with databases, you know <strong>B-Trees</strong>. They are the undisputed champions of indexing — the silent workhorses behind almost every fast SELECT, JOIN, and ORDER BY.<br> They’re brilliant, elegant, and efficient — as long as your data is one-dimensional.</p><p>A B-Tree organizes data linearly, one key after another in ascending order. This works perfectly for numbers (&lt;, =, &gt;), strings (LIKE &#39;abc%&#39;), and dates.</p><blockquote><strong>LIKE &#39;abc%&#39; </strong>can use a <strong>B-Tree index</strong> because it defines a prefix range that respects the column’s sort order. PostgreSQL can perform a range scan from the first value greater than or equal to &#39;abc&#39; up to the first value greater than or equal to &#39;abd&#39;, efficiently retrieving all matching rows without scanning the entire table.</blockquote><p>But what happens when your data isn’t that simple?</p><p>How do you index geographic data to find <em>all restaurants within a bounding box</em>? In practice, a bounding box is a rectangle defined by latitude and longitude coordinates representing the search area.</p><p>Or how do you efficiently detect collisions in a game? Each object can be represented by a bounding rectangle, and collisions occur where these rectangles overlap.</p><p>Before 1995, you had two unsatisfying choices:</p><ol><li><strong>Build a new index type from scratch.</strong><br> You could design something like an R-Tree for spatial data, but then you’d have to implement everything — balancing, searching, insertion, deletion, concurrency, crash recovery, and write-ahead logging. A massive engineering effort, but rarely practical.</li><li><strong>Force it into a B-Tree.</strong><br> You could try to “linearize” your data using tricks like space-filling curves (Z-order or Hilbert). That approach works sometimes, but not when your queries involve arbitrary shapes, ranges, or fuzzy matches.</li></ol><p>The database world was stuck between flexibility and reliability. You could have a general-purpose, robust database, or one that could index complex data — but not both.</p><p>Then came a paper by <strong>Joseph Hellerstein, Jeffrey Naughton, and Avi Pfeffer</strong>:<br><strong> </strong><a href="https://www.vldb.org/conf/1995/P562.PDF"><strong><em>“Generalized Search Trees for Database Systems” (1995)</em></strong></a><strong>.</strong></p><p>It introduced the <strong>Generalized Search Tree (GiST)</strong> — not an index, but a <em>framework</em> for building indexes.</p><p>GiST is the <em>one index to rule them all</em>. It provides a single, unified structure that can behave like a B-Tree, an R-Tree, or something entirely new.</p><p>This deceptively simple idea became the foundation of extensibility in modern databases and is the reason PostgreSQL can handle geospatial, textual, hierarchical, and JSON queries without needing a brand-new index structure for each.</p><h3>Why do we need more than B Trees ?</h3><p>A <strong>B-Tree</strong> organizes data as a <strong>hierarchy of ranges</strong> along a single dimension. Each node partitions the data into contiguous slices.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sbnKxIedpRRptgdYSGg9FA.png" /></figure><p>To find “David,” you check</p><p><strong>180 &gt; 200? → No</strong></p><p><strong>180 &gt; 100? → Yes</strong></p><p>You then follow the pointer between 100 and 200 to node B, and find your value.</p><p>This works efficiently <strong>because the data has a total ordering on one axis</strong>.</p><h3>Why a 2D Plane Can’t Be Totally Ordered</h3><p>Imagine you want to index locations on a map using longitude (x) and latitude (y). You might try to sort points like this:</p><pre>Point A: (2, 3)<br>Point B: (3, 2)</pre><ul><li>Which point comes first? A or B?</li><li>Sorting by x: A (2,3) &lt; B (3,2) ✅</li><li>Sorting by y: B (3,2) &lt; A (2,3) ❌</li></ul><p>No matter which axis you choose, some points will always “break” the order. There’s <strong>no linear sequence that preserves spatial proximity</strong> in two dimensions.</p><p>B-Trees rely on a single-axis ordering, so they <strong>cannot efficiently index multi-dimensional data</strong> like points, rectangles, or polygons.</p><h3>Enter the R-Tree</h3><p>An <strong>R-Tree</strong> solves this by <strong>partitioning space into nested bounding boxes</strong> instead of sorting along a line. Each node stores a rectangle that covers all its children.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RHsiSo1iBPWwyVoyiZSnQA.png" /><figcaption>R Tree stores Bounding Box ( Rectangles ) for Efficient Searching of 2-D Data.</figcaption></figure><ul><li>Each internal node represents a <strong>bounding rectangle</strong> covering all children.</li><li>Queries can quickly <strong>prune entire rectangles</strong> that don’t intersect the search area.</li><li>Overlaps between rectangles are allowed, enabling efficient multi-dimensional search.</li></ul><h3>The “Gist” of a Generalized Search Tree</h3><p>The fundamental insight of GiST is deceptively simple but incredibly powerful:</p><blockquote><em>A search tree is just a hierarchy of predicates, not a rigid data structure.</em></blockquote><p>Each internal node in a tree does not necessarily store raw data; instead, it represents a <strong>rule</strong> or condition describing everything in its subtree. By thinking in terms of predicates, GiST generalizes the concept of a tree: instead of hard-coding comparisons like “less than” or “greater than,” you define the <em>semantics</em> of the tree for your data type.</p><p>For example:</p><ul><li>In a <strong>B-Tree</strong>, the predicate is a numeric range, e.g., “values ≥ 100 and &lt; 200.”</li><li>In an <strong>R-Tree</strong>, the predicate is a geometric bounding box, e.g., “all shapes contained within this rectangle.”</li><li>In a <strong>set tree</strong>, the predicate might be “all sets that are subsets of {A,B,C}.”</li></ul><p>This abstraction allows the <strong>tree mechanics</strong> — balancing, splitting nodes, traversing branches — to be <strong>generic</strong>, while the database developer provides the rules that make sense for the data being indexed.</p><h3>GiST as a Framework</h3><p>GiST is <strong>not a specific index type</strong> like a B-Tree or R-Tree. Instead, it is a <strong>framework</strong> for building balanced search trees that can handle arbitrary predicates.</p><p>Key guarantees of the GiST framework:</p><ol><li><strong>Balanced storage:</strong> Data is stored in a tree with roughly equal depth across leaves, ensuring logarithmic access in ideal conditions.</li><li><strong>Concurrency safety:</strong> Multiple operations like inserts and searches can happen concurrently without corrupting the tree.</li><li><strong>WAL logging support:</strong> Changes are properly logged, allowing recovery after crashes.</li><li><strong>Efficient pruning:</strong> Entire subtrees can be skipped during a search if the query predicate does not overlap the subtree predicate, dramatically reducing the number of nodes visited.</li></ol><p>GiST itself doesn’t know what a predicate represents — it could be:</p><ul><li>A rectangle for spatial queries.</li><li>A trigram signature for approximate text search.</li><li>A JSON path for document containment.</li><li>A vector in high-dimensional space.</li></ul><p>The <strong>tree mechanics are fixed</strong>, while the <strong>semantics of the predicates are pluggable</strong>. This flexibility is what makes GiST a “one index to rule them all.”</p><h3>Visualizing a Generic GiST</h3><ul><li><strong>Internal nodes</strong> contain <strong>predicates</strong> summarizing all entries in their child nodes. They guide the search by telling the engine which subtrees could potentially contain matching data.</li><li><strong>Leaf nodes</strong> contain actual data pointers and their associated predicates.</li><li>A <strong>linked list of leaf nodes</strong> allows sequential access if needed, such as range scans.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/666/1*v7Yb_E02F5ZSLzUu95TPIA.png" /><figcaption>Leaf Node Points to Actual Tuple and Internal Node contains predicate for searching.</figcaption></figure><p>During a search, the engine evaluates each subtree’s predicate against the query. If there is no possible match (e.g., a bounding rectangle does not intersect the query rectangle), the entire branch is skipped — saving enormous time compared to scanning every record.</p><h3>Turning a GiST Skeleton into a Working Index</h3><p>To make GiST functional, you define <strong>six small, data-type-specific methods</strong>:</p><ol><li><strong>consistent</strong> – Should this subtree be explored for the query?</li><li><strong>union</strong> – How do we summarize multiple child predicates into a parent predicate?</li><li><strong>compress / </strong><strong>decompress</strong> – Store and retrieve predicates efficiently.</li><li><strong>penalty</strong> – How costly is inserting a new entry into this node?</li><li><strong>picksplit</strong> – How should we split a node when it overflows?</li></ol><p>With these six methods, you can implement B-Trees, R-Trees, K-d Trees, or any custom search structure without touching the core tree mechanics. The engine handles balancing, concurrency, and disk I/O; you define only the logic of your predicates.</p><blockquote>GiST’s brilliance lies in <strong>separating the “how” of managing a tree from the “what” of your data’s semantics</strong>. This abstraction makes PostgreSQL capable of indexing everything from numeric ranges to spatial polygons to JSON documents — all using the same underlying framework.</blockquote><h3>Turning a GiST Skeleton into a Working R-Tree</h3><blockquote>R-trees are <strong>hierarchical spatial indexes</strong> that store <strong>bounding boxes</strong> instead of individual points.</blockquote><blockquote>Each internal node covers its children’s rectangles, allowing queries like <em>“find all objects intersecting this region”</em> to quickly skip irrelevant areas.</blockquote><blockquote>Perfect for <strong>geometric</strong>, <strong>GIS</strong>, and <strong>range-based</strong> data — think maps, bounding boxes, or multidimensional coordinates.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*zwoi3uMBI49Poark.png" /><figcaption>How an R-Tree organizes spatial data, where red rectangles represent objects and blue boxes represent grouped bounding regions used for efficient spatial lookups.</figcaption></figure><p><strong>Context:</strong> Imagine you are building a <strong>spatial index</strong> for rectangles in a game or GIS application. Each rectangle represents:</p><ul><li>A <strong>building or park</strong> on a map (latitude-longitude coordinates)</li><li>Or a <strong>game object</strong> in a simulation (bounding rectangle for collision detection)</li></ul><p>The goal is to <strong>quickly find all rectangles that overlap a query rectangle</strong> without scanning the entire dataset.</p><p>GiST provides a <strong>generic tree framework</strong> that manages balancing, concurrency, and disk access. You only need to define the <strong>six methods</strong> that describe how rectangles interact with the tree structure.</p><h3>The Six GiST Skeleton Methods for R tree</h3><h3>1. consistent()</h3><p><strong>Purpose:</strong> Determines whether a subtree <em>could</em> contain matching entries.</p><p><strong>Used in:</strong></p><p><strong>Query phase</strong> — to decide which branches of the tree to explore.</p><p><strong>Analogy (R-tree):</strong></p><p>For a spatial query WHERE region &amp;&amp; query_box, consistent() checks if the node’s bounding box overlaps with the query box.</p><p><strong>Example:</strong></p><pre>bool consistent(entry, query) {<br>    return overlaps(entry-&gt;bounding_box, query-&gt;box);<br>}</pre><p><strong>Why it’s needed:</strong></p><p>If GiST had no consistent() step, it would have to scan <em>every subtree</em>.</p><p>This function provides <strong>pruning</strong> — skipping branches that can’t contain matches.</p><h3>2. union()</h3><p><strong>Purpose:</strong> Combines all child entries into a single parent key.</p><p><strong>Used in:</strong></p><p><strong>During insertion and node splits</strong> — when creating or updating parent nodes.</p><p><strong>Analogy (R-tree):</strong></p><p>If children have boxes A, B, and C , union() returns the <strong>minimal bounding box</strong> that encloses all three.</p><p><strong>Example:</strong></p><pre>box union(child_boxes[]) {<br>    return minimal_enclosing_box(child_boxes);<br>}</pre><p><strong>Why it’s needed:</strong></p><p>Without union(), GiST wouldn’t know what to store in parent nodes.</p><p>It summarizes child data into a <strong>representative predicate</strong>, making traversal efficient.</p><h3>3. compress() / decompress()</h3><p><strong>Purpose:</strong> Converts between the user’s data type and the internal, index-storable form.</p><p><strong>Used in:</strong></p><p><strong>cBefore writing or after reading from disk.</strong></p><p><strong>Analogy (R-tree):</strong></p><p>Maybe your indexed type is a polygon, but for storage you only keep its <strong>bounding rectangle</strong>.</p><p><strong>Example:</strong></p><pre>compressed_entry compress(polygon p) {<br>    return bounding_box(p);<br>}</pre><p><strong>Why it’s needed:</strong></p><p>Compression lets you store <strong>simpler or smaller representations</strong> of complex data thus reducing index size and speeding up comparisons.</p><h3>4. penalty()</h3><p><strong>Purpose:</strong> Measures how much a child’s predicate would “expand” if a new entry were added.</p><p><strong>Used in:</strong></p><p><strong>During insertion</strong> — to choose the <em>best subtree</em> to insert into.</p><p><strong>Analogy (R-tree):</strong></p><p>If you’re inserting a new box, and node A’s bounding box would expand slightly (area +5%), but node B’s would expand a lot (area +40%), then you pick A — smaller penalty.</p><p><strong>Example:</strong></p><pre>float penalty(existing_box, new_box) {<br>    return area(union(existing_box, new_box)) - area(existing_box);<br>}</pre><p><strong>Why it’s needed:</strong></p><p>It keeps the tree <strong>tightly packed</strong> and <strong>balanced</strong>, minimizing search time.</p><p>Without it, insertion would be random — leading to overlapping boxes and poor performance.</p><h3>5. picksplit()</h3><p><strong>Purpose:</strong> When a node overflows, decides how to split its entries into two groups.</p><p><strong>Used in:</strong></p><p><strong>During insertion</strong> — when a page is full.</p><p><strong>Analogy (R-tree):</strong></p><p>You have 10 boxes that don’t fit in one node , picksplit() chooses which go to the left or right child so overlap is minimized.</p><p><strong>Example:</strong></p><pre>SplitResult picksplit(boxes[]) {<br>    // Group boxes to minimize overlap between new parent boxes<br>}</pre><p><strong>Why it’s needed:</strong></p><p>Splitting nodes <strong>maintains balance</strong> and reduces overlap between bounding regions.</p><p>A bad split leads to <em>query paths overlapping too much</em>, degrading performance.</p><h3>6. same()</h3><p><strong>Purpose:</strong> Determines if two entries are equivalent at the same level of the tree.</p><p><strong>Used in:</strong></p><p><strong>When merging or deduplicating entries.</strong></p><p><strong>Analogy (R-tree):</strong></p><p>Checks if two bounding boxes represent the same region.</p><p><strong>Example:</strong></p><pre>bool same(box a, box b) {<br>    return equals(a, b);<br>}</pre><p><strong>Why it’s needed:</strong></p><p>Ensures correctness , avoids duplicates and ensures logical consistency during updates.</p><h3>GiST in PostgreSQL: A Living Example</h3><p>GiST isn’t just theory. It’s a <strong>first-class access method</strong> inside PostgreSQL.</p><p>You can inspect it through the system catalogs:</p><pre>SELECT amname, amhandler FROM pg_am WHERE amname = &#39;gist&#39;;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/1*gYcffTKYaR99t7xJSgXBfg.png" /></figure><p>This tells us that gist is implemented by the C function gist_handler(), defined in <a href="https://github.com/postgres/postgres/tree/master/src/backend/access/gist">src/backend/access/gist/gist.c</a>.</p><p>Each GiST-based index type registers its own <strong>operator class</strong> in pg_opclass:</p><pre>SELECT opcname, opcmethod<br>FROM pg_opclass<br>WHERE opcmethod = (SELECT oid FROM pg_am WHERE amname = &#39;gist&#39;);</pre><p>You’ll see entries like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/916/1*-JGWGubVZbWvc2ts_LxQCQ.png" /></figure><p>Each of these operator classes implements those six methods — written in C, registered in pg_proc, and linked through catalog dependencies.</p><p>That’s why you can write:</p><pre>CREATE INDEX shapes_region_gist<br>  ON shapes<br>  USING gist (region);</pre><p>or</p><pre>CREATE INDEX docs_trgm_gist<br>  ON docs USING gist (content gist_trgm_ops);</pre><p>and PostgreSQL knows <em>exactly</em> which functions to call.</p><h3>Performance and Pitfalls</h3><p>GiST’s power comes with trade-offs. Its performance depends almost entirely on <strong>how well your predicates partition space</strong>. If two bounding predicates overlap heavily, queries will have to search both branches — killing selectivity.</p><p>GiST query cost can therefore vary between:</p><ul><li><strong>Best case:</strong> O(log N) — perfect partitioning, non-overlapping boxes.</li><li><strong>Worst case:</strong> O(N) — everything overlaps, the index degenerates into a full scan.</li></ul><p>Two major factors cause this:</p><ol><li><strong>Data Overlap</strong></li></ol><p>If your real data naturally clusters (say, all points in Manhattan), your bounding boxes overlap almost completely.</p><p>Every query intersects every box. That’s not a GiST problem; it’s a data-geometry problem.</p><p><strong>2. Lossy Compression</strong></p><p>Your Compress method simplifies predicates — e.g., turning spaghetti-shaped polygons into bounding rectangles.</p><p>Those rectangles may overlap far more than the true shapes, creating false positives.</p><p>In PostgreSQL, GiST reports such false positives via a flag (recheck = true) in EXPLAIN.</p><p>It means the index helped narrow results, but the executor had to re-verify matches against the base table.</p><blockquote><em>Tip: For geometric data, prefer the SP-GiST (Space-Partitioned GiST) index when your data is well distributed — it avoids overlap entirely by partitioning space deterministically.</em></blockquote><h3>Implementation Insight</h3><p>Inside PostgreSQL’s source tree (<a href="https://github.com/postgres/postgres/tree/master/src/backend/access/gist">src/backend/access/gist/</a>), you’ll find files like:</p><ul><li>gist.c — entry points and handler functions</li><li>gistbuild.c — bulk index build</li><li>gistget.c — search logic</li><li>gistinsert.c — insertion and split algorithms</li><li>gistutil.c — helper math functions for areas, penalties, etc.</li></ul><p>Every operator class (like gist_box_ops) lives in its own extension, defining those six C functions and registering them via SQL scripts in <a href="https://github.com/postgres/postgres/blob/master/src/include/catalog/pg_proc.dat">pg_proc.sql.</a></p><p>You can even write your own. Start by defining a new data type and these functions in C, then register an operator class via:</p><pre>CREATE OPERATOR CLASS my_type_ops<br>DEFAULT FOR TYPE my_type USING gist AS<br>  STORAGE my_storage_type,<br>  OPERATOR 1 my_overlap_function,<br>  FUNCTION 1 my_consistent(internal, my_type, int, oid, internal),<br>  FUNCTION 2 my_union(internal, internal),<br>  FUNCTION 3 my_compress(internal),<br>  FUNCTION 4 my_decompress(internal),<br>  FUNCTION 5 my_penalty(internal, internal, internal),<br>  FUNCTION 6 my_picksplit(internal, internal),<br>  FUNCTION 7 my_same(internal, internal, internal);</pre><p>Congratulations , you’ve extended with a new native index type.</p><h3>The Broader Impact</h3><p>The <strong>Generalized Search Tree (GiST)</strong> didn’t just make PostgreSQL extensible, it reshaped how we think about indexing itself.<br>Instead of maintaining a zoo of specialized index structures, GiST gave us a way to <strong><em>parameterize</em></strong> the very concept of a tree.</p><p>It was a radical shift: data structures could now be treated like composable building blocks , abstracted and extended just like functions or types.</p><p>Three decades later, that idea still scales effortlessly — from 2D spatial queries and JSON documents to neural embedding search.<br> In fact, many modern vector databases quietly echo GiST’s design philosophy.<br> Systems like <strong>Faiss</strong>, <strong>Milvus</strong>, and <strong>pgvector</strong> all rely on the same principle: flexible, pluggable partitioning logic built atop a generic, balanced tree.</p><h3>Practical Example: Using GiST Index for box_ops</h3><p>In this example, we’ll use PostgreSQL’s built-in box_ops operator class to index geometric data. We’ll create a table, insert 1 million box coordinates, and analyze how query performance improves by comparing execution plans — with and without the GiST index.</p><pre>EXPLAIN ANALYZE<br>SELECT count(*)<br>FROM spatial_data<br>WHERE bbox &amp;&amp; box(point(100,100), point(150,150));</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*B2hr2mh1IA3rLDIK7gEBSg.png" /><figcaption>Query Plan without Index</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JeSlHHdW_c94lZtsV_xoEA.png" /><figcaption>Query Plan with Index</figcaption></figure><h3>Plan 1: The “Gather” / Parallel Seq Scan Plan (SLOW)</h3><p>This plan was executed <strong>without a usable index</strong>.</p><ol><li><strong>Parallel Seq Scan on spatial_data</strong>: This is the main problem. The database has no efficient way to find the matching rows, so it makes a &quot;brute force&quot; decision. It launches 2 &quot;Workers&quot; (plus the main process, for 3 total) to read <strong>the entire table</strong>, block by block.</li><li><strong>Filter: (bbox &amp;&amp; ...)</strong>: As each of the 3 workers scans its part of the table, it checks <em>every single row</em> against your bbox filter.</li><li><strong>Rows Removed by Filter: 333055</strong>: This is the evidence of the inefficiency. The database read 333,055 rows from disk just to discover they <em>didn&#39;t</em> match. This is a massive waste of I/O.</li><li><strong>Partial Aggregate</strong>: Each of the 3 workers counts the matching rows <em>it</em> found (279 rows each, for a total of 837).</li><li><strong>Gather</strong>: This is the step you asked about. Its only job is to <strong>collect the 3 partial counts</strong> (one from each worker) and pass them up. The Gather step itself isn&#39;t slow; it&#39;s just necessary because the <em>scan</em> was done in parallel.</li><li><strong>Finalize Aggregate</strong>: This node takes the 3 partial counts and adds them together to get the final result.</li></ol><p><strong>Analogy:</strong> You are looking for everyone named “Smith” in a phone book. This plan is like hiring 3 people to <em>read the entire phone book from start to finish</em>, write down the “Smiths” they find, and then “gathering” their lists at the end to add them up.</p><h3>Plan 2: The Index Only Scan Plan (FAST)</h3><p>This plan was executed <strong>after a GiST index was created</strong> (idx_gist_bbox).</p><ol><li><strong>Index Only Scan using idx_gist_bbox</strong>: This is the perfect operation. Instead of reading the table, the database goes to the small, highly-efficient idx_gist_bbox index. This index is like a special map sorted for spatial queries.</li><li><strong>Index Cond: (bbox &amp;&amp; ...)</strong>: The database uses the index to <em>instantly</em> find the 836 rows that match the bbox condition, completely skipping the 333,055 non-matching rows.</li><li><strong>Heap Fetches: 0</strong>: This makes it even faster. &quot;Index Only&quot; means all the data needed for the query (likely just a count) was available <em>inside the index itself</em>. The database didn&#39;t even need to take the extra step of visiting the main table (&quot;heap&quot;) to get the data. It just counted the index entries.</li><li><strong>Aggregate</strong>: This single node takes the 836 rows found by the index and computes the final aggregate (e.g., COUNT). It&#39;s simple, non-parallel, and extremely fast.</li></ol><p><strong>Analogy:</strong> Using the same phone book, this plan is like turning to the “S” section, finding the “Smiths,” counting them, and being done. You <em>never</em> even look at the “A” through “R” sections.</p><h3>Conclusion: Why the Index Plan is Better</h3><p>The Gather node is not the <em>problem</em>; it&#39;s a <em>symptom</em> of the problem. The problem is the Parallel Seq Scan, which must read the entire table.</p><p>The <strong>Index Plan</strong> is better because it changes the fundamental strategy from <strong>“Read everything and filter”</strong> (Plan 1) to <strong>“Find only what you need”</strong> (Plan 2). This surgical precision is why it’s over 12 times faster, and it would be even more effective (thousands of times faster) on a larger table.</p><h3>Key Takeaways</h3><ul><li><strong>GiST = Index Framework</strong>, not algorithm.</li><li>It defines structure and concurrency; you define semantics.</li><li><strong>Six small methods</strong> (consistent, union, compress, decompress, penalty, picksplit) let you build an index for <em>any</em> data type.</li><li><strong>PostgreSQL’s extensibility</strong> — from PostGIS to pg_trgm — exists because of GiST.</li><li><strong>Performance depends on overlap</strong>. Good predicate design = good index selectivity.</li><li><strong>One idea, infinite applications</strong> — GiST is the unsung bridge between database theory and real-world engineering.</li></ul><blockquote>GiST is the most underrated genius in PostgreSQL’s index family. It’s not just an index — it’s an architecture.</blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9376356d2f58" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Mastering PostgreSQL GIN Indexes: The Ultimate Guide to Faster JSONB, Array, and Full-Text Search]]></title>
            <link>https://medium.com/@vedantthakkar1003/mastering-postgresql-gin-indexes-the-ultimate-guide-to-faster-jsonb-array-and-full-text-search-f1f8ec3e67af?source=rss-a728a5a0f4a6------2</link>
            <guid isPermaLink="false">https://medium.com/p/f1f8ec3e67af</guid>
            <category><![CDATA[database]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[gin-index]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[database-indexing]]></category>
            <dc:creator><![CDATA[Vedant Thakkar]]></dc:creator>
            <pubDate>Sun, 19 Oct 2025 06:06:07 GMT</pubDate>
            <atom:updated>2025-10-19T06:06:07.504Z</atom:updated>
            <content:encoded><![CDATA[<p>Searching inside complex or multi-valued data such as arrays, JSON documents, or unstructured text is a notoriously difficult problem for relational databases. Traditional B-tree indexes, the default in PostgreSQL and most other RDBMSs, are built for scalar values and range queries (=, &lt;, &gt;). They are fundamentally unsuited for answering questions like, &quot;Which documents contain this specific key-value pair?&quot; or &quot;Which users have all of these three tags?&quot;</p><p>This is where the <strong>Generalized Inverted Index (GIN)</strong> comes in. GIN is PostgreSQL’s secret weapon for “element-level” search. Instead of indexing the <em>entire</em> complex value, GIN indexes the <em>individual components</em> within it.</p><p>GIN is the technology that underpins many of PostgreSQL’s most powerful features:</p><p><strong>Full-text search (tsvector)SQL:</strong></p><ul><li>Full-text search (tsvector) in PostgreSQL converts text into lexemes (normalized words) for efficient searching. Using GIN indexes, it supports stemming, stop-word removal, phrase searches, and relevance ranking. It uses two function to_tsvector and to_tsquery which converts the given string into set of lexemes with the position of occurrences .</li></ul><blockquote>to_tsquery lets you build structured search queries using operators like &amp; (AND), | (OR), ! (NOT), and :* (prefix matching). This allows flexible full-text search logic, e.g., combining, excluding, or partially matching words.</blockquote><ul><li><strong>Use case:</strong> Quickly find documents or articles containing specific words or phrases without scanning the entire table.</li></ul><pre>-- Query for articles containing both &#39;postgres&#39; and &#39;index&#39; <br>SELECT * FROM articles <br>WHERE to_tsvector(&#39;english&#39;, content) @@ to_tsquery(&#39;postgres &amp; index&#39;);</pre><p><strong>JSONB containment queries (@ &gt;):</strong></p><ul><li>JSONB containment queries (@&gt;) in PostgreSQL allow checking if a JSONB column contains a specified JSON structure. Using GIN indexes, these queries can efficiently filter rows based on nested keys and values.</li><li><strong>Use case:</strong> Quickly find users, documents, or configuration data matching specific JSON attributes without scanning the entire table.</li></ul><pre>-- Create a table with JSONB data <br>CREATE TABLE users ( id serial PRIMARY KEY, profile jsonb );<br>  <br>-- Create a GIN index for fast JSONB containment queries <br>CREATE INDEX idx_profile_gin ON users USING gin (profile jsonb_ops);  <br><br>-- Query: Find users who are active admins <br>SELECT * FROM users WHERE profile @&gt; &#39;{&quot;role&quot;: &quot;admin&quot;, &quot;active&quot;: true}&#39;;`</pre><p><strong>Array membership queries (ANY / ALL):</strong></p><ul><li>Array membership queries (ANY / ALL) in PostgreSQL allow checking whether one or more values exist in an array column. Using GIN indexes, these queries can efficiently filter rows based on single or multiple array elements.</li><li><strong>Use case:</strong> Quickly find users, posts, or products that belong to specific tags or categories without scanning the entire table.</li></ul><pre>--Create a table with array column <br>CREATE TABLE users ( id serial PRIMARY KEY, tags text[] ); <br><br>-- Create a GIN index for array membership <br>CREATE INDEX idx_tags_gin ON users USING gin (tags);  <br><br>-- Query: Find users with &#39;developer&#39; tag <br>SELECT * FROM users WHERE &#39;developer&#39; = ANY(tags);<br>  <br>-- Query: Find users with both &#39;developer&#39; AND &#39;postgres&#39; tags <br>-- This operator (@&gt;) is the GIN &quot;containment&quot; operator<br>SELECT * FROM users WHERE tags @&gt; ARRAY[&#39;developer&#39;,&#39;postgres&#39;];</pre><p><strong>Trigram-based fuzzy searches (pg_trgm):</strong></p><ul><li>Trigram-based fuzzy searches (pg_trgm) in PostgreSQL break text into 3-character sequences (trigrams) and use GIN or GiST indexes for fast similarity or partial-match searches.</li><li><strong>Use case:</strong> Quickly find strings that are <strong>similar or partially matching</strong>, such as misspelled names, keywords, or text fragments, without scanning the entire table.</li></ul><pre>--Enable the pg_trgm extension <br>CREATE EXTENSION IF NOT EXISTS pg_trgm; <br><br>-- Create a table <br>CREATE TABLE users ( id serial PRIMARY KEY, name text); <br><br>-- Create a GIN index for trigram search <br>CREATE INDEX idx_name_trgm ON users USING gin (name gin_trgm_ops); <br><br>-- Query: Find names similar to &#39;Postgres&#39; <br>SELECT * FROM users WHERE name % &#39;Postgres&#39;;</pre><p>But GIN is more than just a “faster search” tool. It’s a <strong>sophisticated, two-level index</strong> with <strong>entry trees, posting trees, and pending lists</strong>, optimized for <strong>read-heavy workloads</strong> and capable of scaling to millions of rows. Understanding GIN requires looking at <strong>how PostgreSQL stores keys, maps them to rows, and merges updates efficiently</strong> — the topics we’ll explore in this article.</p><h3>The Problem GIN Solves: Multi-valued Columns</h3><p>Consider a table with an array column:</p><pre>CREATE TABLE users (<br>    id serial PRIMARY KEY,<br>    tags text[]<br>);<br><br>INSERT INTO users (id, tags) VALUES<br>(1, ARRAY[&#39;developer&#39;, &#39;postgres&#39;]),<br>(2, ARRAY[&#39;developer&#39;, &#39;go&#39;]),<br>(3, ARRAY[&#39;designer&#39;, &#39;ui&#39;]);</pre><p>A query like SELECT * FROM users WHERE tags @&gt; ARRAY[&#39;developer&#39;]; cannot efficiently use a B-tree index. A B-tree on tags would index the <em>entire array</em> as a single, opaque value. It could quickly find ARRAY[&#39;developer&#39;, &#39;postgres&#39;], but it has no knowledge of the individual elements <em>within</em> the array.</p><p><strong>GIN solves this by creating an inverted mapping.</strong></p><ul><li><strong>B-tree (Row → Value):</strong> It maps a row’s TID (Tuple Identifier) to the full value stored in that row.</li><li>TID 1 → ARRAY[&#39;developer&#39;, &#39;postgres&#39;]</li><li>TID 2 → ARRAY[&#39;developer&#39;, &#39;go&#39;]</li><li>TID 3 → ARRAY[&#39;designer&#39;, &#39;ui&#39;]</li><li><strong>GIN (Value → Rows):</strong> It maps each <em>individual element</em> (the “key”) to a list of rows that contain it.</li><li>‘developer’ → [TID 1, TID 2]</li><li>‘postgres’ → [TID 1]</li><li>‘go’ → [TID 2]</li><li>‘designer’ → [TID 3]</li><li>‘ui’ → [TID 3]</li></ul><p>This inverted structure allows PostgreSQL to instantly find all rows containing ‘developer’ by just looking up one key in the GIN index.</p><p>Here’s a visual comparison:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Um4lQoW4Z4nAwoYqKpXDXw.png" /></figure><h3>GIN Index Internals: The Two-Level Structure</h3><p>A GIN index is not a single, simple structure. It’s a sophisticated “index within an index” designed to handle the “many-to-many” relationship between keys and rows efficiently.</p><h3>1. The Entry Tree</h3><p>The top level is the <strong>Entry Tree</strong>. This is a <strong>B-tree</strong> that stores all the unique keys (lexemes, array elements, JSON keys, trigrams) extracted from the indexed column.</p><ul><li><strong>Structure:</strong> Standard B-tree.</li><li><strong>Content:</strong> It maps each unique <strong>key</strong> to a <strong>posting list</strong>.</li><li><strong>Purpose:</strong> To very quickly find a specific key (like &#39;developer&#39;) among millions or billions of other keys.</li></ul><h3>2. The Posting List (or Posting Tree)</h3><p>This is the “list of rows” (TIDs) associated with a key. To optimize storage and performance, GIN has two ways to store this list:</p><ol><li><strong>Inline Posting List:</strong> If a key is rare and appears in only a few rows, the list of TIDs is stored <em>directly in the Entry Tree’s leaf page</em> alongside the key. This is extremely fast for lookups, as it avoids a second index hop.</li><li><strong>Posting Tree:</strong> If a key is common and appears in many rows (e.g., the word “the” in a text document, or a common tag like “user”), storing a giant list of TIDs inline would bloat the Entry Tree and destroy its cache efficiency. In this case, the Entry Tree stores a <em>pointer</em> to a separate, dedicated <strong>Posting Tree</strong>. This secondary tree is another B-tree, but this one is specially designed to store and search <em>only</em> TIDs.</li></ol><h3>Key Optimizations: Compression</h3><p>GIN employs two critical compression techniques:</p><ul><li><strong>Delta Encoding (in Posting Trees):</strong> TIDs on disk are stored sorted. Instead of storing [10001, 10002, 10004, 10009], GIN stores the differences (deltas): [10001, +1, +2, +5]. This uses far fewer bits, especially for dense, physically clustered data, dramatically reducing the size of the posting trees.</li><li><strong>Lossy Compression (Optional):</strong> For <em>extremely</em> common keys (e.g., stop-words like ‘a’, ‘is’, ‘the’), even a compressed posting tree can be enormous. GIN can switch to a <strong>lossy</strong> strategy where it doesn’t store individual TIDs but rather <em>page numbers</em>. Instead of “key ‘the’ is in rows 1, 2, and 5 (all on page 100),” it just stores “key ‘the’ is on page 100.” This is a <em>massive</em> space saving but introduces <strong>false positives</strong>. This is one of the primary reasons a GIN index scan <em>requires</em> a recheck, which we’ll cover in Query Execution.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZhLDwIzFA1vdsgaP--4ZNQ.png" /><figcaption>The high-level B-tree structure of a GIN (Generalized Inverted Index) and its components.</figcaption></figure><h3>Physical Storage: Pages, Tuples, and Meta Information</h3><p>A GIN index, like all PostgreSQL relations, is stored in a collection of <strong>8KB pages (blocks)</strong>. These pages are specialized by type:</p><h3>1. Meta Page (Block 0)</h3><p>This is the index’s “header.” It’s a single page (the very first one) that stores global information, such as:</p><ul><li>Pointers to the root of the Entry Tree.</li><li>Pointers to the start and end of the <strong>Pending List</strong> (see next section).</li><li>The fastupdate flag (whether the pending list is enabled).</li><li>Version information and other statistics.</li></ul><h3>2. Entry Tree Pages</h3><p>These are standard B-tree pages (internal and leaf nodes) that make up the main key-to-posting index. Leaf pages are where the keys are stored, along with either an inline posting list or a pointer to a posting tree.</p><h3>3. Posting Tree Pages</h3><p>These are separate B-tree pages used <em>only</em> for storing the (often delta-encoded) lists of TIDs for common keys. Separating them from the Entry Tree keeps the Entry Tree small and fast to navigate.</p><h3>4. Pending List Pages</h3><p>This is a separate, unstructured list of pages used as a temporary write buffer. New index entries are dumped here to be processed in a batch later.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0YRkJHjH_tue4jJwv66ZLw.png" /><figcaption>All GIN components (Taken from <a href="https://pganalyze.com/blog/gin-index">https://pganalyze.com/blog/gin-index</a>)</figcaption></figure><h3>FastUpdate and the Pending List</h3><p>This is GIN’s most important optimization for write performance.</p><p>Inserting into a GIN index is <em>conceptually very expensive</em>. A single INSERT for a tsvector column could involve adding <em>hundreds</em> of keys (words) to the index. If fastupdate was OFF, PostgreSQL would have to:</p><ol><li>For <em>each</em> key in the new row:</li><li>Find the key in the Entry Tree (1–2 disk I/Os).</li><li>Load the corresponding posting list or tree (another 1–2 disk I/Os).</li><li>Add the new row’s TID to that list.</li><li>Write the modified page back to disk.</li></ol><p>This would result in massive I/O amplification and intense lock contention.</p><p>To solve this, PostgreSQL uses fastupdate (which is ON by default).</p><ul><li><strong>How it works:</strong> When you INSERT or UPDATE a row, GIN&#39;s extractValue function breaks the new data into keys (e.g., &#39;dev&#39;, &#39;go&#39;). Instead of immediately merging them into the main index, it writes these new (key, TID) pairs into a simple, append-only buffer called the <strong>Pending List</strong>.</li><li><strong>Result:</strong> The INSERT operation becomes extremely fast. It&#39;s just a quick, sequential write to the pending list, avoiding all the random I/O and locking of the main index.</li></ul><h3>How Reads Work with the Pending List</h3><p>This is the critical trade-off. If new data is in the pending list and not the main index, how does a SELECT find it?</p><p><strong>The query has to check both places.</strong></p><p>A GIN index scan with a non-empty pending list does the following:</p><ol><li>Looks up the key (e.g., &#39;dev&#39;) in the main <strong>Entry Tree</strong>, retrieving its posting list (e.g., [TID 1, TID 5, TID 10]).</li><li>Scans the entire <strong>Pending List</strong> for all entries matching &#39;dev&#39; (e.g., [TID 50, TID 52]).</li><li><strong>Merges</strong> these two lists in memory to get the final result: [TID 1, TID 5, TID 10, TID 50, TID 52].</li></ol><p>This is why <strong>a large pending list can slow down reads</strong>. The pending list is an <em>unindexed</em> list, so PostgreSQL must sequentially scan it for every key in your query.</p><h3>The Merge Mechanism</h3><p>The pending list is “cleaned” (merged into the main index) automatically by:</p><ul><li><strong>VACUUM</strong> (either autovacuum or manual).</li><li><strong>ANALYZE</strong>.</li><li>When the pending list grows past the gin_pending_list_limit (a configurable setting, default 4MB).</li><li>The gin_clean_pending_list() function is called.</li></ul><p>This merge process is a large batch operation: it sorts all entries in the pending list by key and then efficiently merges them into the main index’s posting lists. This can cause temporary I/O spikes, which is the “con” of the fastupdate optimization.</p><h3>Query Execution: Step by Step</h3><p>Let’s trace a query: SELECT * FROM users WHERE tags @&gt; ARRAY[&#39;dev&#39;,&#39;go&#39;];</p><ol><li><strong>Key Extraction:</strong> PostgreSQL’s GIN operator class (array_ops) takes the query ARRAY[&#39;dev&#39;,&#39;go&#39;] and knows it needs to find rows that contain <em>both</em> keys.</li></ol><p><strong>2. Entry Lookup (Bitmap Creation):</strong></p><ul><li>It looks up &#39;dev&#39; in the GIN <strong>Entry Tree</strong>. It finds a posting list (or tree) and retrieves all TIDs: [1, 2, 5, 7, 10, ...].</li><li>It also scans the <strong>Pending List</strong> for &#39;dev&#39; and finds [12, 15].</li><li>It merges these into an in-memory <strong>bitmap</strong> for ‘dev’: Bitmap(dev) = [1, 2, 5, 7, 10, 12, 15, ...].</li><li>It repeats this for &#39;go&#39;, finding [2, 6, 12, 20].</li><li>It creates a second bitmap: Bitmap(go) = [2, 6, 12, 20].</li></ul><p><strong>3. Set Operations (Bitmap Logic):</strong></p><ul><li>The query operator @&gt; (contains) maps to a <strong>bitmap AND</strong> operation.</li><li>PostgreSQL performs a bitwise AND on the two in-memory bitmaps.</li><li>Bitmap(dev) AND Bitmap(go) = [2, 12]</li><li>(If the query was tags &amp;&amp; ARRAY[&#39;dev&#39;,&#39;go&#39;] (overlaps), it would use a <strong>bitmap OR</strong> operation).</li></ul><p><strong>4. Heap Fetch (Bitmap Heap Scan):</strong></p><ul><li>The index scan is now done. It has produced a list of <em>candidate</em> TIDs: [2, 12].</li><li>PostgreSQL now performs a <strong>Bitmap Heap Scan</strong>. It sorts the TIDs by their physical page location to ensure it reads each data page only once, then fetches these rows from the main table (the “heap”).</li></ul><p><strong>5. Recheck (Filtering False Positives):</strong></p><ul><li>This is the final, crucial step. For <em>every row</em> fetched from the heap, PostgreSQL <strong>re-evaluates the original </strong><strong>WHERE clause</strong>: tags @&gt; ARRAY[&#39;dev&#39;,&#39;go&#39;].</li><li>This “recheck” is necessary to filter out <strong>false positives</strong>.</li></ul><blockquote><strong>Why do false positives happen?</strong></blockquote><blockquote>The most common reason is the <strong>lossy compression</strong> mentioned earlier. If the index stored “key ‘dev’ is on page 100” (which contains rows 1–50), the bitmap would include all 50 rows. The recheck step would then filter these down to just the rows <em>actually</em> containing ‘dev’.</blockquote><blockquote>False positives can also be produced by the <em>operator class</em> itself. For pg_trgm, a search for &#39;%postgres%&#39; might be simplified by the index to find all rows containing the trigram &#39;pos&#39;. This will also match &#39;postman&#39; and &#39;position&#39;. The recheck (name ILIKE &#39;%postgres%&#39;) filters these out.</blockquote><h3>Data Types and Operator Classes</h3><p>A GIN index’s behavior is defined by its <strong>operator class</strong>. This is the “plugin” that tells GIN how to extract keys, how to interpret query operators, and whether it can be lossy.</p><p><strong>Operator Class</strong> <strong>Data Type</strong> <strong>Deeper Dive:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zUUqeg8PD_fhMDLFijiL1w.png" /></figure><blockquote><strong>Hstore:</strong> A PostgreSQL column type for storing multiple key-value pairs in a single row, like &#39;theme=&gt;&quot;dark&quot;, font=&gt;&quot;mono&quot;, layout=&gt;&quot;grid&quot;&#39;. Using a <strong>GIN index</strong>, you can efficiently query for specific keys or key/value pairs, e.g., finding all rows where theme=&quot;dark&quot;.</blockquote><blockquote><strong>Use case:</strong> Ideal for storing dynamic settings, metadata, or configuration per row without creating multiple columns.</blockquote><h3>Choosing Your JSONB Index: jsonb_ops vs. jsonb_path_ops</h3><p>This is a critical decision:</p><ul><li>Use <strong>jsonb_ops</strong> (the default) if you need flexibility. It lets you query for the existence of top-level keys (profile ? &#39;role&#39;) or check for specific key-value pairs (profile @&gt; &#39;{&quot;role&quot;: &quot;admin&quot;}&#39;). It is the &quot;index everything&quot; solution.</li><li>Use <strong>jsonb_path_ops</strong> if your <em>only</em> query pattern is containment (@&gt;) and your JSON documents are large and complex. It creates a much smaller index, leading to faster builds, faster writes, and (often) faster containment queries.</li></ul><p>Example :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6jycj18lAMrAhkZ2k4OHcg.png" /><figcaption>Difference between json_path_ops and json_ops index. Both create different index keys</figcaption></figure><p><strong>Takeaway:</strong></p><ul><li><strong>jsonb_ops</strong> → Use when you need to query <strong>nested keys individually</strong>. Flexible, supports ? and @&gt;, but larger index.</li></ul><pre>SELECT * FROM users WHERE profile-&gt;&#39;prefs&#39;-&gt;&gt;&#39;theme&#39; = &#39;dark&#39;;</pre><ul><li><strong>jsonb_path_ops</strong> → Use when you only need <strong>top-level containment (</strong><strong>@&gt;) queries</strong>. Smaller, faster, but cannot index nested keys individually.</li></ul><pre>SELECT * FROM users WHERE profile @&gt; &#39;{&quot;prefs&quot;:{&quot;theme&quot;:&quot;dark&quot;}}&#39;;</pre><h3>Common Pitfalls and Performance Trade-offs</h3><ol><li><strong>Extremely Slow Build Time:</strong></li></ol><ul><li><strong>Problem:</strong> CREATE INDEX on a large table can take hours. GIN has to extract <em>every key</em> from <em>every row</em>, sort this massive list, and then build the two-level tree.</li><li><strong>Solution:</strong> Increase maintenance_work_mem significantly (for example, to several GB) before creating the index. This allows PostgreSQL to perform more of the sorting and index-building operations in memory, drastically reducing disk I/O. Additionally, using the CONCURRENTLY keyword lets you create the index without locking the table, handling the operation asynchronously.</li></ul><p><strong>2. High Disk Usage:</strong></p><ul><li><strong>Problem:</strong> GIN indexes are often <em>larger</em> than the table itself. An index on a tsvector column can be massive, as it stores every unique word.</li><li><strong>Solution:</strong> For jsonb, use jsonb_path_ops if you only need containment. For text, be critical about whether you <em>really</em> need full-text search or if pg_trgm (which is often smaller) is sufficient.</li></ul><p><strong>3. Pending List Spikes:</strong></p><ul><li><strong>Problem:</strong> autovacuum or a VACUUM command triggers a GIN pending list merge, causing a sudden, high spike in CPU and I/O that can impact application performance.</li><li><strong>Solution:</strong> Tune autovacuum to run more frequently on that table, keeping the pending list small. You can also manually call gin_clean_pending_list() during off-peak hours.</li></ul><p><strong>4. Slow Writes (The </strong><strong>fastupdate Trade-off):</strong></p><ul><li><strong>Problem:</strong> Even with fastupdate, GIN is fundamentally write-slower than B-tree. Each INSERT still writes to the pending list, and the merge process is a deferred cost.</li><li><strong>Solution:</strong> Don’t use GIN on tables with extremely high INSERT/UPDATE rates where read performance is less critical. Batch INSERTs together if possible.</li></ul><p><strong>5. UPDATE-Heavy Workloads:</strong></p><ul><li><strong>Problem:</strong> An UPDATE to an indexed column is a &quot;DELETE + INSERT.&quot; For GIN, this is doubly expensive: it has to find and <em>remove</em> all the TIDs for the old keys (a difficult operation) and then <em>add</em> all the new keys to the pending list. This is the worst-case scenario for GIN.</li><li><strong>Solution:</strong> Avoid indexing columns that are updated frequently. If you must, consider partitioning the table or using a GiST index, which often handles updates more gracefully.</li></ul><p><strong>6. Index Bloat:</strong></p><ul><li><strong>Problem:</strong> Because of the complex way TIDs are added (pending list) and removed (by marking them in the posting tree), GIN indexes can “bloat” significantly, containing a lot of empty, unused space.</li><li><strong>Solution:</strong> VACUUM helps, but sometimes a REINDEX is the only way to fully reclaim the space and restore performance.</li></ul><h3>Benchmark Results: Analysis &amp; Key Insights</h3><p>After running our benchmark script (available on <a href="https://github.com/vedant381/GIN">GitHub</a>) against 1 million rows for each data type, the results are in. The table clearly demonstrates the strengths and weaknesses of B-tree and GIN indexes for different data types.</p><p>Here’s a breakdown of the insights from our test:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CiZ5p06Gaw0qPEk0bW7koA.png" /></figure><h3>1. JSONB Query</h3><ul><li><strong>Query Time:</strong> The performance is nearly identical. The B-tree clocked in at 222.30 ms, while the GIN index was slightly faster at 215.53 ms, a negligible 1.03x speedup.</li><li><strong>Index Size:</strong> This is the real story. The B-tree index was <strong>78.31 MB</strong>, while the GIN index (using the jsonb_path_ops operator class) was a mere <strong>2.14 MB</strong>. That&#39;s over <strong>36 times smaller</strong>!</li></ul><p><strong>Insight:</strong> For querying specific key-value pairs within a JSONB document, a GIN index provides the same high performance as a B-tree but at a <em>fraction</em> of the storage cost. The jsonb_path_ops class is highly optimized for this, creating a compact index that far outperforms the B-tree&#39;s bulky attempt to index the entire JSONB structure.</p><h3>2. Array Query</h3><ul><li><strong>Query Time:</strong> GIN was the clear winner at 73.67 ms, compared to the B-tree’s 95.42 ms. This 1.30x speedup is significant.</li><li><strong>Index Size:</strong> The difference is staggering. The B-tree on the array column consumed <strong>93.19 MB</strong>, while the GIN index was only <strong>4.15 MB</strong> (over 22x smaller).</li></ul><p><strong>Insight:</strong> This is the classic GIN use case. A B-tree indexes the <em>entire array</em> as a single, opaque value, which is inefficient for searching <em>inside</em> it. A GIN (Generalized <strong>Inverted</strong> Index) index, by contrast, creates an entry for <em>each unique element</em> in <em>all</em> the arrays and points back to the rows. When we search for &#39;postgres&#39;, GIN can instantly find all rows containing that tag. It is fundamentally the correct technology for this &quot;contains&quot; operation, leading to faster queries and a dramatically smaller index.</p><h3>3. Full-Text Query</h3><ul><li><strong>Query Time:</strong> This was a landslide victory for GIN. The GIN index responded in 138.40 ms, while the B-tree equivalent took a slow 952.71 ms. This is a massive <strong>6.88x speedup</strong>.</li><li><strong>Index Size:</strong> The sizes were more comparable here, with GIN (<strong>81.87 MB</strong>) still being more efficient than the B-tree (<strong>100.55 MB</strong>).</li></ul><p><strong>Insight:</strong> The B-tree’s 952.71 ms time is likely the result of a <strong>full sequential scan</strong>. A standard B-tree indexes the raw text (content) and is completely useless for a to_tsvector query, which searches for processed <em>lexemes</em> (like &#39;gin&#39; and &#39;index&#39;). The query planner correctly ignored the B-tree.</p><p>The GIN index, however, was created <em>on</em> the to_tsvector output. It is purpose-built to index these lexemes, allowing it to find matching documents almost instantly. This isn&#39;t just an optimization; it&#39;s the difference between an index being <em>usable</em> and <em>unusable</em> for the query.</p><h3>Conclusion</h3><p>GIN indexes are a <strong>highly optimized, multi-level inverted indexing system</strong> in PostgreSQL. They are not a “one-size-fits-all” solution like B-trees, but they are a masterpiece of database engineering. They combine <strong>B-tree structures, posting trees, and fastupdate pending lists</strong> to solve one of the hardest problems in data: efficiently searching <em>inside</em> complex, multi-valued data types.</p><p>By understanding their internal mechanics, including the entry/posting tree split, the fastupdate pending list, and the crucial recheck step, you can confidently use them to power sophisticated search features, avoid common pitfalls like disk bloat and slow merges, and turn your PostgreSQL database into a powerful search engine without the need for external tools.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f1f8ec3e67af" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[PostgreSQL Indexes and MVCC: How Queries Stay Fast and Consistent]]></title>
            <link>https://medium.com/@vedantthakkar1003/postgresql-indexes-and-mvcc-how-queries-stay-fast-and-consistent-cde8795a7ae6?source=rss-a728a5a0f4a6------2</link>
            <guid isPermaLink="false">https://medium.com/p/cde8795a7ae6</guid>
            <category><![CDATA[indexing]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[mvcc]]></category>
            <category><![CDATA[relational-databases]]></category>
            <dc:creator><![CDATA[Vedant Thakkar]]></dc:creator>
            <pubDate>Wed, 15 Oct 2025 05:09:10 GMT</pubDate>
            <atom:updated>2025-10-15T05:09:10.675Z</atom:updated>
            <content:encoded><![CDATA[<p>Every developer knows the first instinct for speeding up slow database queries is to add an index. It often feels like magic, run CREATE INDEX and queries that once took seconds now complete in milliseconds. But there’s no trickery here. Behind the scenes, PostgreSQL uses carefully designed data structures, cost-based planning, and MVCC (Multi-Version Concurrency Control) to make your queries fast and consistent. In this article, we’ll peel back the layers to explore how PostgreSQL indexes work, how they interact with MVCC, and why understanding this can help you write smarter, faster queries. We’ll focus on the most common index type: the B-Tree.</p><h3>Where Are Indexes Stored? The Physical Reality</h3><p>First, an index is <strong>not</strong> part of the main table data. When you create a table, PostgreSQL creates a file on disk to store its data. This file is often called the “heap.”</p><blockquote>Heap files are the <strong>unordered storage structure</strong> PostgreSQL uses to store table rows on disk, where each row has a physical location (TID). <strong>Indexes point to these heap tuples</strong> to allow fast lookups, while all visibility and transaction checks happen at the heap level.</blockquote><blockquote>Read more here : <a href="https://www.postgresql.org/docs/current/storage-page-layout.html">https://www.postgresql.org/docs/current/storage-page-layout.html</a></blockquote><p>When you run CREATE INDEX, PostgreSQL creates a <strong>completely separate new file</strong> on disk just for that index.</p><p>This physical separation is crucial. An index only contains the data from the column(s) you indexed, plus a pointer to the actual row. This makes the index file much smaller than the table file. When PostgreSQL needs to find data, it can scan this smaller, highly organized index file instead of the larger, unordered table heap, dramatically reducing the amount of disk I/O required.</p><p>Each index is linked to its table via the table’s internal Object Identifier (OID). You can see this for yourself by querying the system catalogs:</p><pre>SELECT<br>    oid AS object_oid,<br>    relname AS object_name,<br>    CASE relkind<br>        WHEN &#39;r&#39; THEN &#39;table&#39;<br>        WHEN &#39;i&#39; THEN &#39;index&#39;<br>        ELSE &#39;other&#39;<br>    END AS object_type,<br>    current_setting(&#39;data_directory&#39;) || &#39;/&#39; || pg_relation_filepath(oid) AS full_physical_path<br>FROM<br>    pg_class<br>WHERE<br>    -- This subquery finds the table itself and all of its indexes<br>    oid IN (<br>        SELECT &#39;users&#39;::regclass::oid -- The OID of the table itself<br>        UNION ALL<br>        SELECT indexrelid FROM pg_index WHERE indrelid = &#39;users&#39;::regclass::oid -- The OIDs of all its indexes<br>    )<br>ORDER BY<br>    object_type;</pre><p>This query will show you that the users table and its indexes live in different files in your PostgreSQL data directory.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RQhtFFMewRknj9XoUy3P-Q.png" /></figure><h3>What Happens During CREATE INDEX?</h3><p>When you execute the CREATE INDEX command, PostgreSQL performs a series of resource-intensive steps:</p><ol><li><strong>Scan the Table:</strong> PostgreSQL reads all the data from the column(s) you specified in the CREATE INDEX statement.</li><li><strong>Sort and Build:</strong> It takes this data, sorts it, and builds the index’s tree structure in memory.</li><li><strong>Write to Disk:</strong> Once the structure is complete, it’s written out to that new, separate file on disk we just discussed.</li></ol><blockquote>During this process, a standard CREATE INDEX command will typically place a lock on the table, preventing writes. For large tables in a production environment, this can be a problem. The solution is to use CREATE INDEX CONCURRENTLY, which does more work and takes longer but avoids locking out write operations.</blockquote><h3>The B-Tree: A Database’s Best Friend</h3><p>By default, PostgreSQL uses a <strong>B-Tree</strong> (Balanced Tree) data structure for its indexes. Think of it like a massive, multi-layered phone book.</p><p>A B-Tree has several key components:</p><ul><li><strong>Root Node:</strong> The single entry point at the top of the tree.</li><li><strong>Internal Nodes (or Branch Nodes):</strong> These nodes don’t hold pointers to actual rows. Instead, they hold “signpost” values that direct the search, pointing to other internal nodes or to leaf nodes.</li><li><strong>Leaf Nodes:</strong> These are the most important nodes at the bottom of the tree. They contain the actual index data: a sorted list of pairs of (indexed_value, TID).</li></ul><p>A <strong>TID</strong> stands for <strong>Tuple ID</strong>. It is the physical address of a row in the table’s heap file, consisting of a (block_number, item_pointer). It&#39;s the most direct way PostgreSQL can find a specific version of a row.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WC6_znYLiHojwr4u3aOioA.png" /><figcaption>CTID: The physical address of each row in PostgreSQL</figcaption></figure><p>The “Balanced” part of B-Tree is the key to its performance. It guarantees that the distance from the root to any leaf node is the same. This means the time it takes to find <em>any</em> value in the index is predictable and incredibly fast. The lookup time is logarithmic, expressed as <strong>O(logN)</strong>. In simple terms, even if a table doubles in size, the time to search the index only increases by a single, tiny step. This is a massive improvement over a <strong>Sequential Scan</strong> of the table heap, which is <strong>O(N)</strong> — meaning the search time grows in direct proportion to the table size.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8XQCjBqNFsPXzDRRrtL6Bw.png" /><figcaption>B-Tree stores key values and Row IDs pointing to actual data rows in the heap table.</figcaption></figure><h3>From Index to Table: The Lookup Process</h3><p>So, how does PostgreSQL use this B-Tree to fetch a row?</p><p>Let’s say we have an index on user_id and we run SELECT * FROM users WHERE user_id = 5;.</p><ol><li><strong>Tree Traversal:</strong> PostgreSQL starts at the root node of the idx_users_user_id index. It compares 5 to the keys in the root node and follows the pointer down to the appropriate next-level node.</li><li><strong>Find the Leaf:</strong> It repeats this process, traversing down the tree until it reaches a leaf node.</li><li><strong>Scan the Leaf Node:</strong> It scans the leaf node to find the entry for user_id = 5.</li><li><strong>Get the TID:</strong> It retrieves the TID associated with that entry. Let’s say it’s (34, 12).</li><li><strong>Fetch from Heap:</strong> PostgreSQL now goes directly to block 34 of the table&#39;s heap file and fetches the 12th item (the row data). This is a <strong>random I/O</strong> operation.</li></ol><h3>What if Multiple Rows Match? Index Scan vs. Bitmap Heap Scan</h3><p>The simple process above works great for unique values. But what if our WHERE clause matches thousands of rows? Fetching them one by one (index scan -&gt; heap fetch -&gt; index scan -&gt; heap fetch ...) would result in thousands of slow, random disk I/O operations.</p><p>The query planner is smart enough to recognize this. It has two main strategies:</p><ul><li><strong>Index Scan:</strong> If the planner estimates that only a <strong>small number of rows</strong> will be returned, it uses a standard Index Scan. It walks the B-Tree leaf nodes (which are linked together, so it can read them sequentially) and fetches each row from the heap one by one as it finds the corresponding TID.</li><li><strong>Bitmap Heap Scan:</strong> If the planner estimates that a <strong>significant number of rows</strong> will be returned (but not enough to justify a full table scan), it opts for a more efficient, two-phase approach mentioned below:</li></ul><ol><li><strong>Bitmap Index Scan:</strong> First, it quickly scans the index and collects <em>all</em> the matching TIDs. It uses these to build a “bitmap” in memory, which is a highly compressed data structure that marks the pages in the table heap that contain matching rows.</li><li><strong>Bitmap Heap Scan:</strong> Next, it reads the bitmap and visits the relevant heap pages. Crucially, it visits them <strong>sequentially according to their physical location on disk</strong>, not in the order they appeared in the index. This converts thousands of costly random I/O operations into a much cheaper, sorted access pattern.</li></ol><h3>Don’t Forget Visibility! The MVCC Check</h3><p>Finding a TID in the index is not the end of the story. PostgreSQL uses <strong>Multi-Version Concurrency Control (MVCC)</strong> to handle transactions, meaning multiple “versions” of a row can exist simultaneously.</p><blockquote><strong>MVCC (Multi-Version Concurrency Control)</strong> is a database mechanism that allows multiple transactions to occur simultaneously without locking data. It achieves this by creating a new version of a data row each time it’s updated, ensuring readers see a consistent snapshot from when their transaction began, thus preventing them from blocking writers.</blockquote><blockquote>Read more here: <a href="https://www.postgresql.org/docs/7.1/mvcc.html">https://www.postgresql.org/docs/7.1/mvcc.html</a></blockquote><p>An index entry might point to a row version that was created by a transaction that hasn’t committed yet, or a version that has already been deleted but not yet cleaned up by VACUUM.</p><blockquote><strong>VACUUM</strong> in PostgreSQL cleans up <strong>dead row versions</strong> left by updates and deletes, reclaiming storage and keeping tables efficient. It also updates visibility information so indexes and queries work correctly under MVCC, ensuring consistent snapshots for all transactions.</blockquote><p>Therefore, after fetching the row data from the heap, PostgreSQL must perform one final, crucial step: a <strong>visibility check</strong>. It checks the row’s header information (xmin and xmax system columns) to determine if the current transaction is actually allowed to see that version of the row.</p><blockquote>To determine if a row is visible, PostgreSQL checks its transaction IDs. The row is only visible if its creating transaction (xmin) is committed and occurred <em>before</em> your query started. Furthermore, if the row was deleted, the deleting transaction (xmax) must have occurred <em>after</em> your query started, otherwise the row is invisible.</blockquote><h3>The MVCC Visibility Check in Action: A Race Condition Solved</h3><p>We’ve discussed how indexes find TIDs and how xmin/xmax perform visibility checks. But what if an index points to multiple versions of what appears to be the same data, due to ongoing transactions? This is where MVCC truly shines, ensuring data consistency even in highly concurrent environments.</p><h3>Problem Statement</h3><p>Imagine an e-commerce application. A user (Transaction A) is trying to view the stock_count for product_id = 101. Simultaneously, an inventory management system (Transaction B) is processing a sale, attempting to UPDATE the stock_count for that exact same product_id. Transaction B has not yet committed its update.</p><p>If our index simply returned every physical row version it found, Transaction A might incorrectly see the uncommitted, updated stock_count. This would be a &quot;dirty read&quot; and a critical data consistency error.</p><p>How does PostgreSQL, using its index and MVCC, prevent this and ensure Transaction A always sees the correct, committed stock count?</p><h3>Explanation: Indexing Meets MVCC</h3><ol><li><strong>Initial State:</strong> Our products table has product_id = 101 with stock_count = 50. Internally, this row has xmin = 800 (meaning transaction 800 created it) and xmax = 0 (meaning it hasn&#39;t been deleted or updated). This row is committed and stable.</li><li><strong>Transaction B Updates (TXID 901):</strong></li></ol><ul><li>Transaction B starts and updates product_id = 101 to stock_count = 49.</li><li><strong>Crucially, PostgreSQL does not delete the old row or overwrite it.</strong></li><li>Instead, it modifies the original row: its xmax is set to 901, marking it as &quot;dead&quot; by TXID 901. So the old row becomes (stock_count: 50, xmin: 800, xmax: 901).</li><li>A <strong>new row version</strong> is created with the updated data: (stock_count: 49, xmin: 901, xmax: 0).</li><li><strong>At this point, Transaction B has not yet committed.</strong> Both row versions physically exist on disk, and the index on product_id now has pointers (TIDs) to <em>both</em> of them.</li></ul><p><strong>3. Transaction A Queries (TXID 902):</strong></p><ul><li>Transaction A starts and executes SELECT stock_count FROM products WHERE product_id = 101;.</li><li><strong>Index Scan:</strong> The index on product_id quickly finds <strong>two TIDs</strong> pointing to the two physical row versions for product_id = 101. It returns both to the query executor. The index&#39;s job is done; it found all relevant physical data.</li></ul><p><strong>4. The MVCC Visibility Check — The Deciding Factor:</strong></p><ul><li><strong>Checking the New Version (Stock 49):</strong></li><li>PostgreSQL fetches the row data for (stock_count: 49).</li><li>It sees xmin = 901.</li><li>The MVCC visibility rules are applied: “Is Transaction 901 committed and visible to TXID 902?&quot;</li><li><strong>No.</strong> Transaction 901 is still in progress. Therefore, this (stock_count: 49) row version is <strong>invisible</strong> to Transaction A. It&#39;s filtered out.</li><li><strong>Checking the Old Version (Stock 50):</strong></li><li>PostgreSQL fetches the row data for (stock_count: 50).</li><li>It sees xmin = 800. Is 800 committed and visible? <strong>YES.</strong> (Assume TXID 800 committed long ago). So far, this row is a candidate.</li><li>It then checks xmax = 901. Has this row been deleted by a transaction visible to TXID 902?</li><li><strong>No</strong>. Although xmax is set to 901, Transaction 901 is still in progress and uncommitted. From the perspective of TX 902, the deletion by TX 901 has not yet taken effect. Therefore, the (stock_count: 50) row remains visible to TX 902. To determine this, TX 902 consults the pg_xact table to check the current status of Transaction 901.</li></ul><blockquote>pg_xact is PostgreSQL’s internal transaction status ledger. It tracks whether each transaction ID (TXID) is <strong>in-progress, committed, or aborted</strong>, using <strong>just a single bit per transaction</strong>. This allows MVCC to provide consistent snapshots and determine row visibility. The data is stored as <strong>binary files in </strong><strong>$PGDATA/pg_xact/</strong>, and it <strong>cannot be queried or read directly via SQL</strong>—only PostgreSQL’s engine accesses it internally.</blockquote><p><strong>5. Result:</strong> Transaction A’s query returns stock_count = 50.</p><p>This example vividly demonstrates that the index’s role is purely about efficient physical data retrieval. The xmin and xmax system columns, combined with the MVCC rules applied during the final heap fetch, are what truly filter the results, guaranteeing that your query sees only the consistent, committed state of the database at the precise moment your transaction began.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oNRfr8ZPvrXpU1NEL9AYfQ.png" /><figcaption>Illustration of MVCC in PostgreSQL showing how multiple row versions coexist during an update, with visibility determined by transaction status.</figcaption></figure><h3>The Query Planner: The Real Brains of the Operation</h3><p>How does PostgreSQL decide whether to use an Index Scan, a Bitmap Heap Scan, or just ignore the index entirely and perform a Sequential Scan? This decision is made by the <strong>query planner</strong>.</p><p>The planner’s sole job is to find the execution plan with the <strong>lowest estimated cost</strong>. It makes this decision by considering several factors:</p><ul><li><strong>Selectivity:</strong> How many rows are likely to match the WHERE clause? If you&#39;re querying for a unique ID (WHERE user_id = 5), the selectivity is very high (few matching rows), making an index scan look cheap. If you&#39;re querying WHERE status = &#39;active&#39; and 99% of your rows are active, the selectivity is very low (many matching rows), and a full sequential scan is almost certainly cheaper. The planner uses statistics gathered by the ANALYZE command to make these estimates.</li><li><strong>Cost of I/O:</strong> The planner has configuration parameters like random_page_cost and seq_page_cost. By default, it knows that a random disk read is significantly more expensive than a sequential one. It weighs the estimated cost of many random reads (for an index scan) against the cost of one large sequential read (for a table scan).</li><li><strong>Index-Only Scans:</strong> If all the data your query needs is already stored in the index (for example, SELECT user_id FROM users WHERE user_id &gt; 100), PostgreSQL can use an <strong>Index-Only Scan</strong>. This is much faster than a regular index scan because it <strong>never has to access the main table (heap)</strong>, avoiding costly random I/O. This works only when PostgreSQL knows that all the rows on the relevant table pages are visible to all transactions, a fact it tracks using the <strong>visibility map</strong>, a data structure that marks which pages are fully “safe” to read from the index alone.</li></ul><h3>Query Performance in Action: EXPLAIN ANALYZE.</h3><h3>Setup</h3><p>First, let’s create a table with a decent amount of data. We’ll query on the username column, which does not have an index initially.</p><pre>-- Create a sample table<br>CREATE TABLE users ( user_id SERIAL PRIMARY KEY, username VARCHAR(50) NOT NULL, email VARCHAR(100) UNIQUE, created_at TIMESTAMPTZ DEFAULT NOW()<br>);<br>-- Insert 200,000 rows<br>INSERT INTO users (username, email)<br>SELECT &#39;user_&#39; || g, &#39;user&#39; || g || &#39;@example.com&#39;<br>FROM generate_series(1, 200000) g;<br>-- Update the table&#39;s statistics for the planner<br>ANALYZE users;</pre><blockquote><strong>ANALYZE</strong> collects statistics about a table’s data such as row counts and value distribution so PostgreSQL can plan queries efficiently. Without it, the planner may choose slower execution plans, especially after large inserts or updates.</blockquote><h3>Query Without an Index</h3><p>Now, let’s ask PostgreSQL to find a single user. We use EXPLAIN ANALYZE to see the plan and the <em>actual</em> execution time.</p><pre>EXPLAIN ANALYZE SELECT user_id, email<br>FROM users WHERE username = &#39;user_123456&#39;;`</pre><p>The output will look something like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PSQWiHHuEYVTQdZXpxEyUg.png" /></figure><p>The key here is <strong>Seq Scan</strong> (Sequential Scan). PostgreSQL had to read the entire table (all 200,000 rows) from disk and check each one to find our match. Note the execution time: <strong>~27 ms</strong>.</p><p><strong>PostgreSQL query cost</strong> (cost=startup..total) is a <strong>unitless</strong> estimate used by the planner to compare execution plans.</p><ul><li><strong>Startup cost</strong> = estimated cost to return the first row.</li><li><strong>Total cost</strong> ≈ startup_cost + (number_of_pages * seq_page_cost) + (number_of_rows * cpu_tuple_cost); lower cost indicates a cheaper plan.</li></ul><h3>Adding the Index</h3><p>Now, let’s create the B-Tree index.</p><pre>CREATE INDEX idx_users_username ON users (username);</pre><h3>Query with an Index</h3><p>Let’s run the exact same query again.</p><pre>EXPLAIN ANALYZE SELECT user_id, email<br>FROM users WHERE username = &#39;user_123456&#39;;</pre><p>The output will be drastically different:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8d-vEh6JXMHJ9E9n6o5aDg.png" /></figure><p>Look at the difference!</p><ul><li><strong>The Plan:</strong> It’s now an <strong>Index Scan</strong> using our new index.</li><li><strong>The Cost:</strong> The estimated cost (0.42..8.44) is minuscule compared to the sequential scan&#39;s cost (0.00..4370.00).</li><li><strong>Execution Time:</strong> The query finished in <strong>~0.1 ms</strong>. It’s hundreds of times faster.</li></ul><p>This is the power of turning an O(N) operation into an O(logN) one. The planner instantly recognized that the highly selective query was a perfect candidate for the B-Tree index.</p><h3>Conclusion</h3><p>Indexes are not a silver bullet, but they are the most powerful tool we have for database performance tuning. By understanding how they work internally, we can move beyond simply adding them and start thinking about <em>why</em> they work.</p><p><strong>Key Takeaways:</strong></p><ul><li>Indexes are <strong>separate physical files</strong> that are smaller and better organized than the main table heap.</li><li>The default <strong>B-Tree</strong> structure allows for incredibly fast, logarithmic (O(logN)) lookups.</li><li>The goal of an index lookup is to find a row’s physical address, its <strong>TID</strong>.</li><li>The query planner makes a <strong>cost-based decision</strong> to use an index, weighing factors like selectivity and the cost of random vs. sequential I/O.</li><li>Even with an index hit, PostgreSQL must always perform a <strong>visibility check (MVCC)</strong> to ensure the row is visible to the current transaction.</li></ul><p>So the next time you CREATE INDEX, you&#39;ll know it&#39;s not magic—it&#39;s just a brilliant piece of computer science at work.</p><p>In future blogs, we’ll explore special cases of indexing, including multi-column indexes, GiST, GIN, and functional expression indexes, to see how they solve more complex performance challenges.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=cde8795a7ae6" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pglogical in Action: Streaming PostgreSQL Changes to GCP DMS]]></title>
            <link>https://medium.com/@vedantthakkar1003/pglogical-node-in-action-streaming-postgresql-changes-to-gcp-dms-7fbedafb0f66?source=rss-a728a5a0f4a6------2</link>
            <guid isPermaLink="false">https://medium.com/p/7fbedafb0f66</guid>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[change-data-capture]]></category>
            <category><![CDATA[data-migration-service]]></category>
            <category><![CDATA[logical-replication]]></category>
            <dc:creator><![CDATA[Vedant Thakkar]]></dc:creator>
            <pubDate>Tue, 07 Oct 2025 09:49:30 GMT</pubDate>
            <atom:updated>2025-10-07T10:00:36.622Z</atom:updated>
            <content:encoded><![CDATA[<p><em>A detailed walkthrough of the data flow and mechanics behind Change Data Capture from PostgreSQL to GCP DMS.</em></p><p>When implementing Change Data Capture (CDC) from PostgreSQL to GCP Database Migration Service (DMS), one of the most important components is the <strong>pglogical.node</strong>. Understanding how it works is essential for building reliable replication pipelines and troubleshooting issues effectively.</p><p>The pglogical.node acts as the <strong>control plane</strong> for logical replication. It identifies data changes, serializes them, and ensures they are transmitted accurately from the source database to downstream consumers. In this article, we break down its role and walk through the <strong>end-to-end replication process</strong>, highlighting key mechanics and best practices.</p><h3>The pglogical.node Component</h3><p>In the context of the pglogical extension for PostgreSQL, a <strong>node</strong> represents an endpoint in a replication topology, acting as either a <strong>provider</strong> or a <strong>subscriber</strong>.</p><ul><li><strong>Provider Node</strong>: Deployed on the source PostgreSQL database, this node reads changes directly from the Write-Ahead Log (WAL) and makes them available to subscribers.</li><li><strong>Subscriber Node</strong>: A client, such as GCP DMS, that connects to the provider node to receive and apply these changes.</li></ul><p>The pglogical.node is not a passive entity; it actively manages critical replication metadata, including:</p><ul><li><strong>Tables included in replication sets:</strong> You can query the source PostgreSQL database to see all tables included in replication:</li></ul><pre>SELECT<br>  r.set_name,<br>  c.relname AS table_name,<br>  n.nspname AS schema_name<br>FROM pglogical.replication_set_table t<br>JOIN pglogical.replication_set r ON t.set_id = r.set_id<br>JOIN pg_class c ON t.set_reloid = c.oid<br>JOIN pg_namespace n ON c.relnamespace = n.oid;<br></pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ux69FnkugTKA6HwfBRjxNg.png" /><figcaption>Result for above query which shows tables for replications</figcaption></figure><ul><li><strong>Replication sets:</strong> Logical grouping of tables into defined replication sets.</li><li><strong>Last processed Log Sequence Number (LSN):</strong> Acts as a bookmark in the WAL stream for each subscriber.</li><li><strong>WAL position tracking:</strong> Ensures a consistent and resumable stream for subscribers.</li></ul><blockquote><strong><em>Note:</em></strong><em> </em>When using GCP DMS, you may not see replication slots or subscription metadata in PostgreSQL. This is normal — DMS tracks LSNs internally. To verify replication, check <strong>target database content</strong> and <strong>DMS task logs</strong>.</blockquote><blockquote><strong><em>Tip:</em></strong><em> To see the current WAL position on the source database, use</em></blockquote><pre>SELECT pg_current_wal_lsn();</pre><blockquote><strong>Caution:</strong> Dropping a pglogical.node does not affect user data, but permanently removes all replication metadata. This will sever the replication stream for active subscribers.</blockquote><h3>Architectural Role and Importance</h3><p>The pglogical.node is a foundational component because it manages several functions critical for reliable data replication.Key Roles of pglogical.node in PostgreSQL Replication</p><ul><li><strong>WAL Change Tracking:</strong> Captures DML operations (INSERT, UPDATE, DELETE) at the table level from the source database’s WAL.</li><li><strong>Subscriber Management:</strong> Maintains a record of all consumers connected to the replication stream, including their LSN positions.</li><li><strong>Replication Set Definition:</strong> Defines which database objects are included in the replication stream to control what gets replicated.</li><li><strong>LSN Offset Coordination:</strong> Enables robust recovery and synchronization after a service interruption or downtime.</li></ul><p>Without a properly configured pglogical.node, GCP DMS would lack the necessary context to identify, fetch, or apply changes, making CDC impossible.</p><h3>High-Level Replication Architecture</h3><p>The data flows in a linear, coordinated path from the source PostgreSQL database to the final target via GCP DMS. The pglogical.node on the source acts as the intermediary that decodes the WAL stream for DMS.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rzoqXKcsVZyQN5W5hXX8Yw.png" /><figcaption>End-to-end flow of PostgreSQL change data capture using pglogical and GCP DMS, from WAL to target database replication</figcaption></figure><h3>The Step-by-Step Replication Flow</h3><p>The following sequence diagram illustrates the interactions between <br>components when a new record is inserted into a replicated table.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eIs2LPENGHcTkj1oCN-s9Q.png" /><figcaption>PostgreSQL CDC workflow: WAL → pglogical → GCP DMS → Target DB.</figcaption></figure><h3>Event Breakdown</h3><ul><li><strong>T0: Data Modification</strong>: A user executes an INSERT statement against a table configured for replication.</li></ul><pre>INSERT INTO orders (customer_id, item, quantity, status) VALUES (123, &#39;iPhone 15&#39;, 1, &#39;PENDING&#39;);</pre><ul><li><strong>T1: WAL Entry</strong>: PostgreSQL records the transaction in its Write-Ahead Log to ensure durability. This entry is stamped with a unique LSN.</li><li><strong>T2: Change Capture</strong>: The pglogical background worker process reads the new entry from the WAL and maps it to the appropriate replication set.</li><li><strong>T3: DMS Polling</strong>: The GCP DMS task, acting as a subscriber, periodically queries the provider node, requesting all changes that have occurred since its last recorded LSN.</li><li><strong>T4: Data Serialization</strong>: The provider node translates the binary WAL record into a logical, structured format (e.g., JSON) and streams it to DMS.</li></ul><pre>{<br>  &quot;action&quot;: &quot;INSERT&quot;,<br>  &quot;schema&quot;: &quot;public&quot;,<br>  &quot;table&quot;: &quot;orders&quot;,<br>  &quot;columns&quot;: {<br>    &quot;order_id&quot;: 101,<br>    &quot;customer_id&quot;: 123,<br>    &quot;item&quot;: &quot;iPhone 15&quot;,<br>    &quot;quantity&quot;: 1,<br>    &quot;status&quot;: &quot;PENDING&quot;<br>  }<br>}</pre><ul><li><strong>T5: Change Application</strong>: DMS receives the payload and executes the corresponding INSERT statement against the target database.</li><li><strong>T6: Checkpoint Update</strong>: Upon successful application, DMS updates its checkpoint by acknowledging the LSN of the processed transaction with the provider node. This prevents data duplication and ensures the stream can be resumed accurately.</li></ul><h3>Common Operational Issues</h3><h3>Error: “Provider Node Already Exists”</h3><p>This error occurs when a DMS task attempts to create a new provider node on a database where one is already configured.</p><pre>ERROR: pglogical provider node already exists on database</pre><p><strong>Solution</strong>: A database can only have one provider node. The DMS task must be configured to use the existing node, or the existing node must be dropped before creating a new one.</p><h3>Performance and Resource Considerations</h3><p>When a DMS task is active, expect the following impacts on the source PostgreSQL instance:</p><ul><li><strong>Transaction Age</strong>: During a full load, DMS initiates a long-running transaction to create a consistent snapshot. This will temporarily increase the “oldest transaction age” metric.</li><li><strong>WAL Retention</strong>: Long-running transactions and replication slots prevent WAL segments from being recycled. This is expected behavior and may lead to increased disk usage until the initial load is complete and the replication lag is minimal.</li><li><strong>Replication Delay</strong>: The latency between a source commit and its application on the target will be highest during the initial data load and will stabilize once the task enters the ongoing replication (CDC) phase.</li></ul><h3>Conclusion</h3><p>The pglogical.node is the core engine that facilitates logical replication from PostgreSQL for services like GCP DMS. It is responsible for decoding the WAL, managing subscriber state via LSN tracking, and ensuring the consistent, ordered delivery of data changes.</p><blockquote><strong>Important Tip</strong>: Before restarting or reconfiguring a failed DMS task, always inspect the state of the pglogical.node and any associated replication slots (pg_replication_slots). Misalignment between the DMS checkpoint and the slot&#39;s LSN position is a common source of replication failures.</blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7fbedafb0f66" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>