<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Richard Yen</title>
    <description></description>
    <link>http://richyen.com/</link>
    <atom:link href="http://richyen.com/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Mon, 06 Jul 2026 19:46:27 +0000</pubDate>
    <lastBuildDate>Mon, 06 Jul 2026 19:46:27 +0000</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
      <item>
        <title>Are You .ready?</title>
        <description>&lt;h1 id=&quot;a-practical-guide-to-what-ready-and-done-mean-and-why-wal-sticks-around&quot;&gt;A practical guide to what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; mean, and why WAL sticks around&lt;/h1&gt;

&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;It is 9:12 a.m. on a Monday.  Someone on your team opens &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_wal/archive_status/&lt;/code&gt; during a storage scare and sees a long list of files ending in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt;.  They ask the question many of us have asked at least once: “Is replication broken?” Streaming replicas still look mostly fine, but &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; files keep piling up, disk usage keeps climbing, and nobody is fully sure what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; are actually telling you.&lt;/p&gt;

&lt;p&gt;What is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; and what (if any) action do I need to take?  Let’s talk about that today.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;hint-its-about-wal-delivery&quot;&gt;Hint: It’s About WAL Delivery&lt;/h2&gt;

&lt;p&gt;Think of WAL delivery as three independent steps:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Generate WAL&lt;/li&gt;
  &lt;li&gt;Transport WAL&lt;/li&gt;
  &lt;li&gt;Replay or consume WAL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt; is one way to do &lt;strong&gt;transport&lt;/strong&gt;.  Streaming replication is another.  Note, logical replication also has a transport channel, but what it transports is decoded logical change data rather than raw WAL segment files.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;what-archive_command-actually-does&quot;&gt;What &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt; Actually Does&lt;/h2&gt;

&lt;p&gt;When &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_mode=on&lt;/code&gt;, Postgres tries to copy each completed WAL segment to long-term storage by running &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt;.&lt;/p&gt;

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

&lt;div class=&quot;language-conf highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;archive_mode&lt;/span&gt; = &lt;span class=&quot;n&quot;&gt;on&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;archive_command&lt;/span&gt; = &lt;span class=&quot;s1&quot;&gt;&apos;rsync -a %p backup@walbox:/archives/%f&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;%p&lt;/code&gt; is the local path to the WAL segment in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_wal&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;%f&lt;/code&gt; is just the filename&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Postgres runs this command from the archiver process.  If the command exits with status &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0&lt;/code&gt;, Postgres treats it as success.  Any non-zero exit code means failure, and it retries later.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Info:&lt;/strong&gt; Archiving usually happens when a WAL segment is complete (typically 16 MB), not every transaction.  So pure archive shipping can have more lag unless segment switches happen frequently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;what-ready-and-done-are-for&quot;&gt;What &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; Are For&lt;/h2&gt;

&lt;p&gt;Inside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_wal/archive_status/&lt;/code&gt;, Postgres tracks each WAL segment’s archiving state with tiny marker files.&lt;/p&gt;

&lt;p&gt;For a segment named:&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;000000010000000A000000FE&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;you may see:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;000000010000000A000000FE.ready&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;000000010000000A000000FE.done&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;ready&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; means: &lt;em&gt;“this WAL segment should be archived (or retried).”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Postgres creates &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; when the segment becomes eligible for archiving.  If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt; fails, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; remains and the archiver keeps retrying.  Note, you’ll see information about failures in the Postgres text logs, so check there if you see many &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; files and not a lot of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; files.&lt;/p&gt;

&lt;h3 id=&quot;done&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; means: &lt;em&gt;“archiving for this WAL segment succeeded.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After a successful &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt;, Postgres marks completion with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; and no longer retries that file.&lt;/p&gt;

&lt;p&gt;In short:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; = pending/retry queue item&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; = WAL archive was successful&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are local bookkeeping files. They are not WAL themselves.&lt;/p&gt;

&lt;h3 id=&quot;if-done-exists-when-is-it-deleted&quot;&gt;If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; Exists, When Is It Deleted?&lt;/h3&gt;

&lt;p&gt;Great question, because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; mean “delete immediately.”&lt;/p&gt;

&lt;p&gt;In practice, The WAL segment in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_wal&lt;/code&gt; can only be recycled/removed after a checkpoint and only when Postgres no longer needs it. This includes retention constraints like replication slots, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wal_keep_size&lt;/code&gt;, or standby/recovery requirements. If a segment is still needed for any of those reasons, it stays in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_wal&lt;/code&gt; even if archiving already succeeded.&lt;/p&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; file is just archive-status metadata. It is cleaned up later as part of normal WAL housekeeping, and it can exist for a while even after archival success.&lt;/p&gt;

&lt;h3 id=&quot;what-if-done-files-pile-up-and-do-not-disappear&quot;&gt;What If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; Files Pile Up and Do Not Disappear?&lt;/h3&gt;

&lt;p&gt;If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; files accumulate, first separate “cosmetic” from “capacity risk”:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Check whether &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_wal&lt;/code&gt; disk usage is actually growing.&lt;/li&gt;
  &lt;li&gt;If disk usage is stable, this can be harmless housekeeping lag.&lt;/li&gt;
  &lt;li&gt;If disk usage is growing, treat it as WAL retention pressure.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then run the diagnostic queries in the &lt;strong&gt;Beginner-Friendly Checks&lt;/strong&gt; section below.&lt;/p&gt;

&lt;p&gt;Finally, remember that WAL recycle/removal is checkpoint-driven.  If checkpoints are infrequent and WAL is still needed, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; files can legitimately linger.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;how-this-relates-to-streaming-replication&quot;&gt;How This Relates to Streaming Replication&lt;/h2&gt;

&lt;p&gt;Streaming replication and WAL archiving can both exist at the same time, and they are distinct WAL delivery mechanisms with different telemetry and failure modes.&lt;/p&gt;

&lt;p&gt;With streaming replication, WAL is pushed over a live replication connection (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;walsender&lt;/code&gt; -&amp;gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;walreceiver&lt;/code&gt;) and can be delivered before a segment is fully closed.  It shows up in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stat_replication&lt;/code&gt; and can use physical replication slots.&lt;/p&gt;

&lt;p&gt;On the other hand, archive shipping copies WAL files out via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt; in a segment-oriented way (usually after file completion), does not require a direct live receiver session, and does &lt;strong&gt;not&lt;/strong&gt; appear as a row in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stat_replication&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Important: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready/.done&lt;/code&gt; are about the archiver path, not the streaming path.  A standby can be fully healthy on streaming while archiving is broken, or vice versa.&lt;/p&gt;

&lt;h3 id=&quot;what-about-pg_receivewal&quot;&gt;What About &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_receivewal&lt;/code&gt;?&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_receivewal&lt;/code&gt; is a useful middle ground: it uses the streaming replication protocol to receive WAL continuously, but writes WAL segment files to disk like an archive pipeline.&lt;/p&gt;

&lt;p&gt;In other words, transport is streaming, while storage is file-based.&lt;/p&gt;

&lt;p&gt;That means:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It can reduce archive lag compared to waiting for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt; segment completion behavior.&lt;/li&gt;
  &lt;li&gt;It can use a replication slot (recommended) so WAL is not lost if the receiver is briefly down.&lt;/li&gt;
  &lt;li&gt;It shows up as a replication sender/receiver relationship, unlike local &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready/.done&lt;/code&gt; bookkeeping.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also important: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_receivewal&lt;/code&gt; does not replace &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready/.done&lt;/code&gt; on the primary by itself.  Those files are specifically tied to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt;/archiver bookkeeping on that server.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;replication-slots-where-people-get-confused&quot;&gt;Replication Slots: Where People Get Confused&lt;/h2&gt;

