<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.alexstoica.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.alexstoica.com/" rel="alternate" type="text/html" /><updated>2026-08-25T08:18:35+00:00</updated><id>https://www.alexstoica.com/feed.xml</id><title type="html">Alex’s nuggets of information</title><subtitle>This is where I collect some of my findings whilst naving the world of Software Development. It&apos;ll be a mix of random/interesting/weird things that I&apos;ve found while I was coding/hacking on something.</subtitle><entry><title type="html">The Hidden CPU Cost of PostgreSQL Logical Replication</title><link href="https://www.alexstoica.com/blog/logical-replication-cpu-cost" rel="alternate" type="text/html" title="The Hidden CPU Cost of PostgreSQL Logical Replication" /><published>2026-07-30T00:00:00+00:00</published><updated>2026-07-30T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/logical-replication-cpu-cost</id><content type="html" xml:base="https://www.alexstoica.com/blog/logical-replication-cpu-cost"><![CDATA[<p>Logical replication looks like data being copied from one database to another,
which is a useful mental model right up until you start paying attention to how
much work it hides.</p>

<!--more-->

<p>Postgres is not simply taking a row from one machine and placing it on another.
The publisher first writes a physical change to WAL. A separate process later
decodes that storage-level change back into a logical row, reconstructs its
transaction, filters it, serialises it, and sends it over the replication
protocol. The subscriber then finds the corresponding local relation, applies a
new write, maintains its indexes, checks constraints and produces <em>another</em> set
of WAL records.</p>

<p>Every replicated change is therefore paid for several times.</p>

<p>This is easy to miss because logical replication is normally asynchronous. The
application can continue committing on the publisher whilst the expensive part
quietly moves into a <code class="language-plaintext highlighter-rouge">walsender</code>, an apply worker, or an ever-growing replication
lag graph.</p>

<p>Let’s dig into where that CPU goes, and then measure how much difference a few
seemingly harmless schema choices can make.</p>

<h2 id="glossary">Glossary</h2>

<ul>
  <li><strong>WAL</strong> - the write-ahead log; the physical record of every change made to heap and index pages</li>
  <li><strong>logical decoding</strong> - the process of reading physical WAL records and reconstructing them as row-level changes grouped into transactions</li>
  <li><strong><code class="language-plaintext highlighter-rouge">walsender</code></strong> - the publisher-side process which streams decoded changes to a subscriber</li>
  <li><strong><code class="language-plaintext highlighter-rouge">pgoutput</code></strong> - the built-in output plugin which serialises decoded changes into logical replication protocol messages</li>
  <li><strong><code class="language-plaintext highlighter-rouge">ReorderBuffer</code></strong> - publisher-side structure holding decoded changes until their transaction commits, so they can be emitted in commit order</li>
  <li><strong>apply worker</strong> - the subscriber-side process which maps incoming messages to local tables and executes the writes</li>
  <li><strong>replica identity</strong> - the columns a publisher sends so the subscriber can locate the row to <code class="language-plaintext highlighter-rouge">UPDATE</code> or <code class="language-plaintext highlighter-rouge">DELETE</code></li>
  <li><strong>replication slot</strong> - publisher-side bookmark which retains WAL until a consumer confirms it has been received</li>
  <li><strong>HOT update</strong> - a <em>heap-only tuple</em> update; a new row version written to the same page without touching any index</li>
</ul>

<h2 id="a-row-becomes-physical-before-it-becomes-logical-again">A Row Becomes Physical Before It Becomes Logical Again</h2>

<p>The strange part of logical replication is that the source database
does not start with a logical stream.</p>

<p>Postgres executes the original <code class="language-plaintext highlighter-rouge">INSERT</code>, <code class="language-plaintext highlighter-rouge">UPDATE</code> or <code class="language-plaintext highlighter-rouge">DELETE</code> as normal. It
modifies heap and index pages and writes WAL describing those storage-level
changes. Logical decoding then has to reverse some of that process: it reads the
WAL and turns it back into a coherent stream of transactions and tuples.</p>

<p>The full path looks roughly like this:</p>

<style>
#lrpipe-root * { box-sizing: border-box; }
#lrpipe-root {
  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
  font-size: 12.5px;
  background: #f8f9fa;
  border: 1px solid #dee2e6;
  border-radius: 8px;
  padding: 18px;
  margin: 1.5rem 0;
  line-height: 1.5;
}

.lrp-grid {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  gap: 10px;
  align-items: stretch;
}

.lrp-col {
  display: flex;
  flex-direction: column;
  background: #fff;
  border: 1px solid #dee2e6;
  border-radius: 6px;
  padding: 12px;
}

.lrp-colhdr {
  font-size: 9.5px; font-weight: 700; text-transform: uppercase;
  letter-spacing: .6px; color: #6c757d;
  margin-bottom: 10px; padding-bottom: 4px;
  border-bottom: 1px solid #e9ecef;
  text-align: center;
}

.lrp-stage {
  background: #fff;
  border: 2px solid #adb5bd;
  border-radius: 6px;
  padding: 7px 10px;
  text-align: center;
}

/* the stages which exist only because the stream is logical */
.lrp-stage.lrp-logical {
  border-color: #0d6efd;
  background: #e7f1ff;
}

/* the write amplification at each end */
.lrp-stage.lrp-write {
  border-color: #fd7e14;
  background: #fff4e6;
}

.lrp-sub {
  display: block;
  font-size: 10.5px;
  color: #6c757d;
  margin-top: 2px;
}

