<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>boringSQL | Supercharge your SQL &amp; PostgreSQL powers - ai</title>
    <subtitle>Learn practical SQL &amp; PostgreSQL techniques. Build rock-solid data systems with &#x27;boring&#x27; database solutions that deliver reliability without the drama.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://boringsql.com/tags/ai/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://boringsql.com"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-08-15T17:30:00+00:00</updated>
    <id>https://boringsql.com/tags/ai/atom.xml</id>
    <entry xml:lang="en">
        <title>The curious case of Google&#x27;s AlloyDB</title>
        <published>2026-08-15T17:30:00+00:00</published>
        <updated>2026-08-15T17:30:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/google-alloydb/"/>
        <id>https://boringsql.com/posts/google-alloydb/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/google-alloydb/">&lt;p&gt;Google launched AlloyDB in 2022. They claimed it is fully compatible with PostgreSQL. Can be up to 100 times faster for analytical queries than vanilla Postgres. Four years later, I haven&#x27;t personally seen it gain significant traction. But it comes in discussions. When people ask me what AlloyDB actually is, I was able to pin point the features, but wasn&#x27;t really sure what it delivers.&lt;&#x2F;p&gt;
&lt;p&gt;Over the past 12 months, I’ve evaluated AlloyDB. This article shares my key findings. I tried to keep it as objective as the topic allows, and where it isn&#x27;t, the text says so. I won&#x27;t pretend to be objective about the verdict. It depends less on one feature and more on where compatibility ends. And as it goes, &quot;it depends&quot; a lot on your workload and needs.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s start with the first claim. What does &quot;fully compatible&quot; mean? In this case it covers the wire protocol. Your &lt;code&gt;psql&lt;&#x2F;code&gt; connects, ORMs work, migration means changing connection string and you are done. What it does not cover is nearly everything you know about the Postgres storage internals and query executor. The 8KB pages that define storage layout for vanilla engine, are no longer the durable representation of your data. The WAL is no longer a recovery mechanism. It becomes the database. VACUUM is there, but runs inside storage layer you don&#x27;t know and and schedule you can&#x27;t control. And the tuning options differ from what you are be used to.&lt;&#x2F;p&gt;
&lt;p&gt;This creates the curious case. The compatibility claim is true, and the engineering behind AlloyDB backs it up. It&#x27;s just narrower than the word &quot;fully&quot; migth suggest. AlloyDB is a different database behind the PostgreSQL protocol.&lt;&#x2F;p&gt;
&lt;p&gt;AlloyDB’s compatibility claim holds at the wire protocol, but the underlying engine diverges immediately at the storage layer.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Gartner defines HTAP, or hybrid transaction&#x2F;analytical processing, as a new application architecture. It &quot;breaks the wall&quot; between transaction processing and analytics. This allows for better decision-making and real-time insights in business.&lt;&#x2F;div&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;The short version&lt;&#x2F;strong&gt; for the impatient. AlloyDB is not the database you reach for to host a side project or run a small app cheaply. It’s an enterprise HTAP engine for teams using GCP or looking to expand there. It handles both transactional and analytical workloads on one dataset. Usually, there&#x27;s little to no ETL needed, which means less data copying between an OLTP store and a warehouse. It discusses PostgreSQL&#x27;s wire protocol. The engineering behind it is solid. However, every feature comes at a cost-both in money and in how much existing PostgreSQL knowledge you can use. The economics aren&#x27;t static, though. It changes increasingly in favor AlloyDB as the data grows and peak demand increases.&lt;&#x2F;div&gt;
&lt;p&gt;What I&#x27;d verify before betting a migration on it:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Whether your extensions survive the move. The allowlist differs between managed AlloyDB and Omni in ways you won&#x27;t guess (&lt;code&gt;plv8&lt;&#x2F;code&gt; and &lt;code&gt;postgis&lt;&#x2F;code&gt; trade places); &lt;code&gt;timescaledb&lt;&#x2F;code&gt; and pgrx are on neither.&lt;&#x2F;li&gt;
&lt;li&gt;Whether the columnar store is actually resident before you trust a benchmark number, yours or mine. It can be enabled and silently empty.&lt;&#x2F;li&gt;
&lt;li&gt;The read pool&#x27;s economics for &lt;em&gt;your&lt;&#x2F;em&gt; read shape. Bursty business-hours reads and flat around-the-clock reads land on opposite sides of the break-even.&lt;&#x2F;li&gt;
&lt;li&gt;Write contention at your scale. My own run inverted between SF10 and SF100, and not in the direction the pitch suggests.&lt;&#x2F;li&gt;
&lt;li&gt;ScaNN recall at your vector count.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;the-log-is-the-database&quot;&gt;The log is the database&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-log-is-the-database&quot; aria-label=&quot;Anchor link for: the-log-is-the-database&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;In standard PostgreSQL the query engine and the storage engine run as a single process, and that process owns its storage. It writes 8KB pages to disk and writes WAL records so that the system can reconstruct those pages after a crash. The pages hold the data, the log is there to repair them, and under normal operation the compute node pays for both.&lt;&#x2F;p&gt;
&lt;p&gt;AlloyDB inverts this. The compute node never writes full database pages to durable storage. Instead, it sends Write-Ahead Log (WAL) records to a distributed storage layer built on top of Colossus, Google&#x27;s cluster file system. In this storage layer, a dedicated Log Processing Service (LPS) processes log records. It updates database pages asynchronously. When processing queries, the compute node fetches these pages as needed into its memory buffer pool. It also utilizes an ultra-fast local SSD block cache to cut reads from durable storage and accelerate page retrieval. Ultimately, the durable representation of your data is the log itself, and the data pages are derived from it without interruption.&lt;&#x2F;p&gt;
&lt;p&gt;This format is proprietary and not open for independent inspection. In contrast, PostgreSQL&#x27;s on-disk format is something I typically examine byte by byte here on the blog. The account that follows therefore describes the architecture as Google documents it.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;If you understand how PostgreSQL recovery works, take a moment to reflect on it. AlloyDB runs, permanently, in the mode a PostgreSQL hot standby lives in, a log replayed into pages. Except here it is the primary&#x27;s write path, there is no other copy of the pages anywhere, and the replay happens on dedicated infrastructure.&lt;&#x2F;div&gt;
&lt;p&gt;Five consequences follow; Google&#x27;s marketing covers four of them.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Failover stops being a replay problem&lt;&#x2F;strong&gt;. In standard PostgreSQL, a promoted standby needs to finish replaying WAL before it can accept writes. The time this takes depends on how far behind it was. In AlloyDB there is no deviation to replay, because the storage is shared. A replacement compute node attaches to the same storage layer and picks up where the old one left off.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Read replicas are no longer copies&lt;&#x2F;strong&gt;. AlloyDB&#x27;s &quot;read pool&quot; nodes are extra compute nodes attached to the same distributed storage as the primary. They still consume a log stream, and still flush and replay it, but they don&#x27;t depend on replaying it to access the data.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Storage management shifts to Google too&lt;&#x2F;strong&gt;. As the storage layer grows on its own, issues like resizing windows and disk-full incidents disappear.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The write path drops full-page writes&lt;&#x2F;strong&gt;. Standard PostgreSQL protects against torn pages. It writes the full 8KB page into the WAL the first time it’s accessed after each checkpoint. &lt;code&gt;full_page_writes&lt;&#x2F;code&gt; is on by default for a good reason. It’s a well-known source of WAL volume. When a workload has small scattered updates, it can use more log space for full-page images than for the row changes themselves. AlloyDB never takes on this overhead. The storage layer replays log records into pages and protects against torn pages. So, the compute node doesn&#x27;t checkpoint or send a full-page image. It only ships change records.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Aurora PostgreSQL disaggregates storage too, and the two get lumped together. The resemblance is real but shallow. Aurora ships redo records to six replicated storage nodes that apply them on their own. AlloyDB, on the other hand, uses a log-structured format on Colossus. It separates log processing from block serving tasks. Neither is open source, so the comparison stops at the whitepapers.&lt;&#x2F;div&gt;
&lt;p&gt;The fifth consequence is the one the marketing leaves out.  &lt;strong&gt;You can no longer look at your own storage.&lt;&#x2F;strong&gt; There is no &lt;code&gt;pageinspect&lt;&#x2F;code&gt; equivalent for AlloyDB&#x27;s durable format. You can check the buffer pool, which holds pages in the compute-node RAM. However, the format used is proprietary and unclear. If you&#x27;ve ever walked through &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-mvcc-byte-by-byte&#x2F;&quot;&gt;MVCC headers byte by byte&lt;&#x2F;a&gt; to solve a bloat issue or check what an UPDATE did, you can’t do that now. For many users this is an acceptable trade: they lose page-level visibility they probably never exercised and gain a managed storage layer.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Azure&#x27;s HorizonDB has been in preview since late 2025. It uses a log-is-the-database method from another cloud. This storage inversion is now the standard style. It stays OLTP-only and mirrors into Fabric for analytics, though, so the columnar half of this post has no equivalent there.&lt;&#x2F;div&gt;
&lt;h2 id=&quot;vacuum-rescheduled&quot;&gt;Vacuum, rescheduled&lt;a class=&quot;zola-anchor&quot; href=&quot;#vacuum-rescheduled&quot; aria-label=&quot;Anchor link for: vacuum-rescheduled&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Google&#x27;s marketing barely mentions the consequence I went looking for first. VACUUM is a common topic for Postgres user and frequently covered on this blog. We’ve discussed everything from &lt;a href=&quot;&#x2F;posts&#x2F;vacuum-is-lie&#x2F;&quot;&gt;the lie in its name&lt;&#x2F;a&gt; to &lt;a href=&quot;&#x2F;posts&#x2F;vacuum-at-the-page-level&#x2F;&quot;&gt;what it does page by page&lt;&#x2F;a&gt;. My first question was: what happens to it when storage moves off the compute node?&lt;&#x2F;p&gt;
&lt;p&gt;In standard PostgreSQL, VACUUM is the compute node&#x27;s burden. Dead tuples build up in heap pages. Autovacuum workers scan the heap, remove them, and update the visibility map. They also compete with your queries for I&#x2F;O during this process.&lt;&#x2F;p&gt;
&lt;p&gt;A whole subculture revolves around this:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;autovacuum_vacuum_scale_factor&lt;&#x2F;li&gt;
&lt;li&gt;Cost-delay tuning&lt;&#x2F;li&gt;
&lt;li&gt;Monitoring pg_stat_user_tables.n_dead_tup&lt;&#x2F;li&gt;
&lt;li&gt;The issue of a long-running transaction that held back the xmin horizon, causing a table to double in size.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Going in, I expected vacuum to be the thing the storage layer absorbed. If the log is the database and pages are materialized on Google&#x27;s side, dead tuples should be Google&#x27;s problem. The stats say otherwise. The compute node uses the standard PostgreSQL executor with a standard buffer pool. So, &lt;code&gt;UPDATE&lt;&#x2F;code&gt; and &lt;code&gt;DELETE&lt;&#x2F;code&gt; leave dead tuples in the heap pages that the node reads. The tuples need pruning. The visibility map and free space map must be maintained. Also, rows need to be frozen before wraparound. That work still runs on the compute node. The storage layer sees every log record. It maintains versions of the blocks created from the log. Some cleanup happens outside the compute node, but the compute-side job remains unchanged. What Google actually replaced is the scheduler.&lt;&#x2F;p&gt;
&lt;p&gt;AlloyDB&#x27;s adaptive autovacuum changes community autovacuum&#x27;s fixed thresholds. It features a smart controller that boosts worker count when idle and reduces it when busy. This approach targets large table fragments instead of whole tables. It also slows down transaction-ID use if a wraparound horizon is near. Plus, it spots problems that block vacuum progress, like long-running transactions and orphaned prepared transactions. If a backend&#x27;s XID age crosses a set limit, it logs a warning. That last part only diagnoses the problem. The benchmark below checks this: if the compute node still vacuums, then &lt;code&gt;autovacuum_count&lt;&#x2F;code&gt; should increase. Also, &lt;code&gt;n_dead_tup&lt;&#x2F;code&gt; should show a saw-tooth pattern.&lt;&#x2F;p&gt;
&lt;p&gt;This is verifiable from the outside, and you should verify it. Run a workload with many updates, like pgbench&#x27;s default mix. Aim for a few hundred TPS for one hour. Test standard PostgreSQL and AlloyDB. Then, we can compare the results.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; relname, n_dead_tup, n_live_tup,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       autovacuum_count, last_autovacuum&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stat_user_tables&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; relname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pgbench_accounts&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;On standard PostgreSQL you&#x27;ll watch &lt;code&gt;n_dead_tup&lt;&#x2F;code&gt; saw-tooth, climbing between autovacuum passes and dropping after each one. AlloyDB does the same. Churning a one-million-row table with full-table &lt;code&gt;UPDATE&lt;&#x2F;code&gt;s on AlloyDB makes &lt;code&gt;n_dead_tup&lt;&#x2F;code&gt; rise to one to three million, then it drops. Meanwhile, &lt;code&gt;autovacuum_count&lt;&#x2F;code&gt; increases with each pass, seven total in seven minutes. The identical workload on Cloud SQL produces the same shape and seven passes. There is no flatline and no vacuum-free steady state. The compute node is running autovacuum, exactly as the stock engine does, and what AlloyDB changed is when it runs, not whether. (Both time series are in the repo.)&lt;&#x2F;p&gt;
&lt;p&gt;Managed AlloyDB supports &lt;code&gt;pg_squeeze&lt;&#x2F;code&gt; with the &lt;code&gt;alloydb.enable_pg_squeeze&lt;&#x2F;code&gt; flag. This means that when bloat builds up, you can perform an online, low-lock rebuild. It works like &lt;code&gt;pg_repack&lt;&#x2F;code&gt; in stock PostgreSQL, so no VACUUM FULL exclusive lock is needed.&lt;&#x2F;p&gt;
&lt;p&gt;Operationally this is a genuine win with one string attached. The win is that a recurring production failure mode (an instance accumulating vacuum debt faster than its autovacuum can clear it) is off your pager. The standard PostgreSQL vacuum rules now work differently or not at all. Any monitoring based on dead-tuple counts and autovacuum timing is tracking a process that Google controls. Alert thresholds calibrated against community autovacuum&#x27;s fixed scale factors will fire on a schedule that no longer means what they were tuned for.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Transaction ID wraparound doesn’t vanish just because storage is disaggregated. The other job, VACUUM, still handles it. AlloyDB still has 32-bit xids on the compute node, and its answer is documented. Adaptive autovacuum checks XID use against vacuum progress. When wraparound risk increases, it limits transaction-ID use and boosts vacuuming resources. That is wraparound protection, not wraparound elimination. Freezing affects how the storage layer cleans up, but the opaque format makes it hard for you to find the answer.&lt;&#x2F;div&gt;
&lt;h2 id=&quot;the-in-memory-columnar-store&quot;&gt;The in-memory columnar store&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-in-memory-columnar-store&quot; aria-label=&quot;Anchor link for: the-in-memory-columnar-store&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;PostgreSQL stores rows. Every heap page packs complete tuples, all columns of a row adjacent on the page. For OLTP this is the right call, since fetching a row is one page read. For analytics it is the wrong one. Computing &lt;code&gt;SUM(l_extendedprice * (1 - l_discount))&lt;&#x2F;code&gt; for 60 million &lt;code&gt;lineitem&lt;&#x2F;code&gt; rows requires reading all 60 million full 16-column tuples. However, only two columns are needed for the calculation. The &lt;a href=&quot;&#x2F;posts&#x2F;explain-buffers&#x2F;&quot;&gt;buffer numbers in your EXPLAIN output&lt;&#x2F;a&gt; tell that story every time.&lt;&#x2F;p&gt;
&lt;p&gt;AlloyDB uses an in-memory columnar store. This is a special memory area next to the buffer pool. It keeps certain columns from specific tables in columnar format. Plus, it supports vectorized execution. It is not a separate analytical database; there is no ETL, no second cluster, no sync job to babysit. The planner sees it as another access path. It picks between heap scan and columnar scan based on cost, just like it decides between sequential scan and index.&lt;&#x2F;p&gt;
&lt;p&gt;The columnar engine is not an extension. It is compiled into the AlloyDB binary; you cannot install it on a standard PostgreSQL installation, and you cannot remove it from AlloyDB.&lt;&#x2F;p&gt;
&lt;p&gt;The engine is observable through &lt;code&gt;g_columnar_&lt;&#x2F;code&gt;-namespaced relations. This is what the store held during the scale-factor-100 run, after the columns the query set touches were pinned (the full per-column dump is in the repo):&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- What&amp;#39;s resident in the columnar store, by table&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; relation_name, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; columns, pg_size_pretty(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;sum&lt;&#x2F;span&gt;&lt;span&gt;(size_in_bytes))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; g_columnar_columns&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;&#x2F;span&gt;&lt;span&gt; relation_name &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(size_in_bytes) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DESC&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; relation_name | columns | pg_size_pretty&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------+---------+----------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lineitem      |      13 | 25 GB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; orders        |       6 | 5289 MB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; part          |       2 | 172 MB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; customer      |       2 | 129 MB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; supplier      |       2 | 8796 kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; nation        |       2 | 954 bytes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; region        |       1 | 417 bytes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Population can happen automatically. A background process looks at query patterns to decide which columns need memory. It can also be done explicitly.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; google_columnar_engine_add(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;  &amp;#39;lineitem&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;  &amp;#39;l_quantity,l_extendedprice,l_discount,l_shipdate,l_returnflag,l_linestatus&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Automatic population has a failure mode worth knowing, because it fails quietly, and I found it the hard way. The recommendation function uses observed columnar query history to decide what to cache. On a newly enabled engine or immediately after a restart, that history is empty, so the function may recommend nothing. The store stays empty, every plan falls back to a heap scan, and the queries run at full row-store speed with no error and no warning. The columnar engine is on, and it is doing nothing. The tell shows an empty &lt;code&gt;g_columnar_columns&lt;&#x2F;code&gt; and a &lt;code&gt;Seq Scan&lt;&#x2F;code&gt;, instead of the expected &lt;code&gt;Custom Scan (columnar scan)&lt;&#x2F;code&gt;. Explicit &lt;code&gt;google_columnar_engine_add&lt;&#x2F;code&gt; sidesteps it. Either way, verify the columns are resident before you trust a number. I didn&#x27;t on one of the early SF100 runs, and that run measured nothing.&lt;&#x2F;p&gt;
&lt;p&gt;When the planner picks the columnar path, the plan says so, and the buffer numbers explain why it&#x27;s faster. Here is TPC-H Q6, which is the revenue query. It performs a selective scan of the &lt;code&gt;lineitem&lt;&#x2F;code&gt; table, accessing four columns. This is tested at a scale factor of 100, with about 600 million rows, on AlloyDB Omni. The tests were done with the columnar store both off and on. Same box, same query. Trimmed to the lines that matter, with &lt;code&gt;cost=&lt;&#x2F;code&gt; estimates and the filter predicates elided. These are the p90 runs, while the table below reports medians. Full plans are in the repo.&lt;&#x2F;p&gt;
&lt;p&gt;Columnar off, the row store:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Finalize Aggregate  (actual time=182546.439..182546.691 rows=1 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Buffers: shared hit=2560 read=11549743&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   I&#x2F;O Timings: shared read=814349.441&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Gather  (actual time=182546.333..182546.679 rows=5 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Workers Planned: 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Workers Launched: 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Partial Aggregate  (actual time=182545.107..182545.108 rows=1 loops=5)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               -&amp;gt;  Parallel Seq Scan on lineitem  (actual time=0.690..181854.379 rows=2284274 loops=5)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Rows Removed by Filter: 117723307&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Buffers: shared hit=2560 read=11549743&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     I&#x2F;O Timings: shared read=814349.441&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Execution Time: 182546.776 ms&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Columnar on:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Finalize Aggregate  (actual time=594.954..595.135 rows=1 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Gather  (actual time=594.058..595.125 rows=5 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Workers Planned: 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Workers Launched: 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Partial Aggregate  (actual time=592.583..592.585 rows=1 loops=5)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               -&amp;gt;  Parallel Append  (actual time=0.187..592.573 rows=564 loops=5)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     -&amp;gt;  Parallel Custom Scan (columnar scan) on lineitem  (actual time=0.186..592.569 rows=564 loops=5)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                           Rows Removed by Columnar Filter: 117723307&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                           Rows Aggregated by Columnar Scan: 500216&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                           Columnar cache search mode: native&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     -&amp;gt;  Parallel Seq Scan on lineitem  (never executed)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Execution Time: 595.220 ms&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;182 seconds to 0.6 seconds, a 307x drop on this run and 311x at the table&#x27;s median below, and the two lines that carry it are &lt;code&gt;Buffers: shared read=11549743&lt;&#x2F;code&gt; and &lt;code&gt;I&#x2F;O Timings: shared read=814349&lt;&#x2F;code&gt;. The row store reads 11.5 million pages off disk, 814 seconds of I&#x2F;O summed across four workers and the leader, to filter out about 51 of every 52 rows. The columnar scan reads no heap pages at all. &lt;code&gt;Columnar cache search mode: native&lt;&#x2F;code&gt; means it answered entirely from the in-memory store, and the &lt;code&gt;Parallel Seq Scan on lineitem (never executed)&lt;&#x2F;code&gt; branch underneath it is the proof: the planner kept a heap path available and never touched it. The mechanism is the elimination of I&#x2F;O: a heap scan of a 16-column table reads whole pages containing complete rows, then discards the unneeded column values; the columnar scan never loads those bytes. At 600 million rows the row store no longer fits in the buffer pool, so that discarded work becomes disk work.&lt;&#x2F;p&gt;
&lt;p&gt;One caveat on this pair, because it is not a single-variable experiment. To hold those columns, C1 had about 31 GB of columnar store on top of its 16 GB buffer pool, while the row-store run got the 16 GB and nothing else. So this measures the engine and roughly three times more memory over the hot data together. The two cannot be cleanly separated, because making the hot set fit is the mechanism: the heap here is about 100 GB, so no buffer pool on a 64 GB box closes that gap. The SF10 comparison further down has no such confound.&lt;&#x2F;p&gt;
&lt;p&gt;Q6 is the best case, not the general one. Across the query set at scale factor 100, the same columnar store on the same box produced these results:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;query&lt;&#x2F;th&gt;&lt;th&gt;shape&lt;&#x2F;th&gt;&lt;th&gt;row store&lt;&#x2F;th&gt;&lt;th&gt;columnar&lt;&#x2F;th&gt;&lt;th&gt;speedup&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Q6&lt;&#x2F;td&gt;&lt;td&gt;selective scan, few columns&lt;&#x2F;td&gt;&lt;td&gt;182,517 ms&lt;&#x2F;td&gt;&lt;td&gt;587 ms&lt;&#x2F;td&gt;&lt;td&gt;311x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Q5&lt;&#x2F;td&gt;&lt;td&gt;six-table join&lt;&#x2F;td&gt;&lt;td&gt;242,559 ms&lt;&#x2F;td&gt;&lt;td&gt;24,688 ms&lt;&#x2F;td&gt;&lt;td&gt;9.8x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Q12&lt;&#x2F;td&gt;&lt;td&gt;join plus date filter&lt;&#x2F;td&gt;&lt;td&gt;201,411 ms&lt;&#x2F;td&gt;&lt;td&gt;41,708 ms&lt;&#x2F;td&gt;&lt;td&gt;4.8x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Q1&lt;&#x2F;td&gt;&lt;td&gt;full-table numeric aggregate&lt;&#x2F;td&gt;&lt;td&gt;186,377 ms&lt;&#x2F;td&gt;&lt;td&gt;94,195 ms&lt;&#x2F;td&gt;&lt;td&gt;2.0x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;point lookup&lt;&#x2F;td&gt;&lt;td&gt;single row by key&lt;&#x2F;td&gt;&lt;td&gt;0.15 ms&lt;&#x2F;td&gt;&lt;td&gt;0.11 ms&lt;&#x2F;td&gt;&lt;td&gt;none&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;A selective scan that reads some columns and ignores most rows is very effective. A join is also quite beneficial. A full-table numeric aggregate is somewhat useful, as the math outweighs the I&#x2F;O. The single-row lookup is the control. The planner takes the b-tree and the columnar store never enters the plan, exactly as it should. So &quot;up to 100x&quot; is a number for one query shape at one scale, not a general multiplier: Q6 clears it comfortably here, and nothing else in the set comes close.&lt;&#x2F;p&gt;
&lt;p&gt;Two queries are missing from that table, and the reasons are worth stating. Q14&#x27;s row-store run never happened. It died with &lt;code&gt;could not resize shared memory segment&lt;&#x2F;code&gt;, which is my own provisioning and not a property of either engine, so there is no baseline to compare the columnar run against. Q18 did run on the row store, three times, at around 655 seconds each. But the columnar run was killed by a 300-second statement timeout, so the two sides never ran under the same limit and no speedup can honestly be derived from the pair. Both are withdrawn rather than reported. At SF10, where both finish cleanly, Q18 gains 1.14x, the smallest win in the set.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;when-it-helps-when-it-doesn-t-when-it-hurts&quot;&gt;When it helps, when it doesn&#x27;t, when it hurts&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-it-helps-when-it-doesn-t-when-it-hurts&quot; aria-label=&quot;Anchor link for: when-it-helps-when-it-doesn-t-when-it-hurts&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The columnar store accelerates one shape of query.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;It helps&lt;&#x2F;strong&gt; for scan-heavy aggregations over large tables touching few columns. TPC-H Q1, Q6, and Q14 are the canonical shapes. The dashboard query that counts and sums everything. AlloyDB&#x27;s main selling point is the HTAP case. It runs a reporting query on live operational data. This happens because no one built the warehouse.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;It doesn&#x27;t help&lt;&#x2F;strong&gt; with point lookups, where the planner correctly takes the b-tree and the columnar store never enters the picture. Single-row OLTP. On small tables, the same aggregation on &lt;code&gt;lineitem&lt;&#x2F;code&gt; subsets shows that columnar is slower than heap with 100K rows (31.6 ms vs 27.5 ms). Here, the store&#x27;s overhead is more than the scan. Columnar pulls ahead at around 1M rows, gaining a 1.2x speedup. This advantage grows to 2x by 20M rows. The crossover is around a few hundred thousand rows. Below that, the heap stays in the buffer pool, so there’s nothing to gain. This is an in-memory result at SF10, so it measures the vectorization crossover; at scale, where the heap spills to disk, columnar pays off far sooner.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;It can hurt&lt;&#x2F;strong&gt; write-heavy tables with pinned columns. The columnar store must match the heap. So, when writes happen, they invalidate columnar blocks. Then, background work repopulates those blocks. Pin the columns of a table taking a thousand INSERTs a second and the maintenance can cost more than the scans save. Google&#x27;s auto-population is supposed to notice and back off; whether it does for your write pattern is something to verify, not assume. Measured at the low end it is cheap. Pinning &lt;code&gt;lineitem&lt;&#x2F;code&gt;&#x27;s columns added about 1% to a one-million-row bulk load (the number is in The numbers below), and that cost scales with write rate.&lt;&#x2F;p&gt;
&lt;p&gt;The main question about the HTAP pitch is: can the columnar engine maintain OLTP throughput while an analytical scan runs on the same instance? I expected it to. Taking the scan&#x27;s I&#x2F;O off the table should free the machine for writes. It doesn&#x27;t. At SF10 it is a wash.&lt;&#x2F;p&gt;
&lt;p&gt;Write throughput drops by about 13.5% during concurrent scans, whether reading from the heap or store. This happens because the scanned table is in memory. The issue is with compute contention, not I&#x2F;O, and columnar structures don&#x27;t help with that. Fair enough at that size, nothing to win.&lt;&#x2F;p&gt;
&lt;p&gt;At SF100, where the heap spills to disk, turning columnar on makes the contention worse (29% versus 16%). The mechanism and the full numbers are in The numbers below. For deployment, what it means is that the isolation has to come from a dedicated read pool node with its own CPUs, and it is paid for in visibility lag.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;alloydb-omni-same-name-different-product&quot;&gt;AlloyDB Omni: same name, different product&lt;a class=&quot;zola-anchor&quot; href=&quot;#alloydb-omni-same-name-different-product&quot; aria-label=&quot;Anchor link for: alloydb-omni-same-name-different-product&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;AlloyDB Omni is the downloadable AlloyDB, a container image you run on your own infrastructure (Kubernetes, a VM, your laptop).&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;docker&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; pull google&#x2F;alloydbomni:18&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;   # or a specific tag like 18.1.1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One sign that Omni is a separate product is its timing. When managed AlloyDB hit PostgreSQL 18 (GA May 2026), the Omni Linux packages updated. However, the Docker Hub image lagged, staying at version 17 for a while. The release trains are separate. Check the tag you actually pull.&lt;&#x2F;p&gt;
&lt;p&gt;The AlloyDB compute layer includes several components. It has a columnar engine. This engine uses the same API and includes the &lt;code&gt;google_columnar_engine_*&lt;&#x2F;code&gt; relations. Also, it has the ScaNN vector index and updates to the AlloyDB planner.&lt;&#x2F;p&gt;
&lt;p&gt;What&#x27;s not inside is everything in the first half of this article. Omni runs on standard PostgreSQL storage. Real 8KB pages on your filesystem, WAL as a recovery mechanism, ordinary autovacuum doing ordinary VACUUM work. Start an Omni container and check &lt;code&gt;pg_settings&lt;&#x2F;code&gt;. &lt;code&gt;full_page_writes&lt;&#x2F;code&gt; is &lt;code&gt;on&lt;&#x2F;code&gt;, and &lt;code&gt;checkpoint_timeout&lt;&#x2F;code&gt; is five minutes. This means the write path handles full-page images like stock PostgreSQL.&lt;&#x2F;p&gt;
&lt;p&gt;However, Omni doesn&#x27;t inherit those write-path advantages. No disaggregated storage. No shared-storage read pools. Omni replicas are just plain streaming replication, with lag included. There’s no managed failover, so bring your own Patroni. The one storage-flavored feature that does come along is the ultra-fast SSD cache, and only as a knob. In Omni you point a disk cache at a local SSD and size it yourself; managed provisions and sizes it for you.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Managed AlloyDB and AlloyDB Omni are different in design but share the same query layer.&lt;&#x2F;strong&gt; Omni is much closer to a PostgreSQL fork with a columnar engine bolted in than to a portable version of the cloud product. That doesn&#x27;t make it uninteresting; the columnar engine on your own hardware, without GCP, stands on its own. But if the storage-layer properties are what attracted you, Omni does not have them.&lt;&#x2F;p&gt;
&lt;p&gt;Omni is the clean benchmark platform for isolating the columnar engine&#x27;s share of AlloyDB&#x27;s analytical performance. It runs TPC-H tests with the engine off and then on. That pair is the two Omni columns in The numbers below, and it is the cleanest isolation in the exercise. Q6 falls from 2,681 ms to 69 ms with nothing changed but the engine flag.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;For &lt;a href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;products&#x2F;dryrun&#x2F;&quot;&gt;dryrun&lt;&#x2F;a&gt; users: Use &lt;code&gt;dryrun snapshot push&lt;&#x2F;code&gt; with AlloyDB Omni. It reads the standard &lt;code&gt;pg_catalog&lt;&#x2F;code&gt; that AlloyDB keeps. The &lt;code&gt;google_columnar_engine_*&lt;&#x2F;code&gt; relations and &lt;code&gt;alloydb_ai&lt;&#x2F;code&gt; objects aren&#x27;t in the snapshot schema yet. However, showing columnar-engine state in the schema advisor is planned for the future.&lt;&#x2F;div&gt;
&lt;h2 id=&quot;read-pool-economics&quot;&gt;Read pool economics&lt;a class=&quot;zola-anchor&quot; href=&quot;#read-pool-economics&quot; aria-label=&quot;Anchor link for: read-pool-economics&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Everything the columnar section measured ended in the same place. On one machine, analytics and OLTP compete for the same compute, and no storage layout changes that. The read pool is the escape from the single machine, and it is where the shared-storage architecture starts to justify its price premium. Stock PostgreSQL can also isolate analytics using a streaming read replica. However, this replica holds a complete second copy of your data. It has some lag, needing to process a full pipeline before changes become readable. Plus, it&#x27;s slow to set up, so you might end up running it all day. AlloyDB&#x27;s read pool improves on all three, with a caveat on each.&lt;&#x2F;p&gt;
&lt;p&gt;A pool consists of N read-only compute nodes connected to the same storage layer as the primary, with load balancing positioned in front. The three properties that set it apart from a streaming replica are economic as much as technical.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The lag is a shorter pipeline, not the absence of one&lt;&#x2F;strong&gt;. A streaming replica&#x27;s lag happens when WAL needs to be generated, shipped, and applied. This must occur before a change is readable. Under heavy write load, this pipeline can back up. Seconds of lag on a busy primary is normal; minutes is not rare. A read pool node runs the same pipeline over a much shorter distance. It never waits for pages, because the storage is already shared, so what it waits for is only the log.&lt;&#x2F;p&gt;
&lt;p&gt;Google&#x27;s troubleshooting page divides issues into two parts:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Flush lag&lt;&#x2F;strong&gt; which occurs while the WAL is sent from the primary and stored on the node.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Replay lag&lt;&#x2F;strong&gt; happens when the node applies the WAL.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Additionally, &lt;code&gt;max_standby_streaming_delay&lt;&#x2F;code&gt; is a setting for read pool nodes, serving its usual purpose.&lt;&#x2F;p&gt;
&lt;p&gt;What the shared storage buys is lag that starts small and stays small under steady load. Under a steady insert load on a two-node read pool, it reached a median of about 60 ms. This is much higher than the single-digit figure from Google. My number is an upper bound, as it factors in cross-instance clock skew and the granularity of batched commits. Over thirty samples, the median stayed stable even as the writes continued. Steady load is the whole claim, though. Google warns that a sudden rise in write workload can create many replication logs. This can overwhelm the read pool instances and lead to replication lag. Also, heavy reads on a node compete with replication for CPU and memory. My run measured the flat case and says nothing about a burst. Design for the burst case that Google warns about. Treat the 60 ms as the calm-day number. Also, reading your own writes across the pool isn’t guaranteed.&lt;&#x2F;p&gt;
&lt;p&gt;You can add read capacity without re-buying storage, though not, as I first assumed, because read pool vCPUs are cheaper. They bill at the same per-vCPU and per-GB rate as the primary, with no read-pool discount. The saving sits on the storage side. A read pool node adds compute against the same stored copy of your data. A Cloud SQL read replica is a full separate instance with its own full copy of storage, so each replica you add re-pays for the data. For read-heavy tasks like dashboards and reporting, lag in the tens of milliseconds is key. It&#x27;s a stronger point than the 100x headline. The storage-duplication saving is real too, but as the numbers below show, it only turns into a smaller bill at large scale.&lt;&#x2F;p&gt;
&lt;p&gt;List-price math, per node at 8 vCPU, includes one primary and two read nodes. These are monthly US list prices before any committed-use discounts. Prices can vary by 20 to 40 percent by region.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;compute per node&lt;&#x2F;th&gt;&lt;th&gt;storage&lt;&#x2F;th&gt;&lt;th&gt;primary + 2 read nodes&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Cloud SQL PG (8 vCPU &#x2F; 52 GB)&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;~$507&lt;&#x2F;td&gt;&lt;td&gt;$0.22&#x2F;GB, per instance&lt;&#x2F;td&gt;&lt;td&gt;3x compute, 3x storage&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;AlloyDB (8 vCPU &#x2F; 64 GB)&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;~$1,028&lt;&#x2F;td&gt;&lt;td&gt;$0.34&#x2F;GB, shared once&lt;&#x2F;td&gt;&lt;td&gt;3x compute, 1x storage&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The two effects work against each other. AlloyDB uses one storage copy for the entire pool, while Cloud SQL makes duplicates for each replica. This means AlloyDB saves two storage copies. However, an AlloyDB node costs about twice as much as a Cloud SQL instance with the same core count. It also has a higher per-vCPU rate, with memory fixed at 8 GB per vCPU. Compute costs dominate until the data size increases. If the read nodes run continuously, the two setups break even at around &lt;strong&gt;5 TB&lt;&#x2F;strong&gt; of storage. Below that, Cloud SQL is cheaper, costing about 40 percent less at a typical 500 GB.&lt;&#x2F;p&gt;
&lt;p&gt;A flat, round-the-clock approach is what a read pool aims to avoid. This is where Cloud SQL falls short. Cloud SQL can’t autoscale its replica count. It prepares for its busiest hour and charges for that every hour, even when no queries run overnight. In contrast, an AlloyDB read pool adapts to demand. It adjusts node count based on load, so you only pay for the node-hours you actually use. The cost isn’t based on data size, but on the day’s bursty nature. It reflects the average node count compared to the peak.&lt;&#x2F;p&gt;
&lt;p&gt;Consider a business-hours pattern: three read nodes during the 9-to-6 peak, one on the edges, and none overnight. This setup averages 1.4 nodes, while Cloud SQL keeps three nodes active all day. A three-node pool running flat all day breaks even at about 3.9 TB. With the business-hours shape, the break-even drops to around 765 GB. Make the day even peakier, and the benefits increase. When this three-node pool averages less than a third of its peak, AlloyDB becomes cheaper at any dataset size. Its part-time compute costs less than the storage it doesn’t duplicate.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;&#x2F;images&#x2F;posts&#x2F;alloydb-breakeven-light.svg&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; class=&quot;only-light&quot;&gt;&lt;img src=&quot;&#x2F;images&#x2F;posts&#x2F;alloydb-breakeven-light.svg&quot; style=&quot;display:block;max-width:720px;width:100%;margin:1.5em auto&quot; alt=&quot;Break-even chart: the storage size at which an AlloyDB read pool becomes cheaper than Cloud SQL read replicas, plotted against read-node utilization (average divided by peak; lower means burstier). For a peak of three read nodes the two break even near 765 GB for a business-hours pattern and near 3.9 TB for flat 24&#x2F;7 load, and below a utilization of about a third AlloyDB is cheaper at any dataset size.&quot;&gt;&lt;&#x2F;a&gt;
&lt;a href=&quot;&#x2F;images&#x2F;posts&#x2F;alloydb-breakeven-dark.svg&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; class=&quot;only-dark&quot;&gt;&lt;img src=&quot;&#x2F;images&#x2F;posts&#x2F;alloydb-breakeven-dark.svg&quot; style=&quot;display:block;max-width:720px;width:100%;margin:1.5em auto&quot; alt=&quot;Break-even chart: the storage size at which an AlloyDB read pool becomes cheaper than Cloud SQL read replicas, plotted against read-node utilization (average divided by peak; lower means burstier). For a peak of three read nodes the two break even near 765 GB for a business-hours pattern and near 3.9 TB for flat 24&#x2F;7 load, and below a utilization of about a third AlloyDB is cheaper at any dataset size.&quot;&gt;&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The curve divides the chart in two. Above it, the shared-storage savings exceed AlloyDB&#x27;s compute premium, making the pool cheaper. Below it, Cloud SQL&#x27;s cheaper compute still wins. The curve goes up to the right. The flatter your read load is, the more data you need. This is necessary before the storage savings outweigh AlloyDB&#x27;s compute cost. Your workload is marked on this chart. More terabytes push it up, while a burstier day shifts it left. Both factors move it closer to AlloyDB&#x27;s side of the line. Below about a third of peak the curve leaves the bottom of the chart entirely, and AlloyDB is cheaper at any size.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;&lt;p&gt;The arithmetic per node at 8 vCPU includes one primary and a read pool, calculated monthly for the US. Let n represent the peak read-node count and r be the pool&#x27;s average-to-peak load factor.&lt;&#x2F;p&gt;
&lt;p&gt;Cloud SQL provisions for peak usage around the clock with the formula: (1+n)·$507 + (1+n)·$0.22·S. In contrast, AlloyDB pays for average node-hours over shared storage: $1028 + r·n·$1028 + $0.34·S. &lt;&#x2F;p&gt;
&lt;p&gt;Setting these equal provides the break-even storage: S* = (521 − 507·n + 1028·r·n) &#x2F; (0.22·n − 0.12) GB. This is the curve shown above, and the generator is in the repo.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;AlloyDB automates this process. Its read pool autoscaling adjusts the node count based on CPU usage, a schedule, or both. This means you can activate low-duty-cycle mode as a policy, not through complex scripts. Cloud SQL does not have a similar feature for replica counts.&lt;&#x2F;p&gt;
&lt;p&gt;Two important points apply here. First, when you add a new node, it starts cold. This means it gradually earns its capacity instead of doing it all at once. The buffer cache fills up over a few minutes. During this time, scans that would normally pull from memory must retrieve data from storage. That’s why autoscaling works better for steady reads throughout the business day than for sudden spikes.&lt;&#x2F;p&gt;
&lt;p&gt;Second, the autoscaler adds nodes without shifting existing long-lived connections. If a session opened a connection and keeps it, that connection stays where it is. The new capacity is only used when clients reconnect. AlloyDB’s managed PgBouncer in transaction mode helps with this, spreading new connections across the pool as nodes come and go. Short-lived pooled connections utilize the new nodes, while long-held sessions leave them unused.&lt;&#x2F;p&gt;
&lt;p&gt;So, the recommendation depends on read patterns. If reads are steady all day and data is under a few terabytes, Cloud SQL replicas are more cost-effective. For bursty reads, like business-hour dashboards or nightly reports, AlloyDB’s autoscaling read pool is cheaper and self-scaling.&lt;&#x2F;p&gt;
&lt;p&gt;If your reporting can handle a few seconds of lag, streaming replication is fine. You can skip this section.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;alloydb-ai-the-part-that-s-interesting-without-gcp&quot;&gt;AlloyDB AI: the part that&#x27;s interesting without GCP&lt;a class=&quot;zola-anchor&quot; href=&quot;#alloydb-ai-the-part-that-s-interesting-without-gcp&quot; aria-label=&quot;Anchor link for: alloydb-ai-the-part-that-s-interesting-without-gcp&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;div class=&quot;sidenote&quot;&gt;&lt;strong&gt;A quick disclosure.&lt;&#x2F;strong&gt;&lt;p&gt;I’m confident with relational internals, but high-dimensional ANN indexing (pgvector, HNSW, ScaNN) is still new to me.&lt;&#x2F;p&gt;&lt;p&gt;Running into silent query knobs and limits on graph builds showed that vector intuition is a different skill. So, think of these numbers as an empirical field report, not a full AI benchmark.&lt;&#x2F;p&gt;&lt;&#x2F;div&gt;
Every managed PostgreSQL has pgvector now. AlloyDB stands out because of ScaNN. This is Google’s approximate-nearest-neighbor algorithm. It powers their internal search and comes as a PostgreSQL index access method. It works with pgvector’s `ivfflat` and `hnsw`.
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- pgvector HNSW, available everywhere&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; embeddings &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;USING&lt;&#x2F;span&gt;&lt;span&gt; hnsw (embedding vector_cosine_ops);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- AlloyDB ScaNN (verified on Omni; sq8 quantization keeps the index small)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; embeddings &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;USING&lt;&#x2F;span&gt;&lt;span&gt; scann (embedding cosine)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; (num_leaves &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1000&lt;&#x2F;span&gt;&lt;span&gt;, quantizer &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;sq8&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- query-time recall&#x2F;latency knob. The LOAD is not optional: without it the SET&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- silently does nothing and the search runs at a shallow default (see below).&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LOAD&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;alloydb_scann&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; scann&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;num_leaves_to_search&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; N;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- The query doesn&amp;#39;t change; the planner picks the index&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, content &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; embeddings&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; embedding &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;=&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;[0.1, 0.2, ...]&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The claim to test is that with tens of millions of vectors, ScaNN maintains high recall while reducing query latency. In contrast, HNSW starts to cost a lot in memory and build time. I ran it at 10M vectors of 1024 dimensions, cosine distance, against exact ground truth. The cost half of that claim holds, and holds convincingly. I couldn&#x27;t settle the recall half, and the reason is more important than the number.&lt;&#x2F;p&gt;
&lt;p&gt;The half that holds is index size and build time. ScaNN&#x27;s sq8-quantized index came to 2.6 GB against ivfflat&#x27;s 76 GB, roughly thirty times smaller, and built in six to seven minutes against fifty-three. Both runs of the harness put the index sizes within a megabyte of each other and ivfflat&#x27;s build within half a percent; ScaNN&#x27;s own build time varied more between them, 357 seconds and 437 seconds, so call the build advantage seven to nine times rather than a single figure.&lt;&#x2F;p&gt;
&lt;p&gt;One of the issues that surprised me is query-time setting &lt;code&gt;scann.num_leaves_to_search&lt;&#x2F;code&gt;. It only works if &lt;code&gt;alloydb_scann&lt;&#x2F;code&gt; is loaded first; otherwise, it defaults to a shallow search. Adding the library to &lt;code&gt;shared_preload_libraries&lt;&#x2F;code&gt; causes server restart failures. The only reliable method is loading it per session. My initial 10M run requested a deep search but returned a recall of 0.15, with no indication that the knob was ignored. I won&#x27;t report the recall figure since my runs disagreed, and the test to clarify this never happened. I’ll focus on ScaNN&#x27;s claim of maintaining recall at deeper levels, but I trust it the least. Proper testing should plot recall against search depth with your own data; I’d prioritise index size and build time instead.&lt;&#x2F;p&gt;
&lt;p&gt;At the end I didn&#x27;t manage to get a clean 10M build number for hnsw using this setup. The parallel build hit resource limits. One attempt faced disk issues, while another had a shared-memory segment that was too small. This shows that building a full-precision graph over ten million high-dimensional vectors is costly.&lt;&#x2F;p&gt;
&lt;p&gt;Therefore, the scorecard is shorter than the datasheet and less detailed than I wanted. ScaNN&#x27;s index excels in size and build time, showcasing solid engineering through a standard PostgreSQL interface. I leave its recall claim open. Both index builds, the ivfflat baseline (76 GB, 53 minutes) and the log of the disrupted run, are in the repo.&lt;&#x2F;p&gt;
&lt;p&gt;ScaNN ships with Omni, so it doesn’t need managed AlloyDB or GCP. The other part of AlloyDB AI, the google_ml_integration extension, which calls Vertex AI models from SQL, does require them.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- model id is a current Vertex model: text-embedding-005 (English&#x2F;code, 768d) here;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- gemini-embedding-001 is the higher-quality, higher-dimension option&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;google_ml&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;embedding&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;text-embedding-005&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, content) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; vec&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; documents;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If you&#x27;re on GCP and already call Vertex, that saves a data round-trip. If you&#x27;re not, it does nothing for you.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;cross-region-dr-not-active-active&quot;&gt;Cross-region: DR, not active-active&lt;a class=&quot;zola-anchor&quot; href=&quot;#cross-region-dr-not-active-active&quot; aria-label=&quot;Anchor link for: cross-region-dr-not-active-active&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;AlloyDB allows up to five secondary clusters in different regions. Google reports replication lag in the tens of milliseconds. It looks like cross-region HA, and it is, within one constraint: writes go to the primary region, always. Secondaries are read-only until promoted, and the two promotion paths are not interchangeable.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Failover (DR promotion)&lt;&#x2F;strong&gt;. The secondary declares itself primary and starts taking writes. Whatever WAL was in flight and unconfirmed when the old primary died is gone. This is the path for &quot;the region is down and we accept the loss window.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Switchover is coordinated&lt;&#x2F;strong&gt;. Drain the primary, confirm the secondary is caught up, swap roles. Zero loss, but both sides must participate.&lt;&#x2F;p&gt;
&lt;p&gt;An active-active deployment  does not exist.If your architecture requires writes in multiple regions, AlloyDB isn&#x27;t the solution. The documentation is clear about this. The key risk to avoid is human error. This happens when an operator promotes a system during a minor issue, incurring data loss. A proper switchover would have managed this situation smoothly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-fully-compatible-actually-covers&quot;&gt;What &quot;fully compatible&quot; actually covers&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-fully-compatible-actually-covers&quot; aria-label=&quot;Anchor link for: what-fully-compatible-actually-covers&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As already mentioned - &quot;fully compatible with PostgreSQL&quot; means AlloyDB uses the PostgreSQL wire protocol and runs its SQL dialect. &lt;code&gt;psql&lt;&#x2F;code&gt;, &lt;code&gt;libpq&lt;&#x2F;code&gt;, JDBC, psycopg, the ORMs all work, and that&#x27;s not nothing; it&#x27;s the difference between migrating and rewriting.&lt;&#x2F;p&gt;
&lt;p&gt;Here is what it does not mean.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;AlloyDB is not community PostgreSQL.&lt;&#x2F;strong&gt; It’s a Google fork with unique changes, including a columnar engine and planner updates, which aren’t shared with the community. The storage interface is tied to Google’s infrastructure. While Cloud SQL uses upstream PostgreSQL, AlloyDB modifications remain in the fork. AlloyDB matches community releases closely: PostgreSQL 18 reached GA on AlloyDB in May 2026, within months of the community release. Upgrades from older versions are easy, but security and behavioural fixes come solely from Google’s fork.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Extensions are an allowlist&lt;&#x2F;strong&gt;, and the allowlist differs between the two products in ways you would not guess. Managed AlloyDB&#x27;s approved set is generous for the mainstream, and the Omni image ships its own bundle; they are not the same bundle. Compare the Omni Docker image contents (from pg_available_extensions) with the managed supported list.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;extension&lt;&#x2F;th&gt;&lt;th&gt;Omni image&lt;&#x2F;th&gt;&lt;th&gt;Managed AlloyDB&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;pg_stat_statements, pg_repack, pg_partman&lt;&#x2F;td&gt;&lt;td&gt;ships&lt;&#x2F;td&gt;&lt;td&gt;yes&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;pgaudit, pg_cron&lt;&#x2F;td&gt;&lt;td&gt;ships&lt;&#x2F;td&gt;&lt;td&gt;yes, behind a flag&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;postgis&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;not bundled&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;yes&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;plv8&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;ships (3.2.3)&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;no&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;timescaledb&lt;&#x2F;td&gt;&lt;td&gt;not bundled&lt;&#x2F;td&gt;&lt;td&gt;no&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;pgvector, hstore, pg_trgm, pgcrypto, uuid-ossp&lt;&#x2F;td&gt;&lt;td&gt;ships&lt;&#x2F;td&gt;&lt;td&gt;yes&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Two counterintuitive points: &lt;code&gt;plv8&lt;&#x2F;code&gt; is included in the Omni image but not on the managed allowlist, making &quot;AlloyDB supports plv8&quot; true for Omni and false for managed. Conversely, &lt;code&gt;postgis&lt;&#x2F;code&gt; is supported by managed but not included in Omni, requiring manual installation. &quot;Built for PostgreSQL 18&quot; doesn&#x27;t guarantee compatibility with AlloyDB Omni 18. &lt;code&gt;timescaledb&lt;&#x2F;code&gt; is unsupported on both.&lt;&#x2F;p&gt;
&lt;p&gt;AlloyDB Omni added Transparent Data Encryption (TDE) in preview (Omni 18.1.0). This feature provides cluster-level at-rest encryption. It also supports keys in external KMS, such as HashiCorp Vault. In contrast, community PostgreSQL lacks built-in TDE, relying instead on filesystem encryption or &lt;code&gt;pgcrypto&lt;&#x2F;code&gt;. Managed AlloyDB uses standard cloud at-rest encryption with Cloud KMS. The Omni feature is important. It adds a debated capability to self-hosted builds. This is similar to the changes seen in the columnar engine and planner.&lt;&#x2F;p&gt;
&lt;p&gt;The config surface is curated too. There is no &lt;code&gt;postgresql.conf&lt;&#x2F;code&gt; and no &lt;code&gt;pg_hba.conf&lt;&#x2F;code&gt;; parameters are exposed (or not) through the GCP console and API, and &lt;code&gt;ALTER SYSTEM&lt;&#x2F;code&gt; is not yours to run. Most teams won’t miss the hidden flags. However, teams that carefully tune PostgreSQL will quickly notice which ones are missing. The storage works similarly: &lt;code&gt;pageinspect&lt;&#x2F;code&gt;, WAL inspection, and anything that deals with the physical format is either removed or has a new meaning. So, you rely on AlloyDB&#x27;s insights about what happens below the buffer pool.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Two bundled features, neither a differentiator. The index advisor is proprietary &lt;code&gt;pg_qualstats&lt;&#x2F;code&gt; plus &lt;code&gt;hypopg&lt;&#x2F;code&gt;, always-on, pushing recommendations to Cloud Monitoring. Useful if you don&#x27;t already analyze indexes yourself. Managed PgBouncer is also included, defaulting to transaction mode with the same old caveats: &lt;code&gt;PREPARE&lt;&#x2F;code&gt;, &lt;code&gt;LISTEN&lt;&#x2F;code&gt;, &lt;code&gt;SET&lt;&#x2F;code&gt;, temp tables, and advisory locks still break. Session mode fixes this but undoes most of the pooling. Omni users run their own PgBouncer.&lt;&#x2F;div&gt;
The version story is a genuine strength, though. AlloyDB tracks new majors fast and ships one-click in-place upgrades, and it keeps old majors alive past community EOL. Both ends of that range are real enterprise value. You are neither forced onto a migration on the community&#x27;s clock nor stranded on a version the community has dropped.
&lt;p&gt;The fixes for your version, including new-major support and extended-support patches, come from Google. They are for Google&#x27;s fork since that&#x27;s the code you are using.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-governance-question&quot;&gt;The governance question&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-governance-question&quot; aria-label=&quot;Anchor link for: the-governance-question&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;This part of the evaluation bothered me the most, and it&#x27;s the hardest to benchmark. PostgreSQL is more than just source code. It’s a development process. There are public mailing lists for design talks, commit messages that explain choices, and release notes that track changes in behaviour. Plus, there’s a 30-year history that anyone can read. When PostgreSQL surprises you, you can trace back to the thread where the decision was made.&lt;&#x2F;p&gt;
&lt;p&gt;Google does invest in that process, and it is worth being honest about how.&lt;&#x2F;p&gt;
&lt;p&gt;Google &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;opensource.googleblog.com&#x2F;2026&#x2F;03&#x2F;google-cloud-investing-in-the-future-of-postgresql.html&quot;&gt;shares&lt;&#x2F;a&gt; that it employs key PostgreSQL contributors. Their latest work is open for all to see. This includes:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Design and review of PostgreSQL 18&#x27;s native conflict detection for logical replication.&lt;&#x2F;li&gt;
&lt;li&gt;The long-awaited logical replication of sequences.&lt;&#x2F;li&gt;
&lt;li&gt;Fixes for core deadlocks and upgrade bugs.&lt;&#x2F;li&gt;
&lt;li&gt;A spot on the PGConf.dev 2026 program committee.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The contribution stops short of AlloyDB itself, though. A &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;search&#x2F;?u=&#x2F;list&#x2F;pgsql-hackers&#x2F;&amp;amp;q=AlloyDB&quot;&gt;search of the pgsql-hackers archives&lt;&#x2F;a&gt; for &quot;AlloyDB&quot; still returns zero hits, and that is not a quirk of branding. Google&#x27;s upstream work is standard PostgreSQL. It’s contributed under individual and Google Cloud names, benefiting everyone. The architecture that makes AlloyDB AlloyDB is developed entirely outside that work, and proposed to the community nowhere.&lt;&#x2F;p&gt;
&lt;p&gt;Aurora and Azure&#x27;s PostgreSQL services are similar. This section applies to both, differing only in serial numbers. Choosing either means selecting a vendor as your database governance authority instead of the PostgreSQL community. This decision should be clearly noted in your architecture decision record.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The PG Scorecard (&lt;a href=&quot;https:&#x2F;&#x2F;pgscorecard.com&#x2F;&quot;&gt;pgscorecard.com&lt;&#x2F;a&gt;, open source) scores feature conformance to community PostgreSQL across twelve categories, each marked full, partial, or none and rolled into one weighted percentage. It is the right tool for its question, does this engine speak PostgreSQL&#x27;s SQL, and a fair answer to vendors who overclaim it. The single number is also its limit. A 93 for AlloyDB and a 100 for a distributed fork flatten exactly the architectural and governance differences this post spent its length drawing. Read it as the protocol-layer axis of a fuller picture.&lt;&#x2F;div&gt;
&lt;p&gt;One observation follows from the architecture itself. The wire protocol is now the only stable boundary. Tools that operate there, like schema introspection via &lt;code&gt;pg_catalog&lt;&#x2F;code&gt;, migration planners, and connection proxies, work the same with AlloyDB. This is because Google has promised to keep that layer stable. Tools that reach below it break. As more of the fleet moves onto wire-compatible-but-divergent engines, the protocol-and-catalog layer is becoming the de facto portability standard, more durable than the &quot;it&#x27;s PostgreSQL&quot; label, which increasingly just means the PostgreSQL wire protocol.&lt;&#x2F;p&gt;
&lt;p&gt;The community could enhance Mayur&#x27;s PostgreSQL Compatibility Index, which already exists. The PG Scorecard rates vendors, with AlloyDB scoring about 93%. This score reflects SQL compatibility but doesn&#x27;t assess the storage engine or operational aspects. The article and score aren&#x27;t conflicting; they evaluate different layers. What&#x27;s needed is a matrix for the underlying components, including extension portability and governance transparency. Each engine would have varied scores, and combining both matrices would provide greater insight than either alone. This could be a great topic for a pgConf talk.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-alloydb-makes-sense&quot;&gt;When AlloyDB makes sense&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-alloydb-makes-sense&quot; aria-label=&quot;Anchor link for: when-alloydb-makes-sense&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Marketing aside, the decision is more tractable than the feature list makes it look.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;A good fit&lt;&#x2F;strong&gt; comes when:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;You&#x27;re already on GCP and want the infrastructure chores gone. Failover is seamless since there’s shared storage and no WAL to replay. Storage expands automatically without needing resize windows. Plus, the write path avoids PostgreSQL&#x27;s full-page-write cost in the managed service. Three concrete wins, and none of them costs you a config knob&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;HTAP load allows for fast reporting and analytics on live data. The columnar engine speeds up scans, bypassing the warehouse, while a read pool node separates these from OLTP writes. This combination of quick analytics on the main node and distinct compute on a shared-storage read node is challenging to achieve with standard PostgreSQL.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Your read-heavy workload needs replica freshness in tens of milliseconds, while a streaming replica can take seconds. You can accept a wider boundary during write bursts. The read pool’s costs work for your needs. If you have multi-terabyte data or bursty reads, you can autoscale down. For flat 24&#x2F;7 reads under a few terabytes, Cloud SQL remains cheaper, as noted above.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;You want the columnar engine without GCP; that&#x27;s Omni, knowing it&#x27;s the query layer only&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;Not so good fit&lt;&#x2F;strong&gt;:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;You depend on extensions off the allowlist: &lt;code&gt;timescaledb&lt;&#x2F;code&gt;, &lt;code&gt;plv8&lt;&#x2F;code&gt;, anything pgrx, anything superuser&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Your tuning, tooling, or debugging relies on upstream PostgreSQL behaviour below the protocol. This includes config access, storage inspection, or WAL tooling.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Major-version upgrades need to happen on your schedule&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;You need active-active across regions&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;You&#x27;re not on GCP and were considering managed AlloyDB; that&#x27;s GCP-only, and everywhere else means Omni, which is different product (see above)&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The columnar engine justifies its memory use when analytical queries scan millions of rows for a few columns. This makes scans faster. However, it doesn&#x27;t isolate these scans from concurrent writes; that&#x27;s the read pool&#x27;s role on a separate node. Use both together. It’s not effective for index-served workloads, point lookups, or small tables in the buffer pool. It can also be expensive for write-heavy tables with pinned columns. See the numbers below for crossover points.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-numbers&quot;&gt;The numbers&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-numbers&quot; aria-label=&quot;Anchor link for: the-numbers&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;TPC-H scale factor 10, median of 5 warm runs, one cold run dropped, all on 8 vCPU &#x2F; 64 GB in the same region. Times in milliseconds. Two parity gaps are worth naming rather than burying: standard PG is 18.4 while Omni is 17.7, because the Docker image lagged a major at the time, and Omni auto-tunes &lt;code&gt;shared_buffers&lt;&#x2F;code&gt; to about 47 GB where the native instance ran 16 GB. I ran Omni at both sizes to check whether the second one mattered, and it doesn&#x27;t move anything material: Q6 is 2,592 ms at 16 GB against 2,681 ms at 47 GB. Both gaps are real, both are in the repo, and neither carries the result. The two Omni columns represent the clean experiment. They use the same binary and storage. One has the columnar engine off, while the other has it on.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;query&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;standard PG 18&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Omni, engine off&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Omni, engine on&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;managed AlloyDB&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Q1&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;11,969&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;14,056&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;10,159&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;9,922&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Q5&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2,496&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2,374&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1,870&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1,521&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Q6&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2,669&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2,681&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;69&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;62&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Q12&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2,357&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2,909&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;706&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;682&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Q14&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1,293&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1,005&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;555&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;481&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Q18&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;34,301&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;29,924&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;26,168&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;31,353&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;point lookup&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.107&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.096&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.119&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.120&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Read the two Omni columns across. Q6, a selective scan of a few columns, drops from 2,681 ms to 69 ms, 39x. Q12 drops 4.1x and Q14 1.8x, both joins with date filters. Q1 moves 1.4x because it sums four &lt;code&gt;numeric&lt;&#x2F;code&gt; columns over nearly every row and the arithmetic dominates. Q18, an uncorrelated &lt;code&gt;IN&lt;&#x2F;code&gt; subquery, moves 1.14x, the smallest win in the set. The point lookup stays put: 0.096 ms with the engine off against 0.119 with it on, and at SF100 it goes the other way, 0.15 against 0.11. Both differences are a few hundredths of a millisecond, which is noise on a query this small. The planner uses the b-tree, and the columnar store doesn’t affect the plan.&lt;&#x2F;p&gt;
&lt;p&gt;Managed AlloyDB (engine on) tracks self-managed Omni almost exactly: Q6 62 vs 69 ms, Q12 682 vs 706, Q14 481 vs 555. The columnar engine behaves the same whether Google runs it or you do, which is the confirmation that the result isn&#x27;t an artifact of one deployment.&lt;&#x2F;p&gt;
&lt;p&gt;This table hides two key points. First, &quot;standard PG 18&quot; is a hand-tuned native instance. Running the same query on Cloud SQL with default settings (&lt;code&gt;work_mem=4MB&lt;&#x2F;code&gt; and two workers per gather) takes 12,436 ms for Q5. This gap is due to a config default, not the engine. It’s the cost that every managed-Postgres comparison faces; it’s noted in the repo as &lt;code&gt;B-stock&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Second, at SF10, the whole dataset fits in RAM for every config. Here, the columnar advantage comes from vectorized execution and skipped columns, since there’s no disk access. At SF100, where the row store spills to disk, Q6&#x27;s 39x becomes 311x (the plans are in the columnar section above).&lt;&#x2F;p&gt;
&lt;p&gt;The two controls the engine has to &lt;em&gt;not&lt;&#x2F;em&gt; lose:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;INSERT throughput&lt;&#x2F;strong&gt;. Loading one million rows into &lt;code&gt;lineitem&lt;&#x2F;code&gt; with its columns pinned into the columnar store, versus not: 1,859 ms pinned, 1,842 ms unpinned. About 1% to keep the columnar blocks coherent, at this write rate. A write-hot table taking thousands of INSERTs a second is where that cost grows; this measures the quiet end of it.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Concurrency and HTAP&lt;&#x2F;strong&gt;. On Omni, writes with pgbench and a concurrent analytical scan on the same table show a 13.6% drop in write throughput for row store and 13.5% for columnar store. The columnar engine, meant to reduce contention, didn&#x27;t help because the scanned table was in memory, eliminating disk wait times. Both scans used CPU and memory bandwidth on the same node as the writes, causing the drop.&lt;&#x2F;p&gt;
&lt;p&gt;To prevent analytical scans from affecting OLTP throughput on AlloyDB, route them to a separate read pool node, accepting visibility lag instead of reduced write throughput.&lt;&#x2F;p&gt;
&lt;p&gt;Testing at SF100 with a 600-million-row lineitem revealed that the disk-bound row-store scan reduced write throughput by 16%, while the in-memory columnar scan dropped it by 29%. Columnar scans, though cheaper and CPU-efficient, increased contention by consuming more memory bandwidth on the write node. Thus, physical isolation on separate hardware is necessary for effective resource management. Each configuration&#x27;s results were based on median values from three 120-second runs, with raw data available in the repo.&lt;&#x2F;p&gt;
&lt;p&gt;Read pool lag. Under a sustained insert load, a two-node read pool trailed the primary by a median of about 60 ms and, across thirty samples, did not climb as the writes continued (raw samples in the repo). The flat-under-sustained-load shape is the finding, not the absolute value. What this run does not cover is a write burst, which Google documents as able to overwhelm a pool&#x27;s replication, or a head-to-head against Cloud SQL.&lt;&#x2F;p&gt;
&lt;p&gt;Methodology, raw timings, and EXPLAIN ANALYZE output for every number is available at https:&#x2F;&#x2F;github.com&#x2F;boringSQL&#x2F;alloydb-evaluation-bench. If a result doesn&#x27;t reproduce for you, that&#x27;s a bug in the article; file an issue.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-google-got-right-and-what-it-costs&quot;&gt;What Google got right, and what it costs&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-google-got-right-and-what-it-costs&quot; aria-label=&quot;Anchor link for: what-google-got-right-and-what-it-costs&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Adaptive autovacuum is a smart scheduler. It speeds up when the instance is idle and slows down when it’s busy. This feature addresses the common issue of vacuum debt in PostgreSQL. It automates tasks that users usually have to tune manually. It does not move the work off the compute node, whatever the pitch implies. It makes the box smart enough that the scheduling stops being your problem.&lt;&#x2F;p&gt;
&lt;p&gt;The read pool model offers a fairer price for read scaling compared to the replica model. This fairness increases as you expand. ScaNN, as an index access method, is robust engineering. It’s available through a standard interface in Omni, without any GCP charges. The team behind this has a deep understanding of PostgreSQL and knows what users can do without.&lt;&#x2F;p&gt;
&lt;p&gt;What it costs is the other half of this post. PostgreSQL in AlloyDB covers the protocol, dialect, and planner&#x27;s behaviour. Google owns the storage engine, operational model, upgrade schedule, extension surface, and governance.&lt;&#x2F;p&gt;
&lt;p&gt;The boring answer, as always, is to test it against your workload. The TPC-H numbers above show the columnar engine&#x27;s best case. Even then, two results didn’t match the datasheet. When the heap spilled to disk, enabling the columnar engine increased write contention to 29%, compared to 16% for the row store. Also, ScaNN’s key query-time knob is inactive unless you &lt;code&gt;LOAD&lt;&#x2F;code&gt; the library first. This cost me a ten-million-vector run and yielded only 0.15 recall, with no warning. Both issues took time to discover, and neither is mentioned in any marketing.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;disclaimer&quot;&gt;Disclaimer&lt;a class=&quot;zola-anchor&quot; href=&quot;#disclaimer&quot; aria-label=&quot;Anchor link for: disclaimer&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The article hasn’t been reviewed or edited by Google Cloud team. I asked for internal insights but haven’t received them for various reasons. The benchmark repository was developed over an extended period of timeand is managed using Claude Code. My company - Clusterity s.r.o. - have covered all costs related to the benchmarking.&lt;&#x2F;p&gt;
&lt;p&gt;The work on this article spanned between April to August 2026. Preceded by the evaluation of the commercial workloads.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Don&#x27;t let AI touch your production database</title>
        <published>2026-04-06T22:24:00+02:00</published>
        <updated>2026-04-06T22:24:00+02:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/dont-let-ai-to-prod/"/>
        <id>https://boringsql.com/posts/dont-let-ai-to-prod/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/dont-let-ai-to-prod/">&lt;p&gt;Not so long ago, the biggest threat to production databases was the developer who claimed it worked on their machine. If you&#x27;ve attended my sessions, you know this is a topic I&#x27;m particularly sensitive to.&lt;&#x2F;p&gt;
&lt;p&gt;These days, AI agents are writing your SQL. The models are getting incredibly good at producing &lt;em&gt;plausible&lt;&#x2F;em&gt; code. It looks right, it feels right, and often it passes a cursory glance. But &quot;plausible&quot; isn&#x27;t a performance metric, and it doesn&#x27;t care about your execution plan or locking strategy.&lt;&#x2F;p&gt;
&lt;p&gt;AI-generated SQL is syntactically correct, which is the easy part. The hard part is knowing what a statement &lt;em&gt;does&lt;&#x2F;em&gt; to a running system: which locks it takes, how long it holds them, whether it rewrites the table on disk.&lt;&#x2F;p&gt;
&lt;p&gt;The spectacular failures get the headlines. In July 2025, an AI coding agent &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;fortune.com&#x2F;2025&#x2F;07&#x2F;23&#x2F;ai-coding-tool-replit-wiped-database-called-it-a-catastrophic-failure&#x2F;&quot;&gt;wiped a production database&lt;&#x2F;a&gt; during a code freeze -- ran destructive commands, panicked, then lied about what it had done.&lt;&#x2F;p&gt;
&lt;p&gt;But the biggest damage is quieter. It&#x27;s the migration that passes every test, ships through CI and then locks a production table during peak traffic. The query written with random assumptions. Indexes added based on copy&#x2F;paste from &lt;code&gt;psql&lt;&#x2F;code&gt;. The cumulative effect that builds over time, when nobody is looking.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-to-give-ai-agent-eyes&quot;&gt;How to give AI agent &quot;eyes&quot;&lt;a class=&quot;zola-anchor&quot; href=&quot;#how-to-give-ai-agent-eyes&quot; aria-label=&quot;Anchor link for: how-to-give-ai-agent-eyes&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When you&#x27;re running Claude Code or any other agentic coding tool, the bottleneck isn&#x27;t the model&#x27;s intelligence. It&#x27;s the fidelity of the environment. Standard AI coding involves the agent guessing column names based on your description, or in better cases parsing a &lt;code&gt;schema.sql&lt;&#x2F;code&gt; file or using a local database with seed data.&lt;&#x2F;p&gt;
&lt;p&gt;None of these give the agent the one thing it actually needs: awareness of your &lt;em&gt;production&lt;&#x2F;em&gt; schema. The table sizes, the indexes, the constraints, the statistics that determine whether a query index-scans in 2ms or sequential-scans for 40 seconds.&lt;&#x2F;p&gt;
&lt;p&gt;The obvious fix is to give it a database connection. Let it query &lt;code&gt;pg_catalog&lt;&#x2F;code&gt;, read table sizes, check existing indexes. This is how Anthropic&#x27;s reference PostgreSQL MCP server worked, and that&#x27;s the one Datadog Security Labs &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;securitylabs.datadoghq.com&#x2F;articles&#x2F;mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server&#x2F;&quot;&gt;found a SQL injection in&lt;&#x2F;a&gt;. The vulnerable npm package still gets 21,000 downloads per week. The server wrapped queries in read-only transactions, but accepted semicolons. &lt;code&gt;COMMIT; DROP SCHEMA public CASCADE;&lt;&#x2F;code&gt; works because the &lt;code&gt;COMMIT&lt;&#x2F;code&gt; ends the read-only transaction and everything after runs with full privileges. Read-only transactions are not a security boundary when the protocol accepts semicolons.&lt;&#x2F;p&gt;
&lt;p&gt;The AI doesn&#x27;t need a &lt;em&gt;connection&lt;&#x2F;em&gt;. It needs the &lt;em&gt;information&lt;&#x2F;em&gt;. Schema metadata is structural data. You can extract it once, save it as a JSON file, commit it to git, and hand that file to every tool in your stack.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;
&lt;a href=&quot;https:&#x2F;&#x2F;modelcontextprotocol.io&quot;&gt;MCP&lt;&#x2F;a&gt; is an open standard for connecting AI assistants to external tools and data sources. Instead of relying on training data, the agent calls structured tools exposed by an MCP server and gets deterministic answers back.
&lt;&#x2F;div&gt;
&lt;p&gt;What&#x27;s been missing is a standard way to wire that information into the agent&#x27;s workflow. Not a one-time dump that sits in a docs folder, but a protocol that lets the agent &lt;em&gt;call tools&lt;&#x2F;em&gt; and get structured answers back. That protocol is &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;modelcontextprotocol.io&quot;&gt;MCP&lt;&#x2F;a&gt; (Model Context Protocol).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-are-postgresql-mcp-servers-actually-for&quot;&gt;What are PostgreSQL MCP servers actually for?&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-are-postgresql-mcp-servers-actually-for&quot; aria-label=&quot;Anchor link for: what-are-postgresql-mcp-servers-actually-for&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;This has been bugging me. If you search for &quot;PostgreSQL MCP server&quot; today, you&#x27;ll find two kinds.&lt;&#x2F;p&gt;
&lt;p&gt;The first is a connection wrapper. You give it a &lt;code&gt;DATABASE_URL&lt;&#x2F;code&gt;, it exposes tools like &lt;code&gt;query&lt;&#x2F;code&gt; and &lt;code&gt;execute&lt;&#x2F;code&gt;. This is &lt;code&gt;psql&lt;&#x2F;code&gt; with extra steps. The Anthropic reference server that got the SQL injection? Connection wrapper. The dozen community forks that replaced it? Connection wrappers with slightly better input validation.&lt;&#x2F;p&gt;
&lt;p&gt;The second is a managed-provider gateway. Cloud database platforms expose their platform APIs through MCP. Create a branch, provision a database, manage users. Useful if you&#x27;re on that platform, but not database intelligence. Control-plane operations wearing a protocol hat.&lt;&#x2F;p&gt;
&lt;p&gt;Neither kind answers the question an AI agent actually needs answered: &lt;em&gt;&quot;is this SQL going to be a problem?&quot;&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A connection wrapper can run &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt;, if you trust it with credentials. But who tells the agent that &lt;code&gt;SET NOT NULL&lt;&#x2F;code&gt; on a 4-million-row table takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;&#x2F;code&gt; lock? Who tells it that its query defeats partition pruning? That&#x27;s not a connection problem. It&#x27;s a knowledge problem.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;dryrun&quot;&gt;dryrun&lt;a class=&quot;zola-anchor&quot; href=&quot;#dryrun&quot; aria-label=&quot;Anchor link for: dryrun&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;I didn&#x27;t set out to build an AI tool. My primary project is &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;products&#x2F;regresql&#x2F;&quot;&gt;RegreSQL&lt;&#x2F;a&gt;, a statistics-based SQL regression testing framework. RegreSQL needs production statistics to produce realistic query plans in test environments, and dryrun&#x27;s schema capture was originally just the plumbing to get those statistics into the right place.&lt;&#x2F;p&gt;
&lt;p&gt;But driving adoption of a testing tool is hard. The best place to plug in is where the coding actually happens. And these days, that&#x27;s increasingly AI agents. Instead of fighting it, I turned the plumbing into a proper tool.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;products&#x2F;dryrun&#x2F;&quot;&gt;dryrun&lt;&#x2F;a&gt; is a PostgreSQL schema intelligence tool. It ships as a CLI and as an MCP server. It doesn&#x27;t connect to your database at runtime. Instead, it works from a JSON snapshot of your schema, captured once, committed to git, and available to everyone.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;offline-first&quot;&gt;Offline first&lt;a class=&quot;zola-anchor&quot; href=&quot;#offline-first&quot; aria-label=&quot;Anchor link for: offline-first&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;One person with production access captures the schema:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; dump-schema&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --source&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;$PROD_URL&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --pretty --name&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;production&amp;quot;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -o&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; schema.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The JSON contains everything structural: tables, columns, constraints, indexes, and the rest of what you&#x27;d find in &lt;code&gt;pg_catalog&lt;&#x2F;code&gt;. It also captures per-table statistics from &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; (histograms, most common values, correlation, &lt;code&gt;n_distinct&lt;&#x2F;code&gt;) and runtime counters from &lt;code&gt;pg_class&lt;&#x2F;code&gt; like &lt;code&gt;reltuples&lt;&#x2F;code&gt;, &lt;code&gt;seq_scan&lt;&#x2F;code&gt;, and &lt;code&gt;idx_scan&lt;&#x2F;code&gt;. One file, typically 200-500KB. Structural metadata, not row data.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
If your schema uses &lt;code&gt;COMMENT ON&lt;&#x2F;code&gt; (and it should), dryrun captures those too. A column named &lt;code&gt;status&lt;&#x2F;code&gt; is ambiguous. &lt;code&gt;COMMENT ON COLUMN orders.status IS &#x27;pending | completed | refunded, set by payment processor webhook&#x27;&lt;&#x2F;code&gt; tells the agent what the values mean, where they come from, and that it shouldn&#x27;t invent new ones.
&lt;p&gt;If you don&#x27;t take my word for it, check what Bruce Momjian &lt;a href=&quot;https:&#x2F;&#x2F;momjian.us&#x2F;main&#x2F;blogs&#x2F;pgblog&#x2F;2026.html#March_16_2026&quot;&gt;has to say&lt;&#x2F;a&gt; about it. MCP clients can only work with schemas, object names, and constraints. Comments are the simplest way to supply the semantic context that makes AI-generated queries meaningful.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;Import it and every subsequent command works offline:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; import schema.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No connection needed, no credentials on developer machines or in CI.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;as-an-mcp-server&quot;&gt;As an MCP server&lt;a class=&quot;zola-anchor&quot; href=&quot;#as-an-mcp-server&quot; aria-label=&quot;Anchor link for: as-an-mcp-server&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;claude&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; mcp add dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; dryrun mcp-serve&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Claude now has 16 tools for exploring your schema, validating queries, checking migrations, and suggesting improvements, all from the offline snapshot.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-the-agent-gets&quot;&gt;What the agent gets&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-the-agent-gets&quot; aria-label=&quot;Anchor link for: what-the-agent-gets&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;dryrun exposes 16 tools through MCP. The four that matter most for the argument in this article:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;check_migration&lt;&#x2F;strong&gt; is the one I find myself using most often, even outside of AI workflows. It analyzes DDL statements for lock implications, table rewrites, and version-specific behavior, and returns the safe multi-step alternative when one exists. Every migration example in this article was caught by this tool.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;suggest_index&lt;&#x2F;strong&gt; reads column statistics from the snapshot and recommends indexes informed by your actual data distribution. Not &quot;add an index on this column&quot; but &quot;add a partial index on the 3% of rows that match your query pattern.&quot; It also uses correlation data to recommend BRIN indexes where appropriate: 3MB instead of 400MB for a B-tree on a column with near-perfect insertion order.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;validate_query&lt;&#x2F;strong&gt; parses SQL against the schema snapshot without executing it. It catches references to tables and columns that don&#x27;t exist, detects anti-patterns, and flags issues before the query goes anywhere near the database.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;lint_schema&lt;&#x2F;strong&gt; runs 18 convention rules and 14 structural audit checks: naming, types, constraints, timestamps, partition health, duplicate indexes, foreign key columns missing indexes, tables without primary keys. All disableable in &lt;code&gt;dryrun.toml&lt;&#x2F;code&gt;, because not every convention fits every project.&lt;&#x2F;p&gt;
&lt;p&gt;The remaining tools cover schema exploration (&lt;strong&gt;list_tables&lt;&#x2F;strong&gt;, &lt;strong&gt;describe_table&lt;&#x2F;strong&gt;, &lt;strong&gt;search_schema&lt;&#x2F;strong&gt;, &lt;strong&gt;find_related&lt;&#x2F;strong&gt;), operations (&lt;strong&gt;compare_nodes&lt;&#x2F;strong&gt;, &lt;strong&gt;detect&lt;&#x2F;strong&gt;, &lt;strong&gt;vacuum_health&lt;&#x2F;strong&gt;), execution plans (&lt;strong&gt;explain_query&lt;&#x2F;strong&gt;), and schema maintenance (&lt;strong&gt;refresh_schema&lt;&#x2F;strong&gt;, &lt;strong&gt;check_drift&lt;&#x2F;strong&gt;). The full list is in the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringsql&#x2F;dryrun&quot;&gt;documentation&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it-yourself&quot;&gt;Try it yourself&lt;a class=&quot;zola-anchor&quot; href=&quot;#try-it-yourself&quot; aria-label=&quot;Anchor link for: try-it-yourself&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s a schema and seed data you can run in any local PostgreSQL:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customers&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name text NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz NOT NULL DEFAULT now&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; orders&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    status text NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    total_amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz NOT NULL DEFAULT now&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Seed it with data that has the distributions that matter, like skewed status values and nullable emails:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 100K customers, ~5% with NULL email&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; customers (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, email, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;customer_&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; i,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;05&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;         ELSE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;user&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    END&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2023-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz +&lt;&#x2F;span&gt;&lt;span&gt; (i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; seconds&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)::interval&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100000&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; i;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 1M orders, 97% completed &#x2F; 3% pending&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; orders (customer_id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, total_amount, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span&gt; (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 99999&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;03&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;         ELSE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;completed&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    END&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 500&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2024-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz +&lt;&#x2F;span&gt;&lt;span&gt; (i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; seconds&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)::interval&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1000000&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; i;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now capture the schema for dryrun and install it as an MCP server:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; dump-schema&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --source&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;$DATABASE_URL&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -o&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; schema.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; init&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; import schema.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;claude&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; mcp add dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; dryrun mcp-serve&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Open Claude Code and try these prompts. First without the dryrun MCP server, then with it.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;add-an-index-to-speed-up-queries-that-filter-orders-by-status&quot;&gt;&quot;Add an index to speed up queries that filter orders by status&quot;&lt;a class=&quot;zola-anchor&quot; href=&quot;#add-an-index-to-speed-up-queries-that-filter-orders-by-status&quot; aria-label=&quot;Anchor link for: add-an-index-to-speed-up-queries-that-filter-orders-by-status&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Without dryrun, Claude sees a &lt;code&gt;text&lt;&#x2F;code&gt; column and does the obvious thing:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; idx_orders_status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; orders (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A full B-tree index on 1 million rows. It works, but 97% of those rows are &lt;code&gt;&#x27;completed&#x27;&lt;&#x2F;code&gt; and nobody queries for completed orders.&lt;&#x2F;p&gt;
&lt;p&gt;With dryrun, Claude calls &lt;strong&gt;suggest_index&lt;&#x2F;strong&gt;, sees &lt;code&gt;most_common_freqs&lt;&#x2F;code&gt; showing &lt;code&gt;&#x27;completed&#x27;&lt;&#x2F;code&gt; at 97% and &lt;code&gt;&#x27;pending&#x27;&lt;&#x2F;code&gt; at 3%, and suggests:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; idx_orders_status_pending&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; orders (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The full index maintains 1 million entries. The partial index maintains 30,000. Every INSERT that creates a &lt;code&gt;&#x27;completed&#x27;&lt;&#x2F;code&gt; order no longer pays for index maintenance it&#x27;ll never use.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;make-the-email-column-on-customers-required&quot;&gt;&quot;Make the email column on customers required&quot;&lt;a class=&quot;zola-anchor&quot; href=&quot;#make-the-email-column-on-customers-required&quot; aria-label=&quot;Anchor link for: make-the-email-column-on-customers-required&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Without dryrun, Claude writes a single statement and moves on:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;&#x2F;span&gt;&lt;span&gt; COLUMN email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This fails immediately because 5% of rows have NULL emails. Claude didn&#x27;t know the data existed. Even if the column had no NULLs, the statement would still take an &lt;code&gt;ACCESS EXCLUSIVE&lt;&#x2F;code&gt; lock and perform a full table scan to verify.&lt;&#x2F;p&gt;
&lt;p&gt;With dryrun, Claude calls &lt;strong&gt;check_migration&lt;&#x2F;strong&gt; and &lt;strong&gt;describe_table&lt;&#x2F;strong&gt;. It sees 100,000 rows, knows the column is nullable, and knows from the statistics that NULLs exist. It generates a three-step migration:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Step 1: backfill NULLs&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;&#x2F;span&gt;&lt;span&gt; customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SET&lt;&#x2F;span&gt;&lt;span&gt; email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;unknown-&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE&lt;&#x2F;span&gt;&lt;span&gt; email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IS NULL&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Step 2: add constraint without full table scan&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    ADD CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; customers_email_not_null&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CHECK&lt;&#x2F;span&gt;&lt;span&gt; (email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IS NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT&lt;&#x2F;span&gt;&lt;span&gt; VALID;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Step 3: validate in the background with a weaker lock&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    VALIDATE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; customers_email_not_null;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The first step handles the data. The second adds the constraint without scanning existing rows. The third validates with a weaker lock that allows concurrent reads and writes. Three statements instead of one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;keeping-the-snapshot-fresh&quot;&gt;Keeping the snapshot fresh&lt;a class=&quot;zola-anchor&quot; href=&quot;#keeping-the-snapshot-fresh&quot; aria-label=&quot;Anchor link for: keeping-the-snapshot-fresh&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;dryrun is only as good as your last snapshot. If the schema changed yesterday and nobody re-dumped, the agent is working from stale data. The &lt;strong&gt;check_drift&lt;&#x2F;strong&gt; tool can detect this, but only if someone runs it.&lt;&#x2F;p&gt;
&lt;p&gt;The best approach is to treat schema snapshots like you treat production backups: automate them. If you&#x27;re already capturing production schema dumps on a schedule, add dryrun to the same pipeline. A nightly &lt;code&gt;dump-schema&lt;&#x2F;code&gt; from the primary gives you current structure and statistics. If you run read replicas with different workloads, add &lt;code&gt;--stats-only&lt;&#x2F;code&gt; dumps from each node for higher-fidelity statistics across the cluster. Commit the resulting &lt;code&gt;schema.json&lt;&#x2F;code&gt; to your repository so every developer and every AI agent starts the day with a current view.&lt;&#x2F;p&gt;
&lt;p&gt;The snapshot also serves as a lightweight audit trail. If someone adds a column through &lt;code&gt;psql&lt;&#x2F;code&gt; without a migration, the next scheduled dump captures it, and the diff in git shows exactly what changed and when.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-dryrun-is-not&quot;&gt;What dryrun is not&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-dryrun-is-not&quot; aria-label=&quot;Anchor link for: what-dryrun-is-not&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;dryrun does not execute arbitrary SQL against your database. It does not manage migrations. It does not provision infrastructure. It is not an ORM, a query builder, or a database admin panel. It&#x27;s read-only and narrow by design, trustworthy enough that you&#x27;d let an AI agent use it unsupervised.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s next&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-s-next&quot; aria-label=&quot;Anchor link for: what-s-next&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;dryrun ships today at v0.5.0. The next releases are focused on:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Shadow EXPLAIN&lt;&#x2F;strong&gt; (v0.6.0): inject production statistics into a local PostgreSQL instance so &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; produces realistic plans from your laptop, without any production data. Currently &lt;strong&gt;explain_query&lt;&#x2F;strong&gt; runs against whatever local database you point it at, but if that database has 10 rows in every table, the planner gives you toy plans that tell you nothing about production behavior.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;CI&#x2F;CD Trust Gate&lt;&#x2F;strong&gt;: a single command that takes a migration file and runs safety analysis, schema diff, lint, and index suggestions in one pass. Designed to block unsafe migrations before they merge.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Improved migration support&lt;&#x2F;strong&gt;: deeper analysis of multi-statement migrations, version-specific advice, and integration with common migration frameworks.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;getting-started&quot;&gt;Getting started&lt;a class=&quot;zola-anchor&quot; href=&quot;#getting-started&quot; aria-label=&quot;Anchor link for: getting-started&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;dryrun is MIT licensed and available on &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringsql&#x2F;dryrun&quot;&gt;GitHub&lt;&#x2F;a&gt;. The &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringsql&#x2F;dryrun&#x2F;blob&#x2F;master&#x2F;TUTORIAL.md&quot;&gt;tutorial&lt;&#x2F;a&gt; covers installation, offline and online workflows, multi-node setups, and MCP integration. It&#x27;s part of the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;boringsql.com&quot;&gt;boringSQL&lt;&#x2F;a&gt; suite.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