&lt;p&gt;Replication slots and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt; solve different problems.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Physical replication slot:&lt;/strong&gt; A physical slot protects WAL needed by a streaming standby.  Postgres will retain WAL until that standby has consumed it.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Logical replication slot:&lt;/strong&gt; A logical slot protects WAL needed for logical decoding/subscribers.  If the subscriber lags, WAL is retained.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Archiving:&lt;/strong&gt; Archiving is about making durable WAL copies somewhere else.  It does not tell Postgres what a standby has consumed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So WAL retention pressure can come from multiple places at once:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;archiving not succeeding (files stay pending)&lt;/li&gt;
  &lt;li&gt;lagging physical slot&lt;/li&gt;
  &lt;li&gt;lagging logical slot&lt;/li&gt;
  &lt;li&gt;infrequent checkpoints (recycle/removal cadence is delayed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When disk fills in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_wal&lt;/code&gt;, it is often one of these, or a combination.  Even after successful archiving, WAL segments are only eligible for recycle/removal after checkpoint processing.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;beginner-friendly-checks&quot;&gt;Beginner-Friendly Checks&lt;/h2&gt;

&lt;p&gt;A few quick checks I like:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;-- Archiver health&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;archived_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;failed_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;last_archived_wal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;last_failed_wal&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_stat_archiver&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- Streaming health&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;application_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sync_state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;write_lag&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;flush_lag&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;replay_lag&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_stat_replication&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- Slot retention pressure&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;slot_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;slot_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;active&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;restart_lsn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;confirmed_flush_lsn&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_replication_slots&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- Quantify physical slot lag in bytes from current WAL position&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;slot_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
	   &lt;span class=&quot;n&quot;&gt;active&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
	   &lt;span class=&quot;n&quot;&gt;pg_size_pretty&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;restart_lsn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bytes_behind&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_replication_slots&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;slot_type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;physical&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;What to look for:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stat_archiver&lt;/code&gt;:&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;failed_count&lt;/code&gt; should not climb continuously.  &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archived_count&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last_archived_wal&lt;/code&gt; should move forward during write activity.  If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last_failed_wal&lt;/code&gt; keeps changing but &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last_archived_wal&lt;/code&gt; does not, archiving is unhealthy.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stat_replication&lt;/code&gt;:&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state&lt;/code&gt; should usually be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;streaming&lt;/code&gt; (or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sync_state&lt;/code&gt; aligned with your design).  Persistent high &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;write_lag&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flush_lag&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;replay_lag&lt;/code&gt;, or missing expected standbys, points to transport/replay trouble.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_replication_slots&lt;/code&gt;:&lt;/strong&gt; watch for inactive slots with old &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restart_lsn&lt;/code&gt; (physical) or stale &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;confirmed_flush_lsn&lt;/code&gt; (logical).  Large &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bytes_behind&lt;/code&gt; is a strong signal that a slot is pinning WAL and growing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_wal&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And on disk:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;ls&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-1&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$PGDATA&lt;/span&gt;/pg_wal/archive_status | &lt;span class=&quot;nb&quot;&gt;tail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; files keep piling up, your archive path is unhealthy.
If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; files pile up &lt;em&gt;and&lt;/em&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_wal&lt;/code&gt; keeps growing, check slot lag and checkpoint cadence.
If slot lag keeps growing, your consumer is unhealthy.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.ready&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.done&lt;/code&gt; files are not mysterious once you view them as a local queue and completion marker for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;archive_command&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;They are adjacent to replication, but not identical to streaming replication or logical replication.&lt;/p&gt;

&lt;p&gt;Getting comfortable with these distinctions makes debugging much faster, especially when someone says, “replication is broken,” and you need to answer: &lt;em&gt;which replication path?&lt;/em&gt;&lt;/p&gt;
</description>
        <pubDate>Mon, 06 Jul 2026 08:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/07/06/are_you_ready.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/07/06/are_you_ready.html</guid>
        
        <category>PostgreSQL</category>
        
        <category>postgres</category>
        
        <category>wal</category>
        
        <category>archiving</category>
        
        <category>archive_command</category>
        
        <category>replication</category>
        
        <category>streaming-replication</category>
        
        <category>logical-replication</category>
        
        <category>replication-slots</category>
        
        
        <category>postgres</category>
        
      </item>
    
      <item>
        <title>Disaster Recovery is a Process, Not a Tool (Part 2)</title>
        <description>&lt;h2 id=&quot;picking-up-where-we-left-off&quot;&gt;Picking Up Where We Left Off&lt;/h2&gt;

&lt;p&gt;In the &lt;a href=&quot;/postgres/2026/06/15/disaster_recovery_is_a_process.html&quot;&gt;previous post&lt;/a&gt;, I tried to lay out the framing half of this material: what actually counts as a disaster, why preparation and prevention aren’t the same as recovery, and how RPO and RTO end up being conversations with leadership rather than numbers an infrastructure team gets to declare on its own.&lt;/p&gt;

&lt;p&gt;That part is largely about understanding the problem.  This part is about actually building the capability to deal with it.  And in my experience, this is where most teams quietly stumble – not because they don’t have backups or replication, but because they’ve never really practiced using either of them under stress.&lt;/p&gt;

&lt;h2 id=&quot;runbook-engineering-why-runbooks-fail&quot;&gt;Runbook Engineering: Why Runbooks Fail&lt;/h2&gt;

&lt;p&gt;Once you’ve accepted that DR is a process, the natural next step is to write some runbooks.  And in my experience, this is where a lot of teams quietly stumble.&lt;/p&gt;

&lt;p&gt;Runbooks usually get written by experts, for experts, in a calm conference room.  That’s almost the opposite of the environment they’ll actually be run in.  Real recovery happens at 3AM, under stress, often with incomplete information and sometimes with someone who isn’t deeply familiar with the system.  A runbook’s job, really, is to &lt;em&gt;reduce ambiguity&lt;/em&gt; in that moment – not to be a comprehensive description of the system, but to be the thing you can follow even when you’re tired and scared.&lt;/p&gt;

&lt;p&gt;That framing alone changes what a good runbook looks like.&lt;/p&gt;

&lt;h3 id=&quot;anti-patterns&quot;&gt;Anti-patterns&lt;/h3&gt;

&lt;p&gt;A few patterns I see often that make runbooks worse, not better:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Giant wiki pages.&lt;/strong&gt;  I think a lot of teams try to kill two birds with one stone by sprinkling DR steps inside their general system documentation, so they don’t have to maintain a separate doc.  What you end up with is something that’s hard to follow under stress, and that drifts quietly as the surrounding documentation evolves.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Stale commands and hostnames.&lt;/strong&gt;  Infrastructure changes over the years.  Commands that worked in Postgres 11 are sometimes not quite right anymore.  I’ve definitely been the person who wrote a runbook step that turned out to be wrong by the time anyone needed it.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No rollback criteria.&lt;/strong&gt;  If the runbook tells you to run a command, it should also tell you what success and failure look like, and what to do if the command does something unexpected.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No clear ownership.&lt;/strong&gt;  Everyone wants to ship the next feature, expand the cluster, do the upgrade.  Updating the runbook is the thing that gets put off.  In my experience, this works best when the reliability engineering team owns the runbooks explicitly, rather than treating them as collective property nobody is really responsible for.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Vague instructions.&lt;/strong&gt;  “Inspect the logs for errors or warnings” is a step I see all the time.  But &lt;em&gt;where&lt;/em&gt; are the logs?  Every organization seems to put them in a slightly different place.  When I drop into a customer’s database server, finding the Postgres log is often surprisingly nontrivial – and that’s the most important source of information about the health of the system, so it really shouldn’t be a scavenger hunt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;what-a-good-runbook-looks-like&quot;&gt;What a good runbook looks like&lt;/h3&gt;

&lt;p&gt;A good runbook is procedural and deterministic.  It tells you “this is the situation; here is exactly what to do.”  If split brain happens, what command do you run?  If replication lag is over a certain threshold, do you fail over or wait?  Those decisions should already be made, by the people who had the luxury of making them calmly.&lt;/p&gt;

&lt;p&gt;Instead of “fail over to the read replica,” a good runbook says which replica, what the promotion command is, what to check after promotion to make sure it actually worked, and what to do if it didn’t.  It includes the pre-flight checks before you bring traffic back, and the rollback path if the recovery itself goes sideways.&lt;/p&gt;

&lt;h3 id=&quot;non-technical-essentials&quot;&gt;Non-technical essentials&lt;/h3&gt;

&lt;p&gt;The non-technical pieces are easy to overlook, and in my experience they make at least as much difference as the technical ones.&lt;/p&gt;

&lt;p&gt;One thing I really appreciated when I worked in EDB Support was that every ticket had a clearly designated commander – the person who talked with the customer, gathered the information, and directed the other support engineers.  That role makes a huge difference in an incident, because somebody needs to be coordinating, and it generally shouldn’t also be the person at the keyboard.&lt;/p&gt;

&lt;p&gt;When I moved to Microsoft, I noticed an additional layer that I’d underrated before: a separate &lt;strong&gt;communications owner&lt;/strong&gt;.  When Azure has an incident, there’s an incident commander focused on getting the system back up, &lt;em&gt;and&lt;/em&gt; there’s a different person whose job is to communicate – via email, social media, status pages, Discord, whatever the channel is – so customers know what’s happening and what to expect.  Splitting those two roles takes a lot of pressure off the technical recovery, and I’d recommend it to anyone running services that customers notice.&lt;/p&gt;

&lt;p&gt;Beyond those two roles, a few other things really do belong in the runbook:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Stakeholder notification cadence.&lt;/strong&gt;  Even if there’s no new information, telling stakeholders “no change, still working on it” every fifteen minutes is far better than silence.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;A clear escalation chain.&lt;/strong&gt;  Primary on-call, backup on-call, the right engineering contact, the business owner.  In my experience, escalations to engineering often happen a little prematurely – and a little unclearly – because nobody mapped out who specifically to call.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Customer impact thresholds.&lt;/strong&gt;  At what point do we change our response posture?  If more than half our customers are affected, what changes?&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Risk authorization.&lt;/strong&gt;  Some recovery actions are dangerous on their own – restoring a month-old backup, accepting data loss, dropping a replication slot.  Who is authorized to make those calls, and how do you reach them?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;validation&quot;&gt;Validation&lt;/h3&gt;

&lt;p&gt;Once you have a runbook, you have to actually validate it.  Some questions I think are worth asking:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Can a new engineer follow it?  This is genuinely worth testing.  I think it’s a fine onboarding exercise to take a dev environment, break it in a controlled way, hand the new hire the runbook, and see if they can get it back.  If they can’t, that’s not a failing of the new hire – it’s feedback on the runbook.&lt;/li&gt;
  &lt;li&gt;Does the runbook assume privileged access the people running it won’t have under stress – passwords, certificates, VPN, bastion access?&lt;/li&gt;
  &lt;li&gt;Are the specifics still right?  Hostnames change.  Schemas change.  Resource group names change.  The runbook needs to drift with them, or, better, be re-tested often enough to catch the drift.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Honestly, keeping runbooks current is close to a full-time job at any reasonably-sized organization.  I think it’s reasonable for it to &lt;em&gt;be&lt;/em&gt; somebody’s main job – regularly talking to teams, keeping the docs aligned with the infrastructure, organizing the drills.&lt;/p&gt;

&lt;h2 id=&quot;game-days&quot;&gt;Game Days&lt;/h2&gt;

&lt;p&gt;Which brings me to game days.  In my experience, this is where DR shifts from a document to an actual capability.  A DR plan is not really proven by the fact that it exists; it’s proven through repeated, successful execution.&lt;/p&gt;

&lt;p&gt;We started doing this at Turnitin around our release cycle.  For a meaningful release, we’d set up an environment that was as close to production as we could make it – same hardware where possible, same CNAMEs, maybe just a different subnet – and we’d practice the release on it.  It started as a release-rehearsal habit, but the same idea applies to DR: a controlled failure simulation designed to test the systems, the procedure, the coordination, and – maybe most importantly – the assumptions people are quietly making.&lt;/p&gt;

&lt;p&gt;I think it helps to think of game days as having levels you grow into.&lt;/p&gt;

&lt;p&gt;You start simple: restore a backup, promote a standby, redirect traffic, validate that the application actually behaves correctly after the cutover.  Just being able to do that end-to-end, on demand, puts you ahead of a lot of teams.&lt;/p&gt;

&lt;p&gt;Then you start adding pressure.  Give the team an SLA on the drill – “we’re going to break something at 10:00 AM, and you have 30 minutes to be back up.”  This is where you find out what your actual achievable RTO is, as opposed to the aspirational one in the spreadsheet.&lt;/p&gt;

&lt;p&gt;Then you start adding chaos.  What if the person who normally runs the failover is unavailable?  What if a certificate has expired and now you have to renew it while the outage is happening?  What if you scheduled the drill – not entirely by accident – on a day when a key engineer happens to be on vacation?  These sound mean, but they’re realistic.  Real incidents are not polite about your team’s calendar.&lt;/p&gt;

&lt;p&gt;A few practical rules I’ve found useful for running drills without making things worse:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Start in staging or on a standby; not on the production primary.&lt;/li&gt;
  &lt;li&gt;Schedule the drill and announce it.  The &lt;em&gt;drill&lt;/em&gt; is the surprise; the &lt;em&gt;date&lt;/em&gt; is not.  You’re testing the runbook, not your team’s reflexes at 2AM on a holiday.&lt;/li&gt;
  &lt;li&gt;One failure mode per drill, at least at first.  Don’t combine “disk fails AND network partitions AND VP of Engineering is on a plane” until you’ve nailed each of those individually.&lt;/li&gt;
  &lt;li&gt;Define what success looks like before you start.  “We restored within 30 minutes” is testable.  “It went well” is not.&lt;/li&gt;
  &lt;li&gt;Run a retro within a couple of days, while it’s still fresh.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-to-measure&quot;&gt;What to Measure&lt;/h2&gt;

&lt;p&gt;When you finish a drill (or a real incident), the obvious thing to measure is “did recovery succeed?”  That’s important, but it’s also kind of binary, and you’ll learn more from the other things you can measure:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;How long did each phase take – detection, decision-making, execution, validation, communication?  That’s where you’ll find your real bottlenecks.&lt;/li&gt;
  &lt;li&gt;Where did the runbook turn vague?  Anywhere a participant had to ask “wait, what does this mean?” is a small documentation bug worth fixing.&lt;/li&gt;
  &lt;li&gt;Which dependencies turned out to be undocumented?&lt;/li&gt;
  &lt;li&gt;Which credentials or permissions were missing or expired?&lt;/li&gt;
  &lt;li&gt;How close were the actual results to the stated RTO and RPO?  If the gap is big, that’s important information for the next conversation with leadership.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;dont-blame-or-youll-feel-lame&quot;&gt;Don’t Blame, or You’ll Feel Lame&lt;/h2&gt;

&lt;p&gt;I want to spend a minute on the cultural side, because I think it might matter as much as the technical side.&lt;/p&gt;

&lt;p&gt;Incidents and drills are stressful.  I don’t think we always give that enough weight – we’re more comfortable thinking about CPU and IOPS than we are about the very real fact that people freeze under pressure, or hide uncertainty, or act too quickly to make the discomfort go away.&lt;/p&gt;

&lt;p&gt;In a blame-heavy environment, people shrivel up.  They stop volunteering information.  They don’t want to admit they don’t know something, or that they did something that didn’t work, so they make assumptions instead of asking.  What you end up with is delayed escalations, silent failures, and risky decisions made by people who didn’t feel safe enough to talk through their thinking.  None of that helps your RTO.&lt;/p&gt;

&lt;p&gt;The opposite culture – one that encourages people to verbalize what they’re seeing, what they’re not seeing, and where they need help – is much harder to build than a runbook, but pays off in every incident.  Post-incident reviews really should be about improving the system, not punishing the person.  And when a drill or a real recovery is hard, I think it’s genuinely worth acknowledging that out loud.  Order pizza, go out for sushi, do something to thank the team.  It’s not a substitute for fixing the technical gaps, but it does make people show up to the next drill.&lt;/p&gt;

&lt;h2 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h2&gt;

&lt;p&gt;If I had to compress this whole series into one line, it would be roughly this: &lt;strong&gt;make your RPO worth it by investing in your RTO.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lower RPO is something you can largely buy – with replication, with hardware, with cloud spend.  Lower RTO isn’t really something you can buy.  It’s something you build, slowly, with runbooks, drills, retrospectives, and a culture that treats incidents as opportunities to learn instead of opportunities to assign blame.&lt;/p&gt;

&lt;p&gt;Tools and AI both have a place in this work, and the tooling around Postgres is genuinely good.  But in my experience, the difference between a team that handles a disaster well and one that doesn’t isn’t usually the tools they had – it’s the process they’d practiced.&lt;/p&gt;

&lt;p&gt;If nothing else, I’d suggest picking a day next month, killing a replica in a safe environment, and seeing what happens.  The first drill is almost always the most informative one.  Good luck out there!&lt;/p&gt;
</description>
        <pubDate>Mon, 29 Jun 2026 08:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/06/29/disaster_recovery_is_a_process_part_2.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/06/29/disaster_recovery_is_a_process_part_2.html</guid>
        
        <category>PostgreSQL</category>
        
        <category>postgres</category>
        
        <category>disaster-recovery</category>
        
        <category>dr</category>
        
        <category>rto</category>
        
        <category>rpo</category>
        
        <category>runbook</category>
        
        <category>game-day</category>
        
        <category>high-availability</category>
        
        <category>operations</category>
        
        
        <category>postgres</category>
        
      </item>
    
      <item>
        <title>pg_stats: How Postgres Internal Stats Work</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;I recently had the privilege of speaking at &lt;a href=&quot;https://posetteconf.com/2026/&quot;&gt;POSETTE 2026&lt;/a&gt; about &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stats&lt;/code&gt; and how Postgres internal statistics work (&lt;a href=&quot;https://www.youtube.com/watch?v=CvvA5P1b5No&amp;amp;list=PLOBORF8Y_l8g&amp;amp;index=24&quot;&gt;YouTube Recording&lt;/a&gt;).  This post is a written companion to that talk – aimed at giving you a working understanding of what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stats&lt;/code&gt; is, how it’s populated, and how it shapes the decisions the query planner makes on your behalf.&lt;/p&gt;

&lt;p&gt;Imagine a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;customers&lt;/code&gt; table that looks roughly like this:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;          &lt;span class=&quot;n&quot;&gt;bigserial&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;PRIMARY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;KEY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;city&lt;/span&gt;        &lt;span class=&quot;nb&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt;       &lt;span class=&quot;nb&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;signup_date&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;date&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;-- Insert 1,000,000 rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Consider a query you’ve probably written many times:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;CA&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With separate indexes on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;city&lt;/code&gt;, you might &lt;em&gt;expect&lt;/em&gt; an index scan on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state&lt;/code&gt;.  But the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EXPLAIN ANALYZE&lt;/code&gt; output may look something like this:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;                              QUERY PLAN
-----------------------------------------------------------------
 Seq Scan on customers  (cost=0.00..19682.66 rows=173829 width=26)
                        (actual time=0.025..120.574 rows=172001 loops=1)
   Filter: (state = &apos;CA&apos;::text)
   Rows Removed by Filter: 827972
   Buffers: shared hit=4601 read=2582
 Planning:   Buffers: shared hit=139
 Planning Time: 0.371 ms
 Execution Time: 128.136 ms
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A sequential scan, even with an index available.  We’ll get into the reasons for this today.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;query-plans-are-made-by-the-query-planner&quot;&gt;Query Plans Are Made by the Query Planner&lt;/h2&gt;

&lt;p&gt;When you submit a query to Postgres, the query planner is responsible for deciding &lt;em&gt;how&lt;/em&gt; to execute it.  You may assume the planner reads your actual data – it doesn’t.  What it really reads is a &lt;strong&gt;summary&lt;/strong&gt; of your data, stored in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_statistic&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That summary tells the planner things like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;How many distinct values appear in a column&lt;/li&gt;
  &lt;li&gt;What the most common values are, and how often they show up&lt;/li&gt;
  &lt;li&gt;What the rough distribution of values looks like across a range&lt;/li&gt;
  &lt;li&gt;Whether the data is laid out on disk in roughly the same order as the column’s natural sort order&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_statistic&lt;/code&gt; itself is a bit hard to read directly – the values are stored in formats optimized for the planner, not for humans.  Fortunately, Postgres provides a view called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stats&lt;/code&gt; that exposes the same information in a far more readable form.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;analyze-how-the-summary-gets-built&quot;&gt;ANALYZE: How the Summary Gets Built&lt;/h2&gt;

&lt;p&gt;The summary in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_statistic&lt;/code&gt; doesn’t populate itself.  It’s built (and refreshed) by the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt; command:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;ANALYZE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt; scans the table (or a sample of it), computes a handful of statistics per column, and writes the results into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_statistic&lt;/code&gt;.  Autovacuum runs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt; for you in the background, but after large data loads or migrations, you’ll often want to run it manually.&lt;/p&gt;

&lt;p&gt;Let’s look at what comes out of it.  Using our &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;customers&lt;/code&gt; table:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;attname&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n_distinct&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;null_frac&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;correlation&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_stats&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tablename&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;customers&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;attname&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

   &lt;span class=&quot;n&quot;&gt;attname&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n_distinct&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;null_frac&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;  &lt;span class=&quot;n&quot;&gt;correlation&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--------------+------------+-----------+--------------&lt;/span&gt;
 &lt;span class=&quot;n&quot;&gt;city&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;      &lt;span class=&quot;mi&quot;&gt;10106&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;         &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;  &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0021338463&lt;/span&gt;
 &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;           &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;         &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;             &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
 &lt;span class=&quot;n&quot;&gt;signup_date&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;       &lt;span class=&quot;mi&quot;&gt;1822&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;         &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;             &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
 &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;         &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;         &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;    &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;06440461&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;rows&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A few things worth pointing out:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;n_distinct&lt;/code&gt;&lt;/strong&gt; is the estimated number of distinct values.  For &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state&lt;/code&gt;, it’s exactly 50 – which lines up with the number of states in the United States.  &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;city&lt;/code&gt; reports around 10,106, which is believable for U.S. cities.  A value of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-1&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; column means the column is &lt;em&gt;unique&lt;/em&gt;: every row has a distinct value.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;null_frac&lt;/code&gt;&lt;/strong&gt; is the fraction of rows where the column is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NULL&lt;/code&gt;.  All four columns here are &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NOT NULL&lt;/code&gt;, so the values are all &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;correlation&lt;/code&gt;&lt;/strong&gt; is a number between &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-1&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+1&lt;/code&gt; that estimates how well the on-disk physical ordering of the table matches the logical ordering of the column. A value of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+1&lt;/code&gt; means the data is perfectly sorted on disk (e.g., an incrementing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; column or a date column in an append-only table). Values close to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0&lt;/code&gt; mean the data is randomly ordered on disk relative to the column’s values. Values close to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-1&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+1&lt;/code&gt; encourage index scans; values near &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0&lt;/code&gt; discourage them. The &lt;a href=&quot;https://www.postgresql.org/docs/current/view-pg-stats.html&quot;&gt;docs go into more detail&lt;/a&gt;, but practically it acts as a penalty multiplier in the cost calculation.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;most-common-values-mcv&quot;&gt;Most Common Values (MCV)&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt; also captures a list of the &lt;strong&gt;most common values&lt;/strong&gt; in a column, along with their &lt;strong&gt;frequencies&lt;/strong&gt;.  These live in two parallel arrays: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;most_common_vals&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;most_common_freqs&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;unnest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;most_common_vals&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;unnest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;most_common_freqs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;              &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;frequency&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_stats&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tablename&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;customers&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;attname&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;state&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

 &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;  &lt;span class=&quot;n&quot;&gt;frequency&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;-------+-------------&lt;/span&gt;
 &lt;span class=&quot;n&quot;&gt;CA&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;  &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;17403333&lt;/span&gt;
 &lt;span class=&quot;n&quot;&gt;TX&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;      &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1165&lt;/span&gt;
 &lt;span class=&quot;n&quot;&gt;NY&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;  &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;08586667&lt;/span&gt;
 &lt;span class=&quot;n&quot;&gt;FL&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;      &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0666&lt;/span&gt;
 &lt;span class=&quot;n&quot;&gt;IL&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;      &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0474&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;rows&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;So &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CA&lt;/code&gt; appears in about 17.4% of the rows, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TX&lt;/code&gt; in 11.6%, and so on.  These frequencies feed directly into how the planner estimates rows – and therefore how it picks scan types.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;costs-how-the-planner-picks-a-plan&quot;&gt;Costs: How the Planner Picks a Plan&lt;/h2&gt;

&lt;p&gt;The planner makes its choices based on &lt;strong&gt;cost&lt;/strong&gt;.  Three of the more commonly-encountered cost parameters are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;random_page_cost&lt;/code&gt; – the cost of fetching a random page from disk (think: index scan plus heap fetch)&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;seq_page_cost&lt;/code&gt; – the cost of fetching a page sequentially (sequential scan)&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cpu_tuple_cost&lt;/code&gt; – the cost of processing each row pulled out of a page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These costs, along with row estimates from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_statistic&lt;/code&gt;, drive two big decisions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scan Type&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Sequential Scan&lt;/li&gt;
  &lt;li&gt;Index Scan&lt;/li&gt;
  &lt;li&gt;Bitmap Heap Scan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Join Type&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Nested Loop&lt;/li&gt;
  &lt;li&gt;Hash Join&lt;/li&gt;
  &lt;li&gt;Merge Join&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The planner generates several candidate plans, calculates the cost of each, and picks the cheapest.  Bad statistics lead to bad row estimates, which can lead to bad plan choice.  As you can see, having good statistics is vital to query performance.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;a-tale-of-two-states&quot;&gt;A Tale of Two States&lt;/h2&gt;

&lt;p&gt;Watch what happens when we query for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CA&lt;/code&gt; versus &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WY&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;EXPLAIN&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ANALYZE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;CA&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;-- Seq Scan on customers  (cost=0.00..19682.66 rows=174029 ...)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--                        (actual time=0.042..50.257 rows=172001 loops=1)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;EXPLAIN&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ANALYZE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;WY&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;-- Index Scan using customers_state_idx on customers&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--    (cost=0.42..13116.39 rows=4233 ...)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--    (actual time=0.045..21.238 rows=4300 loops=1)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CA&lt;/code&gt; matches about 18% of the table – around 180,000 rows.  For every matching row, an index scan would need to look up the row in the index, fetch the page off disk, and pull the tuple out.  Doing that 180,000 times turns out to be more expensive than just reading the whole table sequentially.  So the planner picks a sequential scan.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WY&lt;/code&gt;, on the other hand, matches only about 4,000 rows.  At that selectivity, the index scan wins by a wide margin.&lt;/p&gt;

&lt;p&gt;We can confirm this is really about cost by forcing the issue. If we temporarily disable sequential scans, the planner is forced to use the index:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SET&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;enable_seqscan&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;off&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;EXPLAIN&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ANALYZE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;CA&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--  Index Scan using customers_state_idx on customers&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--    (cost=0.42..32172.73 rows=170529 width=26)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--    (actual time=0.053..75.656 rows=172001 loops=1)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The planner’s original choice of a sequential scan (cost &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~19,682&lt;/code&gt;) was cheaper than this forced index scan (cost &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~32,172&lt;/code&gt;). The MCV statistics told the planner that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CA&lt;/code&gt; shows up &lt;em&gt;a lot&lt;/em&gt;, and the planner correctly judged that a sequential scan would be cheaper. Skewed data is exactly when MCV earns its keep.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;histograms-for-everything-that-isnt-equality&quot;&gt;Histograms: For Everything That Isn’t Equality&lt;/h2&gt;

&lt;p&gt;MCVs are great when you’re searching for specific values.  But what about ranges – &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;signup_date BETWEEN ...&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id &amp;gt; ...&lt;/code&gt;?  For that, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt; builds a &lt;strong&gt;histogram&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;By default, the histogram has 100 buckets, each holding roughly the same number of rows (it’s an &lt;em&gt;equi-depth&lt;/em&gt; histogram).  You can look at the bucket boundaries:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;unnest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;histogram_bounds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[]))::&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;date&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bucket_bound&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_stats&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tablename&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;customers&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;attname&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;signup_date&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

 &lt;span class=&quot;n&quot;&gt;bucket_bound&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--------------&lt;/span&gt;
 &lt;span class=&quot;mi&quot;&gt;2018&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;01&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;01&lt;/span&gt;
 &lt;span class=&quot;mi&quot;&gt;2018&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;03&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;01&lt;/span&gt;
 &lt;span class=&quot;mi&quot;&gt;2018&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;04&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;28&lt;/span&gt;
 &lt;span class=&quot;mi&quot;&gt;2018&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;07&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;04&lt;/span&gt;
 &lt;span class=&quot;mi&quot;&gt;2018&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;09&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;05&lt;/span&gt;
 &lt;span class=&quot;mi&quot;&gt;2018&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;28&lt;/span&gt;
 &lt;span class=&quot;mi&quot;&gt;2018&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;12&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;27&lt;/span&gt;
 &lt;span class=&quot;mi&quot;&gt;2019&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;02&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;14&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;rows&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Each bucket here covers roughly two months – about 1% of the table.  That’s fine for many cases, but if you have a large table with a skewed time distribution, you may want more precision.&lt;/p&gt;