.lrp-stage.lrp-logical .lrp-sub { color: #4b6ea8; }
.lrp-stage.lrp-write   .lrp-sub { color: #a1652b; }

.lrp-arrow {
  text-align: center;
  color: #adb5bd;
  font-size: 14px;
  line-height: 1;
  margin: 5px 0;
}

/* middle column: the wire between the two servers */
.lrp-link {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-width: 108px;
  padding: 0 2px;
}

.lrp-linklabel {
  font-size: 10.5px;
  color: #6c757d;
  text-align: center;
  text-transform: uppercase;
  letter-spacing: .5px;
  font-weight: 700;
}

.lrp-linkwire {
  width: 100%;
  border-top: 2px dashed #adb5bd;
  position: relative;
}

.lrp-linkwire::after {
  content: '';
  position: absolute;
  right: -1px; top: -6px;
  border-left: 8px solid #adb5bd;
  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
}

.lrp-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  justify-content: center;
  margin-top: 14px;
  font-size: 10.5px;
  color: #6c757d;
}

.lrp-key {
  display: inline-block;
  width: 10px; height: 10px;
  border-radius: 2px;
  border: 2px solid #adb5bd;
  vertical-align: -1px;
  margin-right: 5px;
}
.lrp-key.lrp-logical { border-color: #0d6efd; background: #e7f1ff; }
.lrp-key.lrp-write   { border-color: #fd7e14; background: #fff4e6; }

@media screen and (max-width: 600px) {
  .lrp-grid { grid-template-columns: 1fr; }
  .lrp-link { min-width: 0; padding: 4px 0; flex-direction: column-reverse; }
  .lrp-linkwire {
    width: 0; height: 26px;
    border-top: 0; border-left: 2px dashed #adb5bd;
  }
  .lrp-linkwire::after {
    right: auto; left: -6px; top: auto; bottom: -1px;
    border-left: 5px solid transparent;
    border-right: 5px solid transparent;
    border-top: 8px solid #adb5bd;
    border-bottom: 0;
  }
}
</style>

<div id="lrpipe-root">
  <div class="lrp-grid">

    <div class="lrp-col">
      <div class="lrp-colhdr">Publisher</div>

      <div class="lrp-stage">Application DML</div>
      <div class="lrp-arrow">&#8595;</div>
      <div class="lrp-stage lrp-write">Heap + indexes</div>
      <div class="lrp-arrow">&#8595;</div>
      <div class="lrp-stage lrp-write">WAL<span class="lrp-sub">wal_level = logical</span></div>
      <div class="lrp-arrow">&#8595;</div>
      <div class="lrp-stage lrp-logical">Walsender<span class="lrp-sub">logical decoding</span></div>
      <div class="lrp-arrow">&#8595;</div>
      <div class="lrp-stage lrp-logical">ReorderBuffer<span class="lrp-sub">holds txn until commit</span></div>
      <div class="lrp-arrow">&#8595;</div>
      <div class="lrp-stage lrp-logical">pgoutput<span class="lrp-sub">filter + serialise</span></div>
    </div>

    <div class="lrp-link">
      <div class="lrp-linklabel">Replication<br />protocol</div>
      <div class="lrp-linkwire"></div>
    </div>

    <div class="lrp-col">
      <div class="lrp-colhdr">Subscriber</div>

      <div class="lrp-stage lrp-logical">Apply worker<span class="lrp-sub">maps to local relation</span></div>
      <div class="lrp-arrow">&#8595;</div>
      <div class="lrp-stage lrp-logical">Replica identity lookup<span class="lrp-sub">UPDATE and DELETE only</span></div>
      <div class="lrp-arrow">&#8595;</div>
      <div class="lrp-stage lrp-write">Heap + indexes<span class="lrp-sub">+ constraints</span></div>
      <div class="lrp-arrow">&#8595;</div>
      <div class="lrp-stage lrp-write">WAL<span class="lrp-sub">a second write workload</span></div>
    </div>

  </div>

  <div class="lrp-legend">
    <span><span class="lrp-key lrp-logical"></span>work which exists only because the stream is logical</span>
    <span><span class="lrp-key lrp-write"></span>physical writes paid for at both ends</span>
  </div>
</div>

<p>The built-in implementation uses a <code class="language-plaintext highlighter-rouge">walsender</code> on the publisher and an apply
worker on the subscriber. The standard <code class="language-plaintext highlighter-rouge">pgoutput</code> plugin transforms decoded
changes into logical replication protocol messages, and the apply worker maps
those messages to local tables in transactional order. This is described in the
<a href="https://www.postgresql.org/docs/18/logical-replication-architecture.html">PostgreSQL logical replication architecture</a>.</p>

<p>Each arrow represents work which can become the bottleneck independently.</p>

<h2 id="where-the-publisher-spends-cpu">Where the Publisher Spends CPU</h2>

<h3 id="writing-enough-wal-to-decode-later">Writing enough WAL to decode later</h3>

<p>Logical replication requires <code class="language-plaintext highlighter-rouge">wal_level = logical</code>, which contains everything in
<code class="language-plaintext highlighter-rouge">replica</code> WAL plus the information needed to extract logical change sets, and
Postgres explicitly warns that this can increase your WAL volume, particularly
for <code class="language-plaintext highlighter-rouge">UPDATE</code> and <code class="language-plaintext highlighter-rouge">DELETE</code> operations on tables using <code class="language-plaintext highlighter-rouge">REPLICA IDENTITY FULL</code>.</p>

<p>This cost can exist even before a consumer starts reading the stream, because the
publisher has to preserve enough information for a future decoder to understand
what happened. See the <a href="https://www.postgresql.org/docs/18/runtime-config-wal.html#GUC-WAL-LEVEL"><code class="language-plaintext highlighter-rouge">wal_level</code> documentation</a>
for the exact distinction.</p>

<h3 id="decoding-and-rebuilding-transactions">Decoding and rebuilding transactions</h3>

<p>WAL describes changes at the storage layer, so logical decoding has to turn those
records back into an application-specific stream of tuples and transactions,
which means identifying the relation, reconstructing tuple data, preserving
transaction order and keeping changes around until it knows whether the
transaction commits.</p>

<p>Postgres uses a <code class="language-plaintext highlighter-rouge">ReorderBuffer</code> for this. Large transactions can exceed
<code class="language-plaintext highlighter-rouge">logical_decoding_work_mem</code> and spill decoded changes to disk, exchanging a
memory problem for CPU and I/O. The spill is visible in
<code class="language-plaintext highlighter-rouge">pg_stat_replication_slots</code> through <code class="language-plaintext highlighter-rouge">spill_txns</code>, <code class="language-plaintext highlighter-rouge">spill_count</code> and
<code class="language-plaintext highlighter-rouge">spill_bytes</code>.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">slot_name</span><span class="p">,</span>
    <span class="n">spill_txns</span><span class="p">,</span>
    <span class="n">pg_size_pretty</span><span class="p">(</span><span class="n">spill_bytes</span><span class="p">)</span> <span class="k">AS</span> <span class="n">spilled</span><span class="p">,</span>
    <span class="n">stream_txns</span><span class="p">,</span>
    <span class="n">pg_size_pretty</span><span class="p">(</span><span class="n">stream_bytes</span><span class="p">)</span> <span class="k">AS</span> <span class="n">streamed</span>
<span class="k">FROM</span> <span class="n">pg_stat_replication_slots</span><span class="p">;</span>
</code></pre></div></div>

<p>This is why one transaction containing a million changes behaves differently
from a million one-row transactions, even when both modify the same
number of rows.</p>

<h3 id="filtering-and-serialising-rows">Filtering and serialising rows</h3>

<p><code class="language-plaintext highlighter-rouge">pgoutput</code> still has to turn decoded tuples into protocol messages, and by
default a subscription requests those values in text format, which invokes type
output on the publisher and type input on the subscriber. <code class="language-plaintext highlighter-rouge">binary = true</code> can
avoid some of that conversion, but it is less portable between PostgreSQL
versions, machine architectures and differing source/target types.</p>

<p>Binary mode can be faster, but it is not a free switch. PostgreSQL documents the
trade-off directly under <a href="https://www.postgresql.org/docs/18/sql-createsubscription.html"><code class="language-plaintext highlighter-rouge">CREATE SUBSCRIPTION</code></a>.</p>

<p>Publication filtering also executes here, so whilst column lists can reduce the
data sent, row filters will consume publisher CPU to do it. <code class="language-plaintext highlighter-rouge">UPDATE</code> is the awkward case:
Postgres evaluates the filter against both the old and new row and may transform
the change into an <code class="language-plaintext highlighter-rouge">INSERT</code> or <code class="language-plaintext highlighter-rouge">DELETE</code> when the row moves across the filter
boundary. The behaviour is covered in the
<a href="https://www.postgresql.org/docs/18/logical-replication-row-filter.html">row-filter documentation</a>.</p>

<h2 id="where-the-subscriber-spends-cpu">Where the Subscriber Spends CPU</h2>

<p>The subscriber is not replaying the publisher’s page changes, it is applying new
logical writes against its <em>own</em> physical layout, which is where most of the
surprise comes from.</p>

<p>For every incoming change it may need to:</p>

<ul>
  <li>parse the replication message and map remote columns to a local relation</li>
  <li>locate the target row for an <code class="language-plaintext highlighter-rouge">UPDATE</code> or <code class="language-plaintext highlighter-rouge">DELETE</code></li>
  <li>create a new MVCC tuple version</li>
  <li>maintain every affected local index</li>
  <li>enforce applicable local constraints such as <code class="language-plaintext highlighter-rouge">NOT NULL</code>, <code class="language-plaintext highlighter-rouge">CHECK</code>, unique and
exclusion constraints</li>
  <li>write heap and index changes into local WAL</li>
</ul>

<p>Normal triggers and rules do not fire because the apply worker runs with
<code class="language-plaintext highlighter-rouge">session_replication_role = replica</code>. This includes the triggers used to enforce
foreign keys. Triggers can be explicitly enabled for replica execution, so they
should not be assumed to be free in every schema. Constraints enforced by the
executor and indexes still matter: an incoming unique or exclusion conflict can
stop replication. PostgreSQL’s <a href="https://www.postgresql.org/docs/18/logical-replication-conflicts.html">conflict documentation</a>
describes the failure modes.</p>

<p>Index maintenance is often the dominant subscriber cost, because a subscriber
with one primary key and a subscriber with six secondary indexes receive exactly
the same logical row whilst doing nowhere near the same amount of work. The second schema produces
more page changes, consumes more CPU and writes more local WAL.</p>

<p>This is also why HOT eligibility matters, given that an update can only remain
HOT when no indexed value needs changing and there is room on the heap page. Adding an index
to a frequently changed subscriber column can turn one heap update into a heap
update plus several B-tree writes.</p>

<p>Subscriptions default their apply workers to <code class="language-plaintext highlighter-rouge">synchronous_commit = off</code>. This
avoids waiting for every subscriber flush and lets Postgres resend changes after
a subscriber crash, but it does not eliminate local WAL generation.</p>

<h2 id="logical-versus-physical-replication">Logical Versus Physical Replication</h2>

<p>Physical replication gets summarised as “copy bytes” and logical replication as
“execute queries”, which is not quite right, because a physical standby still
spends CPU replaying WAL and modifying heap and index pages. It is not free.</p>

<p>The difference is that physical replication sends the existing physical WAL
records and replays them against an identical physical structure. Logical
replication decodes those records into row-level meaning and then generates new
local changes on a potentially different schema.</p>

<table>
  <thead>
    <tr>
      <th>Work</th>
      <th>Physical replication</th>
      <th>Logical replication</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Publisher writes WAL</td>
      <td>Yes</td>
      <td>Yes, with extra logical information</td>
    </tr>
    <tr>
      <td>Publisher decodes WAL into rows</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Transfer format</td>
      <td>Physical WAL records</td>
      <td>Relation and tuple messages</td>
    </tr>
    <tr>
      <td>Receiver locates rows by key</td>
      <td>No</td>
      <td>For <code class="language-plaintext highlighter-rouge">UPDATE</code> and <code class="language-plaintext highlighter-rouge">DELETE</code></td>
    </tr>
    <tr>
      <td>Receiver maintains its own indexes</td>
      <td>WAL redo changes index pages</td>
      <td>Executor maintains local indexes</td>
    </tr>
    <tr>
      <td>Receiver checks local constraints</td>
      <td>No SQL-level recheck</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Receiver generates new WAL for the replicated change</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Publisher and subscriber schemas may differ</td>
      <td>No</td>
      <td>Yes, within logical replication rules</td>
    </tr>
  </tbody>
</table>

<p>Logical replication buys per-table selection, row and column filtering,
cross-version replication and independently designed subscribers. The additional
CPU is the price of that flexibility.</p>

<h2 id="measuring-the-cost">Measuring the Cost</h2>

<p>I ran two isolated PostgreSQL 18.1 clusters on the same 10-core Apple M1 Pro with
32 GB of memory. Keeping both clusters on one machine is not representative of a
production topology, but it makes the CPU competition visible and keeps the
test reproducible.</p>

<p>The harness is
<a href="/benchmarks/logical-replication-cpu/run-benchmark.sh"><code class="language-plaintext highlighter-rouge">run-benchmark.sh</code></a>. It
builds two disposable clusters, runs every scenario and tears them down again,
so the numbers below can be reproduced directly. The full per-run figures are on
the <a href="/benchmarks/logical-replication-cpu/results">benchmark results page</a>.</p>

<p>The main table was:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">events</span> <span class="p">(</span>
    <span class="n">id</span>          <span class="nb">bigint</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="n">account_id</span>  <span class="nb">integer</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">status</span>      <span class="nb">smallint</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">payload</span>     <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">updated_at</span>  <span class="n">timestamptz</span> <span class="k">NOT</span> <span class="k">NULL</span>
<span class="p">);</span>
</code></pre></div></div>

<p>Eight clients committed 20,000 transactions in total, each of them inserting 25
rows with a 128-character payload, which comes to 500,000 rows per scenario.</p>

<p>To focus the test on CPU rather than storage durability, both clusters used
<code class="language-plaintext highlighter-rouge">fsync = off</code>, <code class="language-plaintext highlighter-rouge">synchronous_commit = off</code>, <code class="language-plaintext highlighter-rouge">full_page_writes = off</code> and
<code class="language-plaintext highlighter-rouge">autovacuum = off</code>. Every publisher scenario used <code class="language-plaintext highlighter-rouge">wal_level = logical</code>,
including the no-consumer baseline, and there was no initial table copy.</p>

<p>I sampled CPU time for the PostgreSQL server processes, excluding <code class="language-plaintext highlighter-rouge">pgbench</code> and
the <code class="language-plaintext highlighter-rouge">pg_recvlogical</code> client. Each logical scenario waited for an explicit marker
row to become queryable on the subscriber; “caught up” therefore means applied,
not merely received. The values below are medians from three clean runs; the raw
per-run figures are on the <a href="/benchmarks/logical-replication-cpu/results">results
page</a>.</p>

<table>
  <thead>
    <tr>
      <th>Scenario</th>
      <th style="text-align: right">Publisher CPU</th>
      <th style="text-align: right">Subscriber CPU</th>
      <th style="text-align: right">Total server CPU</th>
      <th style="text-align: right">Relative to baseline</th>
      <th style="text-align: right">Post-load drain</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>No consumer</td>
      <td style="text-align: right">4.71 s</td>
      <td style="text-align: right">0.00 s</td>
      <td style="text-align: right">4.71 s</td>
      <td style="text-align: right">1.00x</td>
      <td style="text-align: right">0.03 s</td>
    </tr>
    <tr>
      <td>Decode to <code class="language-plaintext highlighter-rouge">/dev/null</code></td>
      <td style="text-align: right">7.92 s</td>
      <td style="text-align: right">0.00 s</td>
      <td style="text-align: right">7.92 s</td>
      <td style="text-align: right">1.68x</td>
      <td style="text-align: right">4.35 s</td>
    </tr>
    <tr>
      <td>Logical apply, text, primary key only</td>
      <td style="text-align: right">7.69 s</td>
      <td style="text-align: right">2.94 s</td>
      <td style="text-align: right">10.62 s</td>
      <td style="text-align: right">2.25x</td>
      <td style="text-align: right">2.49 s</td>
    </tr>
    <tr>
      <td>Logical apply, binary, primary key only</td>
      <td style="text-align: right">7.74 s</td>
      <td style="text-align: right">2.64 s</td>
      <td style="text-align: right">10.50 s</td>
      <td style="text-align: right">2.23x</td>
      <td style="text-align: right">2.12 s</td>
    </tr>
    <tr>
      <td>Logical apply, text, four extra subscriber indexes</td>
      <td style="text-align: right">6.09 s</td>
      <td style="text-align: right">4.66 s</td>
      <td style="text-align: right">10.71 s</td>
      <td style="text-align: right">2.27x</td>
      <td style="text-align: right">3.60 s</td>
    </tr>
  </tbody>
</table>

<p>Each column is calculated independently. <code class="language-plaintext highlighter-rouge">Total server CPU</code> is the median of the
publisher-plus-subscriber total for each run, so it does not always equal the
sum of the two independently calculated CPU medians shown beside it.</p>

<p>Decoding the stream without applying it increased publisher-side server
CPU from 4.71 to 7.92 seconds. Applying the text stream end-to-end more than
doubled total server CPU compared with the no-consumer case.</p>

<p>Binary transfer helped, but not dramatically for these data types. It reduced
subscriber CPU by roughly 10%, whilst total measured server CPU fell by about
1%. Measure it against your real column types and version topology before
turning it on.</p>

<p>The four additional indexes increased subscriber CPU by 59% and extended
the drain from 2.49 to 3.60 seconds. The lower publisher CPU in that row should
not be interpreted as an optimisation: both clusters were competing for the same
cores, which is one reason publisher TPS alone is a poor logical-replication
health signal.</p>

<p>The subscriber WAL counter makes the index cost even clearer. These values are
from one representative run, with <code class="language-plaintext highlighter-rouge">pg_stat_wal</code> reset immediately before the
workload:</p>

<table>
  <thead>
    <tr>
      <th>Subscriber schema</th>
      <th style="text-align: right">Subscriber WAL</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Primary key only, text transfer</td>
      <td style="text-align: right">136.8 MiB</td>
    </tr>
    <tr>
      <td>Primary key only, binary transfer</td>
      <td style="text-align: right">136.8 MiB</td>
    </tr>
    <tr>
      <td>Primary key plus four indexes</td>
      <td style="text-align: right">271.7 MiB</td>
    </tr>
  </tbody>
</table>

<p>Binary encoding changed the wire representation, not the local tuple or index
layout, so subscriber WAL remained almost identical. Four additional indexes
almost exactly doubled it.</p>

<p>These numbers are not a universal logical-replication multiplier, they are a
CPU-focused local experiment with durability disabled and no network latency.
The receiver client’s CPU is also excluded, so this undercounts total system
cost. The shape is what matters: decoding costs something measurable, applying
creates a second write workload, and subscriber schema design changes how
expensive that is.</p>

<h2 id="the-replica-identity-full-trap">The <code class="language-plaintext highlighter-rouge">REPLICA IDENTITY FULL</code> Trap</h2>

<p><code class="language-plaintext highlighter-rouge">INSERT</code> is straightforward because the subscriber only needs to create a row.
<code class="language-plaintext highlighter-rouge">UPDATE</code> and <code class="language-plaintext highlighter-rouge">DELETE</code> first need to find one.</p>

<p>Postgres normally uses the primary key as the replica identity, though you can
explicitly select another suitable unique index instead. When neither exists,
<code class="language-plaintext highlighter-rouge">REPLICA IDENTITY FULL</code> sends the old row as its identity.</p>

<p>On PostgreSQL 15 and earlier, applying this meant sequentially scanning the
subscriber table for a match, and whilst PostgreSQL 16 added the ability to use a
suitable B-tree index for a <code class="language-plaintext highlighter-rouge">FULL</code> identity lookup, without one the sequential
path still exists.</p>

<p>The apply worker makes the choice explicitly: <code class="language-plaintext highlighter-rouge">FindReplTupleInLocalRel</code> calls
<code class="language-plaintext highlighter-rouge">RelationFindReplTupleByIndex</code> when it has a usable local index, and otherwise
falls back to <code class="language-plaintext highlighter-rouge">RelationFindReplTupleSeq</code>. You can see both branches in the
<a href="https://doxygen.postgresql.org/backend_2replication_2logical_2worker_8c.html">PostgreSQL apply-worker source</a>.</p>

<p>I created 50,000 identical rows on publisher and subscriber, configured the
publisher table with <code class="language-plaintext highlighter-rouge">REPLICA IDENTITY FULL</code>, paused the subscription and queued
updates to the final 2,000 rows. Updating rows near the end is deliberately
adversarial: a sequential lookup has to walk most of the table for each change.</p>

<p>The only difference between the two subscriber runs was:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">full_events_lookup_idx</span> <span class="k">ON</span> <span class="n">full_events</span> <span class="p">(</span><span class="n">id</span><span class="p">);</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Subscriber lookup path</th>
      <th style="text-align: right">Apply time, run 1</th>
      <th style="text-align: right">Apply time, run 2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>No usable index</td>
      <td style="text-align: right">4.565 s</td>
      <td style="text-align: right">4.777 s</td>
    </tr>
    <tr>
      <td>B-tree on <code class="language-plaintext highlighter-rouge">id</code></td>
      <td style="text-align: right">0.149 s</td>
      <td style="text-align: right">0.151 s</td>
    </tr>
  </tbody>
</table>

<p>The index made this deliberately bad case approximately <strong>31 times faster</strong>.</p>

<p>This is not an argument that every <code class="language-plaintext highlighter-rouge">REPLICA IDENTITY FULL</code> workload will be 31x
slower. Row order, table width, cache state, update distribution and candidate
indexes all matter. What it shows is narrower: the wrong replica identity changes the lookup
algorithm for every replicated update.</p>

<p>PostgreSQL’s <a href="https://www.postgresql.org/docs/18/logical-replication-publication.html">publication documentation</a>
describes which indexes can be used with <code class="language-plaintext highlighter-rouge">FULL</code>. PostgreSQL 16’s
<a href="https://www.postgresql.org/docs/16/release-16.html">release notes</a> are also worth
reading if you operate a mixture of pre-16 and newer subscribers.</p>

<h2 id="what-to-monitor">What to Monitor</h2>

<p>Logical replication lag is the final symptom, not the diagnosis. I normally want
to answer four separate questions.</p>

<h3 id="is-the-publisher-retaining-wal">Is the publisher retaining WAL?</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">slot_name</span><span class="p">,</span>
    <span class="n">active</span><span class="p">,</span>
    <span class="n">pg_size_pretty</span><span class="p">(</span>
        <span class="n">pg_wal_lsn_diff</span><span class="p">(</span><span class="n">pg_current_wal_lsn</span><span class="p">(),</span> <span class="n">confirmed_flush_lsn</span><span class="p">)</span>
    <span class="p">)</span> <span class="k">AS</span> <span class="n">unconfirmed_wal</span><span class="p">,</span>
    <span class="n">restart_lsn</span><span class="p">,</span>
    <span class="n">confirmed_flush_lsn</span>
<span class="k">FROM</span> <span class="n">pg_replication_slots</span>
<span class="k">WHERE</span> <span class="n">slot_type</span> <span class="o">=</span> <span class="s1">'logical'</span><span class="p">;</span>
</code></pre></div></div>

<p>An inactive or stalled slot can retain WAL indefinitely, and it will also hold
back catalog cleanup through <code class="language-plaintext highlighter-rouge">catalog_xmin</code>, so disk usage is not the only thing
you are risking.</p>

<h3 id="is-decoding-spilling">Is decoding spilling?</h3>

<p>Use <code class="language-plaintext highlighter-rouge">pg_stat_replication_slots</code> and watch <code class="language-plaintext highlighter-rouge">spill_bytes</code> as shown earlier. A
growing value says the publisher is no longer keeping the decoded transaction in
memory.</p>

<h3 id="is-the-subscriber-receiving-or-actually-applying">Is the subscriber receiving or actually applying?</h3>

<p><code class="language-plaintext highlighter-rouge">pg_stat_subscription</code> shows the worker type, received LSN and message times,
whilst <code class="language-plaintext highlighter-rouge">pg_replication_origin_status.remote_lsn</code> records how far changes have
actually been replayed for an origin. That distinction matters, because a
subscriber can happily receive data faster than it can update its tables.</p>

<h3 id="what-is-the-subscriber-writing">What is the subscriber writing?</h3>

<p>Use <code class="language-plaintext highlighter-rouge">pg_stat_wal</code> for local WAL generation, <code class="language-plaintext highlighter-rouge">pg_stat_user_tables</code> for tuple and
HOT-update behaviour, and normal OS process metrics for the logical replication
workers. <code class="language-plaintext highlighter-rouge">pg_stat_subscription_stats</code> exposes apply errors and conflicts which
can otherwise look like a performance stall.</p>

<h2 id="practical-ways-to-reduce-the-cost">Practical Ways to Reduce the Cost</h2>

<h3 id="use-a-narrow-indexed-replica-identity">Use a narrow, indexed replica identity</h3>

<p>This is the single biggest win for update-heavy workloads, so you want either a
primary key or a unique index you have deliberately chosen for the job. Treat
<code class="language-plaintext highlighter-rouge">REPLICA IDENTITY FULL</code> as a fallback, and remember that pre-16 subscribers
cannot use the newer indexed <code class="language-plaintext highlighter-rouge">FULL</code> lookup path.</p>

<h3 id="design-the-subscriber-as-a-write-heavy-database">Design the subscriber as a write-heavy database</h3>

<p>A read replica tends to accumulate indexes because they make analytical queries
faster, but under logical replication every one of those indexes joins the write
path. Remove the redundant ones, avoid indexing frequently changed columns where
you can, and check whether your subscriber updates are still HOT-eligible.</p>

<h3 id="publish-less-data">Publish less data</h3>

<p>Column lists reduce serialisation, transfer and apply work. Row filters can
reduce downstream work too, but they execute on the publisher and an update may
evaluate them twice, so you are moving the cost rather than making it disappear. See the PostgreSQL
<a href="https://www.postgresql.org/docs/18/logical-replication-col-lists.html">column-list documentation</a>
for the replica-identity requirements.</p>

<h3 id="test-binary-transfer-against-the-actual-topology">Test binary transfer against the actual topology</h3>

<p><code class="language-plaintext highlighter-rouge">binary = true</code> can reduce type conversion, but it is more restrictive across
versions and types, and it did not reduce subscriber WAL in this test because the
wire encoding does not alter the local write. Use it when your measured CPU or
bandwidth justifies the compatibility trade-off.</p>

<h3 id="watch-transaction-shape">Watch transaction shape</h3>

<p><code class="language-plaintext highlighter-rouge">logical_decoding_work_mem</code> and streaming settings matter for large in-progress
transactions, and PostgreSQL 16 introduced parallel application for streamed
large ones, controlled by <code class="language-plaintext highlighter-rouge">max_parallel_apply_workers_per_subscription</code>. That
does not turn every ordinary transaction in your subscription into a freely
parallel workload, since transactional ordering still constrains apply.</p>

<h3 id="capacity-plan-both-ends">Capacity-plan both ends</h3>

<p>An asynchronous publisher can report perfectly healthy commit latency whilst the
subscriber quietly burns CPU and falls behind, which is why your capacity
planning needs publisher decode CPU, subscriber apply CPU, subscriber WAL and
retained publisher WAL - not only application TPS.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Logical replication is not a cheap copy of data. It is a pipeline which turns a
physical change back into a logical one and then turns it into a new physical
change somewhere else.</p>

<p>That extra interpretation is what makes it useful. It allows different
versions, different schemas, filtered datasets and writable subscribers. It also
means that a wide row, an unnecessary index or the wrong replica identity is
multiplied across every change in the stream.</p>

<p><strong>Treat a logical subscriber as another write-heavy database, not as passive
storage.</strong> Measure decoding and apply separately, give updates an efficient
identity lookup, and include the subscriber’s indexes and WAL in the cost of
every design decision.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://www.postgresql.org/docs/18/logical-replication-architecture.html">Logical replication architecture</a></li>
  <li><a href="https://www.postgresql.org/docs/18/logicaldecoding-explanation.html">Logical decoding concepts</a></li>
  <li><a href="https://www.postgresql.org/docs/18/protocol-logical-replication.html">Logical streaming replication protocol</a></li>
  <li><a href="https://www.postgresql.org/docs/18/sql-createsubscription.html"><code class="language-plaintext highlighter-rouge">CREATE SUBSCRIPTION</code> options</a></li>
  <li><a href="https://www.postgresql.org/docs/18/logical-replication-publication.html">Replica identity and publications</a></li>
  <li><a href="https://www.postgresql.org/docs/16/release-16.html">PostgreSQL 16 logical replication changes</a></li>
  <li><a href="https://www.postgresql.org/docs/18/monitoring-stats.html">Logical replication monitoring statistics</a></li>
  <li><a href="https://doxygen.postgresql.org/backend_2replication_2logical_2worker_8c.html">Logical apply-worker source</a></li>
</ul>]]></content><author><name></name></author><summary type="html"><![CDATA[Logical replication looks like data being copied from one database to another, which is a useful mental model right up until you start paying attention to how much work it hides.]]></summary></entry><entry><title type="html">How Postgres Stores Composite Indexes on Disk</title><link href="https://www.alexstoica.com/blog/composite-index-storage" rel="alternate" type="text/html" title="How Postgres Stores Composite Indexes on Disk" /><published>2026-04-03T00:00:00+00:00</published><updated>2026-04-03T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/composite-index-storage</id><content type="html" xml:base="https://www.alexstoica.com/blog/composite-index-storage"><![CDATA[<p>Composite indexes can seem like an easy way to “put two columns together and get great 
performance benefits” - as Postgres can just <em>figure it out</em>. 
However, the physical representation is not intuitive unless you’ve looked 
inside a B-tree page. Once you understand how these indexes are stored, 
you can understand why certain queries leveraging a composite index perform better
whilst others don’t quite get the same <em>lift-off</em> effect.</p>

<!--more-->

<p>This post walks through exactly how a composite index like the one below will
end up getting stored on disk.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">test_tbl</span><span class="p">(</span>
  <span class="n">id</span>   <span class="n">bigserial</span> <span class="k">primary</span> <span class="k">key</span><span class="p">,</span>
  <span class="n">col1</span> <span class="nb">int</span><span class="p">,</span>
  <span class="n">col2</span> <span class="nb">text</span>
<span class="p">);</span>

<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">t_idx1</span> <span class="k">ON</span> <span class="n">test_tbl</span><span class="p">(</span><span class="n">col1</span><span class="p">,</span> <span class="n">col2</span><span class="p">);</span>
</code></pre></div></div>

<h2 id="composite-indexes-are-one-key-not-two">Composite Indexes Are One Key, Not Two</h2>

<p><code class="language-plaintext highlighter-rouge">t_idx1</code> is a <em>single</em> B-tree index whose key is the pair <code class="language-plaintext highlighter-rouge">(col1, col2)</code>.</p>

<p>Postgres stores that pair as one logical key and orders it lexicographically:</p>

<ol>
  <li>Sort by <code class="language-plaintext highlighter-rouge">col1</code></li>
  <li>For rows with the same <code class="language-plaintext highlighter-rouge">col1</code>, sort by <code class="language-plaintext highlighter-rouge">col2</code></li>
</ol>

<p>Everything about how queries use this index flows from this rule.</p>

<h2 id="on-disk-storage-a-separate-file-b-tree-structured">On-Disk Storage: A Separate File, B-Tree Structured</h2>

<p><code class="language-plaintext highlighter-rouge">t_idx1</code> is its own table-like structure on disk:</p>

<ul>
  <li>It lives in <code class="language-plaintext highlighter-rouge">pgdata/base/&lt;db_oid&gt;/&lt;relfilenode&gt;</code></li>
  <li>It uses the B-tree access method</li>
  <li>It is split into 8kB pages</li>
  <li>
    <p>Pages are arranged as:</p>

    <ul>
      <li><strong>Meta page</strong> (block 0)</li>
      <li><strong>Internal pages</strong> (routing nodes)</li>
      <li><strong>Leaf pages</strong> (actual key + TID entries)</li>
    </ul>
  </li>
</ul>

<p>This file has no connection to the heap except via the TIDs stored inside each
tuple.</p>

<pre><code class="language-mermaid">graph TD
    subgraph file["Index File (relfilenode)"]
        M["Block 0 · Meta Page\nroot=1, level=2, fast-root=1"]
        I1["Block 1 · Internal Page\nsep: (col1=2)"]
        L1["Block 2 · Leaf Page\n(1,'aaa', TID)\n(1,'bbb', TID)"]
        L2["Block 3 · Leaf Page\n(2,'abc', TID)\n(2,'xyz', TID)"]
    end

    M --&gt; I1
    I1 --&gt;|"left child\ncol1 &lt; 2"| L1
    I1 --&gt;|"right child\ncol1 ≥ 2"| L2
    L1 &lt;--&gt;|"prev / next ptrs"| L2
</code></pre>

<p>For large indexes Postgres splits the file into 1 GB segments (<code class="language-plaintext highlighter-rouge">.1</code>, <code class="language-plaintext highlighter-rouge">.2</code>, …) but
the page structure and B-tree layout remain identical across segments.</p>

<p>Each entry in a leaf page stores:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">col1</code> - 4-byte integer</li>
  <li><code class="language-plaintext highlighter-rouge">col2</code> - text datum</li>
  <li><code class="language-plaintext highlighter-rouge">TID</code> - 6-byte heap pointer (block number + offset)</li>
</ul>

<p>This structure is wrapped in an <code class="language-plaintext highlighter-rouge">IndexTupleData</code> header that contains flags,
tuple size, and a null bitmap if needed.</p>

<p>Text values follow varlena rules:</p>

<ul>
  <li><strong>Short text</strong>: stored inline (<code class="language-plaintext highlighter-rouge">[length][bytes]</code>)</li>
  <li><strong>Large text</strong>: replaced by an 18-byte TOAST pointer rather than embedding
huge strings in the index</li>
</ul>

<p>A leaf entry is physically:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[IndexTupleHeader]
[col1 value]
[col2 varlena or toast pointer]
[TID]
</code></pre></div></div>

<h4 id="how-composite-keys-are-ordered">How Composite Keys Are Ordered</h4>

<p>The B-tree compares keys in order:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(col1=1, col2='aaa')
(col1=1, col2='bbb')
(col1=2, col2='abc')
</code></pre></div></div>

<p>If a query filters only on <code class="language-plaintext highlighter-rouge">col2</code>, the index cannot be traversed meaningfully
because the tree is structured around <code class="language-plaintext highlighter-rouge">col1</code>.
Column order in the index will define physical locality.</p>

<h2 id="a-concrete-example-12-rows-across-three-levels">A Concrete Example: 12 Rows Across Three Levels</h2>

<p>Consider inserting these 12 rows (insertion order is irrelevant — the index
always maintains sorted order):</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">test_tbl</span> <span class="p">(</span><span class="n">col1</span><span class="p">,</span> <span class="n">col2</span><span class="p">)</span> <span class="k">VALUES</span>
  <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="s1">'ivy'</span><span class="p">),</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'bee'</span><span class="p">),</span> <span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="s1">'koi'</span><span class="p">),</span>
  <span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s1">'fox'</span><span class="p">),</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'ant'</span><span class="p">),</span> <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="s1">'gnu'</span><span class="p">),</span>
  <span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="s1">'jay'</span><span class="p">),</span> <span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s1">'dog'</span><span class="p">),</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'cat'</span><span class="p">),</span>
  <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="s1">'hen'</span><span class="p">),</span> <span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="s1">'lynx'</span><span class="p">),(</span><span class="mi">2</span><span class="p">,</span> <span class="s1">'elk'</span><span class="p">);</span>
