Why architecture matters here
The architecture matters because the containment query it serves is genuinely unsupported by ordinary indexes, and the naive alternative is a full scan. Ask a B-tree 'which documents contain the word peering' and it has nothing to offer — there is no single column value 'peering' to seek to; the word is buried among hundreds of others in each row's text. Without an inverted index the only option is to read every row and test it, which is linear in the table size and collapses the moment the table is large or the query rate is high. GIN exists because 'find rows containing an element' is a first-class need — search, tag filtering, JSON querying — that the relational index toolkit otherwise cannot answer efficiently.
It matters because the inversion changes the complexity of the query, not just its constant factor. A GIN lookup finds a key in a B-tree over the distinct keys — logarithmic in the vocabulary — and then reads a pre-sorted posting list of exactly the matching rows. A multi-term query intersects or unions already-sorted lists, which is linear only in the size of those lists, not the table. The result is that 'documents containing peering AND gateway' touches only the rows that plausibly match, however large the table grows. That is the same reason web search returns in milliseconds over billions of documents: you never look at the non-matching majority.
The architecture also matters because its write cost is real and must be designed around. One row with two hundred distinct words is two hundred posting-list insertions, each keeping its list sorted — orders of magnitude more work than a B-tree's single write. If GIN wrote every key straight into the main structure on every insert, write-heavy tables would grind. The pending list is the architectural response: new entries are appended to an unsorted buffer cheaply, and periodically merged into the main posting lists in one amortized pass. Understanding that GIN is a read-optimized structure with a write buffer is essential to using it without accidentally building a write bottleneck.
Finally, it matters because GIN is lossy by design for some operators, which makes the recheck step load-bearing. For efficiency, GIN may index a coarser representation than the exact value — enough to find candidate rows but not always enough to confirm the match — so after intersecting posting lists it must fetch the candidate rows from the heap and recheck the original condition. This recheck is why a GIN query's cost is not just the index lookup: if the index returns many false-positive candidates, the heap rechecks dominate. Knowing that the index produces candidates, not final answers, explains both why GIN is correct and where its performance can quietly degrade.
The architecture: every piece explained
Top row: from a composite value to indexed keys. Each row value is a composite — a text document, an array of tags, a JSONB object — that contains many searchable elements. GIN runs an extract-keys step appropriate to the type: tokenizing text into lexemes, enumerating the elements of an array, or flattening a JSONB blob into its paths and values. For each extracted key, GIN maintains a posting list: a sorted list of the row identifiers (tuple IDs, or TIDs) of every row that contains that key. Over the set of distinct keys sits a B-tree over keys, so that looking up a specific key — 'peering', or the JSON path city=Paris — is a fast logarithmic seek rather than a scan of the vocabulary.
Middle row: answering a query. A query supplies one or more keys combined with AND or OR — 'contains peering and gateway', 'has tag urgent or important'. GIN looks up each key in the B-tree, retrieves its posting list, and intersects the lists for AND or unions them for OR. Because the posting lists are pre-sorted by TID, these set operations are efficient merges. The result is a set of candidate rows — the rows the index believes match. For exact operators these candidates are the answer; for lossy operators they are only candidates, which is why the final step is a heap recheck: GIN fetches each candidate row and re-evaluates the original condition to confirm the match (and to rank it, for relevance-ordered search).
Bottom-left: the write buffer. Because indexing one row can touch hundreds of posting lists, GIN offers a pending list (the fastupdate mechanism): new entries are appended to an unsorted pending area cheaply at insert time, deferring the expensive merge into the main posting lists. Periodically — when the pending list exceeds a size limit, during vacuum, or when a query would benefit — the pending entries are sorted and merged into the main structure in one amortized pass. Reads must scan both the main index and the pending list, so a large pending list slows queries; the size limit trades write amortization against read overhead.
Bottom-right and ops: the fundamental trade-off. GIN is explicitly slow to update, fast to read, because the many-keys-per-row fan-out makes writes heavy and makes containment queries cheap. The ops strip names the levers that manage this: enabling or disabling fastupdate and sizing gin_pending_list_limit to balance write cost against read overhead, watching the recheck cost that lossy operators impose, and giving the index enough maintenance memory at build time so constructing it over a large table does not thrash. Tuned well, GIN turns containment queries that would otherwise scan the whole table into targeted posting-list merges.
End-to-end flow
Trace a support-ticket table with a full-text-searchable body and a JSONB metadata column, from indexing a new ticket through a multi-term search and a maintenance merge.
Indexing a ticket: a new ticket is inserted with a body of roughly two hundred words and a JSONB metadata object carrying a customer id, a priority, and a list of product tags. GIN's extract step tokenizes the body into distinct lexemes and flattens the JSONB into path/value keys. That is well over a hundred distinct keys for one row. With fastupdate on, GIN does not merge all of these into the main posting lists immediately; it appends them to the pending list, a cheap unsorted write, and the insert returns quickly. The heavy work is deferred.
A search arrives: an agent searches for tickets containing both 'timeout' and 'gateway' with priority 'high'. GIN looks up each key in the B-tree over keys: it retrieves the posting list for 'timeout', the posting list for 'gateway', and the posting list for the JSONB path priority=high. Because all three lists are sorted by TID, GIN intersects them in a single merge pass, yielding the small set of candidate rows present in all three. It also scans the pending list for any not-yet-merged entries matching these keys, so recent tickets are not missed.
Recheck and rank: for the full-text terms GIN's index is lossy for phrase and ranking purposes, so it fetches each candidate row from the heap and rechecks the original text-search condition, confirming that the document genuinely matches and computing a relevance rank for ordering. The JSONB equality is exact, so those candidates need no recheck for correctness. The query returns the confirmed, ranked tickets — having examined only the handful of candidate rows the posting-list intersection produced, never the millions of non-matching tickets.
The pending list grows: after a batch of ticket imports, the pending list has accumulated many thousands of unmerged entries. Queries are now paying to scan a large pending list on top of the main index, and read latency creeps up. When the pending list crosses gin_pending_list_limit, or when autovacuum runs, GIN sorts the pending entries and merges them into the main posting lists in one amortized pass — cheaper per entry than hundreds of individual insertions would have been. Afterward the pending list is small again and query latency drops back.
What the flow shows: the write path stayed cheap by buffering hundreds of per-row keys into the pending list; the read path stayed fast by intersecting pre-sorted posting lists down to a few candidates and rechecking only those; and the amortized merge reconciled the two without ever making an individual insert pay the full fan-out cost. Every property of GIN — the inversion, the posting lists, the recheck, the pending list — earned its place in that single search. The whole design is 'buffer the heavy writes, invert for cheap containment reads, and confirm candidates against the heap.'