&lt;p&gt;Consider this side-by-side comparison:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/richyen/richyen.github.io/refs/heads/gh-pages/img/histogram_precision_comparison.png&quot; alt=&quot;Histogram&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;strong&gt;low-precision&lt;/strong&gt; histogram (few buckets) might tell you “the most data lives somewhere between 30 and 65.”&lt;/li&gt;
  &lt;li&gt;A &lt;strong&gt;high-precision&lt;/strong&gt; histogram (many buckets) might tell you “the peak is between 50 and 52, with a clear dip around 65–70 and a second smaller peak near 80.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are technically correct.  Only one helps the planner make a &lt;em&gt;good&lt;/em&gt; decision when your query is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WHERE value BETWEEN 65 AND 70&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can increase the bucket count on a per-column basis:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;ALTER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ALTER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;COLUMN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signup_date&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;SET&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;STATISTICS&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ANALYZE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Now the same query shows buckets that are about two days wide instead of two months:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt; bucket_bound
--------------
 2018-01-01
 2018-01-03
 2018-01-05
 2018-01-07
 2018-01-10
 2018-01-12
 2018-01-14
 2018-01-16
(8 rows)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Beware of trade-offs.&lt;/strong&gt;  More buckets means more precision, but also more work for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt; and a larger &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_statistic&lt;/code&gt; row to traverse during planning.  Don’t increase &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;default_statistics_target&lt;/code&gt; across the entire database – target only the columns where you actually have problematic estimates.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;correlation-between-columns&quot;&gt;Correlation Between Columns&lt;/h2&gt;

&lt;p&gt;So far we’ve looked at a single column at a time.  Things get more interesting – and more wrong – when you filter on two columns at once:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;EXPLAIN&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ANALYZE&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;city&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;Cheyenne&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;WY&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- Index Scan on customers  (cost=... rows=8 width=...)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--                          (actual rows=4012)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The planner estimates &lt;strong&gt;8 rows&lt;/strong&gt;, but the query returns &lt;strong&gt;4,012&lt;/strong&gt;.  That’s a 500x miss.&lt;/p&gt;

&lt;p&gt;This happens because, by default, the planner assumes columns are statistically independent:&lt;/p&gt;

\[P(\text{city} = \text{Cheyenne} \;\wedge\; \text{state} = \text{WY}) = P(\text{city}) \times P(\text{state})\]