</code></pre></div></div>

<p>The index sorts them by <code class="language-plaintext highlighter-rouge">(col1, col2)</code>. TIDs reflect insertion order, not sort
order:</p>

<table>
  <thead>
    <tr>
      <th>col1</th>
      <th>col2</th>
      <th>TID</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>‘ant’</td>
      <td>(0,5)</td>
    </tr>
    <tr>
      <td>1</td>
      <td>‘bee’</td>
      <td>(0,2)</td>
    </tr>
    <tr>
      <td>1</td>
      <td>‘cat’</td>
      <td>(0,9)</td>
    </tr>
    <tr>
      <td>2</td>
      <td>‘dog’</td>
      <td>(0,8)</td>
    </tr>
    <tr>
      <td>2</td>
      <td>‘elk’</td>
      <td>(0,12)</td>
    </tr>
    <tr>
      <td>2</td>
      <td>‘fox’</td>
      <td>(0,4)</td>
    </tr>
    <tr>
      <td>3</td>
      <td>‘gnu’</td>
      <td>(0,6)</td>
    </tr>
    <tr>
      <td>3</td>
      <td>‘hen’</td>
      <td>(0,10)</td>
    </tr>
    <tr>
      <td>3</td>
      <td>‘ivy’</td>
      <td>(0,1)</td>
    </tr>
    <tr>
      <td>4</td>
      <td>‘jay’</td>
      <td>(0,7)</td>
    </tr>
    <tr>
      <td>4</td>
      <td>‘koi’</td>
      <td>(0,3)</td>
    </tr>
    <tr>
      <td>4</td>
      <td>‘lynx’</td>
      <td>(0,11)</td>
    </tr>
  </tbody>
</table>

<p>With 3 entries per leaf page the index grows to <strong>three levels</strong>: a root
internal page, two level-2 internal pages, and four leaf pages.</p>

<pre><code class="language-mermaid">graph TD
    META["Meta Page · block 0\nroot → block 1"]
    ROOT["Root Internal · block 1\nsep = (3)"]
    LI["Left Internal · block 2\nsep = (2)"]
    RI["Right Internal · block 3\nsep = (4)"]
    L4["Leaf · block 4\n(1,'ant') TID(0,5)\n(1,'bee') TID(0,2)\n(1,'cat') TID(0,9)"]
    L5["Leaf · block 5\n(2,'dog') TID(0,8)\n(2,'elk') TID(0,12)\n(2,'fox') TID(0,4)"]
    L6["Leaf · block 6\n(3,'gnu') TID(0,6)\n(3,'hen') TID(0,10)\n(3,'ivy') TID(0,1)"]
    L7["Leaf · block 7\n(4,'jay') TID(0,7)\n(4,'koi') TID(0,3)\n(4,'lynx') TID(0,11)"]

    META --&gt; ROOT
    ROOT --&gt;|"key &lt; (3)"| LI
    ROOT --&gt;|"key ≥ (3)"| RI
    LI --&gt;|"key &lt; (2)"| L4
    LI --&gt;|"key ≥ (2)"| L5
    RI --&gt;|"key &lt; (4)"| L6
    RI --&gt;|"key ≥ (4)"| L7
    L4 &lt;-.-&gt;|"prev/next"| L5
    L5 &lt;-.-&gt;|"prev/next"| L6
    L6 &lt;-.-&gt;|"prev/next"| L7
</code></pre>

<p>Every split in this tree falls between rows with <strong>different <code class="language-plaintext highlighter-rouge">col1</code> values</strong>, so
suffix truncation reduces all three separator keys to a single integer:</p>

<ul>
  <li><strong>Root sep = (3)</strong> — split between <code class="language-plaintext highlighter-rouge">(2,'fox')</code> and <code class="language-plaintext highlighter-rouge">(3,'gnu')</code> → col1 differs → stored as <code class="language-plaintext highlighter-rouge">(3)</code></li>
  <li><strong>Left internal sep = (2)</strong> — split between <code class="language-plaintext highlighter-rouge">(1,'cat')</code> and <code class="language-plaintext highlighter-rouge">(2,'dog')</code> → stored as <code class="language-plaintext highlighter-rouge">(2)</code></li>
  <li><strong>Right internal sep = (4)</strong> — split between <code class="language-plaintext highlighter-rouge">(3,'ivy')</code> and <code class="language-plaintext highlighter-rouge">(4,'jay')</code> → stored as <code class="language-plaintext highlighter-rouge">(4)</code></li>
</ul>

<h3 id="query-traversal">Query Traversal</h3>

<p>Select a query below to step through how the B-tree is traversed:</p>