&lt;p&gt;In reality, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;city&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state&lt;/code&gt; are &lt;em&gt;correlated&lt;/em&gt;.  There’s basically one Cheyenne in the U.S., and it’s in Wyoming.  (There’s also a &lt;a href=&quot;https://en.wikipedia.org/wiki/Cheyenne,_Oklahoma&quot;&gt;Cheyenne, Oklahoma&lt;/a&gt;, but with a population of around 700, it doesn’t really move the needle.)  So filtering on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;city = &apos;Cheyenne&apos;&lt;/code&gt; is almost equivalent to filtering on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state = &apos;WY&apos;&lt;/code&gt;, but the planner doesn’t know that.&lt;/p&gt;

&lt;p&gt;Since Postgres 10, you can tell it:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;STATISTICS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers_city_state&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dependencies&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ndistinct&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;city&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;ANALYZE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dependencies&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ndistinct&lt;/code&gt; arguments are &lt;em&gt;statistic types&lt;/em&gt; – they tell Postgres what kind of cross-column information to track.  After re-running &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;EXPLAIN&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ANALYZE&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;customers&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;city&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;Cheyenne&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;WY&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- Index Scan on customers  (cost=... rows=4087 width=...)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;--                          (actual rows=4012)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The estimate of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4,087&lt;/code&gt; versus actual &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4,012&lt;/code&gt; is essentially perfect.  Just as importantly, this estimate now feeds correctly into any joins or aggregations that sit on top of this scan.  Mis-estimates at the &lt;em&gt;bottom&lt;/em&gt; of a plan tend to compound – a wrong scan choice deep in the tree can cause cascading mistakes in the joins above it.  That’s part of why getting the foundational statistics right matters so much.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Creating extended statistics isn’t free. It adds a small amount of overhead to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt; and to the query planning process itself. You should only create them when you’ve identified a clear case of mis-estimation due to correlated columns.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;a-quick-checklist-for-query-performance&quot;&gt;A Quick Checklist for Query Performance&lt;/h2&gt;

&lt;p&gt;When a query plan looks wrong, here’s roughly the order I work through:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Compare estimated vs. actual rows.&lt;/strong&gt;  Run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EXPLAIN ANALYZE&lt;/code&gt; and look for the &lt;em&gt;deepest&lt;/em&gt; node where the estimate disagrees with reality.  That’s usually where the problem starts.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Check &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stats&lt;/code&gt; for that column.&lt;/strong&gt;  Look at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;n_distinct&lt;/code&gt; and the MCV list.  Do they match what you know about your data?&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;If the stats look stale, run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt;.&lt;/strong&gt;  This is especially common after a big batch load, a migration, or a partition swap.  Autovacuum may not have caught up.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;If estimates are still off on a single column, raise the statistics target.&lt;/strong&gt;  &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 1000;&lt;/code&gt; and re-&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;If the bad estimate involves two columns in the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WHERE&lt;/code&gt; clause, consider correlation.&lt;/strong&gt;  Create extended statistics on the pair.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Only after all of that should you consider rewriting the query&lt;/strong&gt; – or reaching out for help.&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;The Postgres query planner is impressively good at its job, but it isn’t magic.  It makes decisions based on a summary of your data, and the quality of those decisions is bounded by the quality of that summary.  &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stats&lt;/code&gt; is your window into what the planner &lt;em&gt;thinks&lt;/em&gt; is true about your tables – and when reality and the planner’s beliefs diverge, that’s usually where bad plans come from.&lt;/p&gt;

&lt;p&gt;The next time &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EXPLAIN ANALYZE&lt;/code&gt; surprises you, before you start setting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;enable_seqscan = off&lt;/code&gt; in production or rewriting the query out of frustration, take a look at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stats&lt;/code&gt; first.  More often than not, the answer is there.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;The Query Planner is only as smart as the statistics you feed it.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
        <pubDate>Mon, 22 Jun 2026 08:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/06/22/pg_stats_how_postgres_internal_stats_work.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/06/22/pg_stats_how_postgres_internal_stats_work.html</guid>
        
        <category>PostgreSQL</category>
        
        <category>postgres</category>
        
        <category>pg_stats</category>
        
        <category>pg_statistic</category>
        
        <category>analyze</category>
        
        <category>query-planner</category>
        
        <category>statistics</category>
        
        <category>histogram</category>
        
        <category>mcv</category>
        
        <category>extended-statistics</category>
        
        <category>explain</category>
        
        
        <category>postgres</category>
        
      </item>
    
      <item>
        <title>Disaster Recovery is a Process, Not a Tool (Part 1)</title>
        <description>&lt;h2 id=&quot;the-landscape-has-changed&quot;&gt;The Landscape Has Changed&lt;/h2&gt;

&lt;p&gt;When I was at Turnitin, we were still kind of riding the tail end of the dot-com boom.  People were rushing to ship things, and brief outages were not exactly &lt;em&gt;good&lt;/em&gt;, but they were considered a normal part of running software on the internet.  If the site was down for a few minutes, you’d shrug, dig in, and fix it.&lt;/p&gt;

&lt;p&gt;That’s not really the world we live in anymore.  Uptime is much more sensitive than it used to be.  Five nines used to be the stretch goal – now four nines is something a lot of teams just treat as the expectation, and even a few minutes of outage in a month feels like a lot.  We don’t really track averages in our metrics anymore, either; we track p99 latencies, because we actually care about that last 1% of users having a good experience.&lt;/p&gt;

&lt;p&gt;The other thing that’s changed is how quickly outages get socialized.  A noticeable hiccup in your service can end up on social media before your on-call has even finished acknowledging the page.  In my experience, the worst situations are the ones where customers find out about an issue before the company does.  That has both a financial cost and a reputational cost, and the reputational cost tends to linger long after the incident is resolved.  Frequent outages chip away at users’ willingness to keep using your product.&lt;/p&gt;

&lt;p&gt;Postgres is, of course, no exception.  So that’s the world a Postgres DR plan has to operate in.&lt;/p&gt;

&lt;h2 id=&quot;what-counts-as-a-disaster&quot;&gt;What Counts as a Disaster?&lt;/h2&gt;

&lt;p&gt;When people hear “disaster recovery,” I think the natural mental picture is a natural disaster – a flood, an earthquake, a wildfire, or maybe a long utility outage that takes a data center offline.  And those are real concerns; we put generators and solar panels and multi-region replication in place partly to deal with exactly that.&lt;/p&gt;

&lt;p&gt;But in my experience, most of the disasters that take a Postgres database down don’t look anything like that.  They look like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A performance regression after a failover, where the service is technically “up” but slow enough that customers can’t really use it.&lt;/li&gt;
  &lt;li&gt;Corruption from a bad migration – something the deployment pipeline didn’t catch, and now half the rows in a table look wrong.&lt;/li&gt;
  &lt;li&gt;A security incident, where somebody got in and may have tampered with data.&lt;/li&gt;
  &lt;li&gt;A subtle application bug that writes the wrong values, or reads them back the wrong way, for days before anyone notices.&lt;/li&gt;
  &lt;li&gt;Replication that quietly broke, or WAL that quietly went missing.&lt;/li&gt;
  &lt;li&gt;Accidental deletes – the classic missing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WHERE&lt;/code&gt; clause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I had to put a definition on it, I’d probably say something like: a disaster is any sustained event that compromises a system’s availability, correctness, or business trust.  Availability is the one that gets the most attention, but the other two are arguably more dangerous, because they tend to be discovered later and resolved with less confidence.&lt;/p&gt;

&lt;h2 id=&quot;how-dr-is-usually-done&quot;&gt;How DR Is Usually Done&lt;/h2&gt;

&lt;p&gt;If you ask most teams how they do disaster recovery, you’ll usually hear two words – not because they’re wrong, but because they’re the first things that come to mind.  Those words are &lt;strong&gt;preparation&lt;/strong&gt; and &lt;strong&gt;prevention&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Preparation looks like checklists, backups, monitoring, scenarios to think through, and playbooks of various levels of detail.  Prevention looks like alerting, automated remediation, self-healing systems, Patroni, redundancy, load balancing, and so on.  Both are good.  Benjamin Franklin’s “an ounce of prevention is worth a pound of cure” is on the wall of more than one ops team I’ve worked with, and there’s a reason for that – prevention really is cheaper than recovery on average.&lt;/p&gt;

&lt;p&gt;But preparation and prevention only get you so far, and I don’t think they’re really the same thing as recovery.  Recovery is what happens after preparation and prevention have already failed to keep the lights on.  It’s the act of taking a system that’s already down (or already untrustworthy) and restoring business operations.&lt;/p&gt;

&lt;p&gt;That distinction sounds almost too obvious to say out loud, but in my experience it’s the part teams are least ready for.  A lot of the customers I worked with at EDB were genuinely well-prepared, with great backups and good monitoring, and they were &lt;em&gt;still&lt;/em&gt; unprepared the day they actually had to recover.  I’ve been in that seat too, as a DBA – everything was in place on paper, and we still fumbled the first real incident.  Recovery is its own skill.&lt;/p&gt;

&lt;h2 id=&quot;postgres-already-gives-us-most-of-the-tools&quot;&gt;Postgres Already Gives Us Most of the Tools&lt;/h2&gt;

&lt;p&gt;One nice thing about Postgres is that the toolbox for recovery is already pretty good.  Off the top of my head, there’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_dump&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_restore&lt;/code&gt; for logical backups, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_basebackup&lt;/code&gt; for physical ones, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stat_replication&lt;/code&gt; to see what your standbys are doing, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stat_activity&lt;/code&gt; to see what your sessions are doing, point-in-time recovery for anything more granular than “last night’s backup,” and tools like &lt;a href=&quot;https://www.repmgr.org/&quot;&gt;repmgr&lt;/a&gt; and &lt;a href=&quot;https://www.enterprisedb.com/docs/efm/latest/&quot;&gt;EFM&lt;/a&gt; (and pgBackRest, Barman, and others) for orchestration and richer backup workflows.&lt;/p&gt;

&lt;p&gt;These tools are not the bottleneck.  In nearly every case I worked at EDB, the question wasn’t “do we have the technology to recover?”  It was, “do we know &lt;em&gt;when&lt;/em&gt; to use it, &lt;em&gt;how&lt;/em&gt; to use it, and &lt;em&gt;who&lt;/em&gt; gets to make the call?”  I had a customer once who had perfectly good backups – they really did – but they opened a P1 ticket asking me to walk them through the keystrokes for the restore.  I think they actually knew what to do; they were just afraid, in the moment, of typing the wrong thing.  That’s a process gap, not a tool gap, and no amount of additional automation would have fixed it.&lt;/p&gt;

&lt;p&gt;I’d add a slightly uncomfortable note here: as a vendor’s support engineer, I was always happy to help, but we probably shouldn’t be the centerpiece of anyone’s DR plan.  Support engineers can hand you tools and walk you through documentation, but we don’t know your data the way your team does, and there’s a liability we’re not really supposed to take on.  If the first time a team reads the failover documentation is during the outage, a support contract alone isn’t going to close that gap.&lt;/p&gt;

&lt;h2 id=&quot;rpo-and-rto-and-why-theyre-negotiations&quot;&gt;RPO and RTO, and Why They’re Negotiations&lt;/h2&gt;

&lt;p&gt;You can’t really talk about recovery without talking about RPO and RTO, so let me do that briefly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RPO&lt;/strong&gt; (Recovery Point Objective) is roughly “how much data are we willing to lose?”  Do we restore from last night’s backup and accept losing the day’s writes?  Or do we replay WAL and try to get as close as we can to the moment of the outage?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RTO&lt;/strong&gt; (Recovery Time Objective) is “how long are we allowed to be down before we’re considered back up?”&lt;/p&gt;

&lt;p&gt;Every choice on either of these axes is a trade-off – against cost, against complexity, against operational burden, against acceptable business loss.  And the reality is that during an outage, you really are losing business; transactions don’t happen, shopping carts don’t get checked out, customers get frustrated.  At the same time, getting up faster usually means accepting more data loss, or paying more for the infrastructure to avoid it.&lt;/p&gt;

&lt;p&gt;It’s helpful to think about RPO in tiers:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;strong&gt;24-hour RPO&lt;/strong&gt; is basically “restore last night’s backup.”  One or two people can usually handle it, the moving parts are simple, and the data loss can be substantial.  That’s fine for some workloads.  It’s not really acceptable for high-traffic services where 24 hours of writes is a lot.&lt;/li&gt;
  &lt;li&gt;A &lt;strong&gt;15-minute RPO&lt;/strong&gt; generally means WAL archiving or shipping, monitoring to make sure none of that WAL goes missing, regular validation that you can actually restore in 15 minutes, and operational discipline around retention.  That’s reasonable for many systems, but probably not acceptable for, say, a financial institution.&lt;/li&gt;
  &lt;li&gt;A &lt;strong&gt;near-zero RPO&lt;/strong&gt; typically means synchronous replication and tightly managed failover.  Now you’re dealing with latency between nodes, distributed-systems complexity, split-brain scenarios, and a much bigger operational footprint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lower RPO isn’t just “better.”  It’s a design and operational commitment, and that commitment costs money, time, and people’s attention.&lt;/p&gt;

&lt;p&gt;The same is true of RTO.  Driving RTO below five minutes generally requires automation and – this part is important – rehearsal.  If you hand someone a document for the first time during an actual outage, they are not going to execute it quickly, no matter how clear the document is.&lt;/p&gt;

&lt;p&gt;This is why I think RPO and RTO really need to be &lt;em&gt;negotiated&lt;/em&gt;, not just declared.  On the surface it’s almost a no-brainer – of course everyone wants an RPO of zero and an RTO of seconds.  But when you actually go to leadership and lay out what those numbers cost, you tend to find out pretty quickly where their priorities really sit.  In a lot of cases, they’d rather spend that money on something that looks more directly tied to the business – a new feature, a marketing push, another engineer on the product team – and they’re willing to accept a softer RPO or RTO in exchange.  That’s a legitimate answer; it just needs to be made explicitly, instead of being assumed one way or the other by the infrastructure team.&lt;/p&gt;

&lt;h2 id=&quot;three-layers-of-dr-planning&quot;&gt;Three Layers of DR Planning&lt;/h2&gt;

&lt;p&gt;When I think about what a DR plan needs to cover, I find it useful to break it into three layers.&lt;/p&gt;

&lt;p&gt;The first layer is &lt;strong&gt;infrastructure failure&lt;/strong&gt;.  This is the one most teams think of first: a region goes down, storage fails, a corruption bug bites, credentials leak, somebody accidentally deletes a table, replication breaks.  Hardware and platform behaving badly.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;procedural failure&lt;/strong&gt;.  Even if the infrastructure problem is well-understood, you can still fail recovery because the procedure is wrong.  Maybe the sequence values weren’t included in the backup and you didn’t realize.  Maybe the runbook references a CNAME nobody can find the host for anymore.  We used to have a setup at Turnitin where, on every failover, we had to repoint a CNAME to the new primary, and we eventually realized that nobody had documented which CNAME pointed to which underlying host.  Maybe the validation step is vague.  Procedural failure tends to be invisible until the moment you actually need the procedure.&lt;/p&gt;

&lt;p&gt;The third is &lt;strong&gt;human failure&lt;/strong&gt;.  People behave differently under duress.  Some panic.  Some zone in so hard on one screen that they miss the bigger picture.  There are conflicting instructions between managers, between teams, between people trying to be helpful.  There’s the 3AM call where the on-call is barely awake and not entirely sure what’s going on.  And there’s the person who can’t wait for the process and decides to just do something heroic and fast – which sometimes works, and sometimes makes things significantly worse.&lt;/p&gt;

&lt;p&gt;To make the layers concrete: I had a 3AM incident at Turnitin once where we rolled out a change in the evening and got paged a few hours later.  The disk had filled, and the filesystem ended up unmounted.  That was the infrastructure failure.  In the scramble to bring it back, somebody tried to remount it as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ext4&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xfs&lt;/code&gt; – that was strike one, a procedural failure, because the runbook didn’t make the filesystem type explicit.  Then we sat for a while waiting on the CTO, because nobody on the bridge had clear authority to call any of the next steps – strike two, no incident commander.  And then somebody prematurely brought the web servers back up before the database was really healthy, causing a second round of errors – strike three, the hero move.  No single one of those was catastrophic; together they turned a one-hour problem into a much longer night.  That’s what the three layers look like in practice.&lt;/p&gt;

&lt;h2 id=&quot;recovery-isnt-always-about-failing-over&quot;&gt;Recovery Isn’t Always About Failing Over&lt;/h2&gt;

&lt;p&gt;A lot of DR talks (and a lot of DR vendors) make it sound like “recovery” basically means “fail over to the standby.”  That’s one tool in the box, but it’s nowhere near the whole box.&lt;/p&gt;

&lt;p&gt;Here’s a story that’s stuck with me.  I was on a small team that shipped a release, and the migration looked clean – everything came up, the smoke tests passed, we went home feeling pretty good.  Later, somebody noticed that the application code had a small typo in its SQL: an extra apostrophe was getting written into every comment in a comment thread.  The data wasn’t lost.  The system was up.  But the data was &lt;em&gt;wrong&lt;/em&gt;, and it kept getting more wrong every minute the application stayed online.&lt;/p&gt;

&lt;p&gt;In that particular case, a careful &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UPDATE&lt;/code&gt; across the table was probably the right call, with all the locking and performance impact that implies.  But if you change the details a little – say the corruption is medical records, or it isn’t discovered for a few days, or some of those rows have already been read by other systems and propagated outward – a simple &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UPDATE&lt;/code&gt; stops being the answer.  Now you’re asking whether you have enough WAL retained to do point-in-time recovery, whether you can safely update some rows in place, when exactly the corruption started, and so on.&lt;/p&gt;

&lt;p&gt;I bring it up because that scenario is just as much a disaster, and just as worth planning for, as a disk failing or a region going dark.  And it can’t be solved by failing over – the standby would have the bad data too.&lt;/p&gt;

&lt;p&gt;While I’m telling stories about quietly-bad situations: another underrated failure mode is “the engineer who knew this part of the system went on vacation,” or quit, or moved teams, and the documentation never quite got updated.  Real DR plans have to assume some of that, too.&lt;/p&gt;

&lt;h2 id=&quot;what-lower-numbers-actually-cost&quot;&gt;What Lower Numbers Actually Cost&lt;/h2&gt;

&lt;p&gt;Negotiating RPO and RTO sounds abstract until you start listing the consequences.  Wanting an RPO of zero pushes you toward synchronous replication and forces you to live with the latency that comes with it.  Wanting an RTO of under five minutes pushes you toward automation that has to be built, tested, and maintained, and toward rehearsal cadence that has to be on someone’s calendar.  Multi-region pushes operational complexity up significantly – you’ve got clusters in different regions talking to each other, you’ve got cross-region replication lag to tolerate, and now your monitoring story has to account for all of it.  Even something as innocuous as “we’d like to be able to do point-in-time recovery to any second over the last 30 days” can mean keeping terabytes of WAL around and paying for storage you barely look at.&lt;/p&gt;

&lt;p&gt;None of this is a reason not to do these things.  It’s just a reason to have honest conversations about which of them you actually need.&lt;/p&gt;

&lt;h2 id=&quot;to-be-continued&quot;&gt;To Be Continued&lt;/h2&gt;

&lt;p&gt;That covers what I think of as the framing half of the talk: what counts as a disaster, why preparation and prevention aren’t the same as recovery, and how RPO and RTO end up being negotiations rather than declarations.&lt;/p&gt;

&lt;p&gt;In two weeks, I’ll get into the part that I think could reduce RTO (something that can’t be replaced by AI): runbook engineering, game days, what to measure, and the cultural piece that holds it all together.&lt;/p&gt;

</description>
        <pubDate>Mon, 15 Jun 2026 08:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/06/15/disaster_recovery_is_a_process.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/06/15/disaster_recovery_is_a_process.html</guid>
        
        <category>PostgreSQL</category>
        
        <category>postgres</category>
        
        <category>disaster-recovery</category>
        
        <category>dr</category>
        
        <category>rto</category>
        
        <category>rpo</category>
        
        <category>high-availability</category>
        
        <category>operations</category>
        
        
        <category>postgres</category>
        
      </item>
    
      <item>
        <title>PGDay Boston 2026</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;PGDay Boston 2026 was a rewarding reminder of why I value the PostgreSQL community so much. It was delightful to reconnect with familiar faces, meet new people, and finally put some faces to names for the first time. One of the best parts of the day was the sense that this community is larger than any one employer or project. It is built on shared curiosity, shared responsibility, and a willingness to help one another learn.  I’m honored to have been able to share my own thoughts in my Disaster Recovery talk as well.&lt;/p&gt;

&lt;p&gt;The keynote, Michael Stonebraker’s “Where Did Postgres Come From?”, was a standout for me. I especially appreciated the history of Postgres and the years before Postgres, during the Ingres era. It was striking to hear how the project could have ended up as just another academic system, yet instead grew into something enduring because people outside of UC Berkeley took ownership of it and built a broader community around it. That story felt like a good reminder that open source succeeds not only through technical merit, but through stewardship and continuity.&lt;/p&gt;

&lt;p&gt;I also enjoyed Brian Brennglass’s talk, “Managing and Observing Locks.” His demos made an intimidating topic much easier to follow, and I found the practical framing especially useful. Shree Vidhya Sampath’s session on leveraging Patroni’s synchronous replication while running PostgreSQL on Kubernetes was another highlight. I appreciated the clear discussion of election behavior, synchronous replication, and failover scenarios, including failure modes I had not experimented with myself.&lt;/p&gt;

&lt;p&gt;Robert Haas’ “pg_plan_advice: Plan Stability and User Planner Control for PostgreSQL?” was impressive in his attention to detail, especially the way he tested edge cases that people might not think to check. Bruce Momjian’s “What’s Missing in Postgres?” was also thought-provoking because it framed missing features not as oversights, but often as deliberate choices shaped by the needs of the broader community. Ryan Booz’s “Mastering PostgreSQL Partitioning: Supercharge Performance and Simplify Maintenance” rounded out the day well with a useful refresher on partitioning behavior, tradeoffs, and current workarounds.&lt;/p&gt;

&lt;p&gt;Overall, the event benefited me in both personal and professional ways. Professionally, it deepened my understanding of PostgreSQL internals, operational patterns, and ecosystem tooling. Personally, it renewed my appreciation for the community that has grown around Postgres and the care that goes into keeping it healthy. Thank you Tom Kincaid, Ken Rugg, Erik Pohi, Greg Burd, Kanchan Mohitey, Shihao Zhong, and so many others – along with PGUS – who worked hard to make the first PGDay Boston a smashing success! I look forward to staying involved and attending future events in Boston and beyond.&lt;/p&gt;
</description>
        <pubDate>Wed, 10 Jun 2026 08:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/06/10/pgday_boston_2026_writeup.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/06/10/pgday_boston_2026_writeup.html</guid>
        
        <category>PostgreSQL</category>
        
        <category>postgres</category>
        
        <category>pgday</category>
        
        <category>boston</category>
        
        <category>conference</category>
        
        <category>community</category>
        
        
        <category>postgres</category>
        
      </item>
    
      <item>
        <title>Foreign Tables and Materialized Views: A Dynamic Duo</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;I recently wrote a post about &lt;a href=&quot;/postgres/2026/04/06/wal_archiving.html&quot;&gt;WAL log shipping&lt;/a&gt; and how a standby built on log shipping is a great way to give data analysts production data without putting the primary at risk.  Having access to the production data in this way is great, but it’s read-only.  How can we create views of this data for better analytics work?  I want to make the case today that Foreign Data Wrappers and Materialized Views can make a great solution – not only in accessing production Postgres data, but also working with other data sources.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;moving-beyond-fdw-demos&quot;&gt;Moving Beyond FDW Demos&lt;/h2&gt;

&lt;p&gt;Most people meet foreign data wrappers (FDWs) through a quick demo, and I’ve &lt;a href=&quot;https://speakerdeck.com/richyen/2023-pgday-chicago-fdw&quot;&gt;highlighted some of their features in previous conference talks&lt;/a&gt;.  There is high novelty in being able to query MySQL from Postgres, but the reality is often that the latency between the local database and the foreign table can be pretty high.  Sometimes, predicate push-down isn’t what you’d expect, and indexing may not be very transparent.  In the end, setting up and managing FDWs may seem more work than it’s worth, and that’s a mistake.  Used correctly, foreign tables are one of the most practical tools for &lt;strong&gt;analytics across heterogeneous data sources&lt;/strong&gt; – especially when paired with materialized views.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-real-problem-heterogeneous-data&quot;&gt;The Real Problem: Heterogeneous Data&lt;/h2&gt;

&lt;p&gt;Modern data rarely lives in one place:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Legacy systems in MySQL&lt;/li&gt;
  &lt;li&gt;Operational data in PostgreSQL&lt;/li&gt;
  &lt;li&gt;Flat files sitting in object storage (I’ve seen people do this with AWS Athena)&lt;/li&gt;
  &lt;li&gt;Maybe even some CSVs someone refuses to migrate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Foreign tables give you a unified SQL interface, but under the hood, the query performance can be unpredictable as you may be forced to rely on another engine’s query planner (and in the case of that CSV data source, it might not even be indexed).&lt;/p&gt;

&lt;p&gt;In other words, FDWs optimize developer experience, not query performance.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-pattern-fdw--materialized-views&quot;&gt;The Pattern: FDW + Materialized Views&lt;/h2&gt;

&lt;p&gt;Instead of querying foreign tables directly in analytics workloads, we can opt to use FDWs as ingestion points, not as the serving layer itself.  To achieve this, we can do the following:&lt;/p&gt;

&lt;h3 id=&quot;step-1-define-the-foreign-table&quot;&gt;Step 1: Define the foreign table&lt;/h3&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FOREIGN&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ext_orders&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bigint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;customer_id&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bigint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;total&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;numeric&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;timestamp&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;SERVER&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mysql_server&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;OPTIONS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;table&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;orders&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;step-2-build-a-materialized-view&quot;&gt;Step 2: Build a materialized view&lt;/h3&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MATERIALIZED&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;VIEW&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;orders_mv&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;customer_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;total&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;date&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;order_date&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ext_orders&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;step-3-index-it-like-a-real-table&quot;&gt;Step 3: Index it like a real table&lt;/h3&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;orders_mv&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;order_date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;orders_mv&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;customer_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Now we’ve turned a slow, remote dataset into a locally optimized analytical structure.&lt;/p&gt;

&lt;p&gt;The materialized view lives inside PostgreSQL, supports full indexing, eliminates network latency during queries, and gives predictable performance.  We essentially have a read-optimized cache on top of the foreign tables.  We can do this with the read-only Postgres replicas as well, to slice up the columns and rows to fit nicely in a view that analysts would want to use.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;refreshing-without-blocking&quot;&gt;Refreshing Without Blocking&lt;/h2&gt;

&lt;p&gt;When it comes to caching, data gets stale, and we’re sort of back at the same problem every ETL pipeline faces.  However, Postgres can refresh a materialized view without blocking users, simply with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONCURRENTLY&lt;/code&gt; syntax.  This results in production-quality data with a little bit of staleness, but the nice thing is that it’s all built-in to the Postgres cluster (no separate ETL pipeline to manage, just all the data accessible from one central place).  Note, however that in order to use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONCURRENTLY&lt;/code&gt; syntax, &lt;a href=&quot;https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html&quot;&gt;a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UNIQUE&lt;/code&gt; key is required&lt;/a&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;good-applications-for-the-pairing&quot;&gt;Good Applications for the Pairing&lt;/h2&gt;

&lt;p&gt;The pairing of FDWs and indexed Materialized Views could be very beneficial in a handful of use cases:&lt;/p&gt;

&lt;h3 id=&quot;1-poorly-indexed-remote-systems&quot;&gt;1. Poorly Indexed Remote Systems&lt;/h3&gt;

&lt;p&gt;If your upstream system:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Lacks proper indexes&lt;/li&gt;
  &lt;li&gt;Is shared with OLTP workloads&lt;/li&gt;
  &lt;li&gt;Is not under your control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This approach isolates analytics from those constraints.&lt;/p&gt;

&lt;h3 id=&quot;2-high-latency-data-sources&quot;&gt;2. High-Latency Data Sources&lt;/h3&gt;

&lt;p&gt;Examples:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Cross-region databases&lt;/li&gt;
  &lt;li&gt;Cloud object storage via FDWs&lt;/li&gt;
  &lt;li&gt;Athena-backed datasets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of paying the latency cost on every query, you pay it once per refresh.&lt;/p&gt;

&lt;h3 id=&quot;3-flat-files-and-large-data&quot;&gt;3. Flat Files and Large Data&lt;/h3&gt;

&lt;p&gt;Yes, people do this:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Querying CSVs via FDWs&lt;/li&gt;
  &lt;li&gt;Treating object storage as a “database”&lt;/li&gt;
  &lt;li&gt;Large JSONB sets that are hard to index well&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h2&gt;

&lt;p&gt;Foreign tables aren’t just novelty – they’re a powerful bridge across messy, real-world data systems.&lt;/p&gt;

&lt;p&gt;It is important to distinguish that FDWs make a &lt;strong&gt;data access layer&lt;/strong&gt;, while Materialized Views are the &lt;strong&gt;analytics engine&lt;/strong&gt;.  If you 1) layer materialized views on top of FDWs, 2) add proper indexing, and 3) refresh intelligently (preferably concurrently), you can get the best of both worlds: flexibility of federated queries and performance of local analytics.&lt;/p&gt;
</description>
        <pubDate>Mon, 25 May 2026 08:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/05/25/fdw_mv_analytics.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/05/25/fdw_mv_analytics.html</guid>
        
        
        <category>postgres</category>
        
      </item>
    
      <item>
        <title>XID Wraparound&apos;s Equally-Evil Twin</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;If you’ve been running PostgreSQL for any length of time, you’ve probably heard about transaction ID (XID) wraparound.  It’s one of the most well-known maintenance concerns in Postgres, and there’s no shortage of blog posts, conference talks, and war stories about it.  But there’s a quieter, less-discussed cousin that can cause the exact same kind of outage: &lt;strong&gt;MultiXact ID wraparound&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I’ve seen this surprise more than a few experienced DBAs.  They’ve got their autovacuum tuned, they’re monitoring &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;age(datfrozenxid)&lt;/code&gt;, and they’re feeling good – and then out of nowhere, Postgres starts refusing certain writes because it’s approaching MultiXact ID wraparound.&lt;/p&gt;

&lt;p&gt;The fix is the same as regular XID wraparound – a simple vacuum.  But the reason is different, and understanding it can help you keep your monitoring complete.&lt;/p&gt;

&lt;h2 id=&quot;whats-a-multixact-id&quot;&gt;What’s a MultiXact ID?&lt;/h2&gt;

&lt;p&gt;In Postgres, every row has a system column called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xmax&lt;/code&gt;.  In the simplest case, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xmax&lt;/code&gt; holds the transaction ID of the transaction that deleted or updated the row.  But what happens when &lt;em&gt;multiple&lt;/em&gt; transactions hold locks on the same row at the same time?&lt;/p&gt;

&lt;p&gt;Consider &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT ... FOR SHARE&lt;/code&gt;.  Multiple transactions can hold a shared lock on the same row concurrently.  Postgres needs to record &lt;em&gt;all&lt;/em&gt; of those transactions somewhere, but &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xmax&lt;/code&gt; is only wide enough to store a single transaction ID.  The solution is the &lt;strong&gt;MultiXact&lt;/strong&gt; mechanism.&lt;/p&gt;

&lt;p&gt;A MultiXact ID is essentially a pointer into a separate structure (stored as a file in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_multixact/&lt;/code&gt; dir) that maps to a &lt;em&gt;list&lt;/em&gt; of transaction IDs and their lock modes.  When multiple transactions need to lock a row, Postgres:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Allocates a new MultiXact ID&lt;/li&gt;
  &lt;li&gt;Records the set of transaction IDs (and their lock types) in the MultiXact member data&lt;/li&gt;
  &lt;li&gt;Stores the MultiXact ID in the row’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xmax&lt;/code&gt; field, with a flag (specifically, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HEAP_XMAX_IS_MULTI&lt;/code&gt; infomask bit in the tuple header) indicating it’s a multi-xact reference rather than a plain XID&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This lets the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xmax&lt;/code&gt; field stay a fixed 32-bit value while still representing an arbitrary number of concurrent row-level lockers.&lt;/p&gt;

&lt;h2 id=&quot;when-are-multixact-ids-created&quot;&gt;When Are MultiXact IDs Created?&lt;/h2&gt;

&lt;p&gt;MultiXact IDs come into play in several scenarios:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT ... FOR SHARE&lt;/code&gt;&lt;/strong&gt; – The classic case.  Multiple transactions can hold shared row locks simultaneously.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT ... FOR KEY SHARE&lt;/code&gt;&lt;/strong&gt; – Used implicitly by foreign key checks.  If you have a parent table with foreign key references, every insert or update on the child table takes a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FOR KEY SHARE&lt;/code&gt; lock on the referenced parent row.  On a busy system with many concurrent inserts referencing the same parent rows, this generates MultiXact IDs rapidly.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Combination locks&lt;/strong&gt; – If one transaction holds a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FOR KEY SHARE&lt;/code&gt; lock and another holds a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FOR NO KEY UPDATE&lt;/code&gt; lock on the same row, the two locks don’t conflict, and the resulting multi-lock is stored as a MultiXact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The foreign key scenario is particularly noteworthy because it’s &lt;em&gt;invisible&lt;/em&gt; to most application developers.  You won’t see any queries explicitly calling out &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FOR SHARE&lt;/code&gt; in application code, but Postgres is silently creating MultiXact IDs behind the scenes to manage the implicit locks.&lt;/p&gt;

&lt;h2 id=&quot;multixact-ids-need-freezing-too&quot;&gt;MultiXact IDs Need Freezing Too!&lt;/h2&gt;

&lt;p&gt;Just like transaction IDs, MultiXact IDs are 32-bit counters.  And just like XIDs, they wrap around.  Postgres can only “see” about 2 billion MultiXact IDs into the past.  If a row still references a MultiXact ID that’s about to fall off the visible horizon, Postgres has a problem: it can no longer determine whether the locks represented by that MultiXact are still relevant.&lt;/p&gt;

&lt;p&gt;To prevent this, Postgres needs to &lt;strong&gt;freeze&lt;/strong&gt; MultiXact IDs, just as it freezes regular XIDs.  Freezing a MultiXact means replacing the MultiXact reference in the row’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xmax&lt;/code&gt; with either the zero value, a single transaction ID, or a newer multixact ID, depending on whether the lock information is still meaningful.&lt;/p&gt;

&lt;p&gt;The relevant settings mirror those for XID freezing:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;XID Setting&lt;/th&gt;
      &lt;th&gt;MultiXact Equivalent&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vacuum_freeze_min_age&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vacuum_multixact_freeze_min_age&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vacuum_freeze_table_age&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vacuum_multixact_freeze_table_age&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autovacuum_freeze_max_age&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autovacuum_multixact_freeze_max_age&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;When the MultiXact age of a table exceeds &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autovacuum_multixact_freeze_max_age&lt;/code&gt;, autovacuum will trigger an aggressive (whole-table) vacuum specifically to freeze old MultiXact IDs – even if the table has no dead tuples and wouldn’t otherwise qualify for autovacuum.&lt;/p&gt;

&lt;h2 id=&quot;dont-let-multixact-fly-under-the-radar&quot;&gt;Don’t Let MultiXact Fly Under the Radar&lt;/h2&gt;