<style>
#btanim-root * { box-sizing: border-box; }
#btanim-root {
  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
  font-size: 12.5px;
  background: #f8f9fa;
  border: 1px solid #dee2e6;
  border-radius: 8px;
  padding: 18px;
  margin: 1.5rem 0;
  line-height: 1.5;
}
.bta-qbar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.bta-qbtn {
  padding: 5px 10px; font-size: 11.5px; font-family: inherit;
  border: 1px solid #ced4da; border-radius: 4px;
  background: #fff; cursor: pointer;
  transition: background .15s, border-color .15s, color .15s;
}
.bta-qbtn:hover { background: #e9ecef; }
.bta-qbtn.on { background: #0d6efd; color: #fff; border-color: #0d6efd; }
#bta-desc {
  background: #fff; border-left: 3px solid #0d6efd;
  padding: 8px 13px; margin: 10px 0; min-height: 52px;
  border-radius: 0 4px 4px 0; font-size: 12.5px;
  white-space: pre-line;
}
.bta-cbar { display: flex; gap: 6px; align-items: center; margin-bottom: 14px; }
.bta-cbtn {
  padding: 4px 11px; font-size: 12px; font-family: inherit;
  border: 1px solid #ced4da; border-radius: 4px; background: #fff; cursor: pointer;
}
.bta-cbtn:disabled { opacity: .35; cursor: default; }
.bta-cbtn:not(:disabled):hover { background: #e9ecef; }
#bta-counter { font-size: 11px; color: #6c757d; margin-left: auto; }

/* 4-column grid so nodes align with their children */
#bta-tree {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: auto 36px auto 36px auto 36px auto;
  column-gap: 8px;
}
.bta-meta  { grid-column: 2 / 4; grid-row: 1; justify-self: center; width: 100%; }
.bta-root  { grid-column: 2 / 4; grid-row: 3; justify-self: center; width: 100%; }
.bta-lint  { grid-column: 1 / 3; grid-row: 5; }
.bta-rint  { grid-column: 3 / 5; grid-row: 5; }
.bta-l4   { grid-column: 1 / 2; grid-row: 7; }
.bta-l5   { grid-column: 2 / 3; grid-row: 7; }
.bta-l6   { grid-column: 3 / 4; grid-row: 7; }
.bta-l7   { grid-column: 4 / 5; grid-row: 7; }

.bta-node {
  background: #fff; border: 2px solid #adb5bd; border-radius: 6px;
  padding: 6px 10px; position: relative; z-index: 1;
  transition: border-color .25s, background .25s, box-shadow .25s, opacity .25s;
}
.bta-node.na {
  border-color: #0d6efd; background: #e7f1ff;
  box-shadow: 0 0 0 3px rgba(13,110,253,.15);
}
.bta-node.nv { opacity: .6; background: #f1f3f5; border-color: #adb5bd; }
.bta-nhdr {
  font-size: 9.5px; font-weight: 700; text-transform: uppercase;
  letter-spacing: .6px; color: #6c757d;
  margin-bottom: 4px; padding-bottom: 3px; border-bottom: 1px solid #e9ecef;
  white-space: nowrap;
}
.bta-e {
  padding: 2px 4px; border-radius: 3px; margin: 1px 0;
  transition: background .25s, opacity .25s;
  white-space: nowrap; font-size: 12px;
}
.bta-e.es { background: #fff3cd; }
.bta-e.ef { background: #d1e7dd; font-weight: 700; }
.bta-e.em { background: #f8d7da; text-decoration: line-through; opacity: .55; }
.bta-e.ec { background: #cfe2ff; }

#bta-svg {
  position: absolute; top: 0; left: 0; width: 100%; height: 100%;
  pointer-events: none; overflow: visible; z-index: 0;
}
</style>

<div id="btanim-root">
  <div style="font-size:11px;color:#6c757d;margin-bottom:7px;">Choose a query to animate the traversal:</div>
  <div class="bta-qbar">
    <button class="bta-qbtn" data-q="0">WHERE col1=2 AND col2=&#39;elk&#39;</button>
    <button class="bta-qbtn" data-q="1">WHERE col1=3</button>
    <button class="bta-qbtn" data-q="2">WHERE col2=&#39;hen&#39;</button>
  </div>
  <div id="bta-desc">Select a query above to step through how the B-tree is traversed.</div>
  <div class="bta-cbar">
    <button class="bta-cbtn" id="bta-prev" disabled="">&#8592; Prev</button>
    <button class="bta-cbtn" id="bta-next" disabled="">Next &#8594;</button>
    <button class="bta-cbtn" id="bta-auto" disabled="">&#9654; Auto</button>
    <span id="bta-counter"></span>
  </div>

  <div id="bta-tree">
    <svg id="bta-svg" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <marker id="bta-a" markerWidth="7" markerHeight="6" refX="7" refY="3" orient="auto">
          <polygon points="0 0,7 3,0 6" fill="#ced4da" />
        </marker>
        <marker id="bta-a1" markerWidth="7" markerHeight="6" refX="7" refY="3" orient="auto">
          <polygon points="0 0,7 3,0 6" fill="#0d6efd" />
        </marker>
        <marker id="bta-ar" markerWidth="7" markerHeight="6" refX="7" refY="3" orient="auto-start-reverse">
          <polygon points="0 0,7 3,0 6" fill="#ced4da" />
        </marker>
        <marker id="bta-ar1" markerWidth="7" markerHeight="6" refX="7" refY="3" orient="auto-start-reverse">
          <polygon points="0 0,7 3,0 6" fill="#0d6efd" />
        </marker>
      </defs>
    </svg>

    <div class="bta-node bta-meta" id="btan-meta">
      <div class="bta-nhdr">Meta Page &middot; block 0</div>
      <div class="bta-e" id="btae-meta" style="text-align:center">root &#8594; block 1</div>
    </div>

    <div class="bta-node bta-root" id="btan-root">
      <div class="bta-nhdr">Root Internal &middot; block 1</div>
      <div class="bta-e" id="btae-r0" style="text-align:center">sep = (3)</div>
    </div>

    <div class="bta-node bta-lint" id="btan-lint">
      <div class="bta-nhdr">Left Internal &middot; block 2</div>
      <div class="bta-e" id="btae-li0">sep = (2)</div>
    </div>
    <div class="bta-node bta-rint" id="btan-rint">
      <div class="bta-nhdr">Right Internal &middot; block 3</div>
      <div class="bta-e" id="btae-ri0">sep = (4)</div>
    </div>

    <div class="bta-node bta-l4" id="btan-l4">
      <div class="bta-nhdr">Leaf &middot; block 4</div>
      <div class="bta-e" id="btae-l40">(1,&#39;ant&#39;) (0,5)</div>
      <div class="bta-e" id="btae-l41">(1,&#39;bee&#39;) (0,2)</div>
      <div class="bta-e" id="btae-l42">(1,&#39;cat&#39;) (0,9)</div>
    </div>
    <div class="bta-node bta-l5" id="btan-l5">
      <div class="bta-nhdr">Leaf &middot; block 5</div>
      <div class="bta-e" id="btae-l50">(2,&#39;dog&#39;) (0,8)</div>
      <div class="bta-e" id="btae-l51">(2,&#39;elk&#39;) (0,12)</div>
      <div class="bta-e" id="btae-l52">(2,&#39;fox&#39;) (0,4)</div>
    </div>
    <div class="bta-node bta-l6" id="btan-l6">
      <div class="bta-nhdr">Leaf &middot; block 6</div>
      <div class="bta-e" id="btae-l60">(3,&#39;gnu&#39;) (0,6)</div>
      <div class="bta-e" id="btae-l61">(3,&#39;hen&#39;) (0,10)</div>
      <div class="bta-e" id="btae-l62">(3,&#39;ivy&#39;) (0,1)</div>
    </div>
    <div class="bta-node bta-l7" id="btan-l7">
      <div class="bta-nhdr">Leaf &middot; block 7</div>
      <div class="bta-e" id="btae-l70">(4,&#39;jay&#39;) (0,7)</div>
      <div class="bta-e" id="btae-l71">(4,&#39;koi&#39;) (0,3)</div>
      <div class="bta-e" id="btae-l72">(4,&#39;lynx&#39;) (0,11)</div>
    </div>
  </div>
</div>

<script>
(function () {
  'use strict';

  var QUERIES = [
    /* Query 0 — point lookup: col1=2, col2='elk' */
    { steps: [
      { desc: 'Read Meta Page (block 0): root pointer \u2192 block 1.',
        nodes: { meta:'na' }, entries: {}, edge: null },
      { desc: 'Root Internal (block 1). Compare (2,\'elk\') against sep=(3):\n\u2022 (2) < (3) \u2192 go LEFT \u2192 block 2.',
        nodes: { meta:'nv', root:'na' }, entries: { r0:'ec' }, edge: 'mr' },
      { desc: 'Left Internal (block 2). Compare (2,\'elk\') against sep=(2):\n\u2022 (2,\'elk\') \u2265 (2) \u2192 go RIGHT \u2192 block 5.',
        nodes: { meta:'nv', root:'nv', lint:'na' }, entries: { li0:'ec' }, edge: 'rl' },
      { desc: 'Leaf Page (block 5). Scan entries in key order\u2026\n(2,\'dog\') \u2014 less than search key, keep scanning.',
        nodes: { meta:'nv', root:'nv', lint:'nv', l5:'na' }, entries: { l50:'es' }, edge: 'l5' },
      { desc: 'Match found: (2,\'elk\') \u2192 TID(0,12).\nReturn heap pointer to executor. 4 page reads total.',
        nodes: { meta:'nv', root:'nv', lint:'nv', l5:'na' }, entries: { l50:'em', l51:'ef' }, edge: 'l5' }
    ]},
    /* Query 1 — prefix range: col1=3 */
    { steps: [
      { desc: 'Read Meta Page (block 0): root pointer \u2192 block 1.',
        nodes: { meta:'na' }, entries: {}, edge: null },
      { desc: 'Root Internal (block 1). Lower bound of range is (3, -\u221e):\n\u2022 (3) \u2265 sep=(3) \u2192 go RIGHT \u2192 block 3.',
        nodes: { meta:'nv', root:'na' }, entries: { r0:'ec' }, edge: 'mr' },
      { desc: 'Right Internal (block 3). Compare (3, -\u221e) against sep=(4):\n\u2022 (3) < (4) \u2192 go LEFT \u2192 block 6.',
        nodes: { meta:'nv', root:'nv', rint:'na' }, entries: { ri0:'ec' }, edge: 'rr' },
      { desc: 'Leaf Page (block 6). All three entries satisfy col1=3. \u2713\u2713\u2713',
        nodes: { meta:'nv', root:'nv', rint:'nv', l6:'na' }, entries: { l60:'ef', l61:'ef', l62:'ef' }, edge: 'l6' },
      { desc: 'End of block 6. Follow next pointer \u2192 block 7 to check for more col1=3 entries\u2026',
        nodes: { meta:'nv', root:'nv', rint:'nv', l6:'nv', l7:'na' },
        entries: { l60:'ef', l61:'ef', l62:'ef', l70:'es' }, edge: 'l67' },
      { desc: 'First entry in block 7 is (4,\'jay\') \u2014 col1=4 > 3. Range ends.\nResult: 3 rows \u2014 TID(0,6), TID(0,10), TID(0,1). 5 page reads total.',
        nodes: { meta:'nv', root:'nv', rint:'nv', l6:'nv', l7:'na' },
        entries: { l60:'ef', l61:'ef', l62:'ef', l70:'em' }, edge: 'l67' }
    ]},
    /* Query 2 — col2-only: full leaf scan */
    { steps: [
      { desc: 'Read Meta Page (block 0): root pointer \u2192 block 1.',
        nodes: { meta:'na' }, entries: {}, edge: null },
      { desc: 'Root Internal (block 1): no col1 predicate.\nThe tree is ordered by col1 \u2014 cannot route to a single subtree. All 4 leaf pages must be scanned.',
        nodes: { meta:'nv', root:'na' }, entries: {}, edge: 'mr' },
      { desc: 'Descend to leftmost leaf via Left Internal.\nLeaf Page (block 4): (1,\'ant\') \u2717  (1,\'bee\') \u2717  (1,\'cat\') \u2717',
        nodes: { meta:'nv', root:'nv', lint:'nv', l4:'na' },
        entries: { l40:'em', l41:'em', l42:'em' }, edge: 'l4' },
      { desc: 'Follow next pointer \u2192 Leaf Page (block 5):\n(2,\'dog\') \u2717  (2,\'elk\') \u2717  (2,\'fox\') \u2717',
        nodes: { meta:'nv', root:'nv', lint:'nv', l4:'nv', l5:'na' },
        entries: { l40:'em', l41:'em', l42:'em', l50:'em', l51:'em', l52:'em' }, edge: 'l45' },
      { desc: 'Follow next pointer \u2192 Leaf Page (block 6):\n(3,\'gnu\') \u2717  (3,\'hen\') \u2713 match!  (3,\'ivy\') \u2717',
        nodes: { meta:'nv', root:'nv', lint:'nv', rint:'nv', l4:'nv', l5:'nv', l6:'na' },
        entries: { l40:'em', l41:'em', l42:'em', l50:'em', l51:'em', l52:'em', l60:'em', l61:'ef', l62:'em' }, edge: 'l56' },
      { desc: 'Follow next pointer \u2192 Leaf Page (block 7):\n(4,\'jay\') \u2717  (4,\'koi\') \u2717  (4,\'lynx\') \u2717\nAll 4 leaf pages scanned \u2014 equivalent to a full sequential scan.',
        nodes: { meta:'nv', root:'nv', lint:'nv', rint:'nv', l4:'nv', l5:'nv', l6:'nv', l7:'na' },
        entries: { l40:'em', l41:'em', l42:'em', l50:'em', l51:'em', l52:'em',
                   l60:'em', l61:'ef', l62:'em', l70:'em', l71:'em', l72:'em' }, edge: 'l67' }
    ]}
  ];

  var NID = { meta:'btan-meta', root:'btan-root', lint:'btan-lint', rint:'btan-rint',
               l4:'btan-l4', l5:'btan-l5', l6:'btan-l6', l7:'btan-l7' };
  var EID = { meta:'btae-meta', r0:'btae-r0', li0:'btae-li0', ri0:'btae-ri0',
              l40:'btae-l40', l41:'btae-l41', l42:'btae-l42',
              l50:'btae-l50', l51:'btae-l51', l52:'btae-l52',
              l60:'btae-l60', l61:'btae-l61', l62:'btae-l62',
              l70:'btae-l70', l71:'btae-l71', l72:'btae-l72' };
  var EDGES = {
    mr:  { a:'meta', b:'root', bidir:false },
    rl:  { a:'root', b:'lint', bidir:false },
    rr:  { a:'root', b:'rint', bidir:false },
    l4:  { a:'lint', b:'l4',   bidir:false },
    l5:  { a:'lint', b:'l5',   bidir:false },
    l6:  { a:'rint', b:'l6',   bidir:false },
    l7:  { a:'rint', b:'l7',   bidir:false },
    l45: { a:'l4',  b:'l5',   bidir:true  },
    l56: { a:'l5',  b:'l6',   bidir:true  },
    l67: { a:'l6',  b:'l7',   bidir:true  }
  };

  var st = { q:-1, s:-1, timer:null };
  function g(id) { return document.getElementById(id); }

  function applyStep(step) {
    g('bta-desc').textContent = step.desc;
    var q = QUERIES[st.q];
    g('bta-counter').textContent = 'Step ' + (st.s + 1) + ' / ' + q.steps.length;

    Object.keys(NID).forEach(function(k) {
      var el = g(NID[k]); if (!el) return;
      el.classList.remove('na', 'nv');
      if (step.nodes[k]) el.classList.add(step.nodes[k]);
    });
    Object.keys(EID).forEach(function(k) {
      var el = g(EID[k]); if (!el) return;
      el.classList.remove('es', 'ef', 'em', 'ec');
      if (step.entries[k]) el.classList.add(step.entries[k]);
    });
    Object.keys(EDGES).forEach(function(key) {
      var line = g('btaedge-' + key); if (!line) return;
      var on = (key === step.edge);
      line.setAttribute('stroke',         on ? '#0d6efd' : '#ced4da');
      line.setAttribute('stroke-opacity', on ? '1'       : '0.35');
      line.setAttribute('stroke-width',   on ? '2.5'     : '1.5');
      line.setAttribute('marker-end',     on ? 'url(#bta-a1)' : 'url(#bta-a)');
      if (EDGES[key].bidir)
        line.setAttribute('marker-start', on ? 'url(#bta-ar1)' : 'url(#bta-ar)');
    });
    g('bta-prev').disabled = (st.s === 0);
    g('bta-next').disabled = (st.s === q.steps.length - 1);
  }

  function drawSVG() {
    var svg  = g('bta-svg');
    var wrap = g('bta-tree');
    if (!svg || !wrap) return;
    var old = svg.querySelectorAll('line[data-edge]');
    for (var i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i]);

    var W = wrap.getBoundingClientRect();
    function rc(nodeKey) {
      var el = g(NID[nodeKey]); if (!el) return null;
      var r = el.getBoundingClientRect();
      return { t:r.top-W.top, b:r.bottom-W.top, l:r.left-W.left, r:r.right-W.left,
               cx:r.left-W.left+r.width/2, cy:r.top-W.top+r.height/2, w:r.width };
    }
    var activeEdge = (st.s >= 0 && st.q >= 0) ? QUERIES[st.q].steps[st.s].edge : null;

    Object.keys(EDGES).forEach(function(key) {
      var def = EDGES[key];
      var ra = rc(def.a), rb = rc(def.b); if (!ra || !rb) return;
      var x1, y1, x2, y2;
      if (def.bidir) {
        x1 = ra.r + 2; y1 = ra.cy; x2 = rb.l - 2; y2 = rb.cy;
      } else {
        x1 = (def.a === 'root') ? (def.b === 'lint' ? ra.cx - ra.w * 0.22 : ra.cx + ra.w * 0.22) : ra.cx;
        y1 = ra.b; x2 = rb.cx; y2 = rb.t;
      }
      var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
      line.setAttribute('id',            'btaedge-' + key);
      line.setAttribute('data-edge',     key);
      line.setAttribute('x1', x1); line.setAttribute('y1', y1);
      line.setAttribute('x2', x2); line.setAttribute('y2', y2);
      var on = (key === activeEdge);
      line.setAttribute('stroke',         on ? '#0d6efd' : '#ced4da');
      line.setAttribute('stroke-opacity', on ? '1'       : '0.35');
      line.setAttribute('stroke-width',   on ? '2.5'     : '1.5');
      line.setAttribute('fill', 'none');
      if (def.bidir) line.setAttribute('stroke-dasharray', '5,3');
      line.setAttribute('marker-end', on ? 'url(#bta-a1)' : 'url(#bta-a)');
      if (def.bidir) line.setAttribute('marker-start', on ? 'url(#bta-ar1)' : 'url(#bta-ar)');
      svg.appendChild(line);
    });
  }

  function stopAuto() {
    if (st.timer) { clearInterval(st.timer); st.timer = null; }
    var btn = g('bta-auto'); if (btn) btn.textContent = '\u25b6 Auto';
  }

  function runQ(qi) {
    stopAuto(); st.q = qi; st.s = 0;
    document.querySelectorAll('#btanim-root .bta-qbtn').forEach(function(b) {
      b.classList.toggle('on', +b.getAttribute('data-q') === qi);
    });
    ['bta-prev','bta-next','bta-auto'].forEach(function(id) { g(id).disabled = false; });
    applyStep(QUERIES[qi].steps[0]);
    setTimeout(drawSVG, 20);
  }

  function init() {
    document.querySelectorAll('#btanim-root .bta-qbtn').forEach(function(btn) {
      btn.addEventListener('click', function() { runQ(+btn.getAttribute('data-q')); });
    });
    g('bta-prev').addEventListener('click', function() {
      if (st.s > 0) { stopAuto(); st.s--; applyStep(QUERIES[st.q].steps[st.s]); }
    });
    g('bta-next').addEventListener('click', function() {
      var ss = QUERIES[st.q].steps;
      if (st.s < ss.length - 1) { stopAuto(); st.s++; applyStep(ss[st.s]); }
    });
    g('bta-auto').addEventListener('click', function() {
      if (st.timer) { stopAuto(); return; }
      var ss = QUERIES[st.q].steps;
      if (st.s >= ss.length - 1) { st.s = 0; applyStep(ss[0]); }
      g('bta-auto').textContent = '\u25a0 Stop';
      st.timer = setInterval(function() {
        var ss2 = QUERIES[st.q].steps;
        if (st.s < ss2.length - 1) { st.s++; applyStep(ss2[st.s]); } else stopAuto();
      }, 1800);
    });
    window.addEventListener('resize', function() { setTimeout(drawSVG, 50); });
    setTimeout(drawSVG, 80);
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
  else init();
})();
</script>

<h2 id="internal-pages-store-limited-information">Internal Pages Store Limited Information</h2>

<p>Internal nodes route lookups downward but do not need the full key. They store
<em>separator keys</em>. Since Postgres 13, the B-tree code applies <strong>suffix truncation</strong>
when writing a separator: it strips trailing attributes that are not needed to
distinguish the two subtrees being separated.</p>

<ul>
  <li>If <code class="language-plaintext highlighter-rouge">col1</code> alone is different at the split boundary → the separator is <code class="language-plaintext highlighter-rouge">(col1)</code>.</li>
  <li>If the split falls between two rows that share the same <code class="language-plaintext highlighter-rouge">col1</code> → the separator
must include <code class="language-plaintext highlighter-rouge">col2</code> to disambiguate, so it becomes <code class="language-plaintext highlighter-rouge">(col1, col2)</code>.</li>
</ul>

<p>In practice, most separators end up as just <code class="language-plaintext highlighter-rouge">col1</code>, keeping internal pages small
and the tree shallower. But the full key can appear whenever a page boundary
falls inside a run of identical <code class="language-plaintext highlighter-rouge">col1</code> values.</p>

<p>Leaf pages store all actual index entries; structure:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[PageHeader]
[line pointers]
...
[free space]
...
[packed index tuples at the end]
</code></pre></div></div>

<p>Leaf pages also form a doubly-linked list, allowing efficient range scans.</p>

<p>Because items are sorted by <code class="language-plaintext highlighter-rouge">(col1, col2)</code>, values with similar keys land on
adjacent leaf pages.</p>

<h2 id="skip-scans-using-the-index-without-the-leading-column">Skip Scans: Using the Index Without the Leading Column</h2>

<p>A query filtering only on <code class="language-plaintext highlighter-rouge">col2</code> can’t traverse the B-tree meaningfully because
the tree is ordered by <code class="language-plaintext highlighter-rouge">col1</code> first. <strong>Before Postgres 17</strong>, the planner would
ignore <code class="language-plaintext highlighter-rouge">t_idx1</code> entirely for <code class="language-plaintext highlighter-rouge">WHERE col2 = 'foo'</code>.</p>

<p><strong>Postgres 17</strong> introduced native <strong>skip scans</strong>. The planner enumerates each
distinct value of <code class="language-plaintext highlighter-rouge">col1</code>, then does a separate descend into the tree for each
one, effectively running:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>col1 = &lt;v1&gt; AND col2 = 'foo'
col1 = &lt;v2&gt; AND col2 = 'foo'
...
</code></pre></div></div>

<p>This is only efficient when <code class="language-plaintext highlighter-rouge">col1</code> has <strong>low cardinality</strong> — the number of
descents equals the number of distinct <code class="language-plaintext highlighter-rouge">col1</code> values. An <code class="language-plaintext highlighter-rouge">ENUM</code> type is the
ideal fit:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TYPE</span> <span class="n">status</span> <span class="k">AS</span> <span class="nb">ENUM</span> <span class="p">(</span><span class="s1">'active'</span><span class="p">,</span> <span class="s1">'pending'</span><span class="p">,</span> <span class="s1">'closed'</span><span class="p">);</span>
<span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">test_tbl</span> <span class="k">ADD</span> <span class="k">COLUMN</span> <span class="n">status</span> <span class="n">status</span><span class="p">;</span>
<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">t_idx2</span> <span class="k">ON</span> <span class="n">test_tbl</span><span class="p">(</span><span class="n">status</span><span class="p">,</span> <span class="n">col2</span><span class="p">);</span>
</code></pre></div></div>

<p>With three enum values the skip scan does three tree descents instead of a full
sequential scan. A high-cardinality integer <code class="language-plaintext highlighter-rouge">col1</code> would make the same approach
impractical (thousands of descents).</p>

<p>On older Postgres versions the equivalent workaround is an explicit <code class="language-plaintext highlighter-rouge">IN</code> list:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">WHERE</span> <span class="n">col1</span> <span class="k">IN</span> <span class="p">(</span><span class="k">SELECT</span> <span class="k">DISTINCT</span> <span class="n">col1</span> <span class="k">FROM</span> <span class="n">test_tbl</span><span class="p">)</span> <span class="k">AND</span> <span class="n">col2</span> <span class="o">=</span> <span class="s1">'foo'</span>
</code></pre></div></div>

<h2 id="practical-implications">Practical Implications</h2>

<ul>
  <li>Column order is structural — put the most selective or most-filtered column first.</li>
  <li>Prefix lookups (<code class="language-plaintext highlighter-rouge">col1 = ?</code>) are ideal; <code class="language-plaintext highlighter-rouge">col2</code>-only queries require a skip scan (PG 17+) or a separate index.</li>
  <li>Low-cardinality leading columns (enums, status codes) make skip scans practical.</li>
  <li>Large text values don’t bloat the index due to TOAST pointers.</li>
  <li>Locality on leaf pages drives range scan speed — similar keys land on adjacent pages.</li>
</ul>

<hr />

<h2 id="closing">Closing</h2>

<p>Composite indexes in Postgres are literally concatenated keys stored in B-tree
leaf pages. Once you understand that physical structure, most planner decisions
become predictable rather than surprising.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Composite indexes can seem like an easy way to “put two columns together and get great performance benefits” - as Postgres can just figure it out. However, the physical representation is not intuitive unless you’ve looked inside a B-tree page. Once you understand how these indexes are stored, you can understand why certain queries leveraging a composite index perform better whilst others don’t quite get the same lift-off effect.]]></summary></entry><entry><title type="html">UUIDs, Protobuf, and the High Cost of Small Decisions</title><link href="https://www.alexstoica.com/blog/uuids-and-protobuf" rel="alternate" type="text/html" title="UUIDs, Protobuf, and the High Cost of Small Decisions" /><published>2025-10-20T00:00:00+00:00</published><updated>2025-10-20T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/uuids-and-protobuf</id><content type="html" xml:base="https://www.alexstoica.com/blog/uuids-and-protobuf"><![CDATA[<p>UUIDs sit at the heart of most modern distributed systems due to the very low
risk of collisions and reduced attack vector compared to traditional <code class="language-plaintext highlighter-rouge">int</code> IDs. 
They are used to identify customers, events … and so much more.
They’re one of core details and very often overlooked, until you start looking
in depth as you’re storing hundreds of millions of them, or maybe billions.</p>

<p>This post explores something deceptively simple: <strong>how UUIDs are stored</strong> across
various systems, and how simple assumptions about common storage can end up 
<em>surprisingly wasteful</em>, and how much can be saved both in bytes and in real money 
by being more deliberate in how we serialize them. A seemingly trivial choice can translate to terabytes of wasted storage and significant financial cost.</p>

<!--more-->

<h2 id="uuids-in-modern-systems">UUIDs in Modern Systems</h2>

<p>UUIDs are easy to generate, globally unique, and require no coordination between
services. They are the <em>obvious</em> choice for any distributed architecture.</p>

<p>Many systems, use <strong>UUID v7</strong> which is <code class="language-plaintext highlighter-rouge">UUID</code> that can be time-ordered, making them
an easy replacement for numerical sequential IDs, as they can fit nicely into the 
traditional <code class="language-plaintext highlighter-rouge">btree</code> database indexes with no performance loss.</p>

<p>A UUID v7 is composed of:</p>

<ul>
  <li>a 48-bit timestamp</li>
  <li>version bits</li>
  <li>variant bits</li>
  <li>60 bits of randomness</li>
</ul>

<div class="uuid-v7-diagram" style="max-width: 900px; margin: 1.5rem 0;">
  <svg viewBox="0 0 900 230" xmlns="http://www.w3.org/2000/svg">
    <defs>
      <marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
        <polygon points="0 0, 8 3, 0 6" fill="#4b5563" />
      </marker>
    </defs>

    <!-- Base bar outline -->
    <rect x="50" y="80" width="800" height="40" fill="none" stroke="#9ca3af" stroke-width="1" />

    <!-- Timestamp (48 bits / 6 bytes) -->
    <rect x="50" y="80" width="337" height="40" fill="#fee2e2" stroke="#111827" />
    <!-- Version (4 bits / 0.5 byte) -->
    <rect x="387" y="80" width="28" height="40" fill="#dbeafe" stroke="#111827" />
    <!-- Variant (2 bits / 0.25 byte) -->
    <rect x="415" y="80" width="14" height="40" fill="#bbf7d0" stroke="#111827" />
    <!-- Randomness (60 bits / 7.5 bytes) -->
    <rect x="429" y="80" width="421" height="40" fill="#fef3c7" stroke="#111827" />

    <!-- Timestamp arrow + label (below) -->
    <line x1="218.5" y1="120" x2="218.5" y2="145" stroke="#4b5563" stroke-width="1.5" marker-end="url(#arrowhead)" />
    <text x="218.5" y="165" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="12">
      48-bit timestamp
    </text>
    <text x="218.5" y="182" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#4b5563">
      6 bytes
    </text>

    <!-- Randomness arrow + label (below) -->
    <line x1="639.5" y1="120" x2="639.5" y2="145" stroke="#4b5563" stroke-width="1.5" marker-end="url(#arrowhead)" />
    <text x="639.5" y="165" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="12">
      randomness
    </text>
    <text x="639.5" y="182" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#4b5563">
      60 bits
    </text>

    <!-- Version arrow + label (above) -->
    <line x1="401" y1="40" x2="401" y2="80" stroke="#4b5563" stroke-width="1.5" marker-end="url(#arrowhead)" />
    <text x="401" y="32" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="12">
      version
    </text>
    <text x="401" y="18" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#4b5563">
      4 bits
    </text>

    <!-- Variant arrow + label (above, slightly right) -->
    <line x2="422" y2="80" x1="465" y1="35" stroke="#4b5563" stroke-width="1.5" marker-end="url(#arrowhead)" />
    <text x="495" y="32" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="12">
      variant
    </text>
    <text x="495" y="18" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#4b5563">
      2 bits
    </text>

    <!-- Caption -->
    <text x="450" y="210" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="20" fill="#374151">
      UUID v7 layout
    </text>
  </svg>
</div>

<p>This structure makes collisions effectively impossible and ensures that 
identifiers cluster by time.</p>

<p>Underneath all of that, a UUID is simply <strong>32 hexadecimal characters</strong> 
representing <strong>16 raw bytes</strong>. When displayed as a string, the addition of 
four decorative dashes increases it to <strong>36 characters</strong>. Readable for humans, yes. 
Efficient for machines, no.</p>

<h2 id="protobufs-role">Protobuf’s Role</h2>

<p>Protobuf remains a common choice for modern service ecosystems: it is compact, 
strongly typed, and designed for high-throughput communication.</p>

<p>Protobuf serializes data using:</p>

<ul>
  <li>field numbers</li>
  <li>wire types</li>
  <li>encoded values</li>
  <li>optional length prefixes</li>
</ul>

<p>This results in efficient, structured binary messages. The downside is that 
strings are not treated the same as fixed-length bytes. Strings are a 
“length-delimited” wire type, which means they incur both:</p>

<ul>
  <li>a UTF-8 encoding cost</li>
  <li>a length-prefix header</li>
</ul>

<p>When dealing with a field that contains UUID, this overhead starts to matter.
And unfortunately, UUIDs do not have a native Protobuf type and as a result, most
often they end up being stored as strings rather than binary byte sequences.</p>

<h2 id="how-uuids-should-be-stored">How UUIDs <em>Should</em> Be Stored</h2>

<p>Let’s break this down.</p>

<p>A UUID can be stored as:</p>

<ul>
  <li><strong>16 bytes</strong> - raw binary; no variable length - requires custom type</li>
  <li><strong>18 bytes</strong> - <code class="language-plaintext highlighter-rouge">bytes</code> type with 2-byte variable length header</li>
  <li><strong>38 bytes</strong> - <code class="language-plaintext highlighter-rouge">string</code>: 36 chars (incl 4 dashes) + 2-byte variable length header</li>
</ul>

<p>The most common but least efficient approach is to store UUIDs as strings. 
That carries more than <strong>double the required space (a 111% increase)</strong>, with no benefit to the 
system.
Using a byte array or a dedicated fixed-length UUID type eliminates the UTF-8 
overhead entirely.</p>

<p>This can look like a micro-optimisation, but at scale - these bytes end up, not
just in storage - but also transfer, backups as well as the additional memory
required to process large amounts of these messages concurrently.</p>

<h2 id="why-this-matters-in-modern-architectures">Why This Matters in Modern Architectures</h2>

<p>In high-volume event systems, data growth rarely happens slowly. It accelerates.
A single table can grow into terabytes quickly, and storing data 
inefficiently compounds the problem.</p>

<p>String-encoded UUIDs double the footprint of one of the most frequently used 
fields. They also increase:</p>

<ul>
  <li>network bandwidth</li>
  <li>Kafka topic sizes</li>
  <li>I/O on storage engines</li>
  <li><code class="language-plaintext highlighter-rouge">VACUUM</code> costs</li>
  <li>CPU usage during encoding/decoding</li>
</ul>

<p>All for no benefit.</p>

<p>Optimizing UUID storage won’t fix everything, but it is one of the cleanest, 
lowest-risk improvements available.</p>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>Performance gains in distributed systems often come from tightening foundational 
pieces the parts executed millions or billions of times.<br />
Storing UUIDs as strings is familiar and convenient, but it is also 
unnecessarily expensive.</p>

<p><strong>Small decisions when done at scale end up having incredibly large impact, so 
in a distributed system world its paramount to pay attention to the details!</strong></p>]]></content><author><name></name></author><category term="protobuf" /><category term="uuid" /><category term="distributed-systems" /><category term="storage" /><summary type="html"><![CDATA[UUIDs sit at the heart of most modern distributed systems due to the very low risk of collisions and reduced attack vector compared to traditional int IDs. They are used to identify customers, events … and so much more. They’re one of core details and very often overlooked, until you start looking in depth as you’re storing hundreds of millions of them, or maybe billions.]]></summary></entry><entry><title type="html">Distributed SQL, Primary Keys &amp;amp; Indexes</title><link href="https://www.alexstoica.com/blog/distributed-sql-pkey-index" rel="alternate" type="text/html" title="Distributed SQL, Primary Keys &amp;amp; Indexes" /><published>2025-09-03T00:00:00+00:00</published><updated>2025-09-03T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/distributed-sql-pkey-index</id><content type="html" xml:base="https://www.alexstoica.com/blog/distributed-sql-pkey-index"><![CDATA[<p>When you move to a distributed SQL database, you quickly learn that schema
design requires a new way of thinking. This post refers specifically to my
experience with YugabyteDB, but the broad concepts will apply to other 
distributed SQL databases as well.</p>

<h2 id="introduction">Introduction</h2>

<p>Once you move from a single server DB, schema design is more important as your data
is now split between <em>many</em> machines. It’s no longer enough to define your 
columns and data types; you have to consider how your data will be 
physically spread across machines: both for performance benefits and also to 
ensure optimal load across the cluster. The two biggest impacts on these metrics
are the choices you make for primary keys and indexes.</p>

<h2 id="how-data-is-stored-and-partitioned">How data is stored and partitioned</h2>

<p>In YugabyteDB a table’s <strong>primary key</strong> is also the <strong>partitioning key</strong>. 
This means the primary key dictates how data is divided into tablets
and which nodes those tablets live on. The database constantly
monitors the size of these tablets, automatically splitting them when they grow
too large to ensure that they remain balanced.</p>

<p>Every table begins with a set number of tablets, typically equal to the number
of nodes in the cluster.</p>

<h2 id="partitioning-strategies-hash-vs-range">Partitioning Strategies: Hash vs. Range</h2>

<p>There are two strategies for partitioning your data:</p>

<h3 id="hash-partitioning">Hash Partitioning</h3>

<p>With hash partitioning, the database applies a hashing function to the primary
key and distributes rows based on the resulting hash value. This is the default
in YugabyteDB and is excellent for ensuring an even spread of data across all
nodes, which helps avoid “hotspots” where one node becomes overloaded with
writes. For example, a table of <code class="language-plaintext highlighter-rouge">events</code> with <code class="language-plaintext highlighter-rouge">PRIMARY KEY (identifier HASH)</code>
will have its data spread evenly across the tablets and thus the cluster.</p>

<p>Attaching an example of how a hash primary key would be distributed on a 3 node
cluster:</p>

<p><img src="/img/hashpkey.svg" alt="Hash Partitioning" /></p>

<h3 id="range-partitioning">Range Partitioning</h3>

<p>Range partitioning groups data based on a continuous range of primary key
values. For instance, a <code class="language-plaintext highlighter-rouge">logs</code> table with <code class="language-plaintext highlighter-rouge">PRIMARY KEY (created_at ASC)</code> would
place records from January on one node, February on another, and so on. This
will be efficient for queries that scan over a specific range, like fetching all
logs from last week. However, it carries a significant risk: if your primary
key is monotonically increasing (like a timestamp or a traditional sequence),
all incoming writes will target a single node, creating a hotspot. There are 
a few tricks that we can use to mitigate this and help distribute the writes
more evenly, which I will dig into a follow-up post!</p>

<p>As a range-partitioned tablet grows, it will eventually split. For example, a
tablet holding data for the entire year of 2023 might split into two, one for
the first six months and another for the second.</p>

<p>Example of how a range primary key would be distributed on a 3 node cluster:
<img src="/img/rangepkey.svg" alt="Range Partitioning" /></p>

<p>Example of how a range primary key would be split:
<img src="/img/rangepkey-split.svg" alt="Range Partitioning Split" /></p>

<h2 id="indexes-are-also-distributed-tables">Indexes Are Also Distributed Tables</h2>

<p>In Yugabyte, an index is stored as its own table with its own primary key and 
partitioning strategy. When you run <code class="language-plaintext highlighter-rouge">CREATE INDEX</code>, you’re creating another 
data structure that consumes storage and requires its own maintenance.</p>

<h3 id="index-creation-syntax-in-yugabytedb">Index Creation Syntax in YugabyteDB</h3>

<p>It’s important to understand how YugabyteDB interprets the <code class="language-plaintext highlighter-rouge">CREATE INDEX</code>
statement. Often, what you write is shorthand for a more explicit command that
defines the partitioning strategy.</p>

<p>For example, a simple index creation:</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_tbl</span> <span class="k">ON</span> <span class="n">tbl</span> <span class="p">(</span><span class="n">id</span><span class="p">);</span>
</code></pre></div></div>
<p>is translated to use hash partitioning on the index’s primary key:</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- This is what YugabyteDB actually runs</span>
<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_tbl</span> <span class="k">ON</span> <span class="n">tbl</span> <span class="p">(</span><span class="n">id</span> <span class="n">HASH</span><span class="p">);</span>
</code></pre></div></div>

<p>Similarly, for a composite index, the first column is hashed by default, while
subsequent columns are stored in ascending order:</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_tbl2</span> <span class="k">ON</span> <span class="n">tbl</span> <span class="p">(</span><span class="n">id</span><span class="p">,</span> <span class="n">created_at</span><span class="p">);</span>
</code></pre></div></div>
<p>This becomes:</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- This is what YugabyteDB actually runs</span>
<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_tbl2</span> <span class="k">ON</span> <span class="n">tbl</span> <span class="p">(</span><span class="n">id</span> <span class="n">HASH</span><span class="p">,</span> <span class="n">created_at</span> <span class="k">ASC</span><span class="p">);</span>
</code></pre></div></div>

<p>You can also create more complex indexes, for example, having a hash of a 
two columns to help with data distribution:</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_tbl_distrb_status</span> <span class="k">ON</span> <span class="k">table</span><span class="p">((</span><span class="n">id</span><span class="p">,</span> <span class="n">status</span><span class="p">)</span> <span class="n">HASH</span><span class="p">);</span>
</code></pre></div></div>

<p>Understanding this translation is key to predicting how the table that gets
created will perform both for writes and also for reads.</p>

<h3 id="common-indexing-pitfalls">Common Indexing Pitfalls</h3>

<ol>
  <li>
    <p><strong>Indexing Low-Cardinality Columns</strong>: Creating an index on a column with
few unique values, like an <code class="language-plaintext highlighter-rouge">enum</code> for <code class="language-plaintext highlighter-rouge">status</code>, is a classic mistake. This
leads to poor data distribution, as all rows with the same status will be
clumped together in a few tablets, creating hotspots. This will lead to both
slower inserts - as all writes will be directed to the same tablet, and 
slower reads - as queries filtering on that status will hit only one tablet,
negating the benefits of distribution.</p>
  </li>
  <li>
    <p><strong>Using Hash Indexes for Range Queries</strong>: A <code class="language-plaintext highlighter-rouge">HASH</code> index on a timestamp
column is often not useful. While it distributes the data well, it is
inefficient for range queries (e.g., <code class="language-plaintext highlighter-rouge">WHERE created_at BETWEEN ? AND ?</code>)
because the data is not stored in chronological order.</p>
  </li>
</ol>

<h2 id="the-need-for-housekeeping">The Need for Housekeeping</h2>

<p>Because data is constantly being written, updated and removed there is a need 
for continuous housekeeping. Processes like compaction: merging small data files 
into larger ones are essential for maintaining read performance. 
While the database handles this automatically, a well-designed schema makes 
these operations far more efficient.</p>

<p>Not only do we have to maintain our table, but also the tables used for the 
indexes. It’s important to remember that for every index you add to your table
there is an additional write cost as the index has to be kept up to date with
every insert, update and delete operation.</p>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>Designing schemas for distributed SQL is about balancing trade-offs.</p>
<ul>
  <li>Your <strong>primary key</strong> is your partitioning strategy. Choose it wisely.</li>
  <li>In Yugabyte, your <strong>indexes are tables</strong>. They add overhead, so use them 
carefully and design them for good distribution - both for write and read
operations.</li>
  <li>The <em>goal</em> is to <strong>leverage the distributed nature</strong> of the system. A schema
that spreads the load evenly across all available nodes will lead to a
healthier, more performant, and more scalable cluster.</li>
</ul>]]></content><author><name></name></author><category term="database" /><category term="distributed-sql" /><category term="yugabyte" /><summary type="html"><![CDATA[When you move to a distributed SQL database, you quickly learn that schema design requires a new way of thinking. This post refers specifically to my experience with YugabyteDB, but the broad concepts will apply to other distributed SQL databases as well.]]></summary></entry><entry><title type="html">CREATE INDEX CONCURRENTLY and what locks it requires</title><link href="https://www.alexstoica.com/blog/create-index-concurrently-locks" rel="alternate" type="text/html" title="CREATE INDEX CONCURRENTLY and what locks it requires" /><published>2025-04-23T00:00:00+00:00</published><updated>2025-04-23T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/create-index-concurrently-locks</id><content type="html" xml:base="https://www.alexstoica.com/blog/create-index-concurrently-locks"><![CDATA[<p>When managing databases, especially in production environments, adding indexes 
is a commonly performed task to improve query performance. A standard 
<code class="language-plaintext highlighter-rouge">CREATE INDEX</code> command in PostgreSQL locks the table against <em>any</em> kind of
updates (<code class="language-plaintext highlighter-rouge">INSERT</code>, <code class="language-plaintext highlighter-rouge">UPDATE</code>, <code class="language-plaintext highlighter-rouge">DELETE</code>) for the duration of the index creation.
This can lead to significant downtime for applications relying on updating 
that table.</p>

<p>PostgreSQL offers an alternative - <code class="language-plaintext highlighter-rouge">CREATE INDEX CONCURRENTLY</code>. This command 
allows you to build an index without blocking write operations on the table,
making it the only option for systems where writes are constant throughout the 
day.</p>

<h2 id="understanding-create-index-concurrently">Understanding <code class="language-plaintext highlighter-rouge">CREATE INDEX CONCURRENTLY</code></h2>

<p>The <em>magic</em> behind <code class="language-plaintext highlighter-rouge">CREATE INDEX CONCURRENTLY</code> is in its multi-phase approach:</p>

<ol>
  <li><strong>Initial Scan and Build:</strong> It performs an initial scan of the table and 
starts building the index structure. During this phase, it only requires a 
<code class="language-plaintext highlighter-rouge">ShareUpdateExclusiveLock</code> on the table. This lock mode blocks schema changes 
(like <code class="language-plaintext highlighter-rouge">ALTER TABLE</code>) and <code class="language-plaintext highlighter-rouge">VACUUM FULL</code>, but it <em>allows</em> <code class="language-plaintext highlighter-rouge">INSERT</code>, <code class="language-plaintext highlighter-rouge">UPDATE</code>
and <code class="language-plaintext highlighter-rouge">DELETE</code> operations to continue normally.</li>
  <li><strong>Waiting for Transactions:</strong> After the initial build, it waits for all 
transactions that started <em>before</em> this phase began to complete. This ensures 
that the index includes changes made by those transactions.</li>
  <li><strong>Second Scan:</strong> It performs a second scan of the table to incorporate 
changes made by transactions that occurred during the initial build phase.</li>
  <li><strong>Finalization:</strong> Briefly locks the table to ensure it’s using the latest
updates, after which the index is marked as ready for use.</li>
</ol>

<h2 id="lock-requirements">Lock Requirements</h2>

<p>While <code class="language-plaintext highlighter-rouge">CREATE INDEX CONCURRENTLY</code> avoids the heavy <code class="language-plaintext highlighter-rouge">AccessExclusiveLock</code> used 
by the standard <code class="language-plaintext highlighter-rouge">CREATE INDEX</code>, however, it still needs an exclusive lock in the
finalization stage:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">ShareUpdateExclusiveLock</code> (Mode 4):</strong> This is held on the table being 
indexed for most of the operation. It blocks schema changes and <code class="language-plaintext highlighter-rouge">VACUUM FULL</code> 
but allows reads and writes (<code class="language-plaintext highlighter-rouge">SELECT</code>, <code class="language-plaintext highlighter-rouge">INSERT</code>, <code class="language-plaintext highlighter-rouge">UPDATE</code>, <code class="language-plaintext highlighter-rouge">DELETE</code>).</li>
  <li><strong>Brief <code class="language-plaintext highlighter-rouge">AccessExclusiveLock</code> (Mode 8):</strong> Towards the very end, when 
finalizing the index and making it visible in the system catalogs, it needs 
to acquire a brief <code class="language-plaintext highlighter-rouge">AccessExclusiveLock</code>. This lock <em>does</em> block all other 
operations - but this lock is typically held for a very short duration, without 
noticeable effect on the performance of the system.</li>
</ul>

<h2 id="why-use-it">Why Use It?</h2>

<p>The primary benefit is minimizing downtime. By allowing writes during the 
lengthy index build process, <code class="language-plaintext highlighter-rouge">CREATE INDEX CONCURRENTLY</code> is essential for 
adding indexes to busy tables in production environments without disrupting 
application availability or performance.</p>

<h3 id="trade-offs">Trade-offs</h3>

<p>It also requires two transactions. If either transaction fails, the index might be 
left in an “invalid” state, requiring cleanup (<code class="language-plaintext highlighter-rouge">DROP INDEX</code>) before retrying.</p>

<ul>
  <li><strong>Slower:</strong> Building an index concurrently takes significantly longer than a 
standard <code class="language-plaintext highlighter-rouge">CREATE INDEX</code> due to the extra scans and waiting phases. For hot
tables, there are likely going to be multiple passes before the index is close
enough to the latest transaction and being able to enter its finalization stage.</li>
  <li><strong>Higher CPU/IO Load:</strong> The process consumes more system resources over a 
longer period.</li>
  <li><strong>Cannot Run Inside a Transaction Block:</strong> <code class="language-plaintext highlighter-rouge">CREATE INDEX CONCURRENTLY</code> must 
be run outside of an explicit transaction (<code class="language-plaintext highlighter-rouge">BEGIN</code>/<code class="language-plaintext highlighter-rouge">COMMIT</code>).</li>
  <li><strong>Potential Failure:</strong> If something goes wrong (e.g., unique constraint 
violation during the second scan, or the final locking phase times out), it can 
leave behind an invalid index that needs manual cleanup.</li>
  <li><strong>Blocked by long running transactions</strong>: If you have longer running 
transactions, those will slow down the creation of the index significantly, as
the index has to wait for <em>all previously open transactions</em> to wrap. This is
problematic if the table targetted by the index creation is particularly hot and
requires multiple scans to catch up.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p><code class="language-plaintext highlighter-rouge">CREATE INDEX CONCURRENTLY</code> is a powerful tool in PostgreSQL for maintaining 
performance without sacrificing availability. While it requires careful 
consideration due to its longer duration, higher resource usage, and inability 
to run within a transaction, its ability to avoid blocking writes makes it the 
preferred method for adding indexes to live production tables. Understanding 
its locking behaviour, as well as what transactions are blocking the index
creation means you can make an informed decision between <code class="language-plaintext highlighter-rouge">CREATE INDEX</code> and
<code class="language-plaintext highlighter-rouge">CREATE INDEX CONCURRENTLY</code>.</p>]]></content><author><name></name></author><category term="postgres" /><category term="database" /><category term="performance" /><category term="locks" /><summary type="html"><![CDATA[When managing databases, especially in production environments, adding indexes is a commonly performed task to improve query performance. A standard CREATE INDEX command in PostgreSQL locks the table against any kind of updates (INSERT, UPDATE, DELETE) for the duration of the index creation. This can lead to significant downtime for applications relying on updating that table.]]></summary></entry><entry><title type="html">Ruby `fetch` unexpected evaluation of params</title><link href="https://www.alexstoica.com/blog/ruby-fetch-always-eval" rel="alternate" type="text/html" title="Ruby `fetch` unexpected evaluation of params" /><published>2025-04-16T00:00:00+00:00</published><updated>2025-04-16T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/ruby-fetch-always-eval</id><content type="html" xml:base="https://www.alexstoica.com/blog/ruby-fetch-always-eval"><![CDATA[<p>Ruby <code class="language-plaintext highlighter-rouge">fetch</code> can have an unexpected side-effect which is that the fallback
value gets evaluated even if the key is present. Lets explore that with a code
example.</p>

<!--more-->

<h2 id="fetch-use-cases"><em>fetch</em> use-cases</h2>

<p>Using <code class="language-plaintext highlighter-rouge">fetch</code> is a common pattern in Ruby, especially when dealing with hashes.
It allows you to retrieve a value for a given key, and if the key is not found,
you can provide a default value. This is useful for avoiding <code class="language-plaintext highlighter-rouge">nil</code> values and
for providing a fallback value.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">hash</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">a: </span><span class="mi">1</span><span class="p">,</span> <span class="ss">b: </span><span class="mi">2</span> <span class="p">}</span>
<span class="nb">hash</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="ss">:a</span><span class="p">)</span> <span class="c1"># =&gt; 1</span>
<span class="nb">hash</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="ss">:c</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span> <span class="c1"># =&gt; 3</span>
</code></pre></div></div>

<h2 id="fetch-fallback-values-and-fallback-blocks"><em>fetch</em> fallback values and fallback blocks</h2>

<p>When using <code class="language-plaintext highlighter-rouge">fetch</code>, the fallback value is evaluated even if the key is present.
This can lead to unexpected behavior, especially if the fallback value is a
complex expression or a method call.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">hash</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">a: </span><span class="mi">1</span><span class="p">,</span> <span class="ss">b: </span><span class="mi">2</span> <span class="p">}</span>
<span class="nb">hash</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="ss">:a</span><span class="p">,</span> <span class="s2">"not found"</span> <span class="p">)</span> <span class="c1"># =&gt; 1</span>
<span class="nb">hash</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="ss">:c</span><span class="p">,</span> <span class="s2">"not found"</span> <span class="p">)</span> <span class="c1"># =&gt; "Key not found"</span>
</code></pre></div></div>

<p>Now with a defined function which throws an exception:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="k">def</span> <span class="nf">complex_fallback</span>
  <span class="k">raise</span> <span class="s2">"This is an exception"</span>
<span class="k">end</span>

<span class="nb">hash</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">a: </span><span class="mi">1</span><span class="p">,</span> <span class="ss">b: </span><span class="mi">2</span> <span class="p">}</span>
<span class="nb">hash</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="ss">:a</span><span class="p">,</span> <span class="n">complex_fallback</span><span class="p">)</span> <span class="c1"># =&gt; This is an exception!</span>
</code></pre></div></div>

<p>This is definitely not what we wanted, as the value <code class="language-plaintext highlighter-rouge">a</code> is present in the hash,
and we’d expect for it to be returned. To avoid this we have to pass a block to
<code class="language-plaintext highlighter-rouge">fetch</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">hash</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">a: </span><span class="mi">1</span><span class="p">,</span> <span class="ss">b: </span><span class="mi">2</span> <span class="p">}</span>
<span class="nb">hash</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="ss">:a</span><span class="p">)</span> <span class="p">{</span> <span class="n">complex_fallback</span> <span class="p">}</span> <span class="c1"># =&gt; 1</span>
<span class="nb">hash</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="ss">:c</span><span class="p">)</span> <span class="p">{</span> <span class="n">complex_fallback</span> <span class="p">}</span> <span class="c1"># =&gt; This is an exception</span>
</code></pre></div></div>

<h2 id="conclusion">Conclusion</h2>

<p>If you want to use <code class="language-plaintext highlighter-rouge">fetch</code> with a fallback value, be careful about the
evaluation of the fallback value. If the fallback value is a complex expression or a
method call, it is better to use a block to avoid unexpected evaluation of the
function.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Ruby fetch can have an unexpected side-effect which is that the fallback value gets evaluated even if the key is present. Lets explore that with a code example.]]></summary></entry><entry><title type="html">Anatomy of a good interview question</title><link href="https://www.alexstoica.com/blog/good-interview-question" rel="alternate" type="text/html" title="Anatomy of a good interview question" /><published>2025-03-25T00:00:00+00:00</published><updated>2025-03-25T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/good-interview-question</id><content type="html" xml:base="https://www.alexstoica.com/blog/good-interview-question"><![CDATA[<p>Good interview questions aren’t just about checking technical boxes; they’re a 
way to understand how someone thinks, give them a sense of the real work, and 
make space for meaningful discussion. They should generate clear signal without 
relying on <em>tricks</em> or <em>trivia</em>. Done right, they leave both sides feeling like the 
time was well spent.</p>

<!--more-->

<h2 id="introduction">Introduction</h2>

<p>The best interview questions are more than just a test. They’re a way to ensure
that both parties get a clear idea of what it would be like to work together.
They should be:</p>
<ul>
  <li>grounded in real-world problems</li>
  <li>encourage discussion</li>
  <li>avoid gimmicks</li>
  <li>expandable across levels</li>
</ul>

<h2 id="grounded-in-real-work">Grounded in Real Work</h2>

<p>The strongest questions come directly from the kinds of challenges your team
faces every day. These questions create context, helping the candidate visualize
what it’s like to work on your codebase, with your data, or within your
constraints. It’s not about mimicking production exactly;it’s about reflecting
the shape of the problems. You want questions that allow candidates to showcase
their problem-solving in a way that actually maps to the role.</p>

<p>This also helps reduce bias: instead of filtering for textbook knowledge,
you’re evaluating the kinds of thinking you actually need on your team.</p>

<h2 id="enables-two-way-alignment">Enables Two-Way Alignment</h2>

<p>A well-structured question encourages discussion. It gives candidates the
opportunity to bring in their perspective, ask questions, and see whether your
environment matches their strengths and interests. When done right, the
interview becomes a collaborative exchange, not a one-sided interrogation.
It’s important to remember that the interview is not a confrontation, and you
want the candidate to succeed.</p>

<p>You learn not just whether they can do the work, but how they communicate, how
they think through ambiguity, and whether their approach aligns with your team’s
engineering culture. And just as importantly, it gives them insight into how
the team work and what the collaboration looks like.</p>

<h2 id="avoids-gimmicks">Avoids Gimmicks</h2>

<p>Puzzles and trick questions usually produce noise. They reward prior exposure
over solid reasoning which are valuable if the environment in which you operate
you require a lot of specific knowledge: you can either try to find that in 
candidates or validate that they can pick up on <em>tricks</em>. They can also create 
unnecessary stress and exclude strong candidates who simply haven’t seen the 
specific pattern before.</p>

<p>A good question is well-scoped, has some constraints and can be reasoned through 
with just simple concepts. Even better if it encourages the candidate to ask 
clarifying questions; that’s often a good sign they’re thinking through the
problem and engaging with the question.</p>

<h2 id="expandable-across-levels">Expandable Across Levels</h2>

<p>A great question can cater to all levels of candidates, it  can scale up or down in
complexity such that everyone can enjoy the question. 
It also gives you flexibility during the interview: if someone finishes the core 
problem quickly, you can dig deeper. If they struggle initially, you can adjust 
without abandoning the question altogether and offers the possibility of 
making the interview a positive experience for the candidate even if they will 
not be moving forward. One has to remember that tech world can be quite small 
and you do not want to have negative experiences due to a bad interview question.</p>

<h3 id="example-real-world-system-design">Example: Real-World System Design</h3>

<p>Skip the textbook stuff it is usually very low signal. Try to ask something 
practical, based on a real-problem you are solving:</p>

<p>If you are working on a system that has to ingest a lot of data from articles,
you can ask the candidate to design that, or in a coding exercise - give them
an array of articles, and have the write a “processing” pipeline.</p>

<p>A more worked through example:
Example: <em>“We handle millions of log events per second. How would you design
a system to make recent logs quickly queryable?”</em></p>

<p>Why I think this leads to better engagement:</p>

<p><strong>It’s real</strong>: Based on the kind of infrastructure problems many teams tackle.</p>

<p><strong>It’s relatable</strong>: Most engineers have some experience with logs or data
pipelines.</p>

<p><strong>It scales</strong>: You can start with a naive solution, then explore tradeoffs:
consistency vs. availability, local vs. distributed stores, etc.</p>

<p>You can also layer in follow-ups: What happens when query volumes spikes? How
would you handle <em>hot partitions</em>? What if logs need to be retained for
compliance? How to deal with data deletion/rotation?</p>

<h2 id="takeaway">Takeaway</h2>

<p>A good interview question should feel like a working session. It should be 
rooted in real problems, flexible for different experience levels, yet have
enough ambiguity to allow for a discussion. If it results in a productive, 
thoughtful conversation, you’re probably on the right track.</p>

<p>It’s worth investing in this. A few well-designed questions can raise the bar on
your hiring process and improve the experience for everyone involved.</p>

<p>Got an interview question you’ve seen that works well;or one that totally misses
the mark? Let’s talk.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Good interview questions aren’t just about checking technical boxes; they’re a way to understand how someone thinks, give them a sense of the real work, and make space for meaningful discussion. They should generate clear signal without relying on tricks or trivia. Done right, they leave both sides feeling like the time was well spent.]]></summary></entry><entry><title type="html">How to leverage indexes for `ILIKE` &amp;amp; `LIKE` queries</title><link href="https://www.alexstoica.com/blog/postgres-ilike-like-lower-perf" rel="alternate" type="text/html" title="How to leverage indexes for `ILIKE` &amp;amp; `LIKE` queries" /><published>2025-02-06T00:00:00+00:00</published><updated>2025-02-06T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/postgres-ilike-like-lower-perf</id><content type="html" xml:base="https://www.alexstoica.com/blog/postgres-ilike-like-lower-perf"><![CDATA[<p>You cannot always avoid <code class="language-plaintext highlighter-rouge">LIKE</code> or <code class="language-plaintext highlighter-rouge">ILIKE</code> queries when it comes to solving
specific business requirements. However, it does not mean that those queries
have to be slow. Lets look at how the keywords affect query usage, and what
can be done to optimise the query plans and the impact each option has on 
the overall performance of the database.</p>

<!--more-->

<h2 id="like-ilike-and-why-you-might-need-to-use-them"><code class="language-plaintext highlighter-rouge">LIKE</code>, <code class="language-plaintext highlighter-rouge">ILIKE</code> and why you might need to use them</h2>

<p><code class="language-plaintext highlighter-rouge">LIKE</code> and <code class="language-plaintext highlighter-rouge">ILIKE</code> operators allow you to use wildcards in your queries to
provide a level of fuzzy-matching should you require it. The wildcard in SQL
syntax is represented by the percent symbol: <code class="language-plaintext highlighter-rouge">%</code>. 
A very common use-case is to find all names or emails matching a prefix, 
suffix or both. Generally this kind of query <em>can</em> be optimised such that we 
avoid the <code class="language-plaintext highlighter-rouge">LIKE</code> or <code class="language-plaintext highlighter-rouge">ILIKE</code> syntax which can have pretty impactful performance
considerations.</p>

<p>The difference between <code class="language-plaintext highlighter-rouge">LIKE</code> and <code class="language-plaintext highlighter-rouge">ILIKE</code> is that the <code class="language-plaintext highlighter-rouge">ILIKE</code> operator will 
<em>ignore</em> the case of the strings which it compares, ie: it will match <code class="language-plaintext highlighter-rouge">AbC</code> with
a record containing <code class="language-plaintext highlighter-rouge">abc</code> or <code class="language-plaintext highlighter-rouge">aBc</code> - this is particularly useful anytime you
are dealing with user input - as case consistency is not a given.</p>

<h2 id="alternatives-to-like-and-ilike">Alternatives to <code class="language-plaintext highlighter-rouge">LIKE</code> and <code class="language-plaintext highlighter-rouge">ILIKE</code></h2>

<p>Firstly, if you need to check for only a prefix - you can actually trim both
strings to the desired length and perform an equality check, which can use an
existing index and yield really good performance.
The same can be applied if you also need to ignore casing, you can create an
index which transforms the column - resulting in slightly more work at INSERT
time but better performance for reads.</p>

<pre><code class="language-SQL">CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lower_name ON
"users" (lower(name));
</code></pre>

<p>Allowing us to write a query like this:</p>
<pre><code class="language-SQL">SELECT *
FROM users
WHERE lower(name) = ?
</code></pre>

<p>The above query will be able to use the index defined above, and as long as we
ensure that we pass a string which has been lower-cased from the calling point,
we are ensuring great performance by using the index.</p>

<p>Another alternative is using the <code class="language-plaintext highlighter-rouge">citext</code> extension - which stands for <code class="language-plaintext highlighter-rouge">case
insensitive text</code>, which can be used as a drop-in replacement for <code class="language-plaintext highlighter-rouge">text</code> columns.
However, if you are expecing to have letters that have accents, those will not
be normalized to the non-accented letter. There is also a small storage overhead
to be paid when using <code class="language-plaintext highlighter-rouge">citext</code> - but it allows you to avoid the 
<code class="language-plaintext highlighter-rouge">LOWER(...)</code> calls on your queries.</p>

<h2 id="optimising-like-and-ilike-queries">Optimising <code class="language-plaintext highlighter-rouge">LIKE</code> and <code class="language-plaintext highlighter-rouge">ILIKE</code> queries</h2>

<p><code class="language-plaintext highlighter-rouge">LIKE</code> queries which check only for prefixes, eg: <code class="language-plaintext highlighter-rouge">LIKE 'alex%'</code> - can make use
of existing <em>BTree</em> indexes to speed up their performance and do an <code class="language-plaintext highlighter-rouge">IndexScan</code>
which can plug some gaps. <code class="language-plaintext highlighter-rouge">ILIKE</code> <em>cannot</em> use <em>BTree</em> indexes and as such cannot
be optimised from.
How the query plan will look like for hitting an <code class="language-plaintext highlighter-rouge">IndexScan</code> for a <code class="language-plaintext highlighter-rouge">LIKE</code> query:</p>

<pre><code class="language-SQL">EXPLAIN ANALYZE 
SELECT * 
FROM emails 
WHERE LOWER(recipient_email) LIKE 'alex%' 
LIMIT 5;
                                                                            QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..129.79 rows=5 width=1370) (actual time=61.852..1837.272 rows=5 loops=1)
   -&gt;  Index Scan using idx_lower_rec_email on emails  (cost=0.43..1472852.26 rows=56930 width=1370) (actual time=61.851..1837.266 rows=5 loops=1)
         Filter: (lower((recipient_email)::text) ~~ 'alex%'::text)
         Rows Removed by Filter: 2079
 Planning Time: 0.165 ms
 Execution Time: 1842.558 ms
(6 rows)
</code></pre>

<p>However, sometimes you cannot get away with just a single non-leading wildcard,
and you require more <em>power</em>.</p>

<h3 id="trigrams--pg_trgm-for-multiple-wildcards">Trigrams &amp; <code class="language-plaintext highlighter-rouge">pg_trgm</code> for multiple wildcards</h3>
<p>Enter <code class="language-plaintext highlighter-rouge">trigrams</code> - via the <code class="language-plaintext highlighter-rouge">pg_trgrm</code> extension, which splits up the words into
3 letter segments, allowing much more granularity when it comes to querying.
You will also need to use a <strong>GIN</strong> or <strong>GiST</strong> index to be able to leverage the 
trigrams.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Trigrams for "Charlie"]
+-------+
| cha   |
| har   |
| arl   |
| rli   |
| lie   |
+-------+

[Trigram GIN Index]
+---------+------------+
| Trigram | Row IDs    |
+---------+------------+
| cha     | {3}        |
| har     | {3}        |
| arl     | {3}        |
| rli     | {3}        |
| lie     | {3}        |
+----------------------+
</code></pre></div></div>

<p>This will allow queries like <code class="language-plaintext highlighter-rouge">SELECT * FROM users WHERE name LIKE "%arl%"</code> to
have <em>very very</em> fast results.
However, it is obviously going to come at a pretty large cost in terms of storage,
as now instead of storing a single reference to the name, you have split it up
in threes:</p>

\[C(n, 3) = \frac{n!}{3!(n-3)!}\]

<p>This is a classic exponential situation which:</p>

<p><img src="/assets/trigram-effect.png" alt="Exponential effect of trigrams" /></p>

<p>Storage considerations aside, you have to consider that the <em>trigrams</em> have to
be computed at <em>INSERT</em> time and saved in the index. This will increase the 
latency of the data ingestion, however trading that off for the faster data lookups.</p>

<h2 id="recommendations">Recommendations</h2>

<ul>
  <li>Try to use <code class="language-plaintext highlighter-rouge">LOWER(...) = </code> functions if possible to do prefix matching, 
rather than a <code class="language-plaintext highlighter-rouge">LIKE "alex%"</code>. Create an index that computes the <code class="language-plaintext highlighter-rouge">LOWER(...)</code> and
can be used to speed up these queries.</li>
  <li>Use <code class="language-plaintext highlighter-rouge">citext</code> if you require case-insensitive, prefix-lookups and want to avoid
the <code class="language-plaintext highlighter-rouge">LOWER(...)</code> changes.</li>
  <li>Use <code class="language-plaintext highlighter-rouge">pg_trgm</code> for <strong>short</strong> strings which require multiple wildcards. Be mindful
of the insert time overhead.</li>
</ul>]]></content><author><name></name></author><summary type="html"><![CDATA[You cannot always avoid LIKE or ILIKE queries when it comes to solving specific business requirements. However, it does not mean that those queries have to be slow. Lets look at how the keywords affect query usage, and what can be done to optimise the query plans and the impact each option has on the overall performance of the database.]]></summary></entry><entry><title type="html">What is `FOR UPDATE SKIP LOCKED` and how it can impact your query plans</title><link href="https://www.alexstoica.com/blog/postgres-select-for-update-perf" rel="alternate" type="text/html" title="What is `FOR UPDATE SKIP LOCKED` and how it can impact your query plans" /><published>2024-09-22T00:00:00+00:00</published><updated>2024-09-22T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/postgres-select-for-update-perf</id><content type="html" xml:base="https://www.alexstoica.com/blog/postgres-select-for-update-perf"><![CDATA[<p>You can use <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE SKIP LOCKED</code> as an very easy way to
parallelize task processing in your application with minimal overhead. But be
careful of the impact this can have on your query plans!</p>

<!--more-->

<h2 id="what-is-select--for-update-skip-locked">What is <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE SKIP LOCKED</code></h2>

<p>Introduced in Postgres 9.5 - the <code class="language-plaintext highlighter-rouge">SKIP LOCKED</code> syntax can be tacked at the end
of a <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code> statement and will return only rows which are not
locked by any other ongoing transaction.
The normal behaviour of <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code> operation is to <em>wait</em> for the
locked rows to be returned, thus making it quite difficult for the user to
parallelize such <code class="language-plaintext highlighter-rouge">SELECT</code> statements on a single table without having appropriate
mechanisms to deal with either updating rows locked, or targeting different
rows by an additional attribute (eg: partitioning by modulo).</p>

<p>The introduction of <code class="language-plaintext highlighter-rouge">SKIP LOCKED</code>, enabled a very powerful way in which users
can parallelize their processing without having to worry about writing locking
mechanisms in their application, and can instead rely on the robust locking
mechanism provided by Postgres.</p>

<h2 id="table-setup">Table Setup</h2>

<p>Given a simple table structure like:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">t_random</span> <span class="k">AS</span>
    <span class="k">SELECT</span>
        <span class="n">uuid_generate_v4</span><span class="p">(),</span>
        <span class="n">ROUND</span><span class="p">(</span><span class="n">RANDOM</span><span class="p">()</span><span class="o">*</span><span class="mi">100</span><span class="p">)</span> <span class="k">AS</span> <span class="n">c1</span><span class="p">,</span>
        <span class="n">ROUND</span><span class="p">(</span><span class="n">RANDOM</span><span class="p">()</span><span class="o">*</span><span class="mi">1000</span><span class="p">)</span> <span class="k">AS</span> <span class="n">c2</span><span class="p">,</span>
        <span class="n">ROUND</span><span class="p">(</span><span class="n">RANDOM</span><span class="p">()</span><span class="o">*</span><span class="mi">100</span><span class="p">)</span> <span class="k">AS</span> <span class="n">c3</span>
    <span class="k">FROM</span> <span class="n">GENERATE_SERIES</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="mi">100000</span><span class="p">)</span> <span class="n">s</span><span class="p">;</span>
</code></pre></div></div>

<p>On this table, lets apply a <code class="language-plaintext highlighter-rouge">status</code> column - and assume that each row in the
table is a <em>job</em> that would need to be executed.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">your_table_name</span> <span class="k">ADD</span> <span class="k">COLUMN</span> <span class="n">status</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">255</span><span class="p">);</span>

<span class="k">UPDATE</span> <span class="n">your_table_name</span>
<span class="k">SET</span> <span class="n">status</span> <span class="o">=</span>
    <span class="k">CASE</span>
        <span class="k">WHEN</span> <span class="p">(</span><span class="n">c1</span> <span class="o">+</span> <span class="n">c2</span> <span class="o">+</span> <span class="n">c3</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">300</span> <span class="k">THEN</span> <span class="s1">'PENDING'</span>
        <span class="k">WHEN</span> <span class="p">(</span><span class="n">c1</span> <span class="o">+</span> <span class="n">c2</span> <span class="o">+</span> <span class="n">c3</span><span class="p">)</span> <span class="k">BETWEEN</span> <span class="mi">300</span> <span class="k">AND</span> <span class="mi">500</span> <span class="k">THEN</span> <span class="s1">'INPROGRESS'</span>
        <span class="k">ELSE</span> <span class="s1">'COMPLETED'</span>
    <span class="k">END</span><span class="p">;</span>
</code></pre></div></div>

<p>Running in 2 side by side transactions you can now experiment with the
<code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code> and <code class="language-plaintext highlighter-rouge">SKIP LOCKED</code>.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- session 1</span>

<span class="k">begin</span><span class="p">;</span>
<span class="k">SELECT</span> <span class="n">id</span> <span class="k">FROM</span> <span class="n">t_random</span> <span class="k">WHERE</span> <span class="n">status</span><span class="o">=</span><span class="s1">'PENDING'</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">id</span> <span class="k">LIMIT</span> <span class="mi">100</span> <span class="k">FOR</span> <span class="k">UPDATE</span> <span class="n">SKIP</span> <span class="n">LOCKED</span><span class="p">;</span>
<span class="c1">-- the above will return 100 rows.</span>

<span class="c1">-- session 2</span>

<span class="k">begin</span><span class="p">;</span>
<span class="k">SELECT</span> <span class="n">id</span> <span class="k">FROM</span> <span class="n">t_random</span> <span class="k">WHERE</span> <span class="n">status</span><span class="o">=</span><span class="s1">'PENDING'</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">ID</span> <span class="k">LIMIT</span> <span class="mi">100</span> <span class="k">FOR</span> <span class="k">UPDATE</span> <span class="n">SKIP</span> <span class="n">LOCKED</span><span class="p">;</span>
<span class="c1">-- this will return a different set of 100 rows, allowing you to processes these</span>
<span class="c1">-- in parallel.</span>

<span class="k">end</span><span class="p">;</span> <span class="c1">-- the 100 rows are unlocked and are now VISIBLE to other sesions.</span>

</code></pre></div></div>

<h2 id="row-locking-under-the-hood">Row locking under the hood</h2>

<p>So how does <strong>row locking</strong> actually work under the hood? When running a
<code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code> query Postgres will find the targeted rows and then
use a <code class="language-plaintext highlighter-rouge">LockTupleExclusive</code> row lock, which in turn will update the header of the
tuple with <code class="language-plaintext highlighter-rouge">xmin</code> and <code class="language-plaintext highlighter-rouge">xmax</code> values. These values affect which transactions
can <em>view</em> the row. This requires both a <strong>write to disk</strong>, which will persist
the tuple change, and also it writes the lock in shared memory and the lock
is now viewable via the <code class="language-plaintext highlighter-rouge">pg_locks</code> view.</p>

<p>This immediately highlights that row locking is an operation which has I/O,
it also shows that to check for row locks the database engine needs to read
the tuple information. This means we can no longer have <code class="language-plaintext highlighter-rouge">Index Only Scan</code> queries,
which will negatively affect performance.</p>

<p>You can put an <code class="language-plaintext highlighter-rouge">EXPLAIN ANALYZE</code> when running the above queries to have a peek
under the hood:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="k">EXPLAIN</span> <span class="k">ANALYZE</span> <span class="k">SELECT</span> <span class="n">id</span> <span class="k">FROM</span> <span class="n">t_random</span> <span class="k">WHERE</span> <span class="n">status</span> <span class="o">=</span> <span class="s1">'PENDING'</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">ID</span> <span class="k">LIMIT</span> <span class="mi">100</span><span class="p">;</span>
<span class="c1">----------------------------------------------------------------------------------------------------------------------------------------------</span>
 <span class="k">Limit</span>  <span class="p">(</span><span class="n">cost</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">42</span><span class="p">..</span><span class="mi">22</span><span class="p">.</span><span class="mi">91</span> <span class="k">rows</span><span class="o">=</span><span class="mi">100</span> <span class="n">width</span><span class="o">=</span><span class="mi">16</span><span class="p">)</span> <span class="p">(</span><span class="n">actual</span> <span class="nb">time</span><span class="o">=</span><span class="mi">4</span><span class="p">.</span><span class="mi">240</span><span class="p">..</span><span class="mi">4</span><span class="p">.</span><span class="mi">642</span> <span class="k">rows</span><span class="o">=</span><span class="mi">100</span> <span class="n">loops</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
   <span class="o">-&gt;</span>  <span class="k">Index</span> <span class="k">Only</span> <span class="n">Scan</span> <span class="k">using</span> <span class="n">trand_status</span> <span class="k">on</span> <span class="n">t_random</span>  <span class="p">(</span><span class="n">cost</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">42</span><span class="p">..</span><span class="mi">80932</span><span class="p">.</span><span class="mi">71</span> <span class="k">rows</span><span class="o">=</span><span class="mi">359853</span> <span class="n">width</span><span class="o">=</span><span class="mi">16</span><span class="p">)</span> <span class="p">(</span><span class="n">actual</span> <span class="nb">time</span><span class="o">=</span><span class="mi">4</span><span class="p">.</span><span class="mi">237</span><span class="p">..</span><span class="mi">4</span><span class="p">.</span><span class="mi">630</span> <span class="k">rows</span><span class="o">=</span><span class="mi">100</span> <span class="n">loops</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
         <span class="n">Heap</span> <span class="n">Fetches</span><span class="p">:</span> <span class="mi">91</span>
 <span class="n">Planning</span> <span class="nb">Time</span><span class="p">:</span> <span class="mi">0</span><span class="p">.</span><span class="mi">399</span> <span class="n">ms</span>
 <span class="n">Execution</span> <span class="nb">Time</span><span class="p">:</span> <span class="mi">4</span><span class="p">.</span><span class="mi">687</span> <span class="n">ms</span>
<span class="p">(</span><span class="mi">5</span> <span class="k">rows</span><span class="p">)</span>

<span class="k">EXPLAIN</span> <span class="k">ANALYZE</span> <span class="k">SELECT</span> <span class="n">id</span> <span class="k">FROM</span> <span class="n">t_random</span> <span class="k">WHERE</span> <span class="n">status</span> <span class="o">=</span> <span class="s1">'PENDING'</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">ID</span> <span class="k">LIMIT</span> <span class="mi">100</span> <span class="k">FOR</span> <span class="k">UPDATE</span> <span class="n">SKIP</span> <span class="n">LOCKED</span><span class="p">;</span>
                                                                   <span class="n">QUERY</span> <span class="n">PLAN</span>
<span class="c1">------------------------------------------------------------------------------------------------------------------------------------------------</span>
 <span class="k">Limit</span>  <span class="p">(</span><span class="n">cost</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">42</span><span class="p">..</span><span class="mi">41</span><span class="p">.</span><span class="mi">94</span> <span class="k">rows</span><span class="o">=</span><span class="mi">100</span> <span class="n">width</span><span class="o">=</span><span class="mi">22</span><span class="p">)</span> <span class="p">(</span><span class="n">actual</span> <span class="nb">time</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">552</span><span class="p">..</span><span class="mi">0</span><span class="p">.</span><span class="mi">780</span> <span class="k">rows</span><span class="o">=</span><span class="mi">100</span> <span class="n">loops</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
   <span class="o">-&gt;</span>  <span class="n">LockRows</span>  <span class="p">(</span><span class="n">cost</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">42</span><span class="p">..</span><span class="mi">149404</span><span class="p">.</span><span class="mi">06</span> <span class="k">rows</span><span class="o">=</span><span class="mi">359853</span> <span class="n">width</span><span class="o">=</span><span class="mi">22</span><span class="p">)</span> <span class="p">(</span><span class="n">actual</span> <span class="nb">time</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">551</span><span class="p">..</span><span class="mi">0</span><span class="p">.</span><span class="mi">769</span> <span class="k">rows</span><span class="o">=</span><span class="mi">100</span> <span class="n">loops</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
         <span class="o">-&gt;</span>  <span class="k">Index</span> <span class="n">Scan</span> <span class="k">using</span> <span class="n">trand_status</span> <span class="k">on</span> <span class="n">t_random</span>  <span class="p">(</span><span class="n">cost</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">42</span><span class="p">..</span><span class="mi">145805</span><span class="p">.</span><span class="mi">53</span> <span class="k">rows</span><span class="o">=</span><span class="mi">359853</span> <span class="n">width</span><span class="o">=</span><span class="mi">22</span><span class="p">)</span> <span class="p">(</span><span class="n">actual</span> <span class="nb">time</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">536</span><span class="p">..</span><span class="mi">0</span><span class="p">.</span><span class="mi">676</span> <span class="k">rows</span><span class="o">=</span><span class="mi">100</span> <span class="n">loops</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
               <span class="n">Filter</span><span class="p">:</span> <span class="p">((</span><span class="n">status</span><span class="p">)::</span><span class="nb">text</span> <span class="o">=</span> <span class="s1">'PENDING'</span><span class="p">::</span><span class="nb">text</span><span class="p">)</span>
 <span class="n">Planning</span> <span class="nb">Time</span><span class="p">:</span> <span class="mi">0</span><span class="p">.</span><span class="mi">212</span> <span class="n">ms</span>
 <span class="n">Execution</span> <span class="nb">Time</span><span class="p">:</span> <span class="mi">0</span><span class="p">.</span><span class="mi">813</span> <span class="n">ms</span>
<span class="p">(</span><span class="mi">6</span> <span class="k">rows</span><span class="p">)</span>
</code></pre></div></div>

<p>The difference in the query plan between a query without <code class="language-plaintext highlighter-rouge">FOR UPDATE</code> and one
with <code class="language-plaintext highlighter-rouge">FOR UPDATE</code> is the presence of the <code class="language-plaintext highlighter-rouge">LockRows</code> statement which forces <em>PG</em>
to check the row tuple headers - meaning we now doing 2 reads - one from the
index, and one from the table.</p>

<h2 id="recommendation">Recommendation</h2>

<p>It is recommended to only <strong>lock <em>a small number</em> of records in one transaction</strong>.
This is because the row locking has both a disk I/O cost as well as a memory
overhead, as the locks get stored in shared memory.</p>

<p>If a large number of rows are locked in a table, the <strong>query performance will
start to noticeably drop</strong> and trend towards more of a <code class="language-plaintext highlighter-rouge">SEQSCAN</code> performance
level due to the sheer number of rows it has to skip.</p>

<p>TL;DR - be mindful that sometimes although you have an index, the query plan can be changed underneath you and it can result in a <code class="language-plaintext highlighter-rouge">seq scan</code>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[You can use SELECT ... FOR UPDATE SKIP LOCKED as an very easy way to parallelize task processing in your application with minimal overhead. But be careful of the impact this can have on your query plans!]]></summary></entry><entry><title type="html">Intermittement errors &amp;amp; CI failures after upgrading to Rails 7.1</title><link href="https://www.alexstoica.com/blog/rails71-upgrade-pg-errors" rel="alternate" type="text/html" title="Intermittement errors &amp;amp; CI failures after upgrading to Rails 7.1" /><published>2024-01-11T00:00:00+00:00</published><updated>2024-01-11T00:00:00+00:00</updated><id>https://www.alexstoica.com/blog/rails71-upgrade-pg-errors</id><content type="html" xml:base="https://www.alexstoica.com/blog/rails71-upgrade-pg-errors"><![CDATA[<p>Are you seeing your CI hang, or random error messages like:
<code class="language-plaintext highlighter-rouge">message type 0x43 arrived from server while idle</code> in your test log?
It could all be related to a missing default setting in your Rails
config.</p>

<!--more-->

<h2 id="introduction">Introduction</h2>
<p>As part of an upgrade to Rails 7.1 we started seeing various CI failures which
all related to either Postgres becoming unavailable, or the test run timing out
or completely random <code class="language-plaintext highlighter-rouge">rspec</code> errors.
This was only affecting 1 particular spec file, but we could not immediately
isolate the culprit.</p>

<h2 id="investigation">Investigation</h2>

<p>I have started by disabling all initializers for <code class="language-plaintext highlighter-rouge">rspec</code>, reducing DB pool size,
and kept continuing until I ended up having to disable <code class="language-plaintext highlighter-rouge">activejob</code>. It turns out
that <code class="language-plaintext highlighter-rouge">ActiveJob</code> by default in test mode uses <code class="language-plaintext highlighter-rouge">:async</code> as its running method,
which will require an active DB connection so it can cache DB schema.</p>

<p>OK, but what was calling <code class="language-plaintext highlighter-rouge">activejob</code>, as we only make use of <code class="language-plaintext highlighter-rouge">sidekiq</code> in the
application? Well, it turns out that the root cause is that <code class="language-plaintext highlighter-rouge">activestorage</code>
depends on <code class="language-plaintext highlighter-rouge">activejob</code> and in turn this lead to the following backtrace:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="sr">/ruby/</span><span class="mf">3.1</span><span class="o">.</span><span class="mi">4</span><span class="o">/</span><span class="n">lib</span><span class="o">/</span><span class="n">ruby</span><span class="o">/</span><span class="n">gems</span><span class="o">/</span><span class="mf">3.1</span><span class="o">.</span><span class="mi">0</span><span class="o">/</span><span class="n">gems</span><span class="o">/</span><span class="n">activestorage</span><span class="o">-</span><span class="mf">7.1</span><span class="o">.</span><span class="mi">2</span><span class="o">/</span><span class="n">app</span><span class="o">/</span><span class="n">jobs</span><span class="o">/</span><span class="n">active_storage</span><span class="o">/</span><span class="n">analyze_job</span><span class="p">.</span><span class="nf">rb</span><span class="p">:</span><span class="mi">5</span>
</code></pre></div></div>

<h2 id="the-solution">The solution</h2>

<p>The solution was swapping the <code class="language-plaintext highlighter-rouge">ActiveJob</code> running mode to <code class="language-plaintext highlighter-rouge">:test</code> in the
environment config file (<code class="language-plaintext highlighter-rouge">config/environments/test.rb</code>). By adding the
below configuration it has immediately resolved all of our CI issues:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">config</span><span class="p">.</span><span class="nf">active_job</span><span class="p">.</span><span class="nf">queue_adapter</span> <span class="o">=</span> <span class="ss">:test</span>
</code></pre></div></div>

<p>This was hinted at in this
<a href="https://github.com/rails/rails/issues/48468#issuecomment-1857889412">Rails issue</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Are you seeing your CI hang, or random error messages like: message type 0x43 arrived from server while idle in your test log? It could all be related to a missing default setting in your Rails config.]]></summary></entry></feed>