&lt;p&gt;The query is straightforward:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datname&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
       &lt;span class=&quot;n&quot;&gt;age&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;datfrozenxid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;xid_age&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
       &lt;span class=&quot;n&quot;&gt;mxid_age&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;datminmxid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mxid_age&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_database&lt;/span&gt;
 &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mxid_age&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For per-table granularity:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;oid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;regclass&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;table_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
       &lt;span class=&quot;n&quot;&gt;age&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;relfrozenxid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;xid_age&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
       &lt;span class=&quot;n&quot;&gt;mxid_age&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;relminmxid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mxid_age&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_class&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;c&lt;/span&gt;
 &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;relkind&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;IN&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;r&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;t&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;m&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
 &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mxid_age&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;
 &lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Keep an eye on any table where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mxid_age&lt;/code&gt; is approaching &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autovacuum_multixact_freeze_max_age&lt;/code&gt; (default: 400 million).  If it gets close, autovacuum &lt;em&gt;should&lt;/em&gt; kick in, but on large tables or systems with constrained autovacuum workers, it may not complete in time.&lt;/p&gt;

&lt;h2 id=&quot;practical-recommendations&quot;&gt;Practical Recommendations&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Add MultiXact monitoring alongside XID monitoring.&lt;/strong&gt;  If your alerting triggers at, say, 500 million XID age, add a similar alert for MultiXact age.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Watch your foreign key parent tables.&lt;/strong&gt;  If you have a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;users&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;accounts&lt;/code&gt; table that’s referenced by every other table in the schema, it’s likely accumulating MultiXact IDs faster than you’d expect.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Consider &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autovacuum_multixact_freeze_max_age&lt;/code&gt; tuning.&lt;/strong&gt;  The default of 400 million is higher than the XID &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autovacuum_freeze_max_age&lt;/code&gt; default of 200 million.  But in workloads with heavy foreign key activity, you may want to lower it – or configure per-table autovacuum settings on hot parent tables.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Don’t ignore “unnecessary” vacuums.&lt;/strong&gt;  If you see autovacuum running on a table that has zero dead tuples, don’t assume it’s wasting resources.  It may be performing MultiXact freezing work that’s critical for preventing wraparound.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;MultiXact ID wraparound is the kind of problem that bites you precisely because you didn’t know to look for it.  The mechanism exists for a good reason – efficiently tracking shared row locks is fundamental to Postgres’s concurrency model.  But the maintenance burden it creates is real, and it demands the same vigilance as XID wraparound.&lt;/p&gt;

&lt;p&gt;If you take one thing away from this post: go check &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mxid_age(datminmxid)&lt;/code&gt; on your databases today.  If you’ve never looked at it before, now’s a good time to start.&lt;/p&gt;
</description>
        <pubDate>Mon, 18 May 2026 06:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/05/18/multixact_wraparound.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/05/18/multixact_wraparound.html</guid>
        
        <category>PostgreSQL</category>
        
        <category>postgres</category>
        
        <category>vacuum</category>
        
        <category>multixact</category>
        
        <category>wraparound</category>
        
        <category>maintenance</category>
        
        <category>autovacuum</category>
        
        
        <category>postgres</category>
        
      </item>
    
      <item>
        <title>Making JSONB More Queryable with Generated Columns</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;Over the past year, I’ve worked in a handful of contexts managing large volumes of data stored as JSONB in PostgreSQL. The scenario is common: users appreciate the flexibility of a document-oriented storage model, avoiding the need to predefine schemas or constantly migrate table structures as their data requirements evolve. JSONB documents can be deeply nested with numerous optional fields, and they scale to hundreds of kilobytes per record without issue. However, when the time comes to query these documents – filtering by user ID, event type, timestamps, or nested action properties – the queries can become slow and/or cumbersome to work with.&lt;/p&gt;

&lt;p&gt;The problem I want to address is: “How do we make searching JSONB data more efficient without breaking apart our documents or forcing it into columns in a relational database?” There are several approaches available in Postgres, each with different tradeoffs. I hope to shed some light on those approaches in this article.&lt;/p&gt;

&lt;h2 id=&quot;the-setup&quot;&gt;The Setup&lt;/h2&gt;

&lt;p&gt;I created a basic, no-frills table for the sake of this test:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;BIGSERIAL&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;PRIMARY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;KEY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;JSONB&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;Here&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;s the document shape I used for testing and writing this post -- it&apos;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;representative&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;the&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;logs&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;audit&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;trails&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;ve encountered: a mix of primitive fields, nested objects, and metadata that accumulates over time.

-- Representative JSONB document
{
  &quot;user_id&quot;: 5234,
  &quot;event_type&quot;: &quot;event_42&quot;,
  &quot;timestamp&quot;: 1712341200,
  &quot;session_id&quot;: &quot;sess_abc123...&quot;,
  &quot;ip_address&quot;: &quot;192.168.1.42&quot;,
  &quot;action&quot;: {
    &quot;type&quot;: &quot;click&quot;,
    &quot;target_id&quot;: 87654,
    &quot;coordinates&quot;: {&quot;x&quot;: 512, &quot;y&quot;: 768},
    &quot;duration_ms&quot;: 1234
  },
  &quot;device&quot;: {
    &quot;type&quot;: &quot;mobile&quot;,
    &quot;os&quot;: &quot;iOS&quot;,
    &quot;screen_width&quot;: 1920,
    &quot;screen_height&quot;: 1080
  },
  &quot;performance&quot;: {
    &quot;page_load_time&quot;: 1234,
    &quot;dns_lookup&quot;: 123,
    &quot;tcp_connection&quot;: 234,
    &quot;server_response&quot;: 876
  },
  &quot;custom_fields&quot;: { ... }
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The queries that matter are straightforward equality and range filters on known fields: find all events for a given user, filter by event type, narrow to a time window. With this setup, we’ll try to discern which kind of index actually serves the specific access pattern, and what the real cost of each option is.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All tests run on PostgreSQL 18.2 in Docker on an Apple M-series host. Tables contain 50,000 rows with realistic JSONB event documents. Query benchmarks run 20 times on a warm cache and report avg/min/max. Insert benchmarks run 5 trials of 5,000 rows each. Schema and scripts are included throughout so you can reproduce these results.&lt;/em&gt;&lt;/p&gt;

&lt;h2 id=&quot;three-approaches-to-indexing-jsonb&quot;&gt;Three Approaches to Indexing JSONB&lt;/h2&gt;

&lt;p&gt;There are three realistic options for this access pattern. Let’s look at each in turn – what it costs to build/maintain, what queries it actually helps, and where it falls down.&lt;/p&gt;

&lt;h3 id=&quot;option-1-gin-indexes&quot;&gt;Option 1: GIN Indexes&lt;/h3&gt;

&lt;p&gt;The natural candidate for indexing a JSONB column would be a GIN (Generalized Inverted Index) index.  After all, GIN indexes are specifically designed for JSON documents and full-text search.  It indexes every key and value pair in every document, making the entire structure searchable:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idx_gin&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;USING&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GIN&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;-- or the path-only variant:&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idx_gin_path&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;USING&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GIN&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;jsonb_path_ops&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;As a refresher, I’ll mention that GIN is designed for containment and key existence operators (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@&amp;gt;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?|&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&amp;amp;&lt;/code&gt;), not for equality on extracted fields:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;-- This query uses a GIN index correctly:&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;@&amp;gt;&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;{&quot;user_id&quot;: 5234}&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- This query does NOT use a GIN index, even if one exists:&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;cast&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;user_id&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;INT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5234&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For the containment form, the GIN index is used and the query is fast – but still slower than a B-tree on the same field, because GIN lookups involve more bookkeeping:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;-- GIN jsonb_ops + containment operator
Bitmap Index Scan on idx_gin
  Index Cond: (data @&amp;gt; &apos;{&quot;user_id&quot;: 5234}&apos;)

lanning Time: 1.173 ms  |  Execution Time: 1.295 ms

-- GIN jsonb_path_ops + containment operator
Bitmap Index Scan on idx_gin_path
  Index Cond: (data @&amp;gt; &apos;{&quot;user_id&quot;: 5234}&apos;)
Planning Time: 3.342 ms  |  Execution Time: 0.450 ms
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jsonb_path_ops&lt;/code&gt; variant is smaller and faster for containment queries, but it trades away support for key-existence operators (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?|&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&amp;amp;&lt;/code&gt;). Neither GIN variant can help with range predicates like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ts &amp;gt; 1700000000&lt;/code&gt; – those always fall through to a filter step.&lt;/p&gt;

&lt;h3 id=&quot;option-2-expression-indexes&quot;&gt;Option 2: Expression Indexes&lt;/h3&gt;

&lt;p&gt;Postgres lets you create an index on an expression, including JSONB extraction:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idx_user_id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;cast&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;user_id&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;INT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is a B-tree index on the &lt;em&gt;result&lt;/em&gt; of evaluating the expression. When the query predicate matches the indexed expression exactly, and after &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ANALYZE&lt;/code&gt; has gathered statistics on it, the planner will use it:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;cast&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;user_id&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;INT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5234&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bitmap Heap Scan on t_expr
  Recheck Cond: ((data -&amp;gt;&amp;gt; &apos;user_id&apos;)::integer = 5234)
  Heap Blocks: exact=3
  -&amp;gt;  Bitmap Index Scan on idx_user_id
        Index Cond: ((data -&amp;gt;&amp;gt; &apos;user_id&apos;)::integer = 5234)
Planning Time: 1.168 ms  |  Execution Time: 0.341 ms
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The execution time on this equality operator seems to be pretty similar to the performance of the GIN index.&lt;/p&gt;

&lt;h3 id=&quot;option-3-generated-columns&quot;&gt;Option 3: Generated Columns&lt;/h3&gt;

&lt;p&gt;Generated columns (available since PostgreSQL 12) let you extract JSONB values into regular typed columns at write time. The values are stored physically alongside the row and kept in sync automatically:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;         &lt;span class=&quot;n&quot;&gt;BIGSERIAL&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;PRIMARY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;KEY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;       &lt;span class=&quot;n&quot;&gt;JSONB&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;    &lt;span class=&quot;nb&quot;&gt;INT&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;GENERATED&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ALWAYS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;user_id&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)::&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;INT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;STORED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;event_type&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;TEXT&lt;/span&gt;   &lt;span class=&quot;k&quot;&gt;GENERATED&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ALWAYS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;event_type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;STORED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;ts&lt;/span&gt;         &lt;span class=&quot;nb&quot;&gt;BIGINT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;GENERATED&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ALWAYS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;timestamp&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)::&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;BIGINT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;STORED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;action&lt;/span&gt;     &lt;span class=&quot;nb&quot;&gt;TEXT&lt;/span&gt;   &lt;span class=&quot;k&quot;&gt;GENERATED&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ALWAYS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;action&apos;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;STORED&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idx_user_id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idx_event_type&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idx_ts&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idx_action&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Queries against generated columns are plain typed-column lookups. The planner sees them as regular B-tree columns and produces tight estimates:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5234&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bitmap Heap Scan on t_gen
  Recheck Cond: (user_id = 5234)
  Heap Blocks: exact=3
  -&amp;gt;  Bitmap Index Scan on idx_user_id
        Index Cond: (user_id = 5234)
Planning Time: 1.159 ms  |  Execution Time: 0.407 ms
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You also get native support for range queries and composite indexes at no extra complexity – just combine columns as you normally would:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;-- Indexed range query on generated timestamp column&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event_type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;event_42&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ts&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1700000000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;-- Execution Time: 0.698 ms (vs 6.6 ms with GIN + post-filter)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;side-by-side-query-performance&quot;&gt;Side-by-Side: Query Performance&lt;/h2&gt;

&lt;p&gt;With all three approaches set up, here are the warm-cache query results averaged over 20 runs for an equality filter on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;user_id&lt;/code&gt;:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Approach&lt;/th&gt;
      &lt;th&gt;Avg (ms)&lt;/th&gt;
      &lt;th&gt;Min (ms)&lt;/th&gt;
      &lt;th&gt;Max (ms)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;GIN jsonb_ops + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;0.198&lt;/td&gt;
      &lt;td&gt;0.101&lt;/td&gt;
      &lt;td&gt;1.769&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GIN jsonb_path_ops + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;0.197&lt;/td&gt;
      &lt;td&gt;0.032&lt;/td&gt;
      &lt;td&gt;3.115&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Expression index&lt;/td&gt;
      &lt;td&gt;0.106&lt;/td&gt;
      &lt;td&gt;0.018&lt;/td&gt;
      &lt;td&gt;1.705&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Generated column B-tree&lt;/td&gt;
      &lt;td&gt;0.112&lt;/td&gt;
      &lt;td&gt;0.016&lt;/td&gt;
      &lt;td&gt;1.839&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Expression indexes and generated columns perform very similarly for equality queries—both around 0.1ms on warm cache. The real work is done in the B-tree lookup and both produce the same index structure. GIN with the correct &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@&amp;gt;&lt;/code&gt; operator is nearly as fast in PG 18.2 – still slightly slower than B-tree for this access pattern, but the gap has narrowed. GIN lookups still require a recheck step that B-tree lookups avoid, and the variance remains notable: GIN max of 3.1ms vs B-tree max of 1.8ms on warm cache.&lt;/p&gt;

&lt;p&gt;The more surprising result is what happens if the GIN index is present but the query is written with extraction-based equality:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;-- GIN index exists, but this query gets a seq scan:&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;cast&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;user_id&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;INT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5234&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;-- Execution Time: 47.935 ms (same as no index at all)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;GIN doesn’t support that operator class. This is by far the most common confusion teams run into with JSONB indexing.&lt;/p&gt;

&lt;h2 id=&quot;the-full-cost-picture-storage-and-writes&quot;&gt;The Full Cost Picture: Storage and Writes&lt;/h2&gt;

&lt;h3 id=&quot;storage&quot;&gt;Storage&lt;/h3&gt;

&lt;p&gt;Here’s what the same 50,000 rows cost on disk under each approach:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Approach&lt;/th&gt;
      &lt;th&gt;Table size&lt;/th&gt;
      &lt;th&gt;Index size&lt;/th&gt;
      &lt;th&gt;Total&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Expression indexes (4)&lt;/td&gt;
      &lt;td&gt;18 MB&lt;/td&gt;
      &lt;td&gt;3.5 MB&lt;/td&gt;
      &lt;td&gt;21 MB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Generated columns + B-tree (4)&lt;/td&gt;
      &lt;td&gt;20 MB&lt;/td&gt;
      &lt;td&gt;3.5 MB&lt;/td&gt;
      &lt;td&gt;23 MB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GIN jsonb_path_ops&lt;/td&gt;
      &lt;td&gt;18 MB&lt;/td&gt;
      &lt;td&gt;13 MB&lt;/td&gt;
      &lt;td&gt;31 MB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GIN jsonb_ops&lt;/td&gt;
      &lt;td&gt;18 MB&lt;/td&gt;
      &lt;td&gt;18 MB&lt;/td&gt;
      &lt;td&gt;36 MB&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Expression indexes and generated column B-tree indexes produce &lt;em&gt;identical&lt;/em&gt; index sizes for the same fields – this makes sense, since the index structures are the same; the only extra cost of generated columns is the 2 MB of additional stored column data in the table (~40 bytes per row for four typed columns). GIN indexes are substantially larger: 13–18 MB for a single index vs 3.5 MB for four targeted B-tree indexes. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jsonb_path_ops&lt;/code&gt; variant is smaller because it only stores value hashes for the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@&amp;gt;&lt;/code&gt; operator path, but it still dwarfs the targeted approach.&lt;/p&gt;

&lt;p&gt;One caveat: these numbers reflect documents with short keys and compact values. Documents with verbose key names, deeply nested structures, or large string values will inflate GIN indexes proportionally more – because GIN indexes every key path. B-tree and expression indexes are unaffected by document verbosity, since they only store the extracted value.&lt;/p&gt;

&lt;h3 id=&quot;write-throughput&quot;&gt;Write Throughput&lt;/h3&gt;

&lt;p&gt;Here’s what 5,000 INSERTs per trial, 5 trials each, on a table already containing 50,000 rows looked like:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Approach&lt;/th&gt;
      &lt;th&gt;Avg (ms)&lt;/th&gt;
      &lt;th&gt;Min (ms)&lt;/th&gt;
      &lt;th&gt;Max (ms)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Generated columns + B-tree (4)&lt;/td&gt;
      &lt;td&gt;157&lt;/td&gt;
      &lt;td&gt;91&lt;/td&gt;
      &lt;td&gt;317&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Expression indexes (4)&lt;/td&gt;
      &lt;td&gt;163&lt;/td&gt;
      &lt;td&gt;93&lt;/td&gt;
      &lt;td&gt;366&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GIN jsonb_path_ops&lt;/td&gt;
      &lt;td&gt;171&lt;/td&gt;
      &lt;td&gt;73&lt;/td&gt;
      &lt;td&gt;408&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GIN jsonb_ops&lt;/td&gt;
      &lt;td&gt;334&lt;/td&gt;
      &lt;td&gt;225&lt;/td&gt;
      &lt;td&gt;525&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Generated columns and expression indexes are now very close in write cost, with generated columns slightly edging out on average. GIN jsonb_path_ops has become more competitive with both. However, the default GIN jsonb_ops variant is dramatically more expensive: 2× slower than expression indexes and generated columns. It must decompose the entire document into key-value pairs and insert entries for each one. The high variance is also worth noting: GIN jsonb_ops max of 525ms vs 366ms for expression indexes.&lt;/p&gt;

&lt;h2 id=&quot;choosing-the-right-approach&quot;&gt;Choosing the Right Approach&lt;/h2&gt;

&lt;p&gt;The benchmarks above tell a consistent story for workloads dominated by equality and range filters on a known set of fields:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Expression indexes&lt;/strong&gt; are the lowest-cost migration path. They add no schema structure, require no application changes to insert logic, and impose minimal write overhead. If your team already has a table in production and just needs to speed up a handful of known slow queries, a well-placed expression index is your first move. The catch: every query must exactly match the expression as written in the index definition, which can be fragile to maintain as codebases evolve.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Generated columns&lt;/strong&gt; take slightly more storage and impose more write overhead than expression indexes, but they offer something the others can’t: the extracted values become first-class columns. You can build composite indexes across them, reference them in views, expose them via ORMs, and sort or aggregate on them without embedding extraction logic everywhere. For new tables or for tables you’re willing to migrate, they’re the most maintainable long-term solution.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;GIN indexes&lt;/strong&gt; serve a different purpose. They’re the right tool when your query patterns are flexible or unknown – searching for the existence of a key, filtering on any field in an ad-hoc fashion, or supporting containment queries on arbitrarily-shaped documents. For those access patterns, they’re genuinely powerful and there’s no clean B-tree equivalent. But for consistent equality and range filters on known fields, they cost more in storage, impose higher write latency, and only work with one operator class (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@&amp;gt;&lt;/code&gt;, not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;=&lt;/code&gt;).&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s a rough decision guide:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Situation&lt;/th&gt;
      &lt;th&gt;Recommended approach&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Unknown or ad-hoc field queries&lt;/td&gt;
      &lt;td&gt;GIN (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@&amp;gt;&lt;/code&gt;, key existence)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Known fields, few queries, no schema change&lt;/td&gt;
      &lt;td&gt;Expression index&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Known fields, high query volume, evolving codebase&lt;/td&gt;
      &lt;td&gt;Generated columns&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Known fields + range queries (e.g., timestamps)&lt;/td&gt;
      &lt;td&gt;Generated columns + composite B-tree&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Mixed: some known fields + some ad-hoc&lt;/td&gt;
      &lt;td&gt;Generated columns + GIN (both)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;caveats-and-considerations&quot;&gt;Caveats and Considerations&lt;/h2&gt;

&lt;p&gt;Regardless of which approach you choose, a few things apply broadly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real win is making data typed and relational again.&lt;/strong&gt; Generated columns aren’t magic. The reason they (and expression indexes) outperform GIN for equality filters is that they produce typed scalar values with precise statistics, letting the planner make accurate row-count estimates and choose cheap comparison operations. JSONB is flexible but opaque; once you extract a field into a typed column or expression, Postgres can reason about it properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expression indexes require exact predicate matching.&lt;/strong&gt; An index on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cast(data-&amp;gt;&amp;gt;&apos;user_id&apos; AS INT)&lt;/code&gt; will not be used by a query written as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(data-&amp;gt;&amp;gt;&apos;user_id&apos;)::int&lt;/code&gt;. The cast form must be identical. Generated columns avoid this fragility – any query that references the column name will benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated column expressions must be immutable.&lt;/strong&gt; The expression cannot reference functions that depend on time, session state, or anything external. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NOW()&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CURRENT_USER&lt;/code&gt;, and similar functions are off-limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated columns cannot be directly updated.&lt;/strong&gt; Their value is always derived from the source column. If you UPDATE the JSONB &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data&lt;/code&gt;, the generated columns recompute automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GIN maintenance overhead compounds on write-heavy tables.&lt;/strong&gt; GIN indexes build an internal pending list and flush it periodically (controlled by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gin_pending_list_limit&lt;/code&gt;). Under sustained write load, this flushing can cause the latency spikes visible in the benchmark max values above. B-tree indexes don’t have this mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;These benchmarks cover one dataset shape and one machine.&lt;/strong&gt; At much larger row counts (hundreds of millions), cache-miss behavior and index bloat will dominate—relative rankings should hold, but absolute numbers will differ. When in doubt, benchmark on your own data before committing to a migration.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;For workloads dominated by equality and range filters on a predictable set of JSONB fields, the data is clear: B-tree indexes on typed values – whether via expression indexes or generated columns – outperform GIN both on read latency and write throughput. GIN’s strength is flexibility, not speed for known-field access patterns; when you know exactly which fields you’ll filter on, a targeted B-tree beats the GIN every time.&lt;/p&gt;

&lt;p&gt;If you’re starting from scratch or are willing to migrate a table, generated columns are the most maintainable path. They make your frequently-queried fields easily accessible, eliminate JSONB extraction logic from your application’s query layer, and support composite indexes and range queries naturally. If you need to add indexing to an existing table without a schema change, expression indexes get you 90% of the way there with a fraction of the write overhead.&lt;/p&gt;

&lt;p&gt;GIN still belongs in your toolkit – but for the right job: ad-hoc containment searches, key-existence checks, and cases where the query patterns genuinely vary by document. For everything else, make your JSONB fields relational.&lt;/p&gt;
</description>
        <pubDate>Mon, 11 May 2026 06:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/05/11/generated_columns_jsonb.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/05/11/generated_columns_jsonb.html</guid>
        
        <category>PostgreSQL</category>
        
        <category>postgres</category>
        
        <category>jsonb</category>
        
        <category>generated</category>
        
        <category>columns</category>
        
        <category>indexing</category>
        
        <category>performance</category>
        
        
        <category>postgres</category>
        
      </item>
    
      <item>
        <title>Potential Consequences of Using Postgres as a Job Queue</title>
        <description>&lt;p&gt;&lt;em&gt;This post was originally published on the &lt;a href=&quot;https://techcommunity.microsoft.com/blog/adforpostgresql/potential-consequences-of-using-postgres-as-a-job-queue/4514332&quot;&gt;Microsoft Tech Community Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;At small scale, using Postgres as a job queue is totally fine, and I’d even say it’s the right call.  Fewer moving parts, one less system to manage, ACID guarantees on your jobs.  What’s not to love?&lt;/p&gt;

&lt;p&gt;The problem is that “small scale” has a ceiling, and the ceiling is lower than most people expect.  When you’ve got thousands of concurrent workers hammering a jobs table with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;, things start to behave in ways that aren’t obvious from the application layer.  CPU usage creeps up.  Also vacuum sometimes can’t keep up.  Finally, in the wait event stats, you start seeing ominous entries like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LWLock:MultiXactSLRU&lt;/code&gt; stacking up across many backends.&lt;/p&gt;

&lt;p&gt;This pattern has tripped up teams more than a few times, and it usually plays out the same way: everything works fine in dev and staging, then goes off a cliff in production once the concurrency gets real.  So let’s dig into why this happens, and what the alternatives look like.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-typical-pattern&quot;&gt;The Typical Pattern&lt;/h2&gt;

&lt;p&gt;When using Postgres as a job queue, the standard approach looks something like this:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job_queue&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;         &lt;span class=&quot;n&quot;&gt;bigserial&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;PRIMARY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;KEY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;     &lt;span class=&quot;nb&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DEFAULT&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;pending&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;jsonb&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timestamptz&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DEFAULT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;locked_by&lt;/span&gt;  &lt;span class=&quot;nb&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;locked_at&lt;/span&gt;  &lt;span class=&quot;n&quot;&gt;timestamptz&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idx_job_queue_status&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job_queue&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;pending&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Workers grab jobs with:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;UPDATE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job_queue&lt;/span&gt;
   &lt;span class=&quot;k&quot;&gt;SET&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;processing&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
       &lt;span class=&quot;n&quot;&gt;locked_by&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;worker-42&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
       &lt;span class=&quot;n&quot;&gt;locked_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
 &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
     &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job_queue&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;pending&apos;&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;FOR&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;UPDATE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SKIP&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;LOCKED&lt;/span&gt;
 &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
 &lt;span class=&quot;n&quot;&gt;RETURNING&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And then mark them done:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;UPDATE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job_queue&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;SET&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;completed&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Some users may &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DELETE&lt;/code&gt; the row entirely.  Either way, the lifecycle is: insert, lock-and-update, update-or-delete.  Repeated thousands of times per second.&lt;/p&gt;

&lt;p&gt;At low concurrency, this works very smoothly.  &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SKIP LOCKED&lt;/code&gt; means workers don’t block each other waiting for the same row.  Postgres handles the locking, visibility, and ordering.  It’s elegant.&lt;/p&gt;

&lt;p&gt;So where does it break?&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-multixact-slru-problem&quot;&gt;The MultiXact SLRU Problem&lt;/h2&gt;

&lt;p&gt;When multiple transactions hold locks on the same row, Postgres stores the set of lockers as a MultiXact ID – a pointer into a side structure under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_multixact/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;, users might think MultiXacts aren’t involved – after all, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SKIP LOCKED&lt;/code&gt; is supposed to avoid contention.  But in practice, with many concurrent workers all racing to lock rows, there are brief windows where multiple transactions reference the same row before one of them “wins” and the others skip.  If you combine this with any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FOR SHARE&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FOR KEY SHARE&lt;/code&gt; locks (which are commonly created implicitly by foreign key checks), MultiXact IDs start accumulating quickly.&lt;/p&gt;

&lt;p&gt;The MultiXact data lives in SLRU buffers (Simple Least Recently Used) – a small, fixed-size shared memory cache.  When backends need to read or write MultiXact data, they acquire LWLocks to access these buffers.  Under high concurrency, this becomes a bottleneck:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;wait_event_type | wait_event
-----------------+-------------------
LWLock          | MultiXactMemberSLRU
LWLock          | MultiXactOffsetSLRU
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You’ll see dozens or hundreds of backends piled up on these waits.  The SLRU cache is small (by design – it’s a fixed number of pages in shared memory), and when the working set of MultiXact lookups exceeds what fits in the cache, you get constant eviction and re-reads from disk.  Every lock acquisition and release on a job row potentially triggers a MultiXact SLRU lookup, and at thousands of concurrent sessions, those lookups serialize on LWLocks.&lt;/p&gt;

&lt;p&gt;The result: CPU gets pegged, throughput collapses, and latency spikes – not because the queries are expensive, but because the locking infrastructure itself is overwhelmed.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;bloat-the-silent-killer&quot;&gt;Bloat: The Silent Killer&lt;/h2&gt;

&lt;p&gt;The other side of this coin is table and index bloat.  Every job row goes through multiple updates (and possibly a delete), and each of those operations creates a new tuple version in the heap.  The old versions stick around until &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VACUUM&lt;/code&gt; cleans them up.&lt;/p&gt;

&lt;p&gt;On a busy job queue table:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Dead tuples accumulate faster than autovacuum can clean them.&lt;/strong&gt;  By the time autovacuum finishes one pass, tens of thousands of new dead tuples have appeared.  The table grows and grows.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Index bloat compounds the problem.&lt;/strong&gt;  Every index on the table also accumulates dead entries.  The partial index on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status = &apos;pending&apos;&lt;/code&gt; gets thrashed especially hard, since rows constantly enter and leave that condition.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Sequential scans get slower.&lt;/strong&gt;  As the table bloats, even index scans start doing more I/O because the heap pages are sparsely populated.  Vacuum reclaims space at the end of the table, but can’t reclaim space in the middle (unless the pages are completely empty).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Job queue tables can grow to tens of gigabytes when the actual “live” data was only a few megabytes.  It makes everything slower: scans, vacuum, even &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_dump&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can mitigate this by running vacuum more aggressively (lower &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autovacuum_vacuum_scale_factor&lt;/code&gt;, higher &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autovacuum_vacuum_cost_limit&lt;/code&gt;), or by partitioning the table and dropping old partitions.  But at some point, you’re fighting the fundamental mismatch between MVCC’s design goals and the write pattern of a job queue.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;cpu-and-lock-overhead&quot;&gt;CPU and Lock Overhead&lt;/h2&gt;

&lt;p&gt;Beyond the SLRU contention and bloat, there’s just the raw overhead of using Postgres’s full transactional machinery for what is essentially a FIFO dispatch operation:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Every lock/unlock is a full WAL-logged transaction.&lt;/strong&gt;  Grabbing a job writes WAL.  Marking it complete writes WAL.  Deleting it writes WAL.  On a system processing thousands of jobs per second, the WAL volume from the job queue alone can saturate your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wal_writer&lt;/code&gt; and checkpoint processes.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SKIP LOCKED&lt;/code&gt; still touches rows.&lt;/strong&gt;  The name suggests rows are skipped, but Postgres still has to &lt;em&gt;find&lt;/em&gt; them, check their lock status, and move on.  With high concurrency, many workers end up scanning past the same locked rows before finding one they can claim.  This is wasted CPU.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Snapshot management overhead also becomes an issue.&lt;/strong&gt;  Each transaction needs a consistent snapshot, and with thousands of concurrent transactions, the ProcArray (the structure that tracks active transactions) becomes a contention point itself.  You might see &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LWLock:ProcArrayLock&lt;/code&gt; waits alongside the MultiXact ones.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Vacuum contention.&lt;/strong&gt;  While vacuum is cleaning up dead tuples, it needs locks too.  On a table under constant write pressure, vacuum can interfere with the workers and vice versa.  I’ve seen systems where disabling autovacuum on the job queue table improved throughput in the short term.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;better-alternatives&quot;&gt;Better Alternatives&lt;/h2&gt;

&lt;p&gt;So what should you use instead?  It depends on your requirements, but there are several options that handle high-throughput job dispatch more gracefully than a Postgres table.&lt;/p&gt;

&lt;h3 id=&quot;advisory-locks-staying-in-postgres&quot;&gt;Advisory Locks (Staying in Postgres)&lt;/h3&gt;

&lt;p&gt;If you want to stay within Postgres and avoid adding infrastructure, advisory locks are worth considering for certain queue patterns.  Instead of locking rows, you lock on an abstract numeric key:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;-- Worker tries to acquire a lock on the job ID&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_try_advisory_lock&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job_queue&lt;/span&gt;
 &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;pending&apos;&lt;/span&gt;
 &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt;
 &lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Advisory locks are lightweight – they don’t touch the heap, don’t create MultiXact entries, and don’t generate dead tuples.  They live entirely in shared memory.  The trade-off is that you lose the atomicity of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;: you need to handle the case where a lock is acquired but the job processing fails, and you need to release the lock explicitly (or rely on session-end cleanup).&lt;/p&gt;

&lt;p&gt;This approach works well when the queue depth is manageable and you want to avoid the MVCC overhead.  But it’s still Postgres, so you’re still subject to connection limits, ProcArray overhead, and general resource contention at very high session counts.&lt;/p&gt;

&lt;h3 id=&quot;pgq-skytools&quot;&gt;pgq (Skytools)&lt;/h3&gt;

&lt;p&gt;pgq is purpose-built for exactly this problem.  It’s a queue implementation that sits inside Postgres but uses a batching model that avoids most of the row-level locking and MVCC pitfalls.  Events are written to a queue table, but consumers read them in batches and the queue maintenance is done via a ticker process that manages rotation.&lt;/p&gt;

&lt;p&gt;The key advantages:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;No row-level contention.  Consumers don’t lock individual rows.&lt;/li&gt;
  &lt;li&gt;Built-in batch processing.  Events are consumed in chunks, reducing transaction overhead.&lt;/li&gt;
  &lt;li&gt;Efficient cleanup.  Old events are rotated out rather than vacuumed row-by-row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The downside is that pgq is not as actively maintained as it once was, and it adds operational complexity (the ticker daemon, consumer registration, etc.).  But for teams already deep in the Postgres ecosystem, it’s a battle-tested option.&lt;/p&gt;

&lt;h3 id=&quot;pgque&quot;&gt;PgQue&lt;/h3&gt;

&lt;p&gt;Coincidentally, during the writing of this post, &lt;a href=&quot;https://github.com/NikolayS/pgque&quot;&gt;Nikolay Samokhvalov has built PgQue&lt;/a&gt;, which is a derivative of pgq.  Like pgq, it sits inside Postgres, but ships as a single SQL file – no C extension and no external daemon – making it deployable on managed services like RDS, Aurora, Cloud SQL, AlloyDB, Supabase, and Neon.  Producers &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;INSERT&lt;/code&gt; events into rotating event tables (recycled via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TRUNCATE&lt;/code&gt; instead of row-by-row deletion), and consumers read batches by diffing two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_snapshot&lt;/code&gt; values captured by a periodic ticker – so the hot path contains zero &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UPDATE&lt;/code&gt;s, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DELETE&lt;/code&gt;s, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;, and therefore produces no dead tuples on the event tables.  For a deeper dive into the algorithm, see &lt;a href=&quot;https://thebuild.com/blog/2026/05/03/pgque-two-snapshots-and-a-diff/&quot;&gt;Christophe Pettus’s writeup&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;redis&quot;&gt;Redis&lt;/h3&gt;

&lt;p&gt;For many teams, Redis is the natural choice for job queues.  Using Redis lists (BRPOPLPUSH or the Streams API), you get:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Sub-millisecond dispatch latency.  No disk I/O, no MVCC, no vacuum.&lt;/li&gt;
  &lt;li&gt;Atomic pop operations.  Workers grab jobs without any locking protocol.&lt;/li&gt;
  &lt;li&gt;Simple scaling.  Redis handles thousands of concurrent consumers trivially.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off is durability.  Redis can persist to disk, but it’s not ACID.  If Redis crashes between a pop and the job completing, you might lose or duplicate work (though Redis Streams with consumer groups mitigate this significantly).  For most job queue use cases, at-least-once delivery is acceptable, and Redis does that well.&lt;/p&gt;

&lt;h3 id=&quot;kafka&quot;&gt;Kafka&lt;/h3&gt;

&lt;p&gt;For truly high-throughput, distributed workloads, Apache Kafka is the heavyweight option.  Kafka partitions give you parallel consumption with ordering guarantees per partition, durable storage, and replay capability.  It’s the right tool when:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;You need to process thousands of events per second&lt;/li&gt;
  &lt;li&gt;Multiple consumers need to read the same events&lt;/li&gt;
  &lt;li&gt;You want event replay or audit trails&lt;/li&gt;
  &lt;li&gt;Your architecture is already event-driven&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The operational overhead is nontrivial – ZooKeeper (or KRaft), brokers, topic management, consumer group coordination.  But for teams already running Kafka for other reasons, adding a job queue topic is practically free.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;choosing-the-right-tool&quot;&gt;Choosing the Right Tool&lt;/h2&gt;

&lt;p&gt;Here’s a rough decision guide:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Scenario&lt;/th&gt;
      &lt;th&gt;Recommendation&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Under 100 concurrent workers, simple jobs&lt;/td&gt;
      &lt;td&gt;Postgres with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SKIP LOCKED&lt;/code&gt; is fine&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Moderate concurrency, want to stay in Postgres&lt;/td&gt;
      &lt;td&gt;Advisory locks or pgq&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;High throughput, low-latency dispatch&lt;/td&gt;
      &lt;td&gt;Redis (Lists or Streams)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Massive scale, distributed, event replay&lt;/td&gt;
      &lt;td&gt;Kafka&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Many teams that start with Postgres (reasonably) hit scaling problems and then try to fix Postgres rather than recognizing that the workload has outgrown the tool.  They throw more autovacuum workers at it, increase &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max_connections&lt;/code&gt;, add connection poolers – all of which help at the margins, but don’t address the fundamental issue: Postgres’s MVCC and locking machinery wasn’t designed for this access pattern at high concurrency.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Postgres is great, but it can’t be the best tool for every job.  Using it as a job queue is a perfectly valid choice when your scale is modest.  But when you’re running thousands of concurrent workers, the combination of MultiXact SLRU contention, heap bloat, vacuum pressure, and raw locking overhead will eventually push you toward a purpose-built solution.&lt;/p&gt;

&lt;p&gt;The good news is that you don’t have to rip out everything.  Advisory locks can buy you headroom without adding infrastructure.  Redis can handle dispatch while Postgres keeps owning the data.  And if you’re already using Kafka, a job topic is a natural fit.  Take your pick – there are many queueing options out there!&lt;/p&gt;
</description>
        <pubDate>Mon, 04 May 2026 06:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/05/04/postgres_job_queue.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/05/04/postgres_job_queue.html</guid>
        
        <category>PostgreSQL</category>
        
        <category>postgres</category>
        
        <category>performance</category>
        
        <category>scaling</category>
        
        <category>job-queue</category>
        
        <category>multixact</category>
        
        <category>lwlock</category>
        
        <category>advisory-locks</category>
        
        <category>redis</category>
        
        <category>kafka</category>
        
        <category>pgq</category>
        
        
        <category>postgres</category>
        
      </item>
    
      <item>
        <title>Understanding Bitmap Heap Scans in PostgreSQL</title>
        <description>&lt;h1 id=&quot;introduction&quot;&gt;Introduction&lt;/h1&gt;

&lt;p&gt;When people first start reading PostgreSQL execution plans, they quickly learn a few common scan types: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Seq Scan&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Index Scan&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Index Only Scan&lt;/code&gt;.  But eventually another one appears that is less obvious: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bitmap Heap Scan&lt;/code&gt;, which is almost always accompanied by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bitmap Index Scan&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At first glance, it sounds like two scans on the same table – a very inefficient choice?! But bitmap scans are actually one of the planner’s most practical tools for balancing random I/O vs sequential access.  Understanding how they work can make execution plans much easier to interpret, so we’ll dive into that a little bit today.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;the-basic-idea&quot;&gt;The Basic Idea&lt;/h1&gt;

&lt;p&gt;A bitmap scan is a two-step process:&lt;/p&gt;

&lt;p&gt;Step 1: Build a bitmap of matching rows using one or more indexes.&lt;/p&gt;

&lt;p&gt;Step 2: Visit the heap pages containing those rows referenced in the bitmap.&lt;/p&gt;

&lt;p&gt;In an execution plan this usually appears as:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bitmap Heap Scan on orders
-&amp;gt; Bitmap Index Scan on orders_customer_id_idx
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The important part is that the index lookup and heap access are separated – this separation allows Postgres to explain heap access costs and actuals more clearly.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;why-not-just-use-an-index-scan&quot;&gt;Why Not Just Use an Index Scan?&lt;/h1&gt;

&lt;p&gt;With a normal index scan, the query executor does something like this:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Find a matching entry in the index&lt;/li&gt;
  &lt;li&gt;Jump to the heap page&lt;/li&gt;
  &lt;li&gt;Fetch the row&lt;/li&gt;
  &lt;li&gt;Repeat&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the query returns only a few rows, this works well.  But if the query returns thousands of rows scattered across the table, the database ends up doing many random heap fetches.  Random I/O can become expensive, so a bitmap scan solves this problem.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;how-the-bitmap-is-built&quot;&gt;How the Bitmap Is Built&lt;/h1&gt;

&lt;p&gt;During the Bitmap Index Scan phase, the executor does not immediately fetch rows.  Instead it records which heap pages contain matching rows.  Conceptually, the structure looks like this:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Page 101 -&amp;gt; rows 2, 7
Page 205 -&amp;gt; rows 1, 3, 8
Page 410 -&amp;gt; row 5
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;These page references are stored as a bitmap structure in memory.  Once the bitmap is complete, the executor can visit heap pages in physical order rather than jumping around randomly.  Visiting heap pages in physical order means less random I/O and therefore less latency.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;multiple-indexes-can-be-combined&quot;&gt;Multiple Indexes Can Be Combined&lt;/h1&gt;

&lt;p&gt;One particularly powerful feature is that bitmap scans allow the query planner to combine multiple indexes.  For example:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;WHERE status = &apos;active&apos;
AND created_at &amp;gt;= &apos;2025-01-01&apos;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The plan might look like:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bitmap Heap Scan
-&amp;gt; BitmapAnd
-&amp;gt; Bitmap Index Scan on status_idx
-&amp;gt; Bitmap Index Scan on created_at_idx
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Each index produces a bitmap, and the planner combines them using logical operations, such as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BitmapAnd&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BitmapOr&lt;/code&gt;.  This allows the planner to efficiently use multiple indexes even when a single composite index does not exist.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;when-does-the-planner-chooses-bitmap-scans&quot;&gt;When Does the Planner Chooses Bitmap Scans?&lt;/h1&gt;

&lt;p&gt;The planner usually prefers bitmap scans in situations where the query returns more rows than a typical index scan, but not enough rows to justify a full sequential scan.  In other words, bitmap scans often appear in the middle selectivity range.&lt;/p&gt;

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

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Selectivity&lt;/th&gt;
      &lt;th&gt;Likely Plan&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Very small&lt;/td&gt;
      &lt;td&gt;Index Scan&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Medium&lt;/td&gt;
      &lt;td&gt;Bitmap Heap Scan&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Very large&lt;/td&gt;
      &lt;td&gt;Seq Scan&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;This is not a strict rule, but it helps explain the planner’s reasoning.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;pros-and-cons&quot;&gt;Pros and Cons&lt;/h1&gt;

&lt;p&gt;As with everything in databases, there’s no free lunch.  Here are some advantages and disadvantages for bitmap scans&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Advantages of Bitmap Heap Scans
    &lt;ul&gt;
      &lt;li&gt;Reduced Random I/O: By grouping heap page accesses, bitmap scans avoid excessive random disk reads.&lt;/li&gt;
      &lt;li&gt;Ability to Combine Indexes: Bitmap operations allow the query planner to use multiple independent indexes efficiently.&lt;/li&gt;
      &lt;li&gt;Better Performance for Medium Selectivity: Queries returning thousands of rows often benefit from bitmap access patterns.&lt;/li&gt;
      &lt;li&gt;Predictable Heap Access: Because heap pages are visited in order, caching behavior tends to improve.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;Disadvantages of Bitmap Heap Scans
    &lt;ul&gt;
      &lt;li&gt;Memory Usage: The bitmap structure is stored in memory.  If the result set becomes too large, the query executor may switch to a lossy bitmap, where only page-level information is stored.  This can cause additional filtering work later.&lt;/li&gt;
      &lt;li&gt;Two-Phase Execution: Because the bitmap must be built before heap access begins, the query cannot stream rows immediately.  This can increase latency for queries expecting early rows.&lt;/li&gt;
      &lt;li&gt;Extra CPU Work: Maintaining and combining bitmap structures adds overhead compared to simple index scans.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;lossy-bitmaps&quot;&gt;Lossy Bitmaps&lt;/h1&gt;

&lt;p&gt;When memory limits are reached, the query executor may degrade the bitmap representation.  Instead of tracking individual tuple offsets, it only records:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Page 205 -&amp;gt; possible matches
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;During the heap scan, the executor must then recheck all rows on that page.  In execution plans you may see mention of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Recheck Cond&lt;/code&gt;.  This indicates that the bitmap became lossy.  While still correct, this can reduce efficiency.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h1&gt;

&lt;p&gt;Bitmap heap scans are one of the planner’s most practical optimization tools, as they allow the database to reduce random I/O, combine multiple indexes, and handle medium-sized result sets efficiently.&lt;/p&gt;

&lt;p&gt;While they may look complicated at first, the core idea is simple: Find matching rows first, then fetch heap pages efficiently.  What a great concept!&lt;/p&gt;
</description>
        <pubDate>Mon, 27 Apr 2026 08:00:00 +0000</pubDate>
        <link>http://richyen.com/postgres/2026/04/27/bitmap_heap_scan.html</link>
        <guid isPermaLink="true">http://richyen.com/postgres/2026/04/27/bitmap_heap_scan.html</guid>
        
        <category>PostgreSQL</category>
        
        <category>postgres</category>
        
        <category>performance</category>
        
        <category>query-planner</category>
        
        <category>indexing</category>
        
        
        <category>postgres</category>
        
      </item>
    
  </channel>
</rss>
