<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>PostgreSQL - dbi Blog</title>
	<atom:link href="https://www.dbi-services.com/blog/category/postgresql/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.dbi-services.com/blog/category/postgresql/</link>
	<description></description>
	<lastBuildDate>Mon, 26 Oct 2026 21:24:48 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2025/05/cropped-favicon_512x512px-min-32x32.png</url>
	<title>PostgreSQL - dbi Blog</title>
	<link>https://www.dbi-services.com/blog/category/postgresql/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>PGConf.EU 2026 – Day 2 &#038; 3</title>
		<link>https://www.dbi-services.com/blog/pgconf-eu-2026-day-2-3/</link>
					<comments>https://www.dbi-services.com/blog/pgconf-eu-2026-day-2-3/#respond</comments>
		
		<dc:creator><![CDATA[Joël Cattin]]></dc:creator>
		<pubDate>Mon, 26 Oct 2026 21:23:07 +0000</pubDate>
				<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[conference]]></category>
		<category><![CDATA[feedback]]></category>
		<category><![CDATA[pgconf]]></category>
		<category><![CDATA[pgconf.eu]]></category>
		<category><![CDATA[postgresql]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=48142</guid>

					<description><![CDATA[<p>Vacuum and autovacuum I started the second day of PGConf.EU with a session about vacuum and autovacuum, presented by Teresa Lopes.After a short introduction to how MVCC works, she presented the different elements related to autovacuum:What are the different phases of autovacuum? What can trigger autovacuum? Which parameters can influence autovacuum? All these points were [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/pgconf-eu-2026-day-2-3/">PGConf.EU 2026 – Day 2 &amp; 3</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<h2 id="h-vacuum-and-autovacuum" class="wp-block-heading">Vacuum and autovacuum</h2>



<p class="wp-block-paragraph">I started the second day of PGConf.EU with a session about vacuum and autovacuum, presented by Teresa Lopes.<br>After a short introduction to how MVCC works, she presented the different elements related to autovacuum:<br>What are the different phases of autovacuum? What can trigger autovacuum? Which parameters can influence autovacuum? All these points were explained in a simple and clear way during the presentation.<br>Teresa also presented several reasons why autovacuum might not start, including a long-running transaction, an abandoned replication slot or an abandoned prepared transaction.</p>



<p class="wp-block-paragraph">The second half of the presentation focused on new autovacuum features in PostgreSQL 19, for example:</p>



<ul class="wp-block-list">
<li>The scoring system for autovacuum, based on criteria such as XID/MXID age and the number of inserted, updated or deleted tuples.</li>



<li>The possibility to use parallel workers for index vacuuming and cleanup.</li>



<li>Improved ways to observe and monitor autovacuum, especially through new views and new logging parameters.</li>
</ul>



<p class="wp-block-paragraph">The session ended with a look at how to recover disk space in tables after autovacuum has cleaned them up, especially with <code><a href="https://www.postgresql.org/docs/19/sql-repack.html" data-type="link" data-id="https://www.postgresql.org/docs/19/sql-repack.html" target="_blank" rel="noreferrer noopener">REPACK</a></code>, which also has a <code>CONCURRENTLY</code> option that allows other transactions to use the table while it is being repacked.</p>



<h2 id="h-zero-downtime" class="wp-block-heading">Zero downtime?</h2>



<p class="wp-block-paragraph">Speaking about <code>REPACK</code>, this new feature was also presented by Rajni Baliyan during her presentation about how to achieve zero downtime with PostgreSQL 19.<br><code>REPACK CONCURRENTLY</code> allows tables to be reorganized and disk space to be recovered without blocking normal database operations for the entire process. Unlike <code>VACUUM FULL</code>, which blocks access to the table during the operation, <code>REPACK CONCURRENTLY</code> allows applications to continue reading and writing data while a new copy of the table and its indexes is being created.<br>In the background, PostgreSQL uses logical decoding to capture changes made to the original table during this process and applies them to the new copy. At the end, it briefly takes an exclusive lock to replace the old table and indexes with the new ones.<br><br>An other interesting feature is the new logical sequence synchronization worker, which helps keep sequence values synchronized between the publisher and the subscriber during logical replication.<br>Before PostgreSQL 19, sequence values were not automatically synchronized through logical replication. This could cause problems during major version upgrades, as sequences on the new server could have outdated values and generate duplicate IDs.<br>With PostgreSQL 19, sequences can now be synchronized automatically when a subscription is created. The <code><a href="https://www.postgresql.org/docs/19/sql-altersubscription.html#SQL-ALTERSUBSCRIPTION-PARAMS-REFRESH-SEQUENCES" data-type="link" data-id="https://www.postgresql.org/docs/19/sql-altersubscription.html#SQL-ALTERSUBSCRIPTION-PARAMS-REFRESH-SEQUENCES" target="_blank" rel="noreferrer noopener">REFRESH SEQUENCES</a></code> command can also be used to synchronize them again when needed.<br><br>As you may have guess, the goal of this presentation was to show how new features can help reduce service interruptions during database maintenance and remove the need for maintenance windows. The presentation focused on making database operations easier and safer while keeping services available for users.<br>It was an interesting topic, especially for production environments where downtime must be avoided as much as possible.</p>



<h2 id="h-modern-backups-for-postgresql" class="wp-block-heading">Modern Backups for PostgreSQL</h2>



<p class="wp-block-paragraph">I then attended a session about <code><a href="https://www.pghardstorage.org/" data-type="link" data-id="https://www.pghardstorage.org/" target="_blank" rel="noreferrer noopener">pg_hardstorage</a></code>, presented by Hans-Jürgen Schönig.<br><code>pg_hardstorage</code> is a modern backup solution for PostgreSQL, designed especially for large environments and critical systems.<br>Unlike traditional backup tools, it uses the PostgreSQL replication protocol to retrieve a base backup and continuously stream WAL files. It does not require an agent or direct shell access on the database server. The tool runs on a separate backup host.<br><br>One interesting feature is its storage system. Each backup is treated as a full backup, but duplicate data blocks are stored only once. This reduces storage requirements without the complexity of traditional incremental backup chains.<br>The continuous WAL streaming also allows point-in-time recovery, making it possible to restore a database to a specific moment in time.<br><br>Another advantage is that <code>pg_hardstorage</code> can store backups on a filesystem or S3-compatible object storage. It also supports different PostgreSQL environments, including bare-metal servers, Patroni clusters and Kubernetes deployments.<br><br>Overall, it was an interesting presentation about an alternative approach to PostgreSQL backups, with a strong focus on simplicity, storage efficiency and reliable recovery.</p>



<p class="wp-block-paragraph"></p>



<h2 id="h-patroni-the-right-way" class="wp-block-heading">Patroni the right way</h2>



<p class="wp-block-paragraph">I then attended a session presented by Alexander Kukushkin and Polina Bungina, two of the main contributors to Patroni.<br>The presentation explained how Patroni really works behind the scenes, with a special focus on automatic failover and the challenges of managing a high-availability PostgreSQL cluster.<br><br>One important topic was the risk of <em>split-brain</em>, when two PostgreSQL nodes believe they are the primary at the same time. The speakers explained why a simple two-node setup is not enough and why quorum and fencing mechanisms are important to prevent this situation.<br>They also explained how Patroni uses a Distributed Configuration Store (DCS), such as etcd, to coordinate the cluster, manage the leader election and keep track of the state of each node.<br>The presentation also covered some important features, such as synchronous replication, standby clusters and DCS failsafe mode. Finally, they presented some upcoming features, including site-aware failover, which will help Patroni make better decisions when a cluster is spread across several data centers.<br><br>This was a very nice session to better understand the internal mechanisms of Patroni and the challenges of building a reliable PostgreSQL high-availability architecture.</p>



<h2 id="h-data-quality" class="wp-block-heading">Data Quality</h2>



<p class="wp-block-paragraph">Due to meetings with my customers, I unfortunately couldn&#8217;t attend any other sessions on Wednesday. However, I just managed to catch my colleague Hervé Schweitzer presentation of our OMrun tool during the &#8220;Silver Sponsor Lightning Talks&#8221;.</p>



<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" width="1024" height="576" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261021_140030609-1024x576.jpg" alt="" class="wp-image-48145" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261021_140030609-1024x576.jpg 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261021_140030609-300x169.jpg 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261021_140030609-768x432.jpg 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261021_140030609-1536x864.jpg 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261021_140030609-2048x1152.jpg 2048w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">Are you interested in data quality?<br>Take a look here to learn more about OMrun: <a href="https://www.dbi-services.com/fr/produits/omrun/" target="_blank" rel="noreferrer noopener">https://www.dbi-services.com/fr/produits/omrun/</a></p>



<h2 id="h-new-communication-hub-for-the-postgresql-community" class="wp-block-heading">New communication Hub for the PostgreSQL Community</h2>



<p class="wp-block-paragraph">On Thursday, I attended a session presented by Jack Bonatakis about <a href="https://hackorum.dev/" data-type="link" data-id="https://hackorum.dev/" target="_blank" rel="noreferrer noopener">Hackorum</a>, a tool designed to make PostgreSQL mailing lists easier to use for both experts and newcomers.<br>PostgreSQL development relies heavily on mailing lists, but following long email discussions can be difficult, especially when there are many messages.<br>Hackorum provides a modern, forum-style web interface on top of the existing mailing lists. It does not replace them. Instead, it makes discussions easier to read, follow and organize. Some of its main features are:</p>



<ul class="wp-block-list">
<li>Better discussion view: messages are grouped into topics, making long conversations easier to follow.</li>



<li>Read tracking: you can see which messages you have already read and continue where you stopped.</li>



<li>Commitfest integration: information about patches and their status is available directly alongside the related discussions.</li>



<li>Tags, notes and favourites: users can organize discussions, save important topics and add personal notes.</li>



<li>Team collaboration: reading status, tags and notes can be shared with other team members.</li>
</ul>



<p class="wp-block-paragraph">Compared to traditional mailing lists, Hackorum offers a more visual and user-friendly interface. It helps reduce the number of emails to manage and makes it easier to follow several discussions at the same time. The integration with Commitfest is also useful for PostgreSQL developers who want to follow patches and their progress. <br>One important point is that the mailing lists remain the main source of information. Hackorum simply provides a more convenient way to access and organize their content.<br><br>I found this an interesting initiative that could make the PostgreSQL community more accessible, especially for newcomers who may find the traditional mailing lists difficult to follow.</p>



<h2 id="h-monitoring" class="wp-block-heading">Monitoring</h2>



<p class="wp-block-paragraph">I finished the conference with a presentation by Jimmy Angelakos about <code><a href="https://github.com/pgEdge/radar" data-type="link" data-id="https://github.com/pgEdge/radar" target="_blank" rel="noreferrer noopener">radar</a></code> and <code><a href="https://github.com/pgEdge/radar-analyst" data-type="link" data-id="https://github.com/pgEdge/radar-analyst" target="_blank" rel="noreferrer noopener">radar-analyst</a></code>, two tools designed to simplify PostgreSQL diagnostics.<br>The idea behind <code>radar</code> is simple: instead of installing monitoring agents and collecting information over a long period, you can run a single command to collect a snapshot of the PostgreSQL server and its operating system. <br>The tool is agentless and does not require any additional dependencies. It creates a ZIP archive containing useful diagnostic information, including PostgreSQL configuration, database statistics, replication status, table and index information, as well as system resources such as CPU, memory and disk I/O.<br><br>The second tool, <code>radar-analyst</code>, analyzes this archive and helps identify potential problems. It uses predefined rules to check different areas, including PostgreSQL configuration, workload, replication and operating system settings. Each finding includes an explanation and recommendations to help fix the problem.<br><br>Compared to traditional monitoring solutions, this approach has several advantages. There is no need to install or maintain an agent, and a diagnostic snapshot can be collected when a problem occurs, even if no monitoring was configured beforehand.<br><br>I found this to be a practical approach to PostgreSQL troubleshooting. It can be particularly useful for support teams and DBAs who need to quickly understand the state of a database server without spending hours collecting information manually.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/pgconf-eu-2026-day-2-3/">PGConf.EU 2026 – Day 2 &amp; 3</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/pgconf-eu-2026-day-2-3/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Why attend PGConf.EU? My feedback from Valencia</title>
		<link>https://www.dbi-services.com/blog/why-attend-pgconf-eu-my-feedback-from-valencia/</link>
					<comments>https://www.dbi-services.com/blog/why-attend-pgconf-eu-my-feedback-from-valencia/#respond</comments>
		
		<dc:creator><![CDATA[Hervé Schweitzer]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 07:47:52 +0000</pubDate>
				<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[pgconf.eu]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=48032</guid>

					<description><![CDATA[<p>This week I was in Valencia for the PGConf.EU 2026, the biggest PostgreSQL conference in Europe. Something surprised me right at the start. During the welcome session on the first day, the organizers asked who was attending for the first time, and around 30% of the room raised their hand! I don’t know exactly what [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/why-attend-pgconf-eu-my-feedback-from-valencia/">Why attend PGConf.EU? My feedback from Valencia</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">This week I was in Valencia for the PGConf.EU 2026, the biggest PostgreSQL conference in Europe.</p>



<p class="wp-block-paragraph">Something surprised me right at the start. During the welcome session on the first day, the organizers asked who was attending for the first time, and around 30% of the room raised their hand! I don’t know exactly what it means, maybe more companies are moving to PostgreSQL, maybe more young people are joining the community. But for sure it means a lot of new people, new ideas and new conversations, and that’s a very good sign for PostgreSQL.</p>



<figure class="wp-block-image size-large"><img decoding="async" width="1024" height="768" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PostgreSQL-Conference-Auditorium-Talk-1024x768.png" alt="Full room during the welcome session on the first day of PGConf.EU 2026 in Valencia" class="wp-image-48034" title="picture of the conference start" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PostgreSQL-Conference-Auditorium-Talk-1024x768.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PostgreSQL-Conference-Auditorium-Talk-300x225.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PostgreSQL-Conference-Auditorium-Talk-768x576.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PostgreSQL-Conference-Auditorium-Talk.png 1448w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>



<h2 id="h-open-source-projects-you-can-use-directly" class="wp-block-heading">Open Source projects you can use directly </h2>



<p class="wp-block-paragraph">Many developers presented their own open source PostgreSQL projects. After the session, you can directly download them for free, use them, and contribute to them!</p>



<p class="wp-block-paragraph">For passionate people, this is a real added value. You can play with the tool right away, without having to contact a salesperson who will not leave you alone afterwards <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f642.png" alt="🙂" class="wp-smiley" style="height: 1em; max-height: 1em;" />. And if it fits your needs, you can simply use it in your daily work, without asking for a budget first!</p>



<p class="wp-block-paragraph">I tried to count them, and I found more than 15 sessions where the project is directly available on GitHub. Some examples:</p>



<ul class="wp-block-list">
<li><a href="https://github.com/boringSQL/pg-storage-visualizer" target="_blank" rel="noreferrer noopener">pg-storage-visualizer</a> (storage internals)</li>



<li><a href="https://github.com/xataio/pgstream" target="_blank" rel="noreferrer noopener">pgstream</a> (CDC)</li>



<li><a href="https://github.com/danolivo/pg_track_optimizer" target="_blank" rel="noreferrer noopener">pg_track_optimizer</a> (bad query plans)</li>



<li><a href="https://github.com/pgEdge/radar" target="_blank" rel="noreferrer noopener">radar</a> (diagnostic snapshots)</li>



<li><a href="https://github.com/hettie-d/pg_acm" target="_blank" rel="noreferrer noopener">pg_acm</a> (access control)</li>
</ul>



<p class="wp-block-paragraph">And many more.</p>



<h2 id="h-real-experience-from-the-field" class="wp-block-heading">Real experience from the field</h2>



<p class="wp-block-paragraph">Many technical sessions were about real projects, where the speakers shared their experience with PostgreSQL.</p>



<p class="wp-block-paragraph">I noticed that there are still a lot of sessions about migrating from Oracle to PostgreSQL. I thought the rush was over, but no, there are still many use cases. What changed is the type of database: today more and more big and critical Oracle environments are starting to leave Oracle. But in most cases these are still databases without too much code inside (PL/SQL). This remains the biggest challenge to leave Oracle.<br><br>Not only Oracle! we also see more and more customers migrating from DB2 to PostgreSQL, often companies that never made the jump to Oracle in the past, and now go directly to PostgreSQL <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f642.png" alt="🙂" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>



<h2 id="h-current-trends-from-the-community" class="wp-block-heading">Current trends from the community</h2>



<p class="wp-block-paragraph">There were a lot of sessions about the new PostgreSQL 19 features. Sometimes the presentations were a little bit redundant (REPACK, REPACK, and again REPACK <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f642.png" alt="🙂" class="wp-smiley" style="height: 1em; max-height: 1em;" />). But at least we know now which feature will push people to migrate to PostgreSQL 19: REPACK in core!</p>



<h2 id="h-sponsor-booths" class="wp-block-heading">Sponsor booths</h2>



<p class="wp-block-paragraph">Between the sessions, the sponsor area was also a very good place to meet people, where I had the opportunity to visit some of our partners, like <a href="https://www.dbtune.com" target="_blank" rel="noreferrer noopener">dbtune</a>, and discuss new topics and opportunities with them. These discussions are often as valuable as the sessions themselves, because you get direct feedback and you can talk about real customer use cases!</p>



<h2 id="h-and-ai" class="wp-block-heading">And AI?</h2>



<p class="wp-block-paragraph">Today, everybody talks about AI. At the conference, it was mainly a topic for the sponsors and not so much for the community (with some exceptions, like the fun &#8220;Dungeon Master with Postgres and MCP&#8221; session). More and more services come with AI integration, but I don&#8217;t think many of them are already used in production. And above all, I didn&#8217;t hear any real customer feedback about them yet!</p>



<h2 id="h-evening-in-valencia" class="wp-block-heading">Evening in Valencia</h2>



<p class="wp-block-paragraph">PGconf.eu it’s also a good opportunity to discover a new place in Europe. I have to say, that I was very positively surprised by Valencia! it’s a very pleasant city, with a lot of culture and history. And after the sessions, it’s also the perfect place to go out for dinner with other participants, where the discussions around PostgreSQL will continue with a beer <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f642.png" alt="🙂" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>



<figure class="wp-block-image size-large"><img decoding="async" width="1024" height="341" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Valencia-in-Five-Evening-Views-1024x341.png" alt="" class="wp-image-48046" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Valencia-in-Five-Evening-Views-1024x341.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Valencia-in-Five-Evening-Views-300x100.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Valencia-in-Five-Evening-Views-768x256.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Valencia-in-Five-Evening-Views-1536x512.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Valencia-in-Five-Evening-Views-2048x683.png 2048w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>



<h2 id="h-conclusion" class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">Overall, I would say that PGConf.EU is still the best place to see where PostgreSQL is going, and above all to meet the people who build it. For dbi services it was also a great event as a Silver Sponsor, and I also had the chance to present <a href="https://www.dbi-services.com/products/omrun/" target="_blank" rel="noreferrer noopener">OMrun</a> in the sponsor lightning talks.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="576" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/pgconf_omrun-1-1024x576.png" alt="" class="wp-image-48043" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/pgconf_omrun-1-1024x576.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/pgconf_omrun-1-300x169.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/pgconf_omrun-1-768x432.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/pgconf_omrun-1-1536x864.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/pgconf_omrun-1.png 1672w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">If you have never been there, I can only recommend it. See you next year!</p>



<h2 id="h-we-are-hiring-postgresql-consultants" class="wp-block-heading">We are hiring PostgreSQL consultants !</h2>



<p class="wp-block-paragraph">If you want to work with PostgreSQL every day and go to conferences like PGConf.EU, join us! At dbi services we are looking for PostgreSQL consultants to work with our customers on HA setups, performance tuning, migrations, Kubernetes and much more.</p>



<p class="wp-block-paragraph"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f449.png" alt="👉" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <a href="https://www.dbi-services.com/jobs/open-infra-consultant-basel/">Open Infrastructure Consultant</a></p>



<p class="wp-block-paragraph"></p>
<p>L’article <a href="https://www.dbi-services.com/blog/why-attend-pgconf-eu-my-feedback-from-valencia/">Why attend PGConf.EU? My feedback from Valencia</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/why-attend-pgconf-eu-my-feedback-from-valencia/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>PGConf.EU 2026 &#8211; Day 1</title>
		<link>https://www.dbi-services.com/blog/pgconf-eu-2026-day-1/</link>
					<comments>https://www.dbi-services.com/blog/pgconf-eu-2026-day-1/#respond</comments>
		
		<dc:creator><![CDATA[Joël Cattin]]></dc:creator>
		<pubDate>Wed, 21 Oct 2026 06:11:55 +0000</pubDate>
				<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[conference]]></category>
		<category><![CDATA[feedback]]></category>
		<category><![CDATA[pgconf]]></category>
		<category><![CDATA[pgconf.eu]]></category>
		<category><![CDATA[postgresql]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=47963</guid>

					<description><![CDATA[<p>Welcome to Valencia! This year, the European PostgreSQL conference, PGConf.EU, takes place at the Palacio de Congresos in Valencia, Spain. I am lucky to be able to attend it with some colleagues from dbi services. Let&#8217;s see what&#8217;s going on there&#8230; I started the first day with a quick tour of the both of the [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/pgconf-eu-2026-day-1/">PGConf.EU 2026 &#8211; Day 1</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<h2 id="h-welcome-to-valencia" class="wp-block-heading">Welcome to Valencia!</h2>



<p class="wp-block-paragraph">This year, the European PostgreSQL conference, PGConf.EU, takes place at the Palacio de Congresos in Valencia, Spain. I am lucky to be able to attend it with some colleagues from dbi services. Let&#8217;s see what&#8217;s going on there&#8230;</p>



<p class="wp-block-paragraph">I started the first day with a quick tour of the both of the different conference <a href="https://2026.pgconf.eu/sponsors/" data-type="link" data-id="2026.pgconf.eu/sponsors/">sponsors</a>.</p>



<p class="wp-block-paragraph">I then joined the main auditorium for the welcome session, presented by Magnus Hagander.<br>After sharing some information about the organization and how the conference would run, Magnus gave a nice overview of the history of PostgreSQL and PGConf. I found it very interesting to discover the history and evolution of the different conferences.<br>Even Slonik came on stage to give us a shy hello <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f642.png" alt="🙂" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="576" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_083643376-1024x576.jpg" alt="" class="wp-image-47966" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_083643376-1024x576.jpg 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_083643376-300x169.jpg 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_083643376-768x432.jpg 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_083643376-1536x864.jpg 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_083643376-2048x1152.jpg 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">During the coffee break, I went to watch a live demo at the DBtune stand. <br>DBtune is a tool that uses AI to automatically optimize PostgreSQL performance. It analyzes the database workload and adjusts server parameters to improve performance and resource usage, with the goal of reducing costs and manual work.</p>



<h2 id="h-extensions" class="wp-block-heading">Extensions</h2>



<p class="wp-block-paragraph">The morning continued with a session presented by Gülcin Yildirim Jelinak and Petr Jelinek. The goal of their presentation was to explain the main strengths of PostgreSQL&#8217;s execution model for OLTP workloads, such as MVCC, vacuuming and WAL management. They also explained the limitations of these mechanisms when dealing with large amounts of data, either for writing or for analytics.<br>The two speakers explained that, in some configurations, PostgreSQL can work as a transactional control layer and an SQL interface, while specialized systems handle execution tasks that are different from traditional OLTP workloads. Many of these solutions are available as extensions, such as <code>pg_search</code>, <code>pg_lake</code>, <code>pg_duckdb</code> and <code>pg_clickhouse</code>. These extensions integrate external execution engines directly with PostgreSQL.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="576" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_092347735-1024x576.jpg" alt="" class="wp-image-47969" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_092347735-1024x576.jpg 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_092347735-300x169.jpg 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_092347735-768x432.jpg 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_092347735-1536x864.jpg 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/PXL_20261020_092347735-2048x1152.jpg 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">Speaking of extensions, the next session I attended was entirely dedicated to this topic. It was presented by Ebru Aydin Gol, who talked about different approaches and best practices for managing changes and updates to extensions while keeping backward compatibility as much as possible. The general idea is to systematically classify all changes according to their impact on backward compatibility and on the behavior experienced by users.</p>



<h2 id="h-new-features-and-pgbackrest" class="wp-block-heading">New features and pgBackRest</h2>



<p class="wp-block-paragraph">I then went to see my former colleague Franck Pachot, who presented some new features of PostgreSQL 19, which we hope will be released very soon! <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f609.png" alt="😉" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <br>The new features were presented through live demos. Many of them focused on performance improvements, especially thanks to changes in the query planner.<br>Franck finished his session by talking about <code>pg_durable</code>, an extension that allows workflows to be defined and executed directly in the database. If a crash happens, it can resume the workflow from where it stopped. This is an interesting extension that could make tools such as <code>pg_cron</code> or other external orchestrators unnecessary for some use cases.</p>



<p class="wp-block-paragraph">After the afternoon break, I attended a session that was a little less technical but just as interesting. It was about the different ways to implement pgBackRest in high-availability environments.<br>Stefan Ferot presented several pgBackRest deployment patterns, starting with the simplest solution (one PostgreSQL host connected to one pgBackRest repository host) and moving to the most robust solution. <br>The most robust architecture uses cloud storage, such as S3, to store archived WAL files directly, together with a repository host that pulls backups from the standby server. This architecture offers several advantages. It removes the SPOF related to on-premises WAL archiving and provides centralized backup orchestration through the repository host.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="928" height="428" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-76.png" alt="" class="wp-image-47971" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-76.png 928w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-76-300x138.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-76-768x354.png 768w" sizes="auto, (max-width: 928px) 100vw, 928px" /><figcaption class="wp-element-caption"><a href="https://pgstef.github.io/talks/en/20261020_PGConfEU_pgBackRest-in-HA-setups.pdf">https://pgstef.github.io/talks/en/20261020_PGConfEU_pgBackRest-in-HA-setups.pdf</a></figcaption></figure>



<p class="wp-block-paragraph">I particularly appreciated that Stefan highlighted the different pgBackRest parameters to consider on both the PostgreSQL hosts and the repository host for each architecture he presented. Despite this, I would have liked to see a demo showing the resilience of the most robust architecture.</p>



<h2 id="h-migration" class="wp-block-heading">Migration</h2>



<p class="wp-block-paragraph">My day ended with a presentation about an Oracle to PostgreSQL migration project of a platform providing more than 500 services to citizens of an Indian state. Hari Kira explained how they handled the rewriting and migration of Oracle-dependent code, the use of ora2pg and its limitations, as well as the main strategic decisions and different project steps.<br>In the end, everything was migrated to a single PostgreSQL instance, with Patroni, etcd and HAProxy providing high availability.</p>



<h2 id="h-a-few-thoughts-after-day-one" class="wp-block-heading">A few thoughts after day one</h2>



<p class="wp-block-paragraph">My first impressions of PGConf.EU are very positive. The atmosphere is relaxed and friendly, and the conference is very well organized. The technical sessions are very interesting, with several talks about the new features coming in PostgreSQL 19. I also noticed that there were very few sessions about AI, which is quite surprising nowadays.<br>Overall, this conference is a great opportunity to learn new things and share experiences with the PostgreSQL community.</p>



<p class="wp-block-paragraph">Let&#8217;s go for the second day!</p>
<p>L’article <a href="https://www.dbi-services.com/blog/pgconf-eu-2026-day-1/">PGConf.EU 2026 &#8211; Day 1</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/pgconf-eu-2026-day-1/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>AI problems are database problems</title>
		<link>https://www.dbi-services.com/blog/ai-problems-are-database-problems/</link>
					<comments>https://www.dbi-services.com/blog/ai-problems-are-database-problems/#respond</comments>
		
		<dc:creator><![CDATA[Adrien Obernesser]]></dc:creator>
		<pubDate>Sun, 04 Oct 2026 18:40:01 +0000</pubDate>
				<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[ai]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=47169</guid>

					<description><![CDATA[<p>Introduction When discussing agentic AI, we spend a lot of time on the model. Which one should we use? How much context does it support? Can it choose the right tool? These are useful questions. But from a my perspective, there are a few others I would ask quite early. Where does the agent store [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/ai-problems-are-database-problems/">AI problems are database problems</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<h2 id="h-introduction" class="wp-block-heading">Introduction</h2>



<p class="wp-block-paragraph">When discussing agentic AI, we spend a lot of time on the model. Which one should we use? How much context does it support? Can it choose the right tool? These are useful questions. But from a my perspective, there are a few others I would ask quite early. Where does the agent store its state? Who can access the information it retrieves? What happens when two agents update the same record? Can we recover after a bad change? And can we explain which data was used to produce an answer? What governance do we need to apply ?  <br>In an upcoming essay, <em>Deciding in the Storm</em>, I am exploring how organizations can make useful decisions while AI keeps changing. Here, I focus on the application underneath: where its knowledge and workflow state live, who may change them, and what happens when several users or agents depend on them at once. These are questions database teams already know how to investigate.</p>



<p class="wp-block-paragraph">That is good news. We already have useful tools and operational experience, but we need to apply them properly.</p>



<h2 id="h-what-a-knowledge-format-gives-us" class="wp-block-heading">What a knowledge format gives us</h2>



<p class="wp-block-paragraph">On 12 June 2026, Google Cloud introduced the <a href="https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing" target="_blank" rel="noopener">Open Knowledge Format</a>, or OKF. The initial specification represents knowledge as Markdown files with YAML frontmatter and conventions for linking and organizing them. Table descriptions, metric definitions, runbooks and business rules can be exchanged in a format that both people and agents can read. You can review the files, version them in Git and move them between tools.<br>I like that approach. We do not need another proprietary API just to explain what a business metric means. The <a href="https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md" target="_blank" rel="noopener">v0.2 specification</a> includes provenance, verification records, lifecycle status and a <code>stale_after</code> timestamp. It also defines attested computations: a concept that carries both the meaning of a value and the approved method to calculate it, so a consumer can check that a figure came from the validated calculation rather than from an agent that improvised it. And it explicitly leaves storage, serving and query infrastructure to the implementation. A field can describe when knowledge becomes stale. Something still has to detect source changes, refresh that knowledge and decide how consumers handle it. Likewise, a verification record describes a check; it does not enforce access permissions.</p>



<p class="wp-block-paragraph">OKF can be part of a governed platform, including one backed by a database. The format and the operational platform have different responsibilities.</p>



<h2 id="h-a-personal-second-brain-is-a-valid-architecture" class="wp-block-heading">A personal second brain is a valid architecture</h2>



<p class="wp-block-paragraph">The famous LLM wiki pattern described by <a href="https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f" target="_blank" rel="noopener">Andrej Karpathy</a> is a useful example, and it is the pattern Google says OKF formalizes: keep source material, then let an LLM build and maintain linked Markdown pages around it.</p>



<p class="wp-block-paragraph">I personally use Obsidian for my own notes, and I understand the appeal. Files are easy to inspect, easy to edit and easy to move. When I work alone, I can decide how to organize the corpus and review what the agent changes. With suitable backups and control over what reaches the model, this can be a perfectly reasonable architecture.</p>



<p class="wp-block-paragraph">As the corpus grows, I can improve how I find information. Start with filenames, links, metadata and text search. I would recommend adding an index when repeated scans become inconvenient (Karpathy&#8217;s own note puts the useful limit of a plain index file at roughly a hundred sources and a few hundred pages, before a local search engine becomes worthwhile). SQLite FTS5 provides full-text search, while ordinary tables can hold document paths, tags, source revisions and indexing status. Markdown remains the authoritative content and SQLite holds a derived index that can be rebuilt from the files, as long as the indexing process detects modifications and deletions. With SQLite we have then included a database, even though there is no database server to operate. Note that the database capabilities can start inside a local application.</p>



<p class="wp-block-paragraph">SQLite is also capable of serving multiple users through an application. Its <a href="https://www.sqlite.org/whentouse.html" target="_blank" rel="noopener">deployment guidance</a> highlights workload and concurrency as deciding factors: it permits one writer at a time per database file. The number of colleagues alone does not determine when PostgreSQL becomes necessary.</p>



<h2 id="h-shared-memory-becomes-an-application-data-problem" class="wp-block-heading">Shared memory becomes an application data problem</h2>



<p class="wp-block-paragraph">Strictly speaking, the LLM is a model inside an application (agents are programmatic). The surrounding application supplies tools, identity, retrieval and any state it needs to retain. An application can persist information without a database: files, object storage and durable logs are all possible storage choices. The precise requirement is that state needed after a restart or a new session must be stored somewhere outside the transient computation and loaded again when required.</p>



<p class="wp-block-paragraph">What changes at team and organizational level is the set of guarantees we need around that state. A team could share reviewed documentation through Git and serve a mostly read-only corpus successfully. But consider a system where several agents update shared tasks, record approvals, remember customer-specific facts and act on behalf of users with different permissions. We now need conflict handling, authoritative versions, controlled visibility and recoverable history. Personal conventions are no longer enough to establish who may change what.</p>



<p class="wp-block-paragraph">The comment thread under Karpathy&#8217;s gist is a good illustration of that evolution. By September, the people building their own versions of the pattern were reporting the same needs: write gates and proposal queues so that several clients can write without overwriting each other; provenance and verification stamps so that an unsupported answer filed into the wiki is not later treated as a source; staleness checks so that someone learns when a page stopped being true; and an MCP server in front of one canonical corpus so that different tools read and write the same memory. One contributor argued that a wiki needs to record both when a fact was true and when the system came to &#8220;believe&#8221; it, and that Markdown makes this hard. Those are database requirements, and the SQL standard has described that last one, bitemporal data, since 2011.</p>



<h3 id="h-four-kinds-of-data-not-one-memory" class="wp-block-heading">Four kinds of data, not one &#8220;memory&#8221;</h3>



<p class="wp-block-paragraph">&#8220;Memory&#8221; hides several responsibilities that do not need the same home:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Kind of data</th><th>Examples</th><th>Where it can live</th><th>What it needs</th></tr></thead><tbody><tr><td>Knowledge documents</td><td>Runbooks, metric definitions, an OKF bundle</td><td>Reviewed Markdown in Git, or ingested into a database</td><td>Review, versioning, freshness metadata</td></tr><tr><td>Authoritative state</td><td>Tasks, approvals, operation revisions, customer facts</td><td>A transactional database</td><td>Concurrency control, a tight recovery objective, access rules</td></tr><tr><td>Derived retrieval data</td><td>Embeddings, search indexes</td><td>Next to the source, for example with pgvector</td><td>A link to the source revision, a rebuild path, the source&#8217;s access rules</td></tr><tr><td>Traces and evaluations</td><td>Requests, retrieved context, answers, scores</td><td>A tracing store</td><td>Its own access control, filtering and retention</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Only the second kind cannot be rebuilt from something else. That is where I would normally put the database first, keeping knowledge documents where they are most useful. The database might already sit behind an existing service; it does not have to be a new PostgreSQL instance dedicated to the agent.<br><strong>This is the argument I am making: when an organizational second brain starts coordinating work, it inherits the data-management requirements of a shared application. A database is often the most direct way to meet them.</strong> PostgreSQL being then my first choice for its maturity in both RDBMS and AI world as long as sovereignty advantages. This part of what we all call the vertical defensibility other and me included, talk about. </p>



<h2 id="h-a-larger-context-window-does-not-replace-data-management" class="wp-block-heading">A larger context window does not replace data management</h2>



<p class="wp-block-paragraph">Could we skip all of this and simply give the model everything, in a larger context window?</p>



<p class="wp-block-paragraph">Sometimes more context helps. If the task requires reasoning across an entire document, retrieving a few fragments may omit something essential. I would not claim that research has established a consensus against long context.</p>



<p class="wp-block-paragraph">The <a href="https://proceedings.mlr.press/v267/li25dv.html" target="_blank" rel="noopener">LaRA benchmark</a>, published at ICML 2025, found that the choice between retrieval and long-context processing depends on the model, task, context length and retrieval characteristics. An <a href="https://aclanthology.org/2024.emnlp-industry.66/" target="_blank" rel="noopener">earlier EMNLP study</a> found stronger average results for sufficiently resourced long-context processing on its benchmarks, with lower cost as an advantage of RAG. Neither establishes a universal winner. At the same time, <a href="https://www.trychroma.com/research/context-rot" target="_blank" rel="noopener">Chroma&#8217;s context-rot report</a> measured 18 models degrading as input length grows, often well before the window is full, so the window size is not a reliable guide to how much of it a model uses well.<br>What recent work does support is the need to manage context deliberately. Anthropic&#8217;s <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener">context-engineering guidance</a> discusses selective retrieval, compaction and notes stored outside the active context. The <a href="https://arxiv.org/abs/2512.24601" target="_blank" rel="noopener">Recursive Language Models paper</a>, first published in December 2025, explores letting a model inspect and decompose external input programmatically. My understanding is that a larger context window is another capability to evaluate but it does not remove the need to select relevant information, track its origin or keep it current.</p>



<p class="wp-block-paragraph">And even if a model could reliably process every document in the company, we would still have to decide which documents a particular user may access. More tokens do not define ownership, resolve conflicting updates or provide a recovery plan. <a href="TODO-ADRIEN-URL">Deeper thoughts on the matter here</a>.</p>



<h2 id="h-one-operation-followed-end-to-end" class="wp-block-heading">One operation, followed end to end</h2>



<p class="wp-block-paragraph">The shared state is where agents differ most from a retrieval demo, so let us follow one piece of it. An agent proposes a maintenance operation: rebuild an index on a busy table during tonight&#8217;s window. A person approves it. Another agent changes the plan. Execution times out. At each step, something must be recorded and something must be checked.</p>



<p class="wp-block-paragraph"><strong>The proposal.</strong> The agent writes an operation record: the plan, a revision number starting at 1, and a state, awaiting approval. The plan can come from a runbook in Markdown; the operation itself is authoritative state, so it goes in the database.</p>



<p class="wp-block-paragraph"><strong>The approval.</strong> A person approves revision 1. The approval row names the operation, the revision and the approver, and it is written by a trusted component that authenticated that person, never by the agent. An approved flag that the agent can set is not an approval.</p>



<p class="wp-block-paragraph"><strong>The change.</strong> A second agent decides the window is too short and edits the plan. It read revision 1, so it updates on that condition:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
UPDATE operation
   SET plan = $2,
       revision = revision + 1,
       state = &#039;awaiting_approval&#039;
 WHERE id = $1
   AND revision = $3;  -- the revision this agent read

</pre></div>


<p class="wp-block-paragraph">If someone changed the record in the meantime, the update touches zero rows and the agent has to read again instead of overwriting work it never saw. The edit also creates revision 2 and sends the operation back to awaiting approval: the person approved revision 1, not whatever the plan became afterwards. Depending on the operation, the same protection can come from a row lock or from serializable transactions with retry handling; PostgreSQL&#8217;s <a href="https://www.postgresql.org/docs/current/transaction-iso.html" target="_blank" rel="noopener">transaction isolation documentation</a> explains why the isolation level matters.</p>



<p class="wp-block-paragraph"><strong>The execution.</strong> Once revision 2 is approved, the execution service claims the operation in one statement, so two executors cannot both start it:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
UPDATE operation o
   SET state = &#039;executing&#039;,
       attempt_id = $2
 WHERE o.id = $1
   AND o.state = &#039;approved&#039;
   AND EXISTS (SELECT 1
                 FROM approval a
                WHERE a.operation_id = o.id
                  AND a.revision = o.revision)
RETURNING o.revision;

</pre></div>


<p class="wp-block-paragraph">Zero rows means another executor got there first, or the approved revision is no longer the current one. The service then verifies its own authority to act and only then touches the target system. The attempt identifier is recorded before the action, so the action can be recognized later.</p>



<p class="wp-block-paragraph"><strong>The timeout.</strong> The call to the target system times out. Maybe the rebuild never started, maybe it finished a second before the connection dropped. A database transaction cannot roll back an action performed outside it, and saving the workflow state does not make the external action atomic with the update that records its completion. So the state becomes requiring recovery, not failed and not completed. Reconciliation then asks the target: does the new index exist, and is it valid? Only then is the operation marked completed, or retried with the same attempt identifier so that a retry cannot apply the action twice. A blind retry is how an agent does the same thing twice.</p>



<p class="wp-block-paragraph"><strong>The record.</strong> At the end, the database holds every revision, who approved which one, every attempt and its outcome. That history cannot be rebuilt from anything else, which is why it deserves a stricter recovery objective than the embeddings.</p>



<p class="wp-block-paragraph"><a href="https://github.com/statelyai/xstate" target="_blank" rel="noopener">XState</a> is one example of a library for expressing this logic: states, allowed transitions and guards that decide whether a transition may happen. It also saves and restores actor state, while the application supplies the persistent storage and the recovery design. A state machine helps express the process; its presence alone does not establish a security boundary. That said, I am quite fond of this project, and I think it is a significant piece of the puzzle. The responsibilities stay explicit:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Component</th><th>Responsibility in this example</th></tr></thead><tbody><tr><td>Procedure or knowledge document</td><td>Describe the operation, prerequisites and constraints</td></tr><tr><td>Workflow state machine</td><td>Define the current stage and allowed transitions</td></tr><tr><td>Database</td><td>Persist operation records, approvals, revisions, attempts and outcomes</td></tr><tr><td>Execution service</td><td>Verify authority and execute the permitted operation</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The retrieval lab I describe below does not cover any of this. Safe autonomous writes and workflow recovery are a separate test, and the one I would want to see before an agent acts on production.</p>



<h2 id="h-the-other-familiar-questions" class="wp-block-heading">The other familiar questions</h2>



<h3 id="h-can-we-recover-it" class="wp-block-heading">Can we recover it</h3>



<p class="wp-block-paragraph">For a DBA, storing data is only the beginning. We also need to know how much we could lose and how long recovery would take: our RPO and RTO. PostgreSQL provides WAL and the mechanisms needed for replication and recovery. Point-in-time recovery still requires a usable base backup and the required WAL. High availability requires a configured deployment and a tested failover process. Installing PostgreSQL does not give us all of this automatically. A replica is also not a substitute for a backup: it can faithfully reproduce an unwanted change, including one an agent made. An embedding index may be reproducible, but rebuilding it takes time and can incur embedding costs.</p>



<h3 id="h-how-do-we-know-the-knowledge-is-current" class="wp-block-heading">How do we know the knowledge is current</h3>



<p class="wp-block-paragraph">A source document changes. Its embedding still represents the previous version. Retrieval continues to work, but it works against outdated information. CDC can tell a pipeline that source data changed. It does not regenerate the embedding by itself, and it does not make an external embedding service part of the source transaction. My approach would be to track the source revision and the embedding revision explicitly, generate the replacement, verify that it still corresponds to the intended source revision, then publish the new content and embedding together. Deletion needs the same attention: removing a source row does not remove copies from caches, downstream stores or conversation history. In <a href="https://www.dbi-services.com/blog/rag-series-embedding-versioning-with-pgvector-why-event-driven-architecture-is-a-precondition-to-ai-data-workflows/" target="_blank" rel="noopener">Embedding Versioning with pgvector</a>, I argued that batch re-embedding is technical debt and event-driven refresh a precondition. For data that changes and drives decisions, I still think so. The more precise rule is that the freshness the use case requires decides whether batch is enough. A documentation corpus refreshed every night may meet its target; operational data behind a time-sensitive decision will not. Declare the target, then measure the delay, including failed refreshes and deletion propagation.</p>



<h3 id="h-who-is-allowed-to-retrieve-it" class="wp-block-heading">Who is allowed to retrieve it</h3>



<p class="wp-block-paragraph">PostgreSQL <a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" target="_blank" rel="noopener">row-level security</a> enforces row visibility in the engine, for every application and retrieval path that reads the same tables, as long as the application does not connect with a role that bypasses it. I built and measured this in <a href="https://www.dbi-services.com/blog/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant/" target="_blank" rel="noopener">AI agents on sensitive data: what PostgreSQL can enforce and what it can&#8217;t</a>: synthetic banks, an agent answering questions over client documents, pgvector next to the data. Policies apply to the embedding tables as they do to the documents. The tenant is set inside every transaction, so it ends at commit and a pooled connection does not hand it to the next request, as long as nothing sets it at session level. But row visibility was one layer of six: column privileges, a register of what is sensitive, deterministic tokens, a gate on what leaves for the model provider, a separate vault server and the audit trail each carry part of the guarantee, and the guarantee ends where the labelling ends.<br>The database enforces the rule once the identity is set; it cannot tell whether the identity is the right one. The trusted service chooses the tenant from the authenticated user, and the agent must not be able to overwrite it, for example by running its own SQL on the same connection. On relevance, the measurements went against the usual fear: blunt redaction collapsed retrieval, while labelled tokens with entity-aware search matched raw text under the same search, the answers cited the expected documents as often, and zero detected sensitive values were sent to the model.</p>



<h3 id="h-can-we-reconstruct-what-happened" class="wp-block-heading">Can we reconstruct what happened</h3>



<p class="wp-block-paragraph"><a href="https://github.com/pgaudit/pgaudit" target="_blank" rel="noopener">pgAudit</a> records which statements ran and which objects they accessed; its optional row logging records a count, not the rows. It sees the database side of one step, not which question triggered it, what the model did with the rows, or whether the answer was right. For a RAG request I would also want a request identifier, the authenticated identity, the database role and whether it bypasses row-level security, the retrieved document identifiers and revisions, the policy version, the context actually sent, and the answer with its evaluation.<br>In the lab, I added Langfuse, self-hosted, to follow that path. Two lessons. The tracing layer is a database workload in its own right, PostgreSQL plus ClickHouse, to size, back up, secure and retain. And with evaluation scores next to the trace spans (a span is one recorded step, such as the search or the model call), a weak answer becomes a join: did the search miss the documents, or did the model ignore them?</p>



<p class="wp-block-paragraph">It also stores every retrieved document once more. In the lab, a tool&#8217;s result was recorded when the tool returned, before the provider gate checked the next request, so a value the gate blocked could still reach the trace store. Tracing needs its own filtering boundary, applied before trace data leaves the application, rather than the assumption that the provider gate covers it. That variant is the next one I want to run.</p>



<h2 id="h-measure-retrieval-quality-and-security-separately" class="wp-block-heading">Measure retrieval quality and security separately</h2>



<p class="wp-block-paragraph">Production RAG evaluation splits the retrieval step from the generation step. For retrieval, the classic measures, recall@k, precision@k and nDCG@k, still do the job (<a href="https://www.dbi-services.com/blog/rag-series-adaptive-rag-understanding-confidence-precision-ndcg/" target="_blank" rel="noopener">Understanding Confidence, Precision and nDCG</a> defines them), and public benchmarks such as BEIR and MTEB still rank on nDCG@10. Their cost is a set of questions with the documents that should come back, written by someone who knows the data. Most tooling (Ragas, TruLens, DeepEval, the evaluators in Langfuse) adds measures scored by a second model acting as a judge: context precision and recall for the retrieval, faithfulness and answer relevance for the answer. Judges scale where human labels do not, but they make errors too, so I would calibrate them on a small labelled set. And each measure claims less than it seems: an answer that cites the expected documents has the right coverage, which is not proof that it is factually correct.<br>Two points matter for sensitive data. First, define the evaluation set per identity: the relevant documents that identity is permitted to retrieve. That avoids treating the exclusion of another customer&#8217;s data as a retrieval failure. Second, a judge model reads the question, the retrieved context and the answer, so it is one more place the data goes, like an external embedding service. Both belong in the same data-flow review as the model that answers. Measures computed from document identifiers need no judge at all.</p>



<p class="wp-block-paragraph">Security testing needs its own checks. Does a request from tenant A ever return tenant B&#8217;s documents? Can a user change the retrieval identity? Do revoked permissions still allow access through a cache? Can retrieved content persuade an agent to use another tool with broader access? A high relevance score does not answer those questions. Unauthorized data can be extremely relevant to a query and still be forbidden. A mandatory access restriction is a requirement to satisfy; we can then work on preserving quality within it.</p>



<h2 id="h-the-operational-work-still-needs-an-owner" class="wp-block-heading">The operational work still needs an owner</h2>



<p class="wp-block-paragraph">A production agent depends on more than retrieval. It needs credentials, tool permissions, monitoring, deployment procedures and recovery paths, and approval before a consequential action, as in the example above. This is shared work between application teams, database teams, infrastructure and security. I would not call every one of these problems a database problem. Model reliability, prompt injection and the safety of external actions need their own controls; I discussed where the trust boundary sits for tools in <a href="https://www.dbi-services.com/blog/rag-mcp-skills-three-paradigms-for-llms-talking-to-your-database-and-why-governance-changes-everything/" target="_blank" rel="noopener">RAG, MCP, Skills, and why governance changes everything</a>. Database experience nevertheless covers a substantial part of the foundation.<br>Anthropic&#8217;s <a href="https://claude.com/blog/the-ai-native-sdlc-playbook" target="_blank" rel="noopener">AI-native SDLC playbook</a> gives a concrete example of this broader work. It describes versioned artifacts such as <code>intent.md</code>, <code>spec.md</code> and <code>plan.md</code>, alongside review, approval gates and operational feedback, and it distinguishes advisory instructions in skills from controls enforced through executable mechanisms such as hooks. Markdown stays valuable inside the workflow; the process around it establishes who may approve a change, what can execute and where the authoritative record lives. It is a vendor&#8217;s implementation guidance, so I would evaluate its controls against the actual environment rather than treat every example as a production guarantee.</p>



<h2 id="h-readiness-needs-evidence" class="wp-block-heading">Readiness needs evidence</h2>



<p class="wp-block-paragraph">Readiness can be described through evidence: a restore that met the recovery objective, a failover test that included the CDC consumer, a tenant-isolation test, a measured refresh delay, a retrieval evaluation based on the intended users and data, and, for an agent that acts, a test of the timeout above. We also need to know where the data goes. Keeping PostgreSQL in Switzerland does not keep the context sent to an external model there: embedding providers, inference endpoints, judges, traces, backups and support access all belong in the assessment, and the EU AI Act and the revised Swiss data-protection law both expect us to know it. How to judge that evidence against targets an organization declared for itself, rather than against someone else&#8217;s checklist, is the subject of <em>Deciding in the Storm</em>.</p>



<h2 id="h-conclusion" class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">I see a useful opportunity here for database teams. We already know how to ask about consistency, permissions, recovery and operational ownership. Agentic applications give us another reason to ask those questions early.<br>A personal second brain can work well with files, search and an embedded database as needed. As it becomes a shared application, separate what it holds, define the guarantees its users depend on and choose the storage and workflow mechanisms that provide them. Keep formats such as OKF where portability and reviewable knowledge help.</p>



<p class="wp-block-paragraph">Larger context windows give the model more room to work. The application still needs a reliable way to retain, update and govern the information it uses.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<p class="wp-block-paragraph"><em>Related articles: <a href="https://www.dbi-services.com/blog/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant/" target="_blank" rel="noopener">AI agents on sensitive data: what PostgreSQL can enforce and what it can&#8217;t</a>; <a href="https://www.dbi-services.com/blog/rag-mcp-skills-three-paradigms-for-llms-talking-to-your-database-and-why-governance-changes-everything/" target="_blank" rel="noopener">RAG, MCP, Skills, and why governance changes everything</a>; <a href="https://www.dbi-services.com/blog/rag-series-embedding-versioning-with-pgvector-why-event-driven-architecture-is-a-precondition-to-ai-data-workflows/" target="_blank" rel="noopener">Embedding Versioning with pgvector</a>; <a href="https://www.dbi-services.com/blog/rag-series-adaptive-rag-understanding-confidence-precision-ndcg/" target="_blank" rel="noopener">Understanding Confidence, Precision and nDCG</a>; the Swiss PGDay 2026 talk <a href="https://2026.pgday.ch/session/388-the-cost-of-security-debt-in-postgresql-when-implementing-ai-workflows/" target="_blank" rel="noopener">&#8220;The cost of security debt in PostgreSQL when implementing AI workflows&#8221;</a>.</em></p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph"></p>
<p>L’article <a href="https://www.dbi-services.com/blog/ai-problems-are-database-problems/">AI problems are database problems</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/ai-problems-are-database-problems/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#8217;t</title>
		<link>https://www.dbi-services.com/blog/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant/</link>
					<comments>https://www.dbi-services.com/blog/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant/#respond</comments>
		
		<dc:creator><![CDATA[Adrien Obernesser]]></dc:creator>
		<pubDate>Sat, 03 Oct 2026 13:22:11 +0000</pubDate>
				<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[Security]]></category>
		<category><![CDATA[ai]]></category>
		<category><![CDATA[AI Agent]]></category>
		<category><![CDATA[Clickhouse]]></category>
		<category><![CDATA[Governance]]></category>
		<category><![CDATA[Langfuse]]></category>
		<category><![CDATA[postgresql]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=47446</guid>

					<description><![CDATA[<p>Every useful database content, document behind an internal AI agent names someone, a company, server names,&#8230; A relationship manager might ask &#8220;what did this client complain about last quarter?&#8221;, an operations engineer might ask &#8220;what happened on this server last month?&#8221;, and the answer sits in notes, emails and incident tickets, CRMs and ERPs data, [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant/">AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#8217;t</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Every useful database content, document behind an internal AI agent names someone, a company, server names,&#8230; <br>A relationship manager might ask &#8220;what did this client complain about last quarter?&#8221;, an operations engineer might ask &#8220;what happened on this server last month?&#8221;, and the answer sits in notes, emails and incident tickets, CRMs and ERPs data, full of client names, staff names, IBANs, hostnames and IP addresses: exactly the text you would rather not send to a model.</p>



<p class="wp-block-paragraph">Usually I see two answers for that. Keep AI away from that data, which in practice means there is no project. Or redact before sending: replace every name with <code>[REDACTED]</code> and send the rest. That one looks safe and fails quietly, because once every client is <code>[REDACTED]</code>, the assistant can no longer tell one client from another because it is like joining two tables without a key. It answers, but without being relevant enough.</p>



<p class="wp-block-paragraph">I built the layer in between, on PostgreSQL: row-level security, labels, the token columns and audit live in the database, the tokenizing and the gate in the application, the key and the mapping on a second server. Here is what it does, on a question from the lab (condensed from the run&#8217;s output):</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
question asked : What did Emmental Immobilien AG complain about regarding custody fees?
question sent  : What did CLIENT_43aa5d8366b5 complain about regarding custody fees?
model answer   : CLIENT_43aa5d8366b5 complained that the custody fees had increased without notice. &#x5B;doc 10]
for a reviewer : Emmental Immobilien AG complained that the custody fees had increased without notice. &#x5B;doc 10]

</pre></div>


<p class="wp-block-paragraph">The model chose its own search, kept the token, answered correctly and cited its source. It never held the name. This is pseudonymization, not anonymization: the tokens are reversible by design (for everything downstream of the filter, only through a separate server), and the text around a token can still say a lot about someone. The goal is narrower and testable: no known sensitive value reaches the provider, and the answers stay as relevant as on raw text.</p>



<p class="wp-block-paragraph">In my lab I take the example of a bank, because in Switzerland, sending client data covered by banking secrecy to an external API is a disclosure question under Article 47 of the Banking Act before it is a quality question, and FINMA expects banks to manage the data risks and third-party dependencies that come with AI. So the question is: can we build something secure enough to still use agentic capabilities? If it works in a sector as regulated as banking, most organizations can use it too. Retrieval techniques make governance possible; the other question is how far they go.<br>The lab has three synthetic banks, 240 clients, 18 relationship managers, 481 accounts, 30 servers and 1,521 free-text documents. It is small on purpose: big enough to measure, small enough to rebuild on a laptop and read every row. Every name in it is generated; where a generated company name turned out to match a firm in the Swiss commercial register, the generator swaps it, and any remaining resemblance is coincidental. This post walks through the data model, the vault that holds the tokens, the layers between the data and the model, a deliberate labelling gap and what catches it, the relevance measured with and without filtering, what a tracing tool ends up storing, and the query plans underneath.</p>



<p class="wp-block-paragraph">The interesting part is not hiding the names. It is knowing precisely which data is sensitive: the labels are what stop the leak, the same labels are what give the relevance back, and a name that no label knows is exactly where the guarantee ends.</p>



<p class="wp-block-paragraph">This demonstrates:</p>



<ul class="wp-block-list">
<li>Row-level security on the vectors, not only on the documents, with the tenant set per transaction.</li>



<li>A label registry and a coverage query that fails toward a finding when a sensitive column is missing.</li>



<li>Deterministic tokens computed with a keyed hash on a separate PostgreSQL server, which is the only way back for everything downstream of the filter.</li>



<li>An egress gate that scans every request to the model, logs a hash of it, and blocks on a single finding.</li>



<li>Relevance kept: with the same search and the same tools, answers on tokens are as good as answers on raw text.</li>
</ul>



<p class="wp-block-paragraph"><strong>Lab Environment Details</strong> <br><br><strong>Bank server: </strong><br>PostgreSQL 18.3, pgvector 0.8.6, pgAudit 18.0, postgresql_anonymizer 3.2.2. <br><strong>Vault server:</strong> <br>PostgreSQL 18.3 with pgcrypto. <br><strong>Embeddings computed locally on CPU: </strong><br>dense voyage-4-nano at 1,024 dimensions, sparse SPLADE (<code>prithivida/Splade_PP_en_v1</code>) through fastembed. <br><strong>Agent</strong>: <br>OpenAI <code>gpt-6-luna</code> through Chat Completions with function tools. <br><strong>Tracing:</strong><br> Langfuse 4.50 self-hosted from its official Docker Compose file (ClickHouse 25.12, PostgreSQL 17, Redis 7, MinIO), Python SDK 4.16. All in Docker on WSL2, in a 6 GB VM. </p>



<p class="wp-block-paragraph">The lab is public and available with a runbook that replays every output below: <a href="https://github.com/boutaga/pgvector_RAG_search_lab/tree/main/lab/16_deterministic_filtering" target="_blank" rel="noopener">lab 16 in pgvector_RAG_search_lab</a>. <br>Building it takes about 40 minutes of embeddings on a CPU, and replaying every step about an hour and a half; the dry runs need no API key.</p>



<h2 id="h-the-data-model" class="wp-block-heading">The data model</h2>



<p class="wp-block-paragraph">Two servers, three schemas. The bank server holds the business data, its filtered copies and the governance tables. The vault server holds only the key and the token mapping. No foreign key, foreign server or <code>dblink</code> crosses between them.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="831" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/01-schema-bank-1024x831.png" alt="" class="wp-image-47449" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/01-schema-bank-1024x831.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/01-schema-bank-300x243.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/01-schema-bank-1536x1246.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/01-schema-bank-768x623.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/01-schema-bank-2048x1661.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph"><em>The bank schema: business data, its filtered copies, the mention index and the versioned embeddings. Each sensitive structured column sits next to its token column; the free text has redacted and tokenized copies (title copies omitted here).</em></p>



<p class="wp-block-paragraph">Each document is stored three times, raw, redacted and tokenized. That is not how you would run production; it is how you measure what each choice costs, on the same rows, with the same model:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE TABLE bank.documents (
    doc_id          int PRIMARY KEY,
    bank_id         text NOT NULL REFERENCES bank.banks,
    doc_type        text NOT NULL CHECK (doc_type IN (&#039;advisor_note&#039;, &#039;incident&#039;, &#039;email&#039;)),
    title           text NOT NULL,
    body            text NOT NULL,
    created_at      timestamptz NOT NULL,
    title_redacted  text,
    body_redacted   text,
    title_tokenized text,
    body_tokenized  text,
    key_version     int
);

</pre></div>


<p class="wp-block-paragraph">Embeddings are versioned: every row says which model, which text state and which vault key produced it. Why that matters, and the event-driven pipeline that keeps vectors current, is the subject of my <a href="https://www.dbi-services.com/blog/rag-series-embedding-versioning-with-pgvector-why-event-driven-architecture-is-a-precondition-to-ai-data-workflows/" target="_blank" rel="noopener">embedding versioning post</a> and <a href="https://www.dbi-services.com/blog/rag-series-embedding-versioning-lab/" target="_blank" rel="noopener">its lab</a>. A partial unique index allows at most one active version per state, so a new set can be built next to the old one and switched when it is ready:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE UNIQUE INDEX embedding_versions_one_active ON bank.embedding_versions (state) WHERE is_active;

-- shortened: the repository version adds a hash of the embedded text
CREATE TABLE bank.embeddings (
    version_id int  NOT NULL REFERENCES bank.embedding_versions,
    doc_id     int  NOT NULL REFERENCES bank.documents,
    bank_id    text NOT NULL,
    dense      vector(1024) NOT NULL,
    sparse     sparsevec(30522) NOT NULL,
    PRIMARY KEY (version_id, doc_id)
);
CREATE INDEX embeddings_dense_hnsw ON bank.embeddings USING hnsw (dense vector_cosine_ops);
CREATE INDEX ON bank.embeddings (version_id, bank_id);

</pre></div>


<p class="wp-block-paragraph">The <code>bank_id</code> on <code>embeddings</code> is deliberate. The vector table is the one people forget to protect: it gets the same tenant column, and the same row-level security policy, as the documents.</p>



<p class="wp-block-paragraph">The governance schema <code>gov</code> is plain tables. A registry says which column is sensitive and where its token goes, and an egress log records every request that leaves, with a hash and the size of the payload and what the scanner found, never the payload itself. The gateway may read the log, insert into it and set each request&#8217;s outcome, nothing else:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE TABLE gov.sensitive_columns (
    table_name   text NOT NULL,
    column_name  text NOT NULL,
    category     text NOT NULL,   -- CLIENT, PERSON, EMAIL, IBAN, HOST, IP, FREE_TEXT
    token_column text,            -- where the deterministic token is written
    PRIMARY KEY (table_name, column_name)
);

GRANT SELECT, INSERT ON gov.egress_log TO gateway;
GRANT UPDATE (outcome) ON gov.egress_log TO gateway;   -- the rest of the log is append-only

</pre></div>


<h2 id="h-the-vault-which-everything-else-depends-on" class="wp-block-heading">The vault, which everything else depends on</h2>



<p class="wp-block-paragraph">The key, and the mapping from a token back to its value, are what turn a token into a name. Anyone who only ever sees tokens (the model, the agent&#8217;s role, a tracing store) has no other way back, and a reviewer gets names only through it, so they live on a second PostgreSQL server, with its own roles and credentials, and no <code>postgres_fdw</code> or <code>dblink</code> on the bank side. The two containers also sit on separate Docker networks. On one laptop that is as far as it goes: the bank&#8217;s superuser can run programs on its server, and the vault&#8217;s port is still reachable through the Docker host. In production the vault belongs on its own host, behind a firewall that only lets the gateway in.</p>



<p class="wp-block-paragraph">One honest limit of this lab&#8217;s layout: the bank tables keep each token next to its raw value (<code>client_token</code> beside <code>client_name</code>), because the pipeline needs both to filter the text. So the pipeline role and the bank&#8217;s administrators can map tokens back without the vault; what keeps the agent out is column privileges, below. One more route stays open in the lab: the agent&#8217;s role can read the raw-text embeddings kept for the measurement, and a sparse vector is indexed by words, so it partly exposes the words it was built from, names included. A stricter design moves the token columns out of the raw tables.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="766" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/03-schema-vault-1024x766.png" alt="" class="wp-image-47451" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/03-schema-vault-1024x766.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/03-schema-vault-300x224.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/03-schema-vault-767x574.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/03-schema-vault-1536x1149.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/03-schema-vault-2048x1532.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph"><em>The vault server holds only the key and the token mapping. <code>tokenizer</code> can compute tokens but read neither table; <code>reidentifier</code> reads the mapping, never the key (the vault&#8217;s admin, a superuser in the lab, reads both).</em></p>



<p class="wp-block-paragraph">Nobody reads the key, not even the role that creates tokens. Tokens are computed inside the vault by a <code>SECURITY DEFINER</code> function, which runs with its owner&#8217;s rights (in the lab the owner is the vault&#8217;s admin, a superuser, which a production setup would replace with a dedicated non-superuser owner), with a pinned <code>search_path</code>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- shortened: the repository version also raises when no key is active
CREATE FUNCTION vault.tokenize(p_category text, p_values text&#x5B;])
RETURNS TABLE (value text, token text)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = vault, public, pg_temp
AS $$
#variable_conflict use_column
DECLARE
    k  bytea;
    kv int;
BEGIN
    SELECT secret, key_version INTO k, kv FROM vault.keys WHERE active;
    RETURN QUERY
    WITH src AS (
        SELECT DISTINCT v AS value FROM unnest(p_values) AS v WHERE v IS NOT NULL
    ), tok AS (
        SELECT s.value,
               upper(p_category) || &#039;_&#039; ||
               substr(encode(hmac(convert_to(upper(p_category) || &#039;:&#039; || lower(btrim(s.value)), &#039;UTF8&#039;),
                                  k, &#039;sha256&#039;), &#039;hex&#039;), 1, 12) AS token
        FROM src s
    ), ins AS (
        INSERT INTO vault.mapping (token, key_version, category, value)
        SELECT DISTINCT ON (t.token) t.token, kv, upper(p_category), t.value
        FROM tok t ORDER BY t.token, t.value COLLATE &quot;C&quot;
        ON CONFLICT DO NOTHING
    )
    SELECT t.value, t.token FROM tok t;
END;
$$;

</pre></div>


<p class="wp-block-paragraph">Functions are executable by <code>PUBLIC</code> by default, so the vault script revokes that before granting anything, and the <code>public</code> schema in that <code>search_path</code> is not writable by either role (the default since PostgreSQL 15, and revoked explicitly in the script):</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
REVOKE ALL ON ALL TABLES    IN SCHEMA vault FROM PUBLIC;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA vault FROM PUBLIC;
GRANT USAGE ON SCHEMA vault TO tokenizer, reidentifier;
GRANT EXECUTE ON FUNCTION vault.tokenize(text, text&#x5B;]), vault.active_key_version() TO tokenizer;
GRANT SELECT ON vault.mapping TO reidentifier;
GRANT EXECUTE ON FUNCTION vault.active_key_version() TO reidentifier;

</pre></div>


<p class="wp-block-paragraph">From the run, by role:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
tokenizer:    SELECT * FROM vault.keys;
              ERROR:  permission denied for table keys
tokenizer:    SELECT * FROM vault.tokenize(&#039;client&#039;, ARRAY&#x5B;&#039;Brenta Kontor AG&#039;, &#039;brenta kontor ag &#039;]);
                     value       |        token
              -------------------+---------------------
               brenta kontor ag  | CLIENT_7119873645a3
               Brenta Kontor AG  | CLIENT_7119873645a3
reidentifier: SELECT * FROM vault.keys;
              ERROR:  permission denied for table keys

</pre></div>


<p class="wp-block-paragraph">Three things the token is, and one it is not. It is the category plus the first 12 hex characters of an HMAC-SHA256 of the normalized value, a keyed hash nobody can reproduce without the key; whoever holds the tokenizer credential can still submit a guess and compare tokens (and the guess lands in the mapping), which is why that credential stays with the gateway. It is 48 bits, so by the birthday bound a collision within one category becomes a 1% risk around 2.4 million values; the largest category here has 481. And this version does not detect a collision: <code>ON CONFLICT DO NOTHING</code> would skip it silently, where a production version should raise.</p>



<p class="wp-block-paragraph">What it is not is entity resolution. The hash input is category and normalized string, with no tenant and no entity ID, so two different clients with the same name would share a token, and the same name in two banks gets the same token. The synthetic data has no such pair, which is why the results below are not affected, but a real CRM has them. A production version would tokenize a tenant-scoped canonical ID, and map aliases to it explicitly. The lab does that in one place only: a server&#8217;s hostname and its full domain name share one token.</p>



<pre class="wp-block-preformatted"> <code>   hostname    |             fqdn              |    host_token     |    fqdn_token
----------------+-------------------------------+-------------------+-------------------
 alb-pg-prd-01  | alb-pg-prd-01.alder.internal  | HOST_f774b8916acd | HOST_f774b8916acd
</code></pre>



<h2 id="h-architecture-two-servers-one-way-in" class="wp-block-heading">Architecture: two servers, one way in</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Layer</th><th>What it answers</th><th>PostgreSQL doing the work</th></tr></thead><tbody><tr><td>1. Row-level security</td><td>Which bank&#8217;s rows a session sees</td><td><code>CREATE POLICY</code> on every business table, embeddings included</td></tr><tr><td>2. Labelling</td><td>What is sensitive, and where the gaps are</td><td>A registry, <code>SECURITY LABEL FOR anon</code>, a coverage view</td></tr><tr><td>3. Deterministic tokens</td><td>Same value, same token, in documents and questions</td><td><code>hmac()</code> in the vault, token columns, a mention index</td></tr><tr><td>4. Egress gate</td><td>What left, proved by a scan of every request</td><td>A gateway function in Python, logging to <code>gov.egress_log</code></td></tr><tr><td>5. Vault</td><td>Who may turn tokens back into values</td><td>A second server, <code>SECURITY DEFINER</code>, revoked defaults</td></tr><tr><td>6. Audit</td><td>What the agent&#8217;s role executed</td><td>pgAudit session logging on <code>app_agent</code></td></tr></tbody></table></figure>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="379" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/06-governance-layers-1024x379.png" alt="" class="wp-image-47453" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/06-governance-layers-1024x379.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/06-governance-layers-768x284.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/06-governance-layers-300x111.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/06-governance-layers-1536x568.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/06-governance-layers-2048x758.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph"><em>Where each layer sits: four on the bank server, the gate in the gateway process, the key and mapping on the vault server. The dotted paths go to the vault: tokens computed once and written back to the bank, and re-identification, used only for a reviewer.</em></p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="725" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/05-question-sequence-1024x725.png" alt="" class="wp-image-47454" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/05-question-sequence-1024x725.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/05-question-sequence-767x543.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/05-question-sequence-300x212.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/05-question-sequence-1536x1087.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/05-question-sequence-2048x1450.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph"><em>One question end to end: the name never leaves the gateway and the model sees tokens only. The last two steps run only when a reviewer needs the real name; otherwise the answer stays in tokens.</em></p>



<p class="wp-block-paragraph">The agent never writes SQL. It gets three tools, <code>search_documents</code>, <code>client_profile</code> and <code>host_documents</code>, each a plain <code>SECURITY INVOKER</code> function that runs as <code>app_agent</code>, so row-level security and column privileges apply inside it. In the terms of my <a href="https://www.dbi-services.com/blog/rag-mcp-skills-three-paradigms-for-llms-talking-to-your-database-and-why-governance-changes-everything/" target="_blank" rel="noopener">RAG, MCP, Skills post</a>, this is an agent with tool access, but every tool is a retrieval function under the database&#8217;s rules, so the DBA keeps the control, MCP and skills still being weaker on the governance aspects. <code>app_agent</code> has no privilege on any raw sensitive column (it reads tokens and attributes that do not identify anyone on their own, such as currency, domicile or balance), so a tool cannot return a client&#8217;s name even by mistake. Those attributes do reach the provider, tied to a token, which is part of why this is pseudonymization. (It can read the raw-text embeddings, which exist here only for the measurement; a production setup would not keep them.)</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT has_column_privilege(&#039;app_agent&#039;, &#039;bank.clients&#039;, &#039;client_name&#039;,  &#039;SELECT&#039;) AS agent_reads_client_name,
       has_column_privilege(&#039;app_agent&#039;, &#039;bank.clients&#039;, &#039;client_token&#039;, &#039;SELECT&#039;) AS agent_reads_client_token;

 agent_reads_client_name | agent_reads_client_token
-------------------------+--------------------------
 f                       | t

</pre></div>


<p class="wp-block-paragraph">Where the trust sits matters as much as the layers. The guarantee holds for what the agent can reach through those tools and that role. The gateway is the trusted part: it chooses the tenant, it holds the tokenizer and reidentifier credentials, and it decides what goes to the provider. It must derive the tenant from the authenticated user, never from an argument the model wrote, because row-level security enforces whatever bank the session declares without asking whether the session was entitled to declare it. A compromised gateway is outside what this design protects.</p>



<h2 id="h-what-postgresql-enforces-what-it-doesn-t-and-why-the-ecosystem-matters" class="wp-block-heading">What PostgreSQL enforces, what it doesn&#8217;t, and why the ecosystem matters</h2>



<p class="wp-block-paragraph">The database enforces these by itself, on every query, whatever the application does: row-level security on the documents, the mention index and the vectors; column privileges that keep raw values away from the agent&#8217;s role; the revoked defaults and the <code>SECURITY DEFINER</code> function that keep the key unreadable in the vault; and pgAudit&#8217;s record of what the agent executed. It also holds the label catalog and reports what is missing from it through the coverage view, though a report only helps when someone reads it.</p>



<p class="wp-block-paragraph">It does not do the rest, and I won&#8217;t pretend it does. The gate that scans every outbound request is a short Python function in the gateway. The tenant is chosen by the application. The labels are only as complete as the people who maintain them, and a name that is in no table passes. The tracing store and the model provider sit outside the database. And the vault is isolated by separate servers and networks (in the lab, separate containers on one host), not by SQL.</p>



<p class="wp-block-paragraph">So why build it on PostgreSQL rather than around a separate vector store? Three reasons. First, one access model: the vectors live in the same engine as the rows they came from, under the same policy and the same privileges, so there is no second copy of every rule to keep in sync. Second, every database-side layer is core PostgreSQL or an extension, not a new product: pgvector for dense and sparse vectors, postgresql_anonymizer for the labels, pgcrypto for the keyed hash, pgAudit for the audit, all under the PostgreSQL License, maintained in the open and readable by the people who have to trust them. Third, everything except the chat model runs on your own machines: the two PostgreSQL servers, the embedding models (Apache 2.0, on CPU), and even the observability, since Langfuse (MIT, apart from its enterprise edition, the one its documentation lists retention under) runs on PostgreSQL and ClickHouse (Apache 2.0), plus a queue and an object store with their own licences. At run time the only external call in the lab is the chat model, and it is the call the gate scans, and in the governed setup blocks. For a bank that has to manage the third-party dependencies FINMA points to, that is what sovereignty means here: the model can move on premises without changing the data, the labels, the vault or the gate.</p>



<h2 id="h-one-design-choice-entity-first-then-meaning" class="wp-block-heading">One design choice: entity first, then meaning</h2>



<p class="wp-block-paragraph">A hex string like <code>CLIENT_43aa5d8366b5</code> means nothing to a semantic model, so dense search barely uses it. But the labels give me something better than semantics: while filtering the text, the tokenizer records which document mentions which entity, in <code>bank.document_mentions</code>. When a question names an entity, the search starts with the documents that mention it, ranks those by meaning, then adds the rest:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- excerpt of bank.retrieve()
ent AS (
    -- documents mentioning every entity the question names
    SELECT m.doc_id FROM bank.document_mentions m
    WHERE p_method = &#039;entity&#039; AND cardinality(p_tokens) &gt; 0 AND m.token = ANY(p_tokens)
    GROUP BY m.doc_id HAVING count(DISTINCT m.token) = cardinality(p_tokens)
), ent_scored AS (
    SELECT e.doc_id,
           1.0 + 1.0 / (60 + row_number() OVER (ORDER BY e.dense &lt;=&gt; p_dense))
               + 1.0 / (60 + row_number() OVER (ORDER BY e.sparse &lt;#&gt; p_sparse)) AS score
    FROM bank.embeddings e JOIN v USING (version_id)
    WHERE e.doc_id IN (SELECT doc_id FROM ent)
)

</pre></div>


<p class="wp-block-paragraph">Inside the entity&#8217;s documents the order is reciprocal rank fusion of dense and sparse, the same as everywhere else. The <code>1.0 +</code> puts them ahead of everything that does not mention the entity. It is a choice, not a constant: if your questions often name an entity only in passing, you would add a boost instead of ranking first. The mention index is a plain table with a B-tree on <code>token</code>, under the same row-level security as the rest. Raw text can use it too, which matters for a fair comparison below. Run on its own, as <code>app_agent</code> with <code>bank_a</code> set (inside the function it is a CTE, and the function&#8217;s own plan is a single opaque function scan):</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT m.doc_id FROM bank.document_mentions m
WHERE m.token = ANY(ARRAY&#x5B;&#039;CLIENT_43aa5d8366b5&#039;])
GROUP BY m.doc_id HAVING count(DISTINCT m.token) = 1;

</pre></div>


<pre class="wp-block-preformatted"> <code>GroupAggregate (actual time=0.072..0.075 rows=6.00 loops=1)
   Group Key: doc_id
   Filter: (count(DISTINCT token) = 1)
   -&gt;  Sort (actual time=0.066..0.067 rows=6.00 loops=1)
         -&gt;  Bitmap Heap Scan on document_mentions m (actual time=0.042..0.044 rows=6.00 loops=1)
               Recheck Cond: (token = ANY ('{CLIENT_43aa5d8366b5}'::text[]))
               Filter: (bank_id = current_setting('app.bank_id'::text, true))
               -&gt;  Bitmap Index Scan on document_mentions_token_idx (actual time=0.025..0.025 rows=6.00 loops=1)
                     Index Cond: (token = ANY ('{CLIENT_43aa5d8366b5}'::text[]))
 Execution Time: 0.185 ms
</code></pre>



<p class="wp-block-paragraph">(Shortened.) The token lookup is one index probe and six rows. Note where the row-level security predicate lands: here it is a <code>Filter</code> after the lookup, because the B-tree is on <code>token</code> alone. Keep that in mind for the vector plan below.</p>



<h2 id="h-row-level-security-on-the-vectors" class="wp-block-heading">Row-level security on the vectors</h2>



<p class="wp-block-paragraph">Every business table that carries <code>bank_id</code> gets the same policy, the vector table included:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
ALTER TABLE bank.embeddings ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bank.embeddings
    USING (bank_id = current_setting(&#039;app.bank_id&#039;, true));

</pre></div>


<p class="wp-block-paragraph">The bank is set per transaction with <code>set_config(..., true)</code>, the function form of <code>SET LOCAL</code>, so a transaction-mode pooler cannot hand one bank&#8217;s setting to the next request. As <code>app_agent</code>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
bank=&gt; SELECT count(*) AS documents_without_tenant FROM bank.documents;
 documents_without_tenant
--------------------------
                        0

bank=&gt; BEGIN;
bank=&gt; SELECT set_config(&#039;app.bank_id&#039;, &#039;bank_a&#039;, true);
bank=&gt; SELECT bank_id, count(*) AS documents FROM bank.documents GROUP BY bank_id;
 bank_id | documents
---------+-----------
 bank_a  |       508
bank=&gt; COMMIT;

bank=&gt; SELECT count(*) AS documents_after_commit FROM bank.documents;
 documents_after_commit
------------------------
                      0

</pre></div>


<p class="wp-block-paragraph">Be precise about why both counts are zero, because they are not the same case. On PostgreSQL 18.3, <code>current_setting('app.bank_id', true)</code> returns NULL when the setting was never defined in the session, and an empty string once an earlier transaction has set it locally: the custom setting now exists, with an empty value. Neither matches a <code>bank_id</code>, so a session that forgets its bank gets nothing. If you would rather have one case, <code>NULLIF(current_setting('app.bank_id', true), '')</code> in the policy makes both NULL. And a transaction-local value only overrides: if something set the parameter at session level earlier, that value is back after the commit, which is why the pooling argument assumes connections that never set it at session level.</p>



<p class="wp-block-paragraph">The raw client name is refused before row-level security even matters: <code>SELECT client_name FROM bank.clients</code> as <code>app_agent</code> fails with <code>permission denied</code>. Neither role owns the tables; if your application connects as the table owner, add <code>FORCE ROW LEVEL SECURITY</code>, or the owner sees every row. The pipeline role, <code>gateway</code>, has <code>BYPASSRLS</code> because it serves all three banks, and the agent never gets near it.</p>



<h2 id="h-labelling-the-gap-and-what-caught-it" class="wp-block-heading">Labelling, the gap, and what caught it</h2>



<p class="wp-block-paragraph">The registry says what is sensitive; a procedure turns it into security labels, with postgresql_anonymizer as the label provider, so the catalog (<code>pg_seclabels</code>) is the one place to ask whether a column is labelled. The coverage view asks it for every text column of the business tables:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- shortened: the repository version also skips three technical tables and the
-- derived columns (tokens, filtered copies, text hashes)
CREATE VIEW gov.label_coverage AS
SELECT c.table_name, c.column_name, sl.label, sc.category,
       (sl.label IS NOT NULL) AS labelled
FROM information_schema.columns c
LEFT JOIN pg_seclabels sl
       ON sl.provider = &#039;anon&#039; AND sl.objtype = &#039;column&#039;
      AND sl.objoid = format(&#039;bank.%I&#039;, c.table_name)::regclass
      AND sl.objsubid = c.ordinal_position
LEFT JOIN gov.sensitive_columns sc
       ON sc.table_name = c.table_name AND sc.column_name = c.column_name
WHERE c.table_schema = &#039;bank&#039; AND c.data_type = &#039;text&#039;
  AND c.column_name NOT IN (&#039;bank_id&#039;, &#039;doc_type&#039;, &#039;client_type&#039;, &#039;currency&#039;,
                            &#039;role&#039;, &#039;environment&#039;, &#039;domicile&#039;, &#039;category&#039;, &#039;token&#039;);

</pre></div>


<p class="wp-block-paragraph">The choice that matters is the direction of the list: it excludes what is known to be harmless, so a new text column shows up as a gap until someone labels it or excludes it on purpose. It fails toward a finding, not toward silence. It only sees text columns; a sensitive value stored as <code>inet</code>, <code>date</code> or <code>jsonb</code> needs its own rule. I left one sensitive column out on purpose, the clients&#8217; phone numbers:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
bank=# SELECT table_name, column_name, category, labelled FROM gov.label_coverage ORDER BY labelled, table_name;
      table_name       |  column_name  | category  | labelled
-----------------------+---------------+-----------+----------
 clients               | contact_phone |           | f
 accounts              | iban          | IBAN      | t
 clients               | client_name   | CLIENT    | t
 ...
(10 rows)

</pre></div>


<p class="wp-block-paragraph">Tokenizing then replaces every labelled value in the free text, using a dictionary of every value the labelled columns hold, matched as whole words ignoring case, longest first, with the grouped spelling of each IBAN added. One caveat before the numbers: in this lab the dictionary and the scanner read hand-written lists of columns that match the registry, rather than reading the registry itself. A production version should derive both from it.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="386" height="1024" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/04-build-flow-386x1024.png" alt="" class="wp-image-47455" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/04-build-flow-386x1024.png 386w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/04-build-flow-113x300.png 113w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/04-build-flow-768x2036.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/04-build-flow-579x1536.png 579w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/04-build-flow-772x2048.png 772w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/04-build-flow-scaled.png 965w" sizes="auto, (max-width: 386px) 100vw, 386px" /></figure>



<p class="wp-block-paragraph"><em>Building the governed corpus: the registry drives the labels and the coverage check, the vault computes the tokens, and one dictionary pass filters the text, fills the mention index and feeds the local embedding models.</em></p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
$ python python/scan_corpus.py
raw         7171 values in 1521 documents: CLIENT 3024, PERSON 1833, EMAIL 960, HOST 630, IBAN 480, PHONE 154, IP 90
redacted     154 values in  154 documents: PHONE 154
tokenized    154 values in  154 documents: PHONE 154

</pre></div>


<p class="wp-block-paragraph">The raw corpus carries 7,171 detected occurrences of sensitive values. After tokenization what is left is exactly the gap: 154 phone numbers from the column nobody labelled. Now the governed agent, asked about a client whose note carries that phone number, in a dry run that builds the real request and stops before it leaves:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
$ python python/agent.py --dry-run --bank bank_a \
    &quot;What did Emmental Immobilien AG complain about regarding custody fees?&quot;
question sent : What did CLIENT_43aa5d8366b5 complain about regarding custody fees?
  &#x5B;tool] search_documents returned:
    doc 10 advisor_note: PERSON_65a94b3e0336 met CLIENT_43aa5d8366b5. The client complained that the custody fees rose without notice. Next step agreed with CLIENT_43aa5d8366b
    doc 12 advisor_note: PERSON_65a94b3e0336 met CLIENT_43aa5d8366b5. The client complained that the custody fees rose without notice. Next step agreed with CLIENT_43aa5d8366b
    doc 11 email: From EMAIL_288e9f2f11a1. Dear client, following our call, CLIENT_43aa5d8366b5 complained that the custody fees rose without notice. Please confirm the
    doc 14 email: From EMAIL_288e9f2f11a1. Dear client, following our call, CLIENT_43aa5d8366b5 requested an ESG screening of current equity holdings. Please confirm th
    doc 13 advisor_note: PERSON_65a94b3e0336 met CLIENT_43aa5d8366b5. The client requested an ESG screening of current equity holdings. Next step agreed with CLIENT_43aa5d8366
  &#x5B;egress] 3729 chars, filtering=tokenized, scanner hits=1 &#x5B;&#039;PHONE&#039;]  BLOCKED
  stopped at the egress gate: 1 sensitive value(s) in outbound payload: &#x5B;&#039;PHONE&#039;]

</pre></div>


<p class="wp-block-paragraph">Every retrieved document is the right client and every name, email and account is a token, but doc 12 still carries the phone number. The gate stopped the request: that is what prevented the transmission. It caught the number because the scanner&#8217;s own list includes the phone column (I left it out of the registry on purpose, so I knew), and a Swiss phone-number pattern would have matched it anyway. Then the label fixed the filtering:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
bank=# INSERT INTO gov.sensitive_columns VALUES (&#039;clients&#039;, &#039;contact_phone&#039;, &#039;PHONE&#039;, &#039;phone_token&#039;);
bank=# CALL gov.apply_labels();
$ python python/tokenize_corpus.py
...
$ python python/agent.py --dry-run --bank bank_a &quot;What did Emmental Immobilien AG complain about regarding custody fees?&quot;
  ... (same question sent, same five documents)
  &#x5B;egress] 3731 chars, filtering=tokenized, scanner hits=0
  payload cleared the gate (dry run: not sent)

</pre></div>


<p class="wp-block-paragraph">Same documents, two characters longer (a 16-character phone number became an 18-character token), zero detected values. The gate stops what it recognizes; the label makes the filter remove it before it ever gets there. Both are needed, and neither knows a name that is in no table: nine documents each name a lawyer who exists in no column (Hugo Vogel in one, Selina Python in another), and those nine pass. Deterministic filtering is exactly as good as your labelling. Named-entity recognition can be added as a second net, but it puts a probabilistic step back into the path of a guarantee; I would rather know the ceiling and work on coverage.</p>



<h2 id="h-did-filtering-cost-relevance" class="wp-block-heading">Did filtering cost relevance?</h2>



<h3 id="h-retrieval" class="wp-block-heading">Retrieval</h3>



<p class="wp-block-paragraph">36 labelled questions, 30 naming a client or a server and 6 generic, run against the raw, redacted and tokenized embeddings as <code>app_agent</code> with its bank set:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
$ python python/measure.py
state      method  set       recall@5  nDCG@5  recall@10  nDCG@10
raw        hybrid  entity       0.956   0.953      1.000    0.973
raw        entity  entity       1.000   0.991      1.000    0.991
redacted   hybrid  entity       0.192   0.185      0.408    0.284
tokenized  dense   entity       0.431   0.447      0.625    0.541
tokenized  sparse  entity       0.822   0.827      0.897    0.860
tokenized  hybrid  entity       0.650   0.670      0.775    0.731
tokenized  entity  entity       1.000   0.990      1.000    0.990

</pre></div>


<p class="wp-block-paragraph">(Shortened; the six generic questions score 1.000 everywhere, which says they are too easy. <code>measure.py</code> prints the full table.) Redaction destroys entity questions, 0.192, because the documents no longer say who. Opaque tokens alone recover part of it: the dense model barely uses a hex string (0.431), the sparse model does (0.822). Entity-aware search over the mention index brings tokenized retrieval to 1.000, the same as raw with the same search. Redaction cannot use it: a redacted question names no one.</p>



<h3 id="h-answers" class="wp-block-heading">Answers</h3>



<p class="wp-block-paragraph">Retrieval is not what a relationship manager reads, so the model answered all 36 questions, citing documents as <code>[doc N]</code>, with deterministic scoring and no model judging another model. To isolate the cost of the tokens, the two setups compared here differ in the text and almost nothing else: both use the same entity-aware search, under the same row-level security and candidate limits, and the same single search tool. One reads raw text, the other tokens. (Two small differences remain: the entity is found by its token on one side and by its name on the other, and the raw side&#8217;s requests are logged but never blocked by the gate; none was blocked on either side in this run.) Both ran together on 3 October:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
$ python python/evaluate_answers.py --label fair2 --modes raw_entity,tokenized_search
mode       set      cited rec cited prec context rec expl. entity blocked
raw_entity entity       0.722      0.989       1.000          1.0       0
tokenized_search entity       0.719      0.993       1.000          1.0       0
egress: {&#039;tokenized_search&#039;: {&#039;sent&#039;: 74, &#039;values_sent&#039;: 0, ...}, &#039;raw_entity&#039;: {&#039;sent&#039;: 73, &#039;values_sent&#039;: 871, ...}}

</pre></div>


<p class="wp-block-paragraph">(Shortened to the entity questions; the generic ones score 1.000 in both.) Both setups had every expected document in front of the model (context recall 1.000), and both cited the same share of them, 0.722 against 0.719. With the same search, the tokens cost nothing measurable. What changed is what left: 871 detected occurrences of sensitive values for the raw setup, none for the tokenized one.</p>



<p class="wp-block-paragraph">For reference, the naive setup (raw text, plain hybrid search across all three banks, filtered to one bank afterwards) put fewer expected documents in front of the model, context recall 0.981, and sent 1,134 detected occurrences in 75 requests, in a separate run the same day. The naive setup differs from the entity-aware ones in two ways at once, the search method and searching all banks before filtering, so these runs cannot say which one accounts for the gap; what it does show is that the tokens are not the cost.</p>



<p class="wp-block-paragraph">Cited recall counts expected documents cited, which is document coverage, not answer completeness: when three notes repeat the same complaint, citing one of them can be a complete answer. So the honest claim is narrower than &#8220;better answers&#8221;: on this corpus, filtering keeps the answers grounded in the right documents while nothing known leaves.</p>



<h2 id="h-what-a-tracing-tool-keeps" class="wp-block-heading">What a tracing tool keeps</h2>



<p class="wp-block-paragraph">The egress log tells me what left and how big it was, and pgAudit tells me what <code>app_agent</code> executed. Neither tells the story of one question: what was asked, which documents came back, under which role, what the model answered, and whether it was right. So I traced every question with Langfuse, the open-source tracing and evaluation tool for LLM applications that <a href="https://clickhouse.com/blog/clickhouse-acquires-langfuse-open-source-llm-observability" target="_blank" rel="noopener">ClickHouse acquired in January 2026</a>, self-hosted next to the lab. Look first at what it runs on: PostgreSQL for its transactional data, ClickHouse for traces and scores, Redis as a queue, S3-compatible storage for the raw events. AI observability is a database workload.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="459" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221406-1024x459.png" alt="" class="wp-image-47456" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221406-1024x459.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221406-300x134.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221406-768x344.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221406-1536x688.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221406-2048x918.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph"><em>Langfuse 4.50, self-hosted: one trace per question, marked <code>off</code> for the naive setup and <code>tokenized</code> for the governed one in the metadata. The naive rows show client names in the input and output columns; the governed rows show tokens.</em></p>



<p class="wp-block-paragraph">Each retrieval step records the database role, whether that role bypasses row-level security, and the document IDs, so the role question becomes SQL on the ClickHouse table that holds the spans, here on the traced run of 2 October:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
clickhouse&gt; SELECT tags&#x5B;1] AS mode,
                   metadata_values&#x5B;indexOf(metadata_names, &#039;db_role&#039;)]    AS db_role,
                   metadata_values&#x5B;indexOf(metadata_names, &#039;bypass_rls&#039;)] AS bypass_rls,
                   metadata_values&#x5B;indexOf(metadata_names, &#039;doc_ids&#039;)]    AS doc_ids
            FROM events_full FINAL
            WHERE session_id = &#039;langfuse&#039; AND name = &#039;search_documents&#039; AND input LIKE &#039;%succession%&#039;
            ORDER BY mode, start_time LIMIT 1 BY mode;
   ┌─mode──────┬─db_role───┬─bypass_rls─┬─doc_ids────────────────┐
1. │ off       │ gateway   │ true       │ &#x5B;18, 19, 206, 100, 22] │
2. │ tokenized │ app_agent │ false      │ &#x5B;18, 19, 16, 17, 292]  │
   └───────────┴───────────┴────────────┴────────────────────────┘

</pre></div>


<p class="wp-block-paragraph">The naive search ran as a role that bypasses row-level security and stayed inside its bank only because the application&#8217;s query filtered on it. And because the evaluation&#8217;s scores sit next to the spans, a quality question becomes a join: for the governed answers that cited fewer expected documents, what had the search returned? For one of the two worst in that run, a question about replication lag on a server, the expected documents were 948 to 951; the search returned all four at the top, and the model cited one. That is not a retrieval problem, and without the trace next to the score I would have gone tuning the index.</p>



<p class="wp-block-paragraph">Then I pointed the egress scanner at the trace store itself, for the three-setup run of 3 October:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
$ python python/scan_traces.py --label fair
ClickHouse, table events_full, session fair
  mode       traces  spans  spans with values  sensitive values
  off            36    150                144              2336
  raw_entity     36    144                138              1850
  tokenized      36    170                  0                 0
object storage, bucket langfuse/events/otel, session fair
  (same counts, shortened)

</pre></div>


<p class="wp-block-paragraph">Tracing records what the application sends and receives; that is its job. On the naive path the trace store ends up with 2,336 detected occurrences for questions that sent 1,134 to the provider: every retrieved document is stored once more as the search result, on top of the copies inside each model call, plus the questions and answers. And all of it twice, in ClickHouse and in object storage. On the governed path both hold zero detected values, and the reason is precise: the search result is recorded when the tool returns, before the gate scans the next request, so the traces are clean only because the tools return tokens. While the phone gap was open, doc 12&#8217;s number would have reached the trace store even though the gate blocked the request.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="968" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-16-1024x968.png" alt="" class="wp-image-47457" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-16-1024x968.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-16-300x283.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-16-767x725.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-16.png 1270w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph"><em>The naive setup, as Langfuse stored it (2 October): the search step&#8217;s output holds the raw text, with the client&#8217;s name and the advisor&#8217;s name and email in clear, and an IBAN and a phone number too, masked here for publication.</em></p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="969" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221624-1024x969.png" alt="" class="wp-image-47458" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221624-1024x969.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221624-767x726.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221624-300x284.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/Screenshot-2026-10-02-221624.png 1243w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph"><em>The governed setup, same kind of step: every name in the stored search result is a token.</em></p>



<p class="wp-block-paragraph">So the observability layer is one more copy of the data. In the Langfuse 4.50 I ran, the table holding the spans carries a full-text index on every span&#8217;s input and output, so questions, retrieved documents and answers are searchable word by word. On a self-hosted instance that copy is kept indefinitely unless you configure retention, a setting <a href="https://langfuse.com/docs/administration/data-retention" target="_blank" rel="noopener">its documentation</a> lists only under the Enterprise Edition. The SDK has <a href="https://langfuse.com/docs/observability/features/masking" target="_blank" rel="noopener">masking hooks</a> that run before trace data leaves the application; that is where I would plug the gate&#8217;s scanner, so the trace store can never hold more than the provider was allowed to see. I have not run that variant yet.</p>



<h2 id="h-what-the-plans-show" class="wp-block-heading">What the plans show</h2>



<p class="wp-block-paragraph">The dense path as <code>app_agent</code> with <code>bank_a</code> set, using a stored vector as the query. The query has no bank predicate:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT e.doc_id FROM bank.embeddings e
JOIN bank.embedding_versions v USING (version_id)
WHERE v.state = &#039;tokenized&#039; AND v.is_active
ORDER BY e.dense &lt;=&gt; :&#039;qd&#039;::vector LIMIT 10;

</pre></div>


<pre class="wp-block-preformatted"> <code>Limit (actual time=1.936..1.938 rows=10.00 loops=1)
   Buffers: shared hit=1762
   -&gt;  Sort (actual time=1.935..1.936 rows=10.00 loops=1)
         Sort Method: top-N heapsort  Memory: 25kB
         -&gt;  Nested Loop (actual time=0.051..1.850 rows=508.00 loops=1)
               Buffers: shared hit=1759
               -&gt;  Index Scan using embedding_versions_one_active on embedding_versions v (actual time=0.006..0.006 rows=1.00 loops=1)
                     Index Cond: (state = 'tokenized'::text)
               -&gt;  Index Scan using embeddings_version_id_bank_id_idx on embeddings e (actual time=0.019..0.144 rows=508.00 loops=1)
                     Index Cond: ((version_id = v.version_id) AND (bank_id = current_setting('app.bank_id'::text, true)))
                     Buffers: shared hit=64
 Execution Time: 2.234 ms
</code></pre>



<p class="wp-block-paragraph">(Shortened: sort key, index searches and planning lines removed.)</p>



<p class="wp-block-paragraph">Reading it from the bottom: the active version comes from the partial unique index, then an index scan on <code>(version_id, bank_id)</code> returns the 508 rows of one bank and one version in 64 buffers, and a top-N heapsort scores all of them exactly and keeps ten. At this size the planner does not use the HNSW index at all.</p>



<p class="wp-block-paragraph">Two details are worth the read. The <code>bank_id = current_setting(...)</code> condition is not in the query: it comes from the policy, and because the index is on <code>(version_id, bank_id)</code>, the planner used it as an index condition next to the version. On the mention index above, with no <code>bank_id</code> in the B-tree, the same predicate was a filter after the lookup: where row-level security lands in a plan depends on your indexes. And of the 1,762 buffers, the index scan accounts for 64; the rest is reading the vectors back. pgvector stores <code>vector</code> with the <code>EXTERNAL</code> storage mode, and at about 4 KB each (1,024 floats plus a small header) the dense vectors live out of line: 48 MB of TOAST against 8 MB of heap, for 9,126 rows across the six embedding versions the table holds. The sparse vectors average 773 bytes and stay inline, and the same plan on the sparse path reads 66 buffers. Over five repeated runs the dense path took 1.9 to 4.5 ms, the sparse path under 1 ms, and the hybrid function 5 to 14 ms.</p>



<p class="wp-block-paragraph">With much larger tables per bank the planner may switch to the HNSW index, and a plain HNSW scan stops after <code>ef_search</code> candidates, some of which row-level security and the version filter then remove, since one HNSW index spans every version. That is why <code>bank.retrieve()</code> carries <code>SET hnsw.iterative_scan = strict_order</code> and <code>SET hnsw.ef_search = 100</code> in its definition: the index keeps scanning until enough rows pass the filter or its configured scan limits are reached, and the settings cannot leak into the caller&#8217;s session. I did not measure where that switch happens.</p>



<p class="wp-block-paragraph">Underneath, there is nothing exotic. Tenant, version and entity are predicates on ordinary indexes, the vault is another PostgreSQL server with revoked defaults, the gate is a short function writing to a table, and the vectors are TOASTed values like any other. Every layer is something a DBA already knows how to operate, back up and audit.</p>



<h2 id="h-a-note-on-method" class="wp-block-heading">A note on method</h2>



<p class="wp-block-paragraph">Everything above was captured from runs of the lab on 1, 2 and 3 October 2026, command by command, and the runbook in the repository replays it, including the two comparison runs (step 19). The data is synthetic and generated with a fixed seed; vault tokens differ per vault (different hex, same behaviour). Embeddings ran on a CPU, between about 0.7 and 3.5 documents per second depending on the run, which is why the corpus is 1,521 documents and not a million.</p>



<p class="wp-block-paragraph">The question set is small and generous: each entity question names one entity, and the generic ones are too easy. An outside review of an earlier run found questions whose wording matched only one of the ways their documents phrase the topic, which rewarded a correct &#8220;nothing found&#8221;; I fixed the wording and every number above uses the corrected set. Answers come from a model and vary between runs, which is why the compared setups were always run together. These numbers show the mechanism on this corpus; they are not a production benchmark.</p>



<p class="wp-block-paragraph">&#8220;Zero&#8221; in this post always means zero detected occurrences: zero matches against the raw values of the bank&#8217;s sensitive columns and a few patterns, which a name in no table escapes. The scanner reads the payload with accents intact, so a name like Müller is matched too, though the lab data itself is plain ASCII. An outcome of <code>sent</code> in the egress log means the provider accepted the request; a request logged as failed may still have reached it before the error, so the log proves what was sent, not everything that was attempted. Key rotation is scripted but I have not run it end to end, and re-tokenizing overwrites the tokenized text in place, so older vector versions are not a full rollback. The agent ran on <code>gpt-6-luna</code> with reasoning turned off: in my runs, Chat Completions refused function tools for that model unless it was.</p>



<h2 id="h-takeaways" class="wp-block-heading">Takeaways</h2>



<p class="wp-block-paragraph">I often hear that you cannot keep sensitive data out of an AI pipeline and still get useful answers. On this lab you can, with one condition: labelling. Row-level security on the vectors decides which rows a session sees (which, as I showed in my <a href="https://2026.pgday.ch/session/388-the-cost-of-security-debt-in-postgresql-when-implementing-ai-workflows/" target="_blank" rel="noopener">Swiss PGDay talk</a>, also improves precision), the labels decide what is sensitive, deterministic tokens replace it the same way every time, the gate proves what left, and the vault keeps the way back, for everything downstream of the filter, on another server. With the same search and tools, the answers on tokens were as good as the answers on raw text, and nothing known left. And the tool you add to watch all of it becomes a copy of the data unless what it records is filtered too.</p>



<p class="wp-block-paragraph">This transfers to any assistant over CRM notes, a ticketing tool or a CMDB, wherever the useful text names people and systems, and it does not depend on which model you use or where it runs. Its weak points are said above: names that live in no table, a tracing store that sits outside the gate unless you put it behind, and a token that matches strings, not identities. It also makes the token mapping sensitive in its own, a secret with a required life cycle somehow. <br>A stale mapping increases the risk of a leak; a rotated one has to keep its history, for traceability and for questions that span a period with several mapping versions. How long to keep which version, and who may read it, is a question the business has to answer, and the real answer is probably a dynamic mapping system. This lab shows that the approach is possible, <strong>not how to run it in production</strong>.</p>



<p class="wp-block-paragraph">Agentic projects put pressure on governance at a level organizations have never known, and it is understandable to feel overwhelmed, even afraid. To me this is an engineering problem, and we already hold most of the keys; the challenge is that the storm we are living through makes it hard to see clearly. Observability and open source will prevail !</p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph"></p>
<p>L’article <a href="https://www.dbi-services.com/blog/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant/">AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#8217;t</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>PostgreSQL Snapshot Backup and Restore with Proxmox ZFS (4/4)</title>
		<link>https://www.dbi-services.com/blog/postgresql-snapshot-backup-and-restore-with-proxmox-zfs-4-4/</link>
					<comments>https://www.dbi-services.com/blog/postgresql-snapshot-backup-and-restore-with-proxmox-zfs-4-4/#respond</comments>
		
		<dc:creator><![CDATA[Amine Haloui]]></dc:creator>
		<pubDate>Mon, 03 Aug 2026 22:13:44 +0000</pubDate>
				<category><![CDATA[Database Administration & Monitoring]]></category>
		<category><![CDATA[Database management]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[proxmox]]></category>
		<category><![CDATA[ZFS]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=46203</guid>

					<description><![CDATA[<p>In the blog series I previously wrote, I did not answer all the customer&#8217;s questions. The last one was the following: Can this also be applied to PostgreSQL? In short, yes, it is possible. Let&#8217;s see how. Here is the list of the previous blog posts: Partitioning and filesystem We will reuse the sqlpool ZFS [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/postgresql-snapshot-backup-and-restore-with-proxmox-zfs-4-4/">PostgreSQL Snapshot Backup and Restore with Proxmox ZFS (4/4)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In the blog series I previously wrote, I did not answer all the customer&#8217;s questions. The last one was the following:</p>



<p class="wp-block-paragraph"><strong>Can this also be applied to PostgreSQL? </strong></p>



<p class="wp-block-paragraph">In short, yes, it is possible. Let&#8217;s see how.</p>



<p class="wp-block-paragraph">Here is the list of the previous blog posts:</p>



<ul class="wp-block-list">
<li><a href="https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs/">https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs/</a></li>



<li><a href="https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-2-3/">https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-2-3/</a></li>



<li><a href="https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-rest-api-with-sql-server-2025-3-3/">https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-rest-api-with-sql-server-2025-3-3/</a></li>
</ul>



<h2 id="h-partitioning-and-filesystem" class="wp-block-heading">Partitioning and filesystem</h2>



<p class="wp-block-paragraph">We will reuse the sqlpool ZFS pool created in the first part of this series.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="134" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-1-1024x134.png" alt="" class="wp-image-46207" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-1-1024x134.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-1-300x39.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-1-768x100.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-1.png 1187w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">We identify the 300 GB disk attached to the VM. In our case, it is /dev/sdb, backed by the sqlpool/pve/vm-307-disk-0 zvol on the Proxmox side:</p>



<pre class="wp-block-code"><code>lsblk</code></pre>



<p class="wp-block-paragraph">We create a single partition of type Linux filesystem:</p>



<pre class="wp-block-code"><code>sudo sgdisk -n 1:0:0 -t 1:8300 /dev/sdb</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="458" height="48" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-2.png" alt="" class="wp-image-46208" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-2.png 458w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-2-300x31.png 300w" sizes="auto, (max-width: 458px) 100vw, 458px" /></figure>



<p class="wp-block-paragraph">We format the partition with XFS, which is the most commonly recommended filesystem for PostgreSQL data directories:</p>



<pre class="wp-block-code"><code>sudo mkfs.xfs -L pgdata /dev/sdb1 -f</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="643" height="208" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-3.png" alt="" class="wp-image-46209" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-3.png 643w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-3-300x97.png 300w" sizes="auto, (max-width: 643px) 100vw, 643px" /></figure>



<p class="wp-block-paragraph">We verify the result:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
ahi@pgl:~$ sudo blkid /dev/sdb1
/dev/sdb1: LABEL=&quot;pgdata&quot; UUID=&quot;028afa2f-7bb3-4a40-92aa-91c1a33f18ae9&quot; BLOCK_SIZE=&quot;512&quot; TYPE=&quot;xfs&quot; PARTUUID=&quot;bd54f285-092e-4b1b-ba5e-6877f054fa7&quot;
</pre></div>


<h3 id="h-mountpoint" class="wp-block-heading"><strong>Mountpoint:</strong></h3>



<p class="wp-block-paragraph">We create the mount point:</p>



<pre class="wp-block-code"><code>sudo mkdir -p /pgdata</code></pre>



<h3 id="h-" class="wp-block-heading"></h3>



<p class="wp-block-paragraph"><strong>Persistent mount via fstab</strong>:</p>



<p class="wp-block-paragraph">We add the mount entry to /etc/fstab using the filesystem label rather than the device name. The device name (/dev/sdb) may change if disks are added or removed while the label remains stable:</p>



<pre class="wp-block-code"><code>echo 'LABEL=pgdata /pgdata xfs noatime,nodiratime 0 2' | sudo tee -a /etc/fstab
sudo systemctl daemon-reload
sudo mount /pgdata</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="763" height="68" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-5.png" alt="" class="wp-image-46211" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-5.png 763w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-5-300x27.png 300w" sizes="auto, (max-width: 763px) 100vw, 763px" /></figure>



<p class="wp-block-paragraph">We verify that the volume is mounted:</p>



<pre class="wp-block-code"><code>df -h /pgdata</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="402" height="48" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-6.png" alt="" class="wp-image-46212" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-6.png 402w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-6-300x36.png 300w" sizes="auto, (max-width: 402px) 100vw, 402px" /></figure>



<h2 id="h-postgresql-installation" class="wp-block-heading">PostgreSQL installation</h2>



<p class="wp-block-paragraph">We install PostgreSQL 18 from the official PGDG repository, which provides the latest PostgreSQL versions for Ubuntu:</p>



<pre class="wp-block-code"><code>sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt install -y postgresql-18</code></pre>



<p class="wp-block-paragraph"><strong>Cluster creation on /pgdata</strong>:</p>



<p class="wp-block-paragraph">The Ubuntu packages create a default cluster under /var/lib/postgresql. This is not what we want. The data files and the WAL must both reside on the ZFS-backed volume, so that a single ZFS snapshot captures a consistent state of the database. If they were on different volumes, the snapshot would not be atomic.</p>



<p class="wp-block-paragraph">We drop the default cluster and recreate it on /pgdata:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
ahi@pgl:~$ sudo pg_dropcluster --stop 18 main
sudo install -d -o postgres -g postgres -m 700 /pgdata/18
sudo pg_createcluster -d /pgdata/18/main 18 main
sudo systemctl enable --now postgresql@18-main
Creating new PostgreSQL cluster 18/main ...
/usr/lib/postgresql/18/bin/initdb -D /pgdata/18/main --auth-local peer --auth-host scram-sha-256 --no-instructions
The files belonging to this database system will be owned by user &quot;postgres&quot;.
This user must also own the server process.

The database cluster will be initialized with locale &quot;en_US.UTF-8&quot;.
The default database encoding has accordingly been set to &quot;UTF8&quot;.
The default text search configuration will be set to &quot;english&quot;.

Data page checksums are enabled.

fixing permissions on existing directory /pgdata/18/main ... ok
creating subdirectories ... ok
selecting dynamic shared memory implementation ... posix
selecting default &quot;max_connections&quot; ... 100
selecting default &quot;shared_buffers&quot; ... 128MB
selecting default time zone ... Etc/UTC
creating configuration files ... ok
running bootstrap script ... ok
performing post-bootstrap initialization ... ok
syncing data to disk ... ok
             Ver Cluster Port Status Owner     Data directory  Log file
             18  main    5432 down   postgres  /pgdata/18/main /var/log/postgresql/postgresql-18-main.log
Created symlink /etc/systemd/system/multi-user.target.wants/postgresql@18-main.service → /usr/lib/systemd/system/postgresql@.service.
</pre></div>


<p class="wp-block-paragraph">We verify that the cluster is online and located on the right volume:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
ahi@pgl:~$ pg_lsclusters
Ver Cluster Port Status Owner     Data directory  Log file
18  main    5432 online &amp;lt;unknown&amp;gt; /pgdata/18/main /var/log/postgresql/postgresql-18-main.log
ahi@pgl:~$ sudo -u postgres psql -c &quot;SHOW data_directory;&quot;
 data_directory
-----------------
 /pgdata/18/main
(1 row)

ahi@pgl:~$ sudo -u postgres psql -c &quot;SELECT version();&quot;
                                                             version
-------------------------------------------------------------------------------------------------------------------------------
 PostgreSQL 18.4 (Ubuntu 18.4-1.pgdg24.04+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0, 64-bit
(1 row)
</pre></div>


<p class="wp-block-paragraph">We can also confirm that the WAL directory lives inside the data directory, and therefore on the zvol:</p>



<pre class="wp-block-code"><code>ls -ld /pgdata/18/main/pg_wal</code></pre>



<h2 id="h-creating-a-large-database" class="wp-block-heading">Creating a large database</h2>



<p class="wp-block-paragraph">We need a database large enough to make traditional backup and restore operations time-consuming. In the SQL Server part of this series, we used the StackOverflow database (about 207 GB). For PostgreSQL, we use pgbench, the benchmarking tool shipped with PostgreSQL.</p>



<p class="wp-block-paragraph">We create the database and initialize it with a scale factor of 10000. This produces a database of approximately 146 GB, with 1 billion rows in the pgbench_accounts table:</p>



<pre class="wp-block-code"><code>sudo -u postgres createdb bench
sudo -u postgres pgbench -i -s 10000 --partitions=8 bench</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="806" height="195" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-9.png" alt="" class="wp-image-46215" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-9.png 806w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-9-300x73.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-9-768x186.png 768w" sizes="auto, (max-width: 806px) 100vw, 806px" /></figure>



<p class="wp-block-paragraph">A few minutes later:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="825" height="88" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-10.png" alt="" class="wp-image-46216" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-10.png 825w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-10-300x32.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-10-768x82.png 768w" sizes="auto, (max-width: 825px) 100vw, 825px" /></figure>



<p class="wp-block-paragraph">We can monitor the data growth during the initialization:</p>



<pre class="wp-block-code"><code>watch -n 30 'df -h /pgdata'</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="663" height="85" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-11.png" alt="" class="wp-image-46217" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-11.png 663w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-11-300x38.png 300w" sizes="auto, (max-width: 663px) 100vw, 663px" /></figure>



<p class="wp-block-paragraph">A few minutes later:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="463" height="81" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-12.png" alt="" class="wp-image-46218" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-12.png 463w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-12-300x52.png 300w" sizes="auto, (max-width: 463px) 100vw, 463px" /></figure>



<p class="wp-block-paragraph">On the Proxmox side:</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="292" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-13-1024x292.png" alt="" class="wp-image-46219" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-13-1024x292.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-13-300x86.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-13-768x219.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-13.png 1103w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">After some time, the process completes:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
vacuuming...

creating primary keys...
done in 1559.55 s (drop tables 0.00 s, create tables 0.02 s, client-side generate 598.12 s, vacuum 675.53 s, primary keys 285.88 s).
ahi@pgl:~$
</pre></div>


<p class="wp-block-paragraph">We check the database size:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="786" height="109" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-15.png" alt="" class="wp-image-46221" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-15.png 786w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-15-300x42.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-15-768x107.png 768w" sizes="auto, (max-width: 786px) 100vw, 786px" /></figure>



<p class="wp-block-paragraph">We run a checkpoint before taking the snapshot. The recovery process starts replaying the WAL from the last checkpoint. By running it right before the snapshot, almost nothing needs to be replayed when the database starts after a restore:</p>



<pre class="wp-block-code"><code>sudo -u postgres psql -c "CHECKPOINT;"</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="408" height="36" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-16.png" alt="" class="wp-image-46222" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-16.png 408w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-16-300x26.png 300w" sizes="auto, (max-width: 408px) 100vw, 408px" /></figure>



<p class="wp-block-paragraph"><strong>Comparison with SQL Server</strong>:</p>



<p class="wp-block-paragraph">On the SQL Server side, we had to run SUSPEND_FOR_SNAPSHOT_BACKUP and BACKUP WITH METADATA_ONLY. On the PostgreSQL side, none of that is needed.</p>



<p class="wp-block-paragraph">The data files and the WAL are on the same zvol. An atomic ZFS snapshot therefore captures a state equivalent to a power loss, and PostgreSQL is designed to recover cleanly from that state through crash recovery: the WAL is replayed from the last checkpoint. This is documented and officially supported.</p>



<p class="wp-block-paragraph">The snapshot is the backup. There is no .bkm file, no metadata backup.</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><tbody><tr><td></td><td><strong>SQL Server</strong></td><td><strong>PostgreSQL</strong></td></tr><tr><td><strong>Before the snapshot</strong></td><td>ALTER DATABASE&#8230;SET SUSPEND_FOR_SNAPSHOT_BACKUP = ON</td><td>CHECKPOINT (optional)</td></tr><tr><td><strong>Backup record</strong></td><td>BACKUP WITH METADATA_ONLY</td><td>None</td></tr><tr><td><strong>During the restore</strong></td><td>RESTORE WITH METADATA_ONLY</td><td>Automatic crash recovery (WAL replay)</td></tr><tr><td><strong>Evidence in the logs</strong></td><td>&#8220;I/O is frozen&#8221; in the ERRORLOG</td><td>&#8220;redo starts/redo done&#8221; in the PostgreSQL log</td></tr></tbody></table></figure>



<h2 id="h-snapshot-process-flow" class="wp-block-heading">Snapshot process flow</h2>



<p class="wp-block-paragraph">On the Proxmox side, we create the snapshot and protect it with a hold:</p>



<pre class="wp-block-code"><code>SNAP="sqlpool/pve/vm-307-disk-0@pg_bench_$(date +%Y%m%dT%H%M%S)" 
zfs snapshot "$SNAP" 
zfs hold sqlsnap "$SNAP" 
echo "$SNAP"</code></pre>



<p class="wp-block-paragraph">The hold protects the snapshot from an accidental destruction, as we did in part 2 of this series. We note the exact snapshot name, it will be needed for the restore.</p>



<p class="wp-block-paragraph">The database stays online during the whole operation. No I/O freeze is required.</p>



<p class="wp-block-paragraph">We list the snapshots:</p>



<pre class="wp-block-code"><code>zfs list -t snapshot -r sqlpool/pve/vm-307-disk-0</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="707" height="108" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-17.png" alt="" class="wp-image-46223" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-17.png 707w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-17-300x46.png 300w" sizes="auto, (max-width: 707px) 100vw, 707px" /></figure>



<p class="wp-block-paragraph">We drop the database then we restore the snapshot:</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="272" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-18-1024x272.png" alt="" class="wp-image-46224" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-18-1024x272.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-18-300x80.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-18-768x204.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-18.png 1048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">We run the snapshot restore procedure. On the VM, we stop the cluster and unmount the volume:</p>



<pre class="wp-block-code"><code>sudo systemctl stop postgresql@18-main
sudo umount /pgdata
</code></pre>



<p class="wp-block-paragraph">On the Proxmox side, we want to restore our snapshot. We can list the available snapshots:</p>



<pre class="wp-block-code"><code>zfs list -t snapshot -r sqlpool/pve/vm-307-disk-0</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="690" height="68" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-19.png" alt="" class="wp-image-46225" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-19.png 690w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-19-300x30.png 300w" sizes="auto, (max-width: 690px) 100vw, 690px" /></figure>



<p class="wp-block-paragraph">We roll back the snapshot:</p>



<pre class="wp-block-code"><code>zfs rollback -r sqlpool/pve/vm-307-disk-0@pg_bench_20260803T165409</code></pre>



<p class="wp-block-paragraph">On the VM, we mount the volume and start the service:</p>



<pre class="wp-block-code"><code>sudo mount /pgdata
sudo systemctl start postgresql@18-main</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="439" height="36" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-20.png" alt="" class="wp-image-46226" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-20.png 439w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-20-300x25.png 300w" sizes="auto, (max-width: 439px) 100vw, 439px" /></figure>



<p class="wp-block-paragraph">We check a few elements in the logs:</p>



<pre class="wp-block-code"><code>sudo tail -30 /var/log/postgresql/postgresql-18-main.log</code></pre>



<p class="wp-block-paragraph">The service shutdown, then the restart:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
UTC &#x5B;232108] LOG:  database system is shut down
UTC &#x5B;233968] LOG:  starting PostgreSQL 18.4 (Ubuntu 18.4-1.pgdg24.04+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0, 64-bit
UTC &#x5B;233968] LOG:  listening on IPv4 address &quot;0.0.0.0&quot;, port 5432
UTC &#x5B;233968] LOG:  listening on IPv6 address &quot;::&quot;, port 5432
UTC &#x5B;233968] LOG:  listening on Unix socket &quot;/var/run/postgresql/.s.PGSQL.5432&quot;
</pre></div>


<p class="wp-block-paragraph">PostgreSQL detects that the database was not shut down properly and replays the WAL. This is the same crash recovery mechanism as in SQL Server. Finally, the database starts.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
UTC &#x5B;233974] LOG:  database system was not properly shut down; automatic recovery in progress
UTC &#x5B;233974] LOG:  redo starts at 20/2CB76278
UTC &#x5B;233974] LOG:  invalid record length at 20/2CB76380: expected at least 24, got 0
UTC &#x5B;233974] LOG:  redo done at 20/2CB76348 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
UTC &#x5B;233974] LOG:  checkpoint starting: end-of-recovery immediate wait
UTC &#x5B;233972] LOG:  checkpoint complete: wrote 0 buffers (0.0%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.002 s, sync=0.009 s, total=0.030 s; sync files=0, longest=0.000 s, average=0.005 s; distance=0 kB, estimate=0 kB; lsn=20/2CB76380, redo lsn=20/2CB76380
UTC &#x5B;233968] LOG:  database system is ready to accept connections
</pre></div>


<p class="wp-block-paragraph">We then verify that the database is available again:</p>



<pre class="wp-block-code"><code>sudo -u postgres psql -d bench -c "SELECT pg_size_pretty(pg_database_size('bench'));"</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="696" height="88" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-23.png" alt="" class="wp-image-46229" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-23.png 696w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-23-300x38.png 300w" sizes="auto, (max-width: 696px) 100vw, 696px" /></figure>



<h2 id="h-consistency-proof-under-load" class="wp-block-heading">Consistency proof under load</h2>



<p class="wp-block-paragraph">The previous test was done on a quiesced database: we ran a CHECKPOINT right before the snapshot, and nothing was writing. The real question is: what happens if the snapshot is taken while the database is being written to?</p>



<p class="wp-block-paragraph">This is where PostgreSQL differs the most from SQL Server. There is no SUSPEND_FOR_SNAPSHOT_BACKUP. We take the snapshot in the middle of the write activity and we let the WAL replay do the work.</p>



<p class="wp-block-paragraph">We start the load. The built-in pgbench script runs a TPC-B-like transaction: three UPDATE statements on the accounts, tellers and branches tables and one INSERT into the history table:</p>



<pre class="wp-block-code"><code>sudo -u postgres pgbench -c 8 -j 4 -T 300 bench &amp;</code></pre>



<figure class="wp-block-image size-full is-resized"><img loading="lazy" decoding="async" width="537" height="81" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-24.png" alt="" class="wp-image-46230" style="width:537px;height:auto" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-24.png 537w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-24-300x45.png 300w" sizes="auto, (max-width: 537px) 100vw, 537px" /></figure>



<p class="wp-block-paragraph">While the load is running, we take a snapshot on the Proxmox side:</p>



<pre class="wp-block-code"><code>zfs snapshot sqlpool/pve/vm-307-disk-0@pg_bench_$(date +%Y%m%dT%H%M%S)

zfs hold sqlsnap sqlpool/pve/vm-307-disk-0@pg_bench_20260803T224551</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="717" height="113" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-25.png" alt="" class="wp-image-46231" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-25.png 717w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-25-300x47.png 300w" sizes="auto, (max-width: 717px) 100vw, 717px" /></figure>



<p class="wp-block-paragraph">No CHECKPOINT this time, no freeze. The database is actively writing while the snapshot is taken.</p>



<p class="wp-block-paragraph">The state after some time under load:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="687" height="187" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-26.png" alt="" class="wp-image-46232" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-26.png 687w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-26-300x82.png 300w" sizes="auto, (max-width: 687px) 100vw, 687px" /></figure>



<p class="wp-block-paragraph">We stop the service:</p>



<pre class="wp-block-code"><code>sudo systemctl stop postgresql@18-main 
sudo umount /pgdata</code></pre>



<p class="wp-block-paragraph">We restore the snapshot:</p>



<pre class="wp-block-code"><code>zfs rollback -r sqlpool/pve/vm-307-disk-0@pg_bench_20260803T224551 </code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="662" height="35" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-27.png" alt="" class="wp-image-46233" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-27.png 662w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-27-300x16.png 300w" sizes="auto, (max-width: 662px) 100vw, 662px" /></figure>



<p class="wp-block-paragraph">We mount the volume, start the service and check the logs:</p>



<pre class="wp-block-code"><code>sudo mount /pgdata 
sudo systemctl start postgresql@18-main</code></pre>



<p class="wp-block-paragraph">This time the log shows a real recovery:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
2026-08-03 20:49:03.244 UTC &#x5B;234405] LOG:  database system is shut down
2026-08-03 20:50:34.333 UTC &#x5B;234620] LOG:  starting PostgreSQL 18.4 (Ubuntu 18.4-1.pgdg24.04+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0, 64-bit
2026-08-03 20:50:34.333 UTC &#x5B;234620] LOG:  listening on IPv4 address &quot;0.0.0.0&quot;, port 5432
2026-08-03 20:50:34.333 UTC &#x5B;234620] LOG:  listening on IPv6 address &quot;::&quot;, port 5432
2026-08-03 20:50:34.335 UTC &#x5B;234620] LOG:  listening on Unix socket &quot;/var/run/postgresql/.s.PGSQL.5432&quot;
2026-08-03 20:50:34.343 UTC &#x5B;234626] LOG:  database system was interrupted; last known up at 2026-08-03 20:44:36 UTC
2026-08-03 20:50:34.381 UTC &#x5B;234626] LOG:  database system was not properly shut down; automatic recovery in progress
2026-08-03 20:50:34.384 UTC &#x5B;234626] LOG:  redo starts at 20/7A3618B0
2026-08-03 20:50:38.024 UTC &#x5B;234626] LOG:  invalid record length at 20/8BC0A4F8: expected at least 24, got 0
2026-08-03 20:50:38.024 UTC &#x5B;234626] LOG:  redo done at 20/8BC0A4D0 system usage: CPU: user: 0.71 s, system: 0.63 s, elapsed: 3.63 s
2026-08-03 20:50:38.028 UTC &#x5B;234624] LOG:  checkpoint starting: end-of-recovery immediate wait
2026-08-03 20:50:53.936 UTC &#x5B;234624] LOG:  checkpoint complete: wrote 105355 buffers (53.6%), wrote 5 SLRU buffers; 0 WAL file(s) added, 17 removed, 0 recycled; write=3.648 s, sync=12.232 s, total=15.911 s; sync files=189, longest=12.223 s, average=0.065 s; distance=287395 kB, estimate=287395 kB; lsn=20/8BC0A4F8, redo lsn=20/8BC0A4F8
2026-08-03 20:50:53.948 UTC &#x5B;234620] LOG:  database system is ready to accept connections
</pre></div>


<p class="wp-block-paragraph">Three differences compared to the first test:</p>



<ul class="wp-block-list">
<li>The &#8220;last known up at&#8221; timestamp (20:44:36) does not match a checkpoint we ran manually. It matches the last automatic checkpoint triggered during the load.</li>
</ul>



<ul class="wp-block-list">
<li>The redo is not instantaneous anymore: 3.63 seconds, replaying about 280 MB of WAL (from LSN 20/7A3618B0 to 20/8BC0A4D0). All the write activity between the last checkpoint and the snapshot had to be replayed. The transactions committed before the snapshot are recovered, the ones that were in flight are rolled back.</li>
</ul>



<ul class="wp-block-list">
<li>The end-of-recovery checkpoint then writes everything the redo rebuilt in memory: 105355 buffers, 53.6% of the buffer pool. The database is ready to accept connections about 19 seconds after the service start.</li>
</ul>



<p class="wp-block-paragraph">The crash recovery completed correctly and the database has been restored. We verify the TPC-B invariant. Each pgbench transaction applies the same delta to the accounts, tellers and branches tables in a single transaction. On a consistent database, the three sums must be equal:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="539" height="146" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-29.png" alt="" class="wp-image-46237" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-29.png 539w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-29-300x81.png 300w" sizes="auto, (max-width: 539px) 100vw, 539px" /></figure>



<p class="wp-block-paragraph"></p>



<h2 id="h-major-drawbacks" class="wp-block-heading">Major drawbacks</h2>



<ul class="wp-block-list">
<li>The snapshot covers the whole zvol. All the databases of the cluster are captured and restored together. There is no per-database restore, unlike the METADATA_ONLY approach on SQL Server which targets a single database.</li>



<li>There is no backup history. SQL Server records the metadata backup in msdb. Here, the only trace is the snapshot itself on the ZFS side.</li>



<li>Point-in-time recovery is not covered. The snapshot alone brings the database back to the moment it was taken. For PITR, WAL archiving would still be required on top of it.</li>
</ul>



<h3 id="h-" class="wp-block-heading"></h3>



<h2 id="h-conclusion" class="wp-block-heading">Conclusion</h2>



<ul class="wp-block-list">
<li>The snapshot backup and restore model of the SQL Server series applies to PostgreSQL (no I/O freeze, no metadata backup).</li>



<li>One important rule: data files and WAL must reside on the same zvol so the snapshot is atomic.</li>



<li>A 146 GB database was restored in a few seconds and in less than 20 seconds under active load, WAL replay included.</li>
</ul>



<p class="wp-block-paragraph">Thank you. <a href="https://www.linkedin.com/in/amine-haloui-76968056/">Amine Haloui</a></p>



<p class="wp-block-paragraph"></p>
<p>L’article <a href="https://www.dbi-services.com/blog/postgresql-snapshot-backup-and-restore-with-proxmox-zfs-4-4/">PostgreSQL Snapshot Backup and Restore with Proxmox ZFS (4/4)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/postgresql-snapshot-backup-and-restore-with-proxmox-zfs-4-4/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Upgrade RHEL from 9.6 to 10.1 (when running PostgreSQL/Patroni)</title>
		<link>https://www.dbi-services.com/blog/upgrade-rhel-from-9-6-to-10-1-when-running-postgresql-patroni/</link>
					<comments>https://www.dbi-services.com/blog/upgrade-rhel-from-9-6-to-10-1-when-running-postgresql-patroni/#respond</comments>
		
		<dc:creator><![CDATA[Joan Frey]]></dc:creator>
		<pubDate>Fri, 26 Jun 2026 10:39:40 +0000</pubDate>
				<category><![CDATA[Database Administration & Monitoring]]></category>
		<category><![CDATA[Operating systems]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[10]]></category>
		<category><![CDATA[leapp]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[operating system]]></category>
		<category><![CDATA[os]]></category>
		<category><![CDATA[RHEL]]></category>
		<category><![CDATA[upgrade]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=43285</guid>

					<description><![CDATA[<p>Upgrading from RHEL 9.6 to 10.1 is not just a routine update, it’s a major platform shift. When your server runs PostgreSQL compiled from source and a Patroni-managed cluster, the complexity increases significantly. System libraries change, Python environments break, ICU versions evolve, and your database binaries may no longer start after reboot. In this guide, [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/upgrade-rhel-from-9-6-to-10-1-when-running-postgresql-patroni/">Upgrade RHEL from 9.6 to 10.1 (when running PostgreSQL/Patroni)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Upgrading from RHEL 9.6 to 10.1 is not just a routine update, it’s a major platform shift. When your server runs PostgreSQL compiled from source and a Patroni-managed cluster, the complexity increases significantly. System libraries change, Python environments break, ICU versions evolve, and your database binaries may no longer start after reboot.</p>



<p class="wp-block-paragraph">In this guide, I walk through a real-world in-place upgrade using Leapp, covering preparation, resolving high-severity warnings, executing the upgrade, recompiling PostgreSQL, fixing collation mismatches, and restoring Patroni.</p>



<h2 class="wp-block-heading" id="h-i-preparation">I. Preparation</h2>



<p class="wp-block-paragraph">Before the upgrade, you must ensure the current OS is healthy and fully patched.</p>



<h3 class="wp-block-heading" id="h-1-pause-high-availability">1. Pause High Availability</h3>



<p class="wp-block-paragraph">Prevent Patroni from triggering a failover during the reboot cycles. If you are using a single PostgreSQL cluster, stop it by stopping the service or by using pg_ctl stop.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
patronictl -c /etc/patroni/patroni.yml pause
systemctl stop patroni
</pre></div>


<h3 class="wp-block-heading" id="h-2-fix-subscription-amp-perform-full-update">2. Fix Subscription &amp; Perform Full Update</h3>



<p class="wp-block-paragraph">I&#8217;m using an old VM for this blog, and If just like me, you see 403 Forbidden errors on repositories like codeready-builder, refresh your registration:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
&#x5B;root@patroni2 ~]# sudo dnf update -y
Updating Subscription Management repositories.

This system is registered with an entitlement server, but is not receiving updates. You can use subscription-manager to assign subscriptions.

Red Hat CodeReady Linux Builder for RHEL 9 x86_64 (RPMs)                                                                                                     761  B/s | 480  B     00:00
Errors during downloading metadata for repository &#039;codeready-builder-for-rhel-9-x86_64-rpms&#039;:
  - Status code: 403 for https://cdn.redhat.com/content/dist/rhel9/9/x86_64/codeready-builder/os/repodata/repomd.xml (IP: 23.206.57.92)
Error: Failed to download metadata for repo &#039;codeready-builder-for-rhel-9-x86_64-rpms&#039;: Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried

&#x5B;root@patroni2 ~]# sudo subscription-manager clean
&#x5B;root@patroni2 ~]# sudo subscription-manager register --force
&#x5B;root@patroni2 ~]# sudo subscription-manager attach --auto
&#x5B;root@patroni2 ~]# sudo subscription-manager refresh

&#x5B;root@patroni2 ~]# sudo dnf update -y
...
Complete!

&#x5B;root@patroni2 ~]# reboot
</pre></div>


<h2 class="wp-block-heading" id="h-ii-the-leapp-upgrade-to-10-1">II. The Leapp Upgrade to 10.1</h2>



<h3 class="wp-block-heading" id="h-1-install-amp-analyze">1. Install &amp; Analyze</h3>



<p class="wp-block-paragraph">In this first phase, we are preparing the system for a major in-place upgrade using Leapp, the official upgrade framework for Red Hat–based distributions. When we install the package:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
&#x5B;root@patroni2 ~]# dnf install leapp-upgrade -y
...
Installed:
  leapp-0.20.0-1.el9.noarch                leapp-deps-0.20.0-1.el9.noarch          leapp-upgrade-el9toel10-0.23.0-1.el9.noarch       leapp-upgrade-el9toel10-deps-0.23.0-1.el9.noarch
  libdb-utils-5.3.28-57.el9_6.x86_64       python3-leapp-0.20.0-1.el9.noarch       systemd-container-252-55.el9_7.7.x86_64

Complete!
</pre></div>


<p class="wp-block-paragraph">When running leapp preupgrade &#8211;target 10.1, we are not performing the upgrade. Instead, Leapp performs a full system audit to determine if the server is ready for RHEL 10.1. It checks:</p>



<ul class="wp-block-list">
<li>Installed packages and their compatibility</li>



<li>Deprecated or removed libraries</li>



<li>Kernel drivers that will not exist in RHEL 10</li>



<li>Bootloader configuration (GRUB2)</li>



<li>GPG key validity</li>



<li>Custom system-level modifications (like dynamic linker changes)</li>



<li>&#8230;</li>
</ul>



<p class="wp-block-paragraph">Think of this step as a dry-run with intelligence.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
&#x5B;root@patroni2 ~]# sudo leapp preupgrade --target 10.1

...

============================================================
                      REPORT OVERVIEW
============================================================

HIGH and MEDIUM severity reports:
    1. GRUB2 core will be automatically updated during the upgrade
    2. Detected customized configuration for dynamic linker.
    3. Leapp detected loaded kernel drivers which are no longer maintained in RHEL 10.
    4. Failed to read GPG keys from provided key files
    5. Berkeley DB (libdb) has been detected on your system

Reports summary:
    Errors:                      0
    Inhibitors:                  0
    HIGH severity reports:       4
    MEDIUM severity reports:     1
    LOW severity reports:        1
    INFO severity reports:       3

Before continuing, review the full report below for details about discovered problems and possible remediation instructions:
    A report has been generated at /var/log/leapp/leapp-report.txt
    A report has been generated at /var/log/leapp/leapp-report.json
</pre></div>


<p class="wp-block-paragraph">After running the pre-upgrade analysis, the next step is to carefully review:</p>



<pre class="wp-block-preformatted">/var/log/leapp/leapp-report.txt</pre>



<p class="wp-block-paragraph">What we are looking for first is simple:</p>



<ul class="wp-block-list">
<li>Errors: 0</li>



<li>Inhibitors: 0</li>
</ul>



<p class="wp-block-paragraph">If an Inhibitor is present, the upgrade will be blocked entirely.<br>In my case, there were no blockers, but I did have several high severity warnings.</p>



<p class="wp-block-paragraph">High severity does not mean the upgrade will fail.<br>It means: This could break something, review it carefully.</p>



<p class="wp-block-paragraph">Let’s look at one concrete example from my system.</p>



<h3 class="wp-block-heading" id="h-2-high-severity-example-dynamic-linker-customization">2. High Severity Example – Dynamic Linker Customization</h3>



<p class="wp-block-paragraph">Leapp detected that my system had a custom dynamic linker configuration:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
Risk Factor: high

Title: Detected customized configuration for dynamic linker.

Summary: Custom configurations to the dynamic linker could potentially impact the upgrade in a negative way. The custom configuration includes modifications to /etc/ld.so.conf, custom or modified drop in config files in the /etc/ld.so.conf.d directory and additional entries in the LD_LIBRARY_PATH or LD_PRELOAD variables. These modifications configure the dynamic linker to use different libraries that might not be provided by Red Hat products or might not be present during the whole upgrade process. The following custom configurations were detected by leapp:

- The following drop in config files were marked as custom:

    - /etc/ld.so.conf.d/postgres.conf

Remediation: &#x5B;hint] Remove or revert the custom dynamic linker configurations and apply the changes using the ldconfig command. In case of possible active software collections we suggest disabling them persistently.

Key: cc9bd972af70b7a27f66a37b11a00dcfcb73b1bc

----------------------------------------
</pre></div>


<h4 class="wp-block-heading" id="h-what-does-this-actually-mean">What does this actually mean?</h4>



<p class="wp-block-paragraph">The dynamic linker (ld.so) is responsible for loading shared libraries at runtime.</p>



<p class="wp-block-paragraph">By modifying:</p>



<ul class="wp-block-list">
<li>/etc/ld.so.conf</li>



<li>files in /etc/ld.so.conf.d/</li>



<li>LD_LIBRARY_PATH</li>



<li><code>LD_PR</code>E<code>LOAD</code></li>
</ul>



<p class="wp-block-paragraph">we are telling the system to load non-standard or custom libraries. In PostgreSQL environments (especially with custom builds or extensions), this is common practice. However, during a major OS upgrade, these custom paths might:</p>



<ul class="wp-block-list">
<li>Point to libraries that do not exist in RHEL 10</li>



<li>Override new system libraries</li>



<li>Break dependency resolution mid-upgrade</li>
</ul>



<p class="wp-block-paragraph">Leapp flags this because it cannot guarantee consistency during the transition phase. In my case, it shouldn&#8217;t be an issue, because inside postgres.conf, I only have a path aiming to the lib directories of my PostgreSQL installation, which will not change, but we will still see how to prevent an error.</p>



<h4 class="wp-block-heading" id="h-understanding-the-remediation">Understanding the Remediation</h4>



<p class="wp-block-paragraph">The report clearly suggests:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Remove or revert the custom dynamic linker configurations and apply the changes using the ldconfig command.</p>
</blockquote>



<p class="wp-block-paragraph">In my case, the configuration was related to PostgreSQL, so temporarily removing it is safe for the upgrade preparation phase. Instead of deleting it permanently, I moved it aside:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
&#x5B;root@patroni2 ~]# sudo mv /etc/ld.so.conf.d/postgres.conf /tmp/postgres.conf.bak
&#x5B;root@patroni2 ~]# sudo ldconfig
</pre></div>


<p class="wp-block-paragraph">ldconfig rebuilds the system library cache now based only on standard paths.</p>



<h4 class="wp-block-heading" id="h-re-run-the-preupgrade-check">Re-Run the Preupgrade Check</h4>



<p class="wp-block-paragraph">After remediation, always re-run the preupgrade command and check the report again. If the fix was successful:</p>



<ul class="wp-block-list">
<li>The severity should disappear from the REPORT OVERVIEW</li>



<li>The issue should no longer appear in leapp-report.txt</li>
</ul>



<p class="wp-block-paragraph">This validation loop is important. We are progressively cleaning the system until it is fully compliant for upgrade.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
============================================================
                      REPORT OVERVIEW
============================================================

HIGH and MEDIUM severity reports:
    1. Leapp detected loaded kernel drivers which are no longer maintained in RHEL 10.
    2. GRUB2 core will be automatically updated during the upgrade
    3. Failed to read GPG keys from provided key files
    4. Berkeley DB (libdb) has been detected on your system

Reports summary:
    Errors:                      0
    Inhibitors:                  0
    HIGH severity reports:       3
    MEDIUM severity reports:     1
    LOW severity reports:        1
    INFO severity reports:       3

Before continuing, review the full report below for details about discovered problems and possible remediation instructions:
    A report has been generated at /var/log/leapp/leapp-report.txt
    A report has been generated at /var/log/leapp/leapp-report.json
</pre></div>


<p class="wp-block-paragraph">Only once the report is clean, or fully understood, should we proceed to the actual upgrade execution.</p>



<h3 class="wp-block-heading" id="h-2-execute-the-upgrade">2. Execute the upgrade</h3>



<p class="wp-block-paragraph">Once all errors and inhibitors are resolved, and high-severity findings have been reviewed or remediated, we are finally ready to perform the actual in-place upgrade. This is the moment where Leapp transitions from analysis mode to execution mode.</p>



<h4 class="wp-block-heading" id="h-about-the-repository-warning">About the Repository Warning</h4>



<p class="wp-block-paragraph">During the preupgrade phase, Leapp informed us that codeready-builder-&#8230; repositories are not officially supported during the upgrade process and are excluded by default.</p>



<p class="wp-block-paragraph">This is expected behavior as Leapp only enables a minimal, controlled set of repositories to ensure:</p>



<ul class="wp-block-list">
<li>Package consistency</li>



<li>Dependency resolution stability</li>



<li>Predictable upgrade paths</li>
</ul>



<p class="wp-block-paragraph">However, in PostgreSQL environments, some packages (extensions, development headers, libraries) may depend on CodeReady Builder. If a repository is truly required during the upgrade, we must explicitly enable it using:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
--enablerepo &lt;repoid&gt;
</pre></div>


<h4 class="wp-block-heading" id="h-running-the-upgrade">Running the Upgrade</h4>



<p class="wp-block-paragraph">Since I need CodeReady Builder for PostgreSQLdependencies, I ran:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
leapp upgrade --target 10.1 --enablerepo codeready-builder-for-rhel-10-x86_64-rpms
</pre></div>


<h4 class="wp-block-heading">What happens when we run this command?</h4>



<p class="wp-block-paragraph">At this stage, Leapp:</p>



<ul class="wp-block-list">
<li>Resolves and downloads required RHEL 10 packages</li>



<li>Builds a temporary upgrade environment</li>



<li>Prepares a special upgrade initramfs</li>



<li>Modifies the bootloader (GRUB) to boot into the upgrade environment on next reboot</li>
</ul>



<p class="wp-block-paragraph">The system is not upgraded immediately. The actual OS transition happens during the next boot.</p>



<p class="wp-block-paragraph">After the command completes, Leapp generates another report. Just like in the preupgrade phase, verify:</p>



<ul class="wp-block-list">
<li>Errors: 0</li>



<li>Inhibitors: 0</li>
</ul>



<p class="wp-block-paragraph">If everything looks clean, we can proceed.</p>



<h4 class="wp-block-heading" id="h-reboot-the-real-upgrade-begins">Reboot – The Real Upgrade Begins</h4>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
# reboot
</pre></div>


<p class="wp-block-paragraph">This is where the real upgrade starts. During boot:</p>



<ul class="wp-block-list">
<li>The system enters a temporary upgrade environment</li>



<li>Packages are replaced</li>



<li>Obsolete components are removed</li>



<li>Configuration files are migrated</li>



<li>The new RHEL 10 kernel is installed</li>
</ul>



<p class="wp-block-paragraph">This phase can take several minutes depending on your VM/server resources. My VM doesn&#8217;t have many resources and it took me around 30 minutes. Be patient, interrupting this process can leave the system in an inconsistent state.</p>



<h4 class="wp-block-heading" id="h-verifying-the-upgrade">Verifying the Upgrade</h4>



<p class="wp-block-paragraph">Once the server is back online, confirm the OS version:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
&#x5B;root@patroni2 ~]# cat /etc/os-release
NAME=&quot;Red Hat Enterprise Linux&quot;
VERSION=&quot;10.1 (Coughlan)&quot;
ID=&quot;rhel&quot;
ID_LIKE=&quot;centos fedora&quot;
VERSION_ID=&quot;10.1&quot;
PLATFORM_ID=&quot;platform:el10&quot;
PRETTY_NAME=&quot;Red Hat Enterprise Linux 10.1 (Coughlan)&quot;
ANSI_COLOR=&quot;0;31&quot;
LOGO=&quot;fedora-logo-icon&quot;
CPE_NAME=&quot;cpe:/o:redhat:enterprise_linux:10.1&quot;
HOME_URL=&quot;https://www.redhat.com/&quot;
VENDOR_NAME=&quot;Red Hat&quot;
VENDOR_URL=&quot;https://www.redhat.com/&quot;
DOCUMENTATION_URL=&quot;https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/10&quot;
BUG_REPORT_URL=&quot;https://issues.redhat.com/&quot;
</pre></div>


<p class="wp-block-paragraph">REDHAT_BUGZILLA_PRODUCT=&#8221;Red Hat Enterprise Linux 10&#8243;<br>REDHAT_BUGZILLA_PRODUCT_VERSION=10.1<br>REDHAT_SUPPORT_PRODUCT=&#8221;Red Hat Enterprise Linux&#8221;<br>REDHAT_SUPPORT_PRODUCT_VERSION=&#8221;10.1&#8243;</p>



<p class="wp-block-paragraph">This confirms that we are now running RHEL 10.1.</p>



<h2 class="wp-block-heading" id="h-iii-post-upgrade-database-recovery">III. Post-Upgrade Database Recovery</h2>



<p class="wp-block-paragraph">Once you login to RHEL 10.1, your Postgres binaries in /u01 will fail because libicuuc.so.67 (from RHEL 9) is missing. It can also fail because of other libraries.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
14:45:55 postgres@patroni2:/home/postgres/ &#x5B;test-op-patroni] pgstart
/u01/app/postgres/product/17/db_6/bin/postgres: error while loading shared libraries: libicuuc.so.67: cannot open shared object file: No such file or directory
no data was returned by command &quot;&quot;/u01/app/postgres/product/17/db_6/bin/postgres&quot; -V&quot;
command not found
program &quot;postgres&quot; is needed by pg_ctl but was not found in the same directory as &quot;/u01/app/postgres/product/17/db_6/bin/pg_ctl&quot;
</pre></div>


<h3 class="wp-block-heading" id="h-1-recompile-postgresql">1. Recompile PostgreSQL</h3>



<p class="wp-block-paragraph">Since you installed from source, you must re compile PostgreSQL with the new RHEL 10 system libraries. Here is the command I personally use to build it, with the postgres user:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
postgres@patroni2:/home/postgres/ &#x5B;dummy] MAJOR=&quot;17&quot;
postgres@patroni2:/home/postgres/ &#x5B;dummy] MINOR=&quot;6&quot;
postgres@patroni2:/home/postgres/ &#x5B;dummy] tar axf postgresql-${MAJOR}.${MINOR}.tar.gz
postgres@patroni2:/home/postgres/ &#x5B;dummy] mkdir build; cd $_
postgres@patroni2:/home/postgres/ &#x5B;dummy] export PGHOME=&quot;/u01/app/postgres/product/${MAJOR}/db_${MINOR}&quot;
postgres@patroni2:/home/postgres/ &#x5B;dummy] export SEGSIZE=2
postgres@patroni2:/home/postgres/ &#x5B;dummy] export BLOCKSIZE=8
postgres@patroni2:/home/postgres/ &#x5B;dummy] meson setup . ../postgresql-${MAJOR}.${MINOR}
postgres@patroni2:/home/postgres/ &#x5B;dummy] meson configure -Dprefix=${PGHOME}                   -Dbindir=${PGHOME}/bin                   -Ddatadir=${PGHOME}/share                   -Dincludedir=${PGHOME}/include                   -Dlibdir=${PGHOME}/lib                   -Dsysconfdir=${PGHOME}/etc                   -Dpgport=5432                   -Dplperl=enabled                   -Dplpython=enabled                   -Dssl=openssl                   -Dpam=enabled                   -Dldap=enabled                   -Dlibxml=enabled                   -Dlibxslt=enabled                   -Dsegsize=${SEGSIZE}                   -Dblocksize=${BLOCKSIZE}                   -Dllvm=enabled                   -Duuid=ossp                   -Dzstd=enabled                   -Dlz4=enabled                   -Dzstd=enabled                   -Dgssapi=enabled                   -Dsystemd=enabled                   -Dicu=enabled                   -Dsystem_tzdata=/usr/share/zoneinfo                   -Dextra_version=&quot; dbi services build&quot;
postgres@patroni2:/home/postgres/ &#x5B;dummy] ninja
postgres@patroni2:/home/postgres/ &#x5B;dummy] ninja install
</pre></div>


<h3 class="wp-block-heading" id="h-2-restore-library-paths">2. Restore Library Paths</h3>



<p class="wp-block-paragraph">[root@patroni2 ~]# mv /tmp/postgres.conf.bak /etc/ld.so.conf.d/postgres.conf<br>[root@patroni2 ~]# ldconfig</p>



<h3 class="wp-block-heading" id="h-3-start-amp-fix-collation-mismatch">3. Start &amp; Fix Collation Mismatch</h3>



<p class="wp-block-paragraph">Postgres will now start, but will warn you about Collation Version Mismatches (2.34 vs 2.39).</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
15:09:55 postgres@patroni2:/home/postgres/build/ &#x5B;test-op-patroni] pgstart
waiting for server to start.... done
server started
15:10:28 postgres@patroni2:/home/postgres/build/ &#x5B;test-op-patroni] psql
WARNING:  database &quot;postgres&quot; has a collation version mismatch
DETAIL:  The database was created using collation version 2.34, but the operating system provides version 2.39.
HINT:  Rebuild all objects in this database that use the default collation and run ALTER DATABASE postgres REFRESH COLLATION VERSION, or build PostgreSQL with the right library version.
psql (17.6 dbi services build)
Type &quot;help&quot; for help.
</pre></div>


<p class="wp-block-paragraph">Inside Postgres, run for every database:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
ALTER DATABASE postgres REFRESH COLLATION VERSION;
ALTER DATABASE

-- Repeat for other DBs if applicable
REINDEX DATABASE postgres;
</pre></div>


<p class="wp-block-paragraph">Your PostgreSQL is now starting properly and your server has been upgraded.</p>



<h3 class="wp-block-heading" id="h-4-in-case-of-a-patroni-cluster">4. In case of a patroni cluster</h3>



<p class="wp-block-paragraph">Since the system Python version has changed after the OS upgrade, your old .local venv is invalid. You must recreate it. Here is how I install patroni using the postgres user:</p>



<p class="wp-block-paragraph">$ python3 -m venv .local<br>$ .local/bin/pip3 install &#8211;upgrade pip<br>$ .local/bin/pip3 install &#8211;upgrade setuptools<br>$ .local/bin/pip3 install wheel<br>$ .local/bin/pip3 install psycopg[binary]<br>$ .local/bin/pip3 install python-etcd<br>$ .local/bin/pip3 install patroni<br>$ .local/bin/patroni version</p>



<p class="wp-block-paragraph">Check if the cluster sees the member again. If patronictl list is empty, a restart of the service is usually required to re-register with etcd.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
10:45:40 postgres@patroni2:/home/postgres/ &#x5B;test-op-patroni] patronictl list
+ Cluster: test-op-patroni (7565882985963789761) -+-----+------------+-----+
| Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag |
+--------+------+------+-------+----+-------------+-----+------------+-----+
+--------+------+------+-------+----+-------------+-----+------------+-----+
10:45:48 postgres@patroni2:/home/postgres/ &#x5B;test-op-patroni] sudo systemctl restart patroni
10:45:55 postgres@patroni2:/home/postgres/ &#x5B;test-op-patroni] patronictl list
+ Cluster: test-op-patroni (7565882985963789761) --+---------+----+-------------+-----+------------+-----+
| Member                 | Host           | Role   | State   | TL | Receive LSN | Lag | Replay LSN | Lag |
+------------------------+----------------+--------+---------+----+-------------+-----+------------+-----+
| patroni-tst-op-geapg02 | 192.168.56.142 | Leader | running | 11 |             |     |            |     |
+------------------------+----------------+--------+---------+----+-------------+-----+------------+-----+

</pre></div>


<h2 class="wp-block-heading" id="h-iv-conclusion">IV. Conclusion</h2>



<p class="wp-block-paragraph">Upgrading from RHEL 9.6 to 10.1 is a big move. It’s not just a simple update; it’s a total shift in the system&#8217;s foundation. Between hardware driver changes and library updates, you really have to pay attention to the details to keep your database running.</p>



<p class="wp-block-paragraph">RHEL 10.1 is a great, modern platform, but you can&#8217;t just click &#8220;update&#8221; and hope for the best. By planning the upgrade, planning to rebuild your binaries, refreshing your database objects, you can make the jump without the drama. Take the pre-upgrade report seriously, it’s there for a reason!</p>



<p class="wp-block-paragraph">That said, for a production PostgreSQL cluster, especially one managed with Patroni and etcd, I would not recommend this in-place upgrade approach. Even if Leapp makes the process technically possible, you are still:</p>



<ul class="wp-block-list">
<li>Modifying the operating system in place</li>



<li>Replacing core libraries underneath a running database stack</li>



<li>Trusting automated dependency resolution during a major version jump</li>
</ul>



<p class="wp-block-paragraph">In production, risk reduction should always be the priority.</p>



<p class="wp-block-paragraph">Instead, I strongly recommend provisioning new VMs or physical servers, installing RHEL 10.1 from scratch, deploying PostgreSQL, Patroni, and etcd cleanly, rebuilding the cluster from best practices, and then migrating the data from the old environment to the new one using replication or another appropriate method.</p>



<p class="wp-block-paragraph">Sometimes the safest upgrade… is a new cluster.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/upgrade-rhel-from-9-6-to-10-1-when-running-postgresql-patroni/">Upgrade RHEL from 9.6 to 10.1 (when running PostgreSQL/Patroni)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/upgrade-rhel-from-9-6-to-10-1-when-running-postgresql-patroni/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Zabbix &#8211; Monitoring full cluster Patroni/PostgreSQL</title>
		<link>https://www.dbi-services.com/blog/zabbix-monitoring-full-cluster-patroni-postgresql/</link>
					<comments>https://www.dbi-services.com/blog/zabbix-monitoring-full-cluster-patroni-postgresql/#respond</comments>
		
		<dc:creator><![CDATA[Aurélien Py]]></dc:creator>
		<pubDate>Fri, 12 Jun 2026 14:06:13 +0000</pubDate>
				<category><![CDATA[Database Administration & Monitoring]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[Zabbix]]></category>
		<category><![CDATA[Patroni]]></category>
		<category><![CDATA[postgresql]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44284</guid>

					<description><![CDATA[<p>Introduction Monitoring a PostgreSQL environment involves much more than simply checking whether the database is running. In a modern high availability architecture, multiple components work together to keep the service available and stable. In this article, we will explain how to monitor a PostgreSQL cluster environment with Zabbix: The goal of this blog is to [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/zabbix-monitoring-full-cluster-patroni-postgresql/">Zabbix &#8211; Monitoring full cluster Patroni/PostgreSQL</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<h1 class="wp-block-heading" id="h-introduction">Introduction</h1>



<p class="wp-block-paragraph">Monitoring a PostgreSQL environment involves much more than simply checking whether the database is running. In a modern high availability architecture, multiple components work together to keep the service available and stable.</p>



<p class="wp-block-paragraph">In this article, we will explain how to monitor a PostgreSQL cluster environment with Zabbix:</p>



<ul class="wp-block-list">
<li>PostgreSQL</li>



<li>Patroni</li>



<li>HAProxy</li>



<li>ETCD</li>



<li>Keepalived</li>
</ul>



<p class="wp-block-paragraph">The goal of this blog is to provide a simple and clear overview that can be understood by both beginners and experienced administrators.</p>



<span id="more-44284"></span>



<h2 class="wp-block-heading" id="h-postgresql-high-availability-architecture">PostgreSQL High Availability Architecture</h2>



<p class="wp-block-paragraph">A typical PostgreSQL high availability environment often contains several components:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Component</th><th>Role</th></tr></thead><tbody><tr><td>PostgreSQL</td><td>Database engine</td></tr><tr><td>Patroni</td><td>PostgreSQL cluster management and failover</td></tr><tr><td>ETCD</td><td>Distributed key-value store used by Patroni</td></tr><tr><td>HAProxy</td><td>Load balancing and traffic routing</td></tr><tr><td>Keepalived</td><td>Virtual IP management and failover</td></tr><tr><td>Zabbix</td><td>Monitoring and alerting platform</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">In a PostgreSQL HA environment, monitoring only the database is not enough because every component directly impacts cluster stability and application availability. Moreover, a failures in components like HAProxy, ETCD, or Keepalived can directly impact application availability and cluster stability. Comprehensive monitoring ensures the entire ecosystem remains healthy and operational.</p>



<h2 class="wp-block-heading" id="h-monitoring-postgresql-with-zabbix">Monitoring PostgreSQL with Zabbix</h2>



<p class="wp-block-paragraph">Zabbix already provides an official PostgreSQL <a href="https://www.zabbix.com/integrations/postgresql#postgresql_agent2">template</a>.</p>



<p class="wp-block-paragraph">This template allows administrators to monitor:</p>



<ul class="wp-block-list">
<li>Database availability</li>



<li>Connections</li>



<li>Transactions</li>



<li>Cache hit ratio</li>



<li>Replication status</li>



<li>Locks</li>



<li>Deadlocks</li>



<li>Database size</li>



<li>Query statistics</li>



<li>WAL activity</li>



<li>Performance metrics</li>
</ul>



<p class="wp-block-paragraph">Therefore, using the official template is usually the fastest and easiest way to start monitoring PostgreSQL.</p>



<h2 class="wp-block-heading" id="h-etcd">ETCD</h2>



<p class="wp-block-paragraph">Zabbix already provides an official template for <a href="https://www.zabbix.com/integrations/etcd">ETCD</a> monitoring.</p>



<p class="wp-block-paragraph">The template uses HTTP Agent items to collect metrics directly from the <code>/metrics</code> endpoint exposed by ETCD.</p>



<p class="wp-block-paragraph">In addition, the template works without external scripts and uses Prometheus-style metrics collection.</p>



<p class="wp-block-paragraph">The official template already includes many useful triggers and metrics for monitoring ETCD health, performance, and cluster activity.</p>



<figure class="wp-block-image size-full is-resized"><img loading="lazy" decoding="async" width="269" height="300" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image.png" alt="" class="wp-image-44286" style="aspect-ratio:0.896661284441898;width:220px;height:auto" /></figure>



<h2 class="wp-block-heading" id="h-haproxy">HAProxy</h2>



<p class="wp-block-paragraph">HAProxy plays a critical role in a PostgreSQL high availability environment, ensuring that client connections are automatically routed to the correct PostgreSQL node and maintaining application connectivity during failovers.</p>



<p class="wp-block-paragraph">Zabbix already provides an official HAProxy monitoring template that can be used as a base for monitoring and customization: <a href="https://www.zabbix.com/integrations/haproxy">HAProxy by HTTP</a></p>



<p class="wp-block-paragraph">The number of active servers in HAProxy is also monitored by the template. However, the template does not create a trigger by default for this metric. This means that a backend could lose one or more active servers without generating any alert in Zabbix. For this reason, it is highly recommended to create custom triggers on this item to immediately detect backend availability issues and avoid unnoticed service degradation.</p>



<p class="wp-block-paragraph">To implement this properly, a new macro should be created in the template to define the minimum expected number of active servers. Then, a custom trigger must be added on the item prototype available in the Low-Level Discovery (LLD) section of the template. Consequently, Zabbix can automatically monitor and alert on all discovered HAProxy backends.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="497" height="115" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-1.png" alt="" class="wp-image-44287" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-1.png 497w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-1-300x69.png 300w" sizes="auto, (max-width: 497px) 100vw, 497px" /></figure>



<h2 class="wp-block-heading" id="h-patroni">Patroni</h2>



<p class="wp-block-paragraph">Zabbix does not directly include Patroni monitoring in the standard PostgreSQL templates.</p>



<p class="wp-block-paragraph">One approach is to use the Zabbix <a href="https://www.zabbix.com/integrations/systemd">Systemd </a>template, which allows monitoring of services running on the server. In this case, it is simply necessary to filter on the <code>patroni</code> service name to verify that the service is active.</p>



<p class="wp-block-paragraph">Another approach is to create a custom item in a template using a Zabbix agent item with the following key:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
net.tcp.service&#x5B;&quot;{$HAPROXY.STATS.SCHEME}&quot;,&quot;{$HAPROXY.STATS.HOST}&quot;,&quot;{$HAPROXY.STATS.PORT.PATRONI}&quot;]
</pre></div>


<p class="wp-block-paragraph">This item performs a direct HTTP TCP connection test on port <code>5000</code>, which is commonly used by the Patroni API. This configuration allows Zabbix to verify that the Patroni service responds correctly and remains reachable.</p>



<p class="wp-block-paragraph">Additionally, combining both monitoring methods significantly improves monitoring reliability.</p>



<h2 class="wp-block-heading" id="h-monitoring-keepalived">Monitoring Keepalived</h2>



<p class="wp-block-paragraph">Keepalived monitoring is not included by default in the standard Zabbix templates.<br>To properly monitor the VIP management layer, administrators should create custom items whether the Virtual IP (VIP) is correctly present on one of the cluster nodes.</p>



<p class="wp-block-paragraph">A common approach is to create three different items:</p>



<ul class="wp-block-list">
<li>Two items using the <code>net.tcp.service</code> key to test TCP connectivity for:
<ul class="wp-block-list">
<li>the READ-ONLY access</li>



<li>the READ-WRITE access</li>
</ul>
</li>



<li>One additional item using a <code>system.run</code> command to directly verify the VIP status on the server.</li>
</ul>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="637" height="98" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-2.png" alt="" class="wp-image-44288" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-2.png 637w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-2-300x46.png 300w" sizes="auto, (max-width: 637px) 100vw, 637px" /></figure>



<p class="wp-block-paragraph">Together, these checks help ensure that:</p>



<ul class="wp-block-list">
<li>the VIP is correctly assigned</li>



<li>the PostgreSQL services are reachable</li>



<li>the failover mechanism is working properly</li>
</ul>



<p class="wp-block-paragraph">Finally, administrators should create the appropriate triggers for these items in order to immediately detect VIP failover or connectivity issues.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="380" height="191" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-3.png" alt="" class="wp-image-44289" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-3.png 380w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-3-300x151.png 300w" sizes="auto, (max-width: 380px) 100vw, 380px" /></figure>



<h2 class="wp-block-heading" id="h-conclusion">Conclusion</h2>



<p class="wp-block-paragraph">In conclusion, monitoring PostgreSQL alone is not enough in a high availability environment.</p>



<p class="wp-block-paragraph">A complete monitoring strategy must include:</p>



<ul class="wp-block-list">
<li>PostgreSQL</li>



<li>Patroni</li>



<li>HAProxy</li>



<li>ETCD</li>



<li>Keepalived</li>
</ul>



<p class="wp-block-paragraph">Using Zabbix with both official and custom templates provides a centralized and efficient monitoring solution.</p>



<p class="wp-block-paragraph">The PostgreSQL template available in Zabbix already offers excellent database monitoring capabilities.</p>



<p class="wp-block-paragraph">Additional templates for ETCD and HAProxy help extend monitoring to the full HA architecture and improve visibility across the entire platform.</p>



<p class="wp-block-paragraph">As a result, this approach helps administrators detect failures earlier, improve troubleshooting, and ensure better service availability.</p>



<p class="wp-block-paragraph">If you need you can try other blogs regarding Zabbix or postgreSQL here: <a href="https://www.dbi-services.com/blog/">dbi Blog</a></p>
<p>L’article <a href="https://www.dbi-services.com/blog/zabbix-monitoring-full-cluster-patroni-postgresql/">Zabbix &#8211; Monitoring full cluster Patroni/PostgreSQL</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/zabbix-monitoring-full-cluster-patroni-postgresql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Surviving a Patroni failover with logical replication</title>
		<link>https://www.dbi-services.com/blog/surviving-a-patroni-failover-with-logical-replication/</link>
					<comments>https://www.dbi-services.com/blog/surviving-a-patroni-failover-with-logical-replication/#respond</comments>
		
		<dc:creator><![CDATA[Adrien Obernesser]]></dc:creator>
		<pubDate>Mon, 25 May 2026 18:54:14 +0000</pubDate>
				<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[cdc]]></category>
		<category><![CDATA[debezium]]></category>
		<category><![CDATA[failover-slots]]></category>
		<category><![CDATA[high-availability]]></category>
		<category><![CDATA[logical-replication]]></category>
		<category><![CDATA[Patroni]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44794</guid>

					<description><![CDATA[<p>Your Patroni cluster does exactly what you built it to do. A node dies at 3 a.m., a new primary is elected in a few seconds, the application reconnects, and nobody gets paged. Your HA setup works. And yet, somewhere downstream, your change-data-capture pipeline has just gone quiet. Debezium or Flink CDC was streaming every [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/surviving-a-patroni-failover-with-logical-replication/">Surviving a Patroni failover with logical replication</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Your Patroni cluster does exactly what you built it to do. A node dies at 3 a.m., a new primary is elected in a few seconds, the application reconnects, and nobody gets paged. Your HA setup works.</p>



<p class="wp-block-paragraph">And yet, somewhere downstream, your change-data-capture pipeline has just gone quiet. Debezium or Flink CDC was streaming every change out of that database into Kafka, into a data lake, into a search index. After the failover it stops. No crash, no alert, just a lag counter that climbs and a topic that never gets new records. By the time someone notices, you are missing hours of changes and the only honest fix is a full reseed.</p>



<p class="wp-block-paragraph">This is one of my favourite operational traps, because it sits in the blind spot between two teams that both think they did their job. The DBA built a solid HA cluster. The data engineer built a solid CDC pipeline. Nobody owned the join between them, and the join is a <strong>logical replication slot</strong>.</p>



<p class="wp-block-paragraph">From a DBA&#8217;s perspective this is a silent killer, and PostgreSQL 17 finally gave us a clean way to disarm it. Let me show you exactly why it breaks, what the old workarounds cost, and how native <strong>failover slots</strong> fix it, with a small Patroni lab you can run on Docker Desktop to see both the failure and the cure.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>If you only run Debezium:</strong> your half is one line, <code>slot.failover=true</code> on the connector (Debezium ≥ 3.0.5 against a PostgreSQL 17+ primary). The other half is server-side, two settings and the failover wiring, and it is your DBA&#8217;s job; the rest of this post is what to ask them for.</p>
</blockquote>



<h2 class="wp-block-heading" id="h-why-the-slot-does-not-survive">Why the slot does not survive</h2>



<p class="wp-block-paragraph">A logical replication slot is a bookmark. It records how far a consumer has read the write-ahead log (the WAL is PostgreSQL&#8217;s running log of every change; an LSN is just a position in it, like a byte offset), and it keeps PostgreSQL from recycling WAL the consumer still needs. Debezium, a native <code>CREATE SUBSCRIPTION</code>, <code>pg_recvlogical</code> all rely on a named slot to guarantee they never miss a change and never have to replay from the beginning.</p>



<p class="wp-block-paragraph">The issue is that a logical slot lives on only one instance, the one where it was created. Physical streaming replication, the mechanism Patroni uses to keep a standby in sync, copies the data blocks and the WAL. It does not copy the logical slots. For years that was a deliberate design decision, not a bug: a slot carries decoding state and a <code>catalog_xmin</code> (a do-not-garbage-collect marker for the old system-catalog rows it still needs to decode the WAL), and replaying that safely onto a standby is genuinely hard.</p>



<p class="wp-block-paragraph">So the picture before failover looks healthy:</p>



<p class="wp-block-paragraph"></p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="880" height="498" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-82.png" alt="" class="wp-image-44814" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-82.png 880w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-82-300x170.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-82-768x435.png 768w" sizes="auto, (max-width: 880px) 100vw, 880px" /></figure>



<p class="wp-block-paragraph">The slot exists only on the primary. Promote the standby, and the new primary has all your data and none of your slots. Debezium reconnects through your load balancer, asks for <code>cdc_slot</code>, and PostgreSQL answers, correctly, that no such slot exists. The pipeline is broken, and depending on how the connector is configured it either fails loudly or, worse, quietly recreates a fresh slot at the current position and skips everything in between.</p>



<h2 class="wp-block-heading" id="h-can-t-my-load-balancer-or-vip-just-handle-this">Can&#8217;t my load balancer or VIP just handle this?</h2>



<p class="wp-block-paragraph">This is the first question I get, and it is a good one. The instinct is reasonable : I already have HAProxy in front of the cluster, or a VIP that floats with the leader on keepalived, or a cloud load balancer, so when the primary moves my consumer simply reconnects to the new one. Doesn&#8217;t that cover it?</p>



<p class="wp-block-paragraph">It does not, and understanding why tells you what class of problem this really is.</p>



<p class="wp-block-paragraph">A load balancer solves <strong>routing</strong>: how the consumer finds whoever is currently the primary. A floating VIP does the same thing one layer down. Both get the client to the right door, and you do need one of them (in the lab that is exactly HAProxy&#8217;s job). But the slot is not a routing concern, it is <strong>state</strong>. It is a file in the primary&#8217;s data directory. When the consumer reconnects through the VIP to the new primary and asks for <code>cdc_slot</code>, that instance still has to physically own the slot. The IP moving does not move the slot, because the new primary is a different PostgreSQL instance with its own <code>pg_replslot</code> directory. So whether HAProxy sits on the database nodes or on its own tier, and whether you use HAProxy or a VIP or a cloud LB, the effect on the slot is identical: none.</p>



<p class="wp-block-paragraph">There is exactly one architecture where the slot survives without any slot synchronization at all, and it is a different design rather than a different load balancer: <strong>shared-storage failover</strong>. If your HA is built on a block volume that detaches from the failed node and reattaches to the new one (DRBD, a SAN, a cloud disk, a Kubernetes PVC), then failover moves the whole data directory, <code>pg_replslot</code> included, and the slot comes along because it never logically moved. That works, but it buys a different set of trade-offs (the storage becomes a hard dependency and a fencing problem, and you give up streaming read replicas off the same volume), and it is not what a streaming-replication cluster like Patroni does. On streaming replication the slot has to be synchronized deliberately, which is the rest of this post.</p>



<h2 class="wp-block-heading" id="h-why-not-just-create-the-slot-on-every-node">Why not just create the slot on every node?</h2>



<p class="wp-block-paragraph">The next idea people reach for is to pre-create the slot on every node, primary and standbys, and let the consumer use whichever one is currently primary. The intuition is right. In fact it is almost exactly what failover slots do, it just misses the one piece that makes it safe.</p>



<p class="wp-block-paragraph">A slot is not only a name. It carries moving state: <code>restart_lsn</code>, <code>confirmed_flush_lsn</code> and <code>catalog_xmin</code>. If you create independent slots on each node, each one starts at that node&#8217;s position and then sits there, because nothing advances the standby&#8217;s copy as the consumer acknowledges changes on the primary. The names match, the positions drift.</p>



<p class="wp-block-paragraph">That is worse than losing the slot, not better. After failover the consumer finds a slot called <code>cdc_slot</code> (so no loud error) and resumes from its stale position. If the copy is behind the consumer, you re-deliver changes it already processed, which is duplicates. If it is ahead, the consumer asks for an LSN the slot has already discarded, which is a gap or an error. You have turned a loud, obvious failure(&#8220;replication slot does not exist&#8221;, which at least pages someone) into a silent data-integrity problem, and for a pipeline feeding a data lake, silent is the worst outcome.</p>



<p class="wp-block-paragraph">Two more walls. Before PostgreSQL 16 you cannot even create a logical slot on a standby (you get &#8220;logical decoding cannot be used while in recovery&#8221;); version 16 added <a href="https://www.postgresql.org/docs/16/logicaldecoding.html">logical decoding on standbys</a>, and 17 added the synchronization that makes a standby slot actually track the primary. And idle slots are not free: every slot, active or not, pins WAL and holds back vacuum on its node, so leaving un-advanced slots on every node invites WAL growth.</p>



<p class="wp-block-paragraph">So the correct version of &#8220;a slot on every node, one active and the rest idle&#8221; is precisely a failover slot: present on the standby, not consumable until promotion, with its position kept in lockstep with the primary. The synchronization is the whole feature.</p>



<h2 class="wp-block-heading" id="h-the-years-before-postgresql-17">The years before PostgreSQL 17</h2>



<p class="wp-block-paragraph">This problem is not new, and the community built two workarounds.</p>



<p class="wp-block-paragraph">The first was the <strong><a href="https://github.com/EnterpriseDB/pg_failover_slots"><code>pg_failover_slots</code></a></strong> extension (from EDB), which runs on theprimary and copies logical slot positions out to physical standbys. It supportsPostgreSQL 11 and up, but from 17 on you would reach for the native feature instead,which is what EDB now recommends too. Either way it is an external extension you haveto install, load via<code>shared_preload_libraries</code>, and keep compatible with your major version.</p>



<p class="wp-block-paragraph">The second, if you run Patroni, is built in. Patroni has its own concept of <strong><a href="https://patroni.readthedocs.io/en/latest/dynamic_configuration.html">permanent replication slots</a></strong>. You declare a slot in the cluster configurationand Patroni makes sure it exists on every eligible node, copying the slot to thestandbys and advancing its position on a schedule. In Patroni terms it looks like this:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
slots:
  cdc_slot:
    type: logical
    database: cdcdemo
    plugin: pgoutput
</pre></div>


<p class="wp-block-paragraph">This genuinely survives a switchover, and for a lot of clusters it has been good enough for years. But notice the words &#8220;on a schedule&#8221;. Patroni advances the copy of the slot on the standby every <code>loop_wait</code> seconds, by default every 10 seconds. That periodic copy is the weak point. The standby&#8217;s idea of &#8220;how far the consumer has read&#8221; can lag the real primary by up to a loop interval, so a failover at the wrong moment can leave the new primary slightly behind what the consumer already acknowledged, which is exactly the window where you get duplicates or, with the wrong settings, a gap. It is a copy advanced on a timer, not a hard guarantee, so itis stale between refreshes and nothing stops the consumer from running ahead of it.</p>



<p class="wp-block-paragraph">That is the gap PostgreSQL 17 closes.</p>



<h2 class="wp-block-heading" id="h-postgresql-17-failover-slots">PostgreSQL 17 failover slots</h2>



<p class="wp-block-paragraph">PostgreSQL 17 introduced native <a href="https://www.postgresql.org/docs/current/logical-replication-failover.html"><strong>failover slots</strong></a>: logical slots that the database itself keeps synchronized to a physical standby as part of replication itself, with a guarantee that the consumer cannot read past what the standby has (the <code>synchronized_standby_slots</code> part below), rather than an external copy on a timer. (The feature landed in 17, so everything here applies to PostgreSQL 17 and later. I built the lab on PostgreSQL18, which adds a couple of conveniences I will mention at the end.)</p>



<p class="wp-block-paragraph">There are three moving parts.</p>



<p class="wp-block-paragraph"><strong>1. Mark the slot as a failover slot.</strong> <a href="https://www.postgresql.org/docs/current/functions-admin.html"><code>pg_create_logical_replication_slot</code></a> grew a fifth argument,<code>failover</code>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
SELECT * FROM pg_create_logical_replication_slot(&#039;cdc_slot&#039;, &#039;pgoutput&#039;,
                                                  false, false, true);
--                                       temporary --^     ^      ^
--                                          twophase ------+      |
--                                          failover -------------+
</pre></div>


<p class="wp-block-paragraph">If you drive logical replication with a subscription, the same flag exists there, <a href="https://www.postgresql.org/docs/current/sql-createsubscription.html"><code>CREATE SUBSCRIPTION ... WITH (failover = true)</code></a>, and the slot and the subscription must agree. If you use Debezium 3.x, you set <code>slot.failover = true</code> and the connector creates the slot with the flag for you, as long as it is talking to a PostgreSQL 17+ primary.</p>



<p class="wp-block-paragraph"><strong>2. Let the standby synchronize slots.</strong> On the standby you enable the slot sync worker (both are <a href="https://www.postgresql.org/docs/current/runtime-config-replication.html">replication settings</a>):</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
sync_replication_slots = on
hot_standby_feedback   = on
</pre></div>


<p class="wp-block-paragraph"><code>hot_standby_feedback</code> is not optional here. The standby has to tell the primary which catalog rows it still needs so the primary does not vacuum away rows the synchronized slot depends on.</p>



<p class="wp-block-paragraph"><strong>3. Hold the logical slot back until the standby has the data.</strong> On the primary you set <a href="https://www.postgresql.org/docs/current/runtime-config-replication.html"><code>synchronized_standby_slots</code></a>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
synchronized_standby_slots = &#039;physical_slot_of_the_standby&#039;
</pre></div>


<p class="wp-block-paragraph">This is the subtle, important one, a leash on the consumer. It tells the primary&#8217;s logical WAL senders never to hand a change to the CDC consumer until the physical standby has also received it, so the consumer can never get ahead of the standby. Without it, after failover the new primary cannot give the consumer what it already saw; with it, promotion is always safe.</p>



<p class="wp-block-paragraph">With those in place, the slot is mirrored in real time. On the standby, <a href="https://www.postgresql.org/docs/current/view-pg-replication-slots.html"><code>pg_replication_slots</code></a> shows it as synced, and it cannot be consumed until the standby is promoted:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
 slot_name | slot_type | active | failover | synced | confirmed_flush_lsn
-----------+-----------+--------+----------+--------+---------------------
 cdc_slot  | logical   | f      | t        | t      | 0/3A1240
</pre></div>


<p class="wp-block-paragraph"><code>failover = t</code>, <code>synced = t</code>, <code>active = f</code>. That row is the whole point. When this standby becomes primary, <code>cdc_slot</code> is already there, at the right LSN, and the consumer reconnects as if nothing happened.</p>



<h3 class="wp-block-heading" id="h-the-gotcha-that-bites-everyone-silently">The gotcha that bites everyone, silently</h3>



<p class="wp-block-paragraph">There is a prerequisite that is easy to miss and fails in the quietest possible way: the standby&#8217;s <code>primary_conninfo</code> must contain a <code>dbname</code> (the <a href="https://www.postgresql.org/docs/current/logical-replication-failover.html">logical replication failover docs</a> call this out, but it is easy to skip past).</p>



<p class="wp-block-paragraph">A physical replication connection does not need a database name, so historically nobody put one there. But the slot sync worker connects to the primary as a normal backend to read slot state, and it needs a database to connect to. If <code>dbname</code> is missing, the worker does not error in your face. It just does not sync anything. Your slots silently fail to appear on the standby, and you find out at failover, which is the worst possible time.</p>



<p class="wp-block-paragraph">Recent Patroni adds <code>dbname</code> to <code>primary_conninfo</code> for you when it is managing logical slots, but I check it explicitly, every time, and so should you, on the standby:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SHOW primary_conninfo;  
</pre></div>


<p class="wp-block-paragraph"><em>It must contain a dbname=… ; if not, slot sync is silently doing nothing</em></p>



<h2 class="wp-block-heading" id="h-seeing-it-break-then-survive">Seeing it break, then survive</h2>



<p class="wp-block-paragraph">Let me walk both cases on a small two-node Patroni cluster: PostgreSQL 18 nodes<code>patroni1</code> and <code>patroni2</code>, a Debezium connector reading through HAProxy (so it always follows the current primary), and a tiny generator inserting rows with a strictly increasing counter <code>n</code>. The proof is simple: compare the <code>n</code> values that reached Kafka against the rows actually in the table. Every committed row should be in Kafka; duplicates are fine, because CDC delivery is at-least-once.</p>



<p class="wp-block-paragraph">The commands below are the real thing, run them on your own cluster if you like. If you would rather watch it happen in a few minutes, there is a ready-to-run Docker version at the end of this post.</p>



<h3 class="wp-block-heading" id="h-first-the-break-a-plain-slot">First, the break: a plain slot</h3>



<p class="wp-block-paragraph">A normal logical slot has <code>failover</code> off, the default. Debezium creates one for you when you register a connector with <code>slot.failover=false</code>; the bare SQL equivalent is:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT pg_create_logical_replication_slot(&#039;dbz_slot&#039;, &#039;pgoutput&#039;);
</pre></div>


<p class="wp-block-paragraph">It lives on the primary and nowhere else, because nothing mirrors it:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- on the primary (patroni1)
SELECT slot_name, slot_type, active, failover, synced FROM pg_replication_slots;

 slot_name | slot_type | active | failover | synced
-----------+-----------+--------+----------+--------
 dbz_slot  | logical   | t      | f        | f
 patroni2  | physical  | t      | f        | f        (the standby&#039;s streaming slot)
</pre></div>


<p class="wp-block-paragraph">On the standby, <code>dbz_slot</code> is simply not there. Now fail over with <code>patronictl</code>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
patronictl -c /etc/patroni/patroni.yml switchover --leader patroni1 --candidate patroni2 --force cdc-demo

Successfully switched over to &quot;patroni2&quot;
</pre></div>


<p class="wp-block-paragraph">The new primary never had the slot. The consumer reconnects through HAProxy and, here is the trap, stays perfectly green:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
curl -s localhost:8083/connectors/orders-plain/status | jq &#039;{state: .connector.state, task: .tasks&#x5B;0].state}&#039;

{ &quot;state&quot;: &quot;RUNNING&quot;, &quot;task&quot;: &quot;RUNNING&quot; }
</pre></div>


<p class="wp-block-paragraph">because Debezium found its slot missing and silently recreated it at the <em>current</em> LSN, straight from the Connect log:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
Creating replication slot with command CREATE_REPLICATION_SLOT &quot;dbz_slot&quot; LOGICAL pgoutput
</pre></div>


<p class="wp-block-paragraph">Everything committed between the switchover and that recreation is skipped. Draining the topic and comparing it to the source table shows the hole:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
table rows: 255    distinct rows in Kafka: 250    missing: 5   (n = 100..104)
</pre></div>


<p class="wp-block-paragraph">Five committed rows, gone, and nothing anywhere raised an error. Whether Debezium recreates the slot quietly (as here) or fails loudly is a matter of configuration; the lab shows the quiet case because it is the one that hurts. That is the silent version that costs you a reseed three days later when someone notices the warehouse is short.</p>



<h3 class="wp-block-heading" id="h-then-the-cure-a-failover-slot">Then, the cure: a failover slot</h3>



<p class="wp-block-paragraph">Same scenario, two changes. First, create the slot with <code>failover</code> on (or set Debezium&#8217;s <code>slot.failover=true</code>, which does it for you):</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT pg_create_logical_replication_slot(&#039;cdc_slot&#039;, &#039;pgoutput&#039;, false, false, true);
</pre></div>


<p class="wp-block-paragraph">Second, turn on synchronization. On the standby:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
sync_replication_slots = on
hot_standby_feedback   = on
</pre></div>


<p class="wp-block-paragraph">and on the primary, hold logical slots back until the standby has the data, naming the standby&#8217;s physical slot:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
ALTER SYSTEM SET synchronized_standby_slots = &#039;patroni2&#039;;
SELECT pg_reload_conf();
</pre></div>


<p class="wp-block-paragraph">Now the slot is mirrored to the standby. Look at it there:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- on the standby (patroni2)
SELECT slot_name, active, failover, synced, temporary, confirmed_flush_lsn
  FROM pg_replication_slots WHERE slot_name = &#039;cdc_slot&#039;;

 slot_name | active | failover | synced | temporary | confirmed_flush_lsn
-----------+--------+----------+--------+-----------+---------------------
 cdc_slot  | t      | t        | t      | f         | 0/34B0A10
</pre></div>


<p class="wp-block-paragraph"><code>failover = t</code>, <code>synced = t</code>. One gotcha worth watching: a freshly synced slot starts <code>temporary = t</code> and is dropped on promotion until it persists. The standby log shows the transition, a transient refusal (its <code>catalog_xmin</code> is briefly ahead of the primary slot&#8217;s) and then sync-ready:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
LOG:  could not synchronize replication slot &quot;cdc_slot&quot;
DETAIL:  ... the remote slot needs catalog xmin 760, but the standby has xmin 761.
LOG:  newly created replication slot &quot;cdc_slot&quot; is sync-ready now
</pre></div>


<p class="wp-block-paragraph">Only once <code>temporary = f</code> is it safe to fail over. So do it:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
patronictl -c /etc/patroni/patroni.yml switchover --leader patroni1 --candidate patroni2 --force cdc-demo
</pre></div>


<p class="wp-block-paragraph">On the new primary the slot is already there and live, and the consumer resumes on its own. Re-point <code>synchronized_standby_slots</code> at the new standby (the correct value flips after a role change), then reconcile:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- on the new primary
ALTER SYSTEM SET synchronized_standby_slots = &#039;patroni1&#039;;
SELECT pg_reload_conf();

table rows: 616    distinct rows in Kafka: 616    missing: 0   (74 duplicates)
</pre></div>


<p class="wp-block-paragraph">Every committed row reached Kafka. Same cluster, same switchover, same consumer; the only difference is one boolean on the slot and the synchronization around it. The duplicates are at-least-once redelivery (you may see a handful or none on a given run), so the consumer must be idempotent either way, but not one committed row was lost. That boolean is the gap between &#8220;we lost four hours of CDC&#8221; and &#8220;nobody noticed&#8221;.</p>



<h2 class="wp-block-heading" id="h-patroni-does-its-part-but-not-all-of-it">Patroni does its part, but not all of it</h2>



<p class="wp-block-paragraph">I want to be honest about the rough edges, because this is where the real-world write-ups tend to go quiet.</p>



<p class="wp-block-paragraph">PostgreSQL 17 gives you the mechanism. Patroni, as of the 4.1 series I tested, does <strong>not</strong> yet fully wire it for you, and there are two manual pieces.</p>



<p class="wp-block-paragraph">First, Patroni does not manage <code>synchronized_standby_slots</code> (this is tracked as Patroni issue #3431). You have to set it yourself, on the primary, to the physical slot name of the current standby. And in a two-node cluster that value is not symmetric: when <code>patroni1</code> leads it must name <code>patroni2</code>&#8216;s slot, and after a switchover to <code>patroni2</code> it must name <code>patroni1</code>&#8216;s slot. So you cannot just bake a single static value into the cluster config, you have to reset it after a role change. The lab&#8217;s <code>enable-failover-config.sh</code> is role-aware and does this, and the walkthrough deliberately runs it again after the switchover so you can see the value flip. In production you would automate it with a Patroni callback on role change.</p>



<p class="wp-block-paragraph">Second, the <code>dbname</code> in <code>primary_conninfo</code> requirement I mentioned. Recent Patroni handles it, but it is exactly the kind of thing that is true on the version you tested and silently false on the version you deployed, so verify it rather than trust it.</p>



<p class="wp-block-paragraph">Neither of these is a reason to avoid failover slots. They are a reason to treat &#8220;failover slots on Patroni&#8221; as a thing you configure and test deliberately, not a checkbox you assume.</p>



<h2 class="wp-block-heading" id="h-the-checklist">The checklist</h2>



<p class="wp-block-paragraph">If you run logical replication or CDC on a Patroni cluster, here is what I would verify :</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Check</th><th>Where</th><th>Why</th></tr></thead><tbody><tr><td><code>wal_level = logical</code></td><td>all nodes</td><td>required for logical decoding at all</td></tr><tr><td>slot created with <code>failover = true</code></td><td>primary</td><td>the slot is eligible to be synchronized</td></tr><tr><td><code>sync_replication_slots = on</code></td><td>standbys</td><td>the standby actually runs the sync worker</td></tr><tr><td><code>hot_standby_feedback = on</code></td><td>standbys</td><td>protects the catalog rows the slot needs</td></tr><tr><td><code>synchronized_standby_slots</code> names the standby slot</td><td>primary</td><td>consumer never gets ahead of the standby</td></tr><tr><td><code>dbname=</code> present in <code>primary_conninfo</code></td><td>standbys</td><td>without it, sync fails silently</td></tr><tr><td><code>synced = t</code> <strong>and</strong> <code>temporary = f</code> on the standby</td><td>standbys</td><td>mirrored <strong>and</strong> persisted: a still-<code>temporary</code> synced slot is dropped on promotion (&#8220;sync-ready now&#8221;)</td></tr><tr><td>re-set <code>synchronized_standby_slots</code> after switchover</td><td>primary</td><td>the correct value flips in a 2-node cluster</td></tr><tr><td>your CDC tool sets the slot failover flag</td><td>consumer</td><td>e.g. Debezium <code>slot.failover = true</code>, needs ≥ 3.0.5 and PG 17+</td></tr></tbody></table></figure>



<h2 class="wp-block-heading" id="h-where-this-is-going-pg18-today-pg19-next">Where this is going: PG18 today, PG19 next</h2>



<p class="wp-block-paragraph">The lab runs on PostgreSQL 18, which keeps the PG17 mechanism intact and adds a few conveniences around it: <a href="https://www.postgresql.org/docs/current/app-pgrecvlogical.html"><code>pg_recvlogical --enable-failover</code></a> lets you create a failover slot straight from the command-line consumer, and <code>--enable-two-phase</code> (plus <code>pg_createsubscriber --enable-two-phase</code>) rounds out the tooling. The configuration is identical to 17, so everything above applies unchanged.</p>



<p class="wp-block-paragraph">PostgreSQL 19 is in development as I write this, so treat this as a direction, not a promise (check the final release notes). The theme is telling: most of the 19 cycle&#8217;s work here makes the failure I hit easier to <em>see</em> rather than changing the mechanism, slot-sync skip counters in <code>pg_stat_replication_slots</code>, a <code>pg_sync_replication_slots()</code>that no longer fails silently, starting logical replication without a <code>wal_level</code> restart (a new <code>effective_wal_level</code>), and logical replication of sequences.</p>



<p class="wp-block-paragraph">What 19 does not appear to change is the manual piece I complained about. <code>synchronized_standby_slots</code> still has to be set, and reset after a role change, by something outside PostgreSQL, and the Patroni side of that (issue #3431) is still open. So the checklist above does not get shorter in 19, it gets easier to debug.</p>



<h2 class="wp-block-heading" id="h-try-it-yourself">Try it yourself</h2>



<p class="wp-block-paragraph">Everything above is in a lab attached to this post, <code>worklab-patroni-failover-slots</code>: a Docker Compose stack (etcd, two PostgreSQL 18 Patroni nodes, HAProxy, Kafka in KRaft mode, and a Debezium connector) plus a handful of helper scripts that wrap exactly the commands shown here. </p>



<p class="wp-block-paragraph">We spend a lot of effort making the database survive a node failure, and then we quietly assume everything hanging off the database survives with it. Logical replication slots are the reminder that high availability is not only about the primary coming back, it is about everything that depends on the primary coming back too.</p>



<p class="wp-block-paragraph">PostgreSQL 17 failover slots close the last real gap between PostgreSQL HA and the CDC pipelines we increasingly build on top of it. So don&#8217;t sleep too much on PG14 version (EOL November this year !). The mechanism is solid, the configuration is small, and the failure it prevents is the kind that is invisible until it is expensive. Clone the lab, break it once with a plain slot, fix it once with a failover slot, and you will never look at a Patroni switchover the same way again. When I started by DBA journey I have always been told that if you want to know how an RDBMS works playing around backups and restores would provide you deep insights into how they are running. I feel like today this is more true for replication. </p>



<p class="wp-block-paragraph"><em><a href="https://github.com/boutaga/pgvector_RAG_search_lab/tree/main/lab/patroni-failover-cdc" id="https://github.com/boutaga/pgvector_RAG_search_lab/tree/main/lab/patroni-failover-cdc" target="_blank" rel="noreferrer noopener">Link for the scripts.</a></em></p>



<p class="wp-block-paragraph"></p>
<p>L’article <a href="https://www.dbi-services.com/blog/surviving-a-patroni-failover-with-logical-replication/">Surviving a Patroni failover with logical replication</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/surviving-a-patroni-failover-with-logical-replication/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>pgvector, a guide for DBA &#8211; Part 2: Indexes (update march 2026)</title>
		<link>https://www.dbi-services.com/blog/pgvector-a-guide-for-dba-part-2-indexes-update-march-2026/</link>
					<comments>https://www.dbi-services.com/blog/pgvector-a-guide-for-dba-part-2-indexes-update-march-2026/#respond</comments>
		
		<dc:creator><![CDATA[Adrien Obernesser]]></dc:creator>
		<pubDate>Sun, 01 Mar 2026 19:09:15 +0000</pubDate>
				<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[AI/LLM]]></category>
		<category><![CDATA[pgvector]]></category>
		<category><![CDATA[postgresql]]></category>
		<category><![CDATA[RAG]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=43256</guid>

					<description><![CDATA[<p>Introduction In Part 1 of this series, we covered what pgvector is, how embeddings work, and how to store them in PostgreSQL. We ended with a working similarity search &#8212; but on a sequential scan. That works fine for a demo table with 1,000 rows. It does not work for production. This post is about [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/pgvector-a-guide-for-dba-part-2-indexes-update-march-2026/">pgvector, a guide for DBA &#8211; Part 2: Indexes (update march 2026)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<h2 class="wp-block-heading" id="h-introduction">Introduction</h2>



<p class="wp-block-paragraph">In <a href="https://www.dbi-services.com/blog/pgvector-a-guide-for-dba-part1-lab-demo/">Part 1</a> of this series, we covered what pgvector is, how embeddings work, and how to store them in PostgreSQL. We ended with a working similarity search &#8212; but on a sequential scan. That works fine for a demo table with 1,000 rows. It does not work for production.</p>



<p class="wp-block-paragraph">This post is about what comes next: <strong>indexes</strong>. Specifically, the <strong>three index families</strong> in the pgvector ecosystem as of February 2026 (HNSW, IVFFlat, and DiskANN), including <strong>two DiskANN implementations targeting different deployment models</strong>, what they&#8217;re good at, where they break, and the patterns you need, whether you&#8217;re the DBA tuning them or the developer looking to understand the the strenghts of PostgreSQL as a vector store. </p>



<p class="wp-block-paragraph">Everything in this post was tested on public dataset: <strong>25,000 Wikipedia articles</strong> embedded with OpenAI&#8217;s <code>text-embedding-3-large</code> at <strong>3,072 dimensions</strong>, the maximum the model supports. The high number of dimension is a choice, to highlight some limitations for pedagogical reasons. You would be ok running and testing with lower dimensions or other embedding models, you might want to look into the RAG series, I will probably make a blog post on how to test embedding models against your data sets.  <br>The environment is PostgreSQL 18 with pgvector 0.8.1 and pgvectorscale 0.9.0. </p>



<p class="wp-block-paragraph">All the SQL scripts, Python code, and Docker configuration are in the companion lab: <a href="https://github.com/boutaga/pgvector_RAG_search_lab/tree/main/lab/06_pgvector_indexes"><code>lab/06_pgvector_indexes</code></a>.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-the-index-types">The Index Types</h2>



<p class="wp-block-paragraph">Before we dive in, here&#8217;s the landscape. pgvector ships with two built-in index types (HNSW and IVFFlat), and two DiskANN implementations are available from different vendors:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th></th><th>HNSW</th><th>IVFFlat</th><th>DiskANN (pgvectorscale)</th><th>DiskANN (pg_diskann)</th></tr></thead><tbody><tr><td><strong>Provider</strong></td><td>pgvector</td><td>pgvector</td><td>Timescale</td><td>Microsoft</td></tr><tr><td><strong>Availability</strong></td><td>Built-in</td><td>Built-in</td><td>Open source, self-hosted</td><td>Azure DB for PostgreSQL</td></tr><tr><td><strong>Algorithm</strong></td><td>Multi-layer graph</td><td>Voronoi cell partitioning</td><td>Vamana graph + SBQ</td><td>Vamana graph + PQ</td></tr><tr><td><strong>Best for</strong></td><td>General purpose</td><td>Fast build</td><td>Storage-constrained</td><td>Azure + high recall</td></tr><tr><td><strong>Build time (25K, 3072d)</strong></td><td>29s</td><td>5s</td><td>49s</td><td>N/A (Azure)</td></tr><tr><td><strong>Index size</strong></td><td>193 MB</td><td>193 MB</td><td><strong>21 MB</strong></td><td>Similar</td></tr><tr><td><strong>Query time</strong></td><td>2-6 ms</td><td>2-10 ms</td><td>3 ms</td><td>~3 ms</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">That pgvectorscale number is not a typo. 21 MB vs 193 MB for the same data. W</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Note:</strong> This post uses pgvectorscale for all DiskANN benchmarks since it&#8217;s the open-source, self-hosted option. We&#8217;ll compare both DiskANN implementations in detail in Section 3. pg_diskann is available only for Azure Flexible Server for PostgreSQL, the managed instance service from Microsoft. </p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-hnsw-the-default-choice">HNSW: The Default Choice</h2>



<p class="wp-block-paragraph">HNSW (Hierarchical Navigable Small World) is the most commonly recommended index type for vector search. It builds a multi-layered graph where each node connects to its nearest neighbors, and search navigates this graph from top to bottom.</p>



<h3 class="wp-block-heading" id="h-the-2-000-dimension-wall">The 2,000-Dimension Wall</h3>



<p class="wp-block-paragraph">Here&#8217;s the first thing you&#8217;ll hit with modern embedding models:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
CREATE INDEX idx_content_hnsw
ON articles USING hnsw (content_vector vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

ERROR:  column cannot have more than 2000 dimensions for hnsw index
</pre></div>


<p class="wp-block-paragraph">The <code>vector</code> type in pgvector has a <strong>2,000-dimension limit</strong> for HNSW indexes. If you&#8217;re using <code>text-embedding-3-large</code> (3,072 dimensions), or <code>voyage-3-large</code> at its 2,048-dimension setting, this is a blocker.</p>



<p class="wp-block-paragraph">The workaround: <strong><code>halfvec</code></strong>.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- Step 1: Store a half-precision copy
ALTER TABLE articles ADD COLUMN content_halfvec halfvec(3072);
UPDATE articles SET content_halfvec = content_vector::halfvec;

-- Step 2: Index the halfvec column (limit: 4,000 dimensions)
CREATE INDEX idx_content_hnsw_halfvec
ON articles USING hnsw (content_halfvec halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64);

Time: 28974.392 ms (00:28.974)

SELECT pg_size_pretty(pg_relation_size(&#039;idx_content_hnsw_halfvec&#039;)) AS hnsw_size;

 hnsw_size
-----------
 193 MB
</pre></div>


<p class="wp-block-paragraph">29 seconds to build, 193 MB for 25,000 articles at 3,072 dimensions. That&#8217;s roughly <strong>8 KB per row</strong> in the index alone.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Important:</strong> <code>halfvec</code> stores each dimension in 2 bytes instead of 4. You lose some floating-point precision, but in practice the recall difference is negligible for similarity search. The storage savings are real: your halfvec column is 6 KB per row vs 12 KB for the full vector.</p>



<p class="wp-block-paragraph"><strong>Alternative:</strong> Instead of a separate column, you can create an expression index that casts on the fly: <code>CREATE INDEX ... ON articles USING hnsw ((content_vector::halfvec(3072)) halfvec_cosine_ops);</code> The trade-off is that your queries must use the matching expression (<code>content_vector::halfvec(3072) &lt;=&gt; ...</code>) for the planner to pick it up, which is harder to read in application code. The separate column approach gives cleaner queries.</p>
</blockquote>



<h3 class="wp-block-heading" id="h-tuning-ef-search-the-recall-vs-speed-dial">Tuning ef_search: The Recall vs Speed Dial</h3>



<p class="wp-block-paragraph"><code>ef_search</code> controls how many candidates the HNSW algorithm considers during search. Higher values mean more candidates examined, better recall, but more work. The default is 40.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- ef_search = 40 (default)
SET hnsw.ef_search = 40;
EXPLAIN ANALYZE
SELECT id, title, content_halfvec &lt;=&gt; (
    SELECT content_halfvec FROM articles WHERE id = 1
) AS distance
FROM articles
ORDER BY content_halfvec &lt;=&gt; (
    SELECT content_halfvec FROM articles WHERE id = 1
)
LIMIT 10;

Index Scan using idx_content_hnsw_halfvec on articles
  Order By: (content_halfvec &lt;=&gt; (InitPlan 1).col1)
  Index Searches: 1
  Buffers: shared hit=551
Execution Time: 6.004 ms

-- ef_search = 100
SET hnsw.ef_search = 100;

Index Scan using idx_content_hnsw_halfvec on articles
  Buffers: shared hit=716
Execution Time: 2.365 ms

-- ef_search = 200
SET hnsw.ef_search = 200;

Index Scan using idx_content_hnsw_halfvec on articles
  Buffers: shared hit=883
Execution Time: 2.542 ms
</pre></div>


<p class="wp-block-paragraph">Wait &#8212; ef_search=100 was <em>faster</em> than ef_search=40? Not really. Those numbers came from a warm cache (<code>shared hit=551</code>, zero disk reads). The apparent speedup is a <strong>cache warming effect</strong>, not a property of the algorithm. To prove this, I restarted PostgreSQL and ran the full sweep from cold:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>ef_search</th><th>Execution Time</th><th>Buffers</th><th>read (disk)</th></tr></thead><tbody><tr><td>40 (cold)</td><td>91 ms</td><td>hit=189, <strong>read=362</strong></td><td>362 pages from disk</td></tr><tr><td>100</td><td>33 ms</td><td>hit=616, <strong>read=132</strong></td><td>fewer cold pages</td></tr><tr><td>200</td><td>22 ms</td><td>hit=850, <strong>read=65</strong></td><td>even fewer</td></tr><tr><td>40 (warm)</td><td><strong>0.8 ms</strong></td><td><strong>hit=551, read=0</strong></td><td>all cached</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The second run at ef_search=40 clocked 0.8 ms &#8212; faster than any ef_search=100 or 200 run. On a warm cache, all three (40/100/200) land in the 0.8-5 ms range. The variance is cache state, not algorithmic shortcuts. <strong>The real cost cliff is at ef_search=400</strong> where the optimizer switches plans entirely:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- ef_search = 400
SET hnsw.ef_search = 400;

Sort  (actual time=364.693..364.695 rows=10.00 loops=1)
  Sort Key: ((articles.content_halfvec &lt;=&gt; (InitPlan 1).col1))
  Sort Method: top-N heapsort  Memory: 25kB
  -&gt;  Seq Scan on articles  (actual time=0.143..356.482 rows=24700.00 loops=1)
        Filter: (content_halfvec IS NOT NULL)
        Rows Removed by Filter: 300
Execution Time: 364.724 ms
</pre></div>


<p class="wp-block-paragraph"><strong>The planner chose a Seq Scan + Sort path.</strong> At ef_search=400, PostgreSQL&#8217;s cost model estimated the index scan would be more expensive than reading every row sequentially, and the query went from 2.5 ms to 365 ms.</p>



<p class="wp-block-paragraph">This is your <strong>optimizer flip-flop</strong>. It&#8217;s not a bug &#8212; it&#8217;s the planner doing its job. But it means you need to be aware of the threshold.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>ef_search</th><th>Execution Time</th><th>Plan</th><th>Buffers</th></tr></thead><tbody><tr><td>40</td><td>6.0 ms</td><td>Index Scan</td><td>551</td></tr><tr><td>100</td><td>2.4 ms</td><td>Index Scan</td><td>716</td></tr><tr><td>200</td><td>2.5 ms</td><td>Index Scan</td><td>883</td></tr><tr><td>400</td><td><strong>365 ms</strong></td><td><strong>Seq Scan</strong></td><td>209,192</td></tr></tbody></table></figure>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>DBA Takeaway:</strong> For HNSW on halfvec(3072), stay in the ef_search 40-200 range. Past that, you&#8217;re fighting the optimizer. If you need ef_search &gt; 200 for recall, you probably need a bigger <code>m</code> parameter at build time.</p>
</blockquote>



<h3 class="wp-block-heading" id="h-build-parameters-m-and-ef-construction">Build Parameters: m and ef_construction</h3>



<p class="wp-block-paragraph"><code>m</code> is the number of connections per node in the graph. <code>ef_construction</code> is the candidate list size during build. Higher values = better graph quality but slower builds and (potentially) larger indexes.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- m=16, ef_construction=64 (default-ish)
Time: 28,974 ms    Size: 193 MB

-- m=32, ef_construction=128
Time: 54,077 ms    Size: 193 MB
</pre></div>


<p class="wp-block-paragraph">At 25K rows, doubling <code>m</code> doubled the build time but didn&#8217;t change the index size. The size effect becomes more visible at larger scales. In general: <strong>start with m=16, ef_construction=64 and only increase if recall is insufficient after tuning ef_search</strong>.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-ivfflat-fast-build">IVFFlat: Fast Build</h2>



<p class="wp-block-paragraph">IVFFlat (Inverted File with Flat quantization) partitions the vector space into Voronoi cells using k-means clustering, then searches only the cells closest to the query vector.</p>



<h3 class="wp-block-heading" id="h-same-dimension-limit">Same Dimension Limit</h3>



<p class="wp-block-paragraph">IVFFlat has the <strong>same 2,000-dimension limit</strong> as HNSW for the <code>vector</code> type. Same workaround:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE INDEX idx_content_ivfflat
ON articles USING ivfflat (content_halfvec halfvec_cosine_ops)
WITH (lists = 25);

Time: 5008.765 ms (00:05.009)

SELECT pg_size_pretty(pg_relation_size(&#039;idx_content_ivfflat&#039;));
-- 193 MB
</pre></div>


<p class="wp-block-paragraph">5 seconds to build vs 29 for HNSW. The index size is nearly identical (193 MB vs 193 MB), but <strong>IVFFlat builds 5.8x faster</strong>.</p>



<p class="wp-block-paragraph">The <code>lists</code> parameter controls the number of Voronoi cells. The pgvector documentation recommends: <strong><code>rows / 1000</code> for tables up to 1M rows</strong>, <code>sqrt(rows)</code> for larger tables. For 25,000 articles: 25000 / 1000 = 25 lists, giving roughly 1,000 rows per cell. A common mistake is applying <code>sqrt(rows)</code> to small tables &#8212; that gives 158 lists here, creating cells with only ~167 rows each, which fragments the index and causes the optimizer to flip to sequential scan at surprisingly low probe counts.</p>



<h3 class="wp-block-heading" id="h-tuning-probes-how-many-cells-to-search">Tuning Probes: How Many Cells to Search</h3>



<p class="wp-block-paragraph"><code>probes</code> controls how many Voronoi cells are searched at query time. Default is 1 &#8212; fast but low recall. A good starting point is <code>sqrt(lists)</code>. Here&#8217;s the sweep with lists=25:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET ivfflat.probes = 1;
-- Index Scan, Execution Time: 1.0 ms, Buffers: 571

SET ivfflat.probes = 2;
-- Index Scan, Execution Time: 3.7 ms, Buffers: 1,944

SET ivfflat.probes = 3;
-- Index Scan, Execution Time: 4.4 ms, Buffers: 2,793

SET ivfflat.probes = 4;
-- Index Scan, Execution Time: 5.9 ms, Buffers: 3,937
</pre></div>


<p class="wp-block-paragraph">And then:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET ivfflat.probes = 5;

Sort  (actual time=152.548..152.549 rows=10.00 loops=1)
  Sort Key: ((articles.content_halfvec &lt;=&gt; (InitPlan 1).col1))
  -&gt;  Seq Scan on articles  (actual time=0.144..148.844 rows=24700.00 loops=1)
        Filter: (content_halfvec IS NOT NULL)
Execution Time: 152.584 ms
</pre></div>


<p class="wp-block-paragraph">Same story as HNSW. At probes=5 (searching 5 of 25 cells = 20%), the optimizer decided a sequential scan was cheaper. The query went from 6 ms to 153 ms.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>probes</th><th>Execution Time</th><th>Plan</th></tr></thead><tbody><tr><td>1</td><td>1.0 ms</td><td>Index Scan</td></tr><tr><td>2</td><td>3.7 ms</td><td>Index Scan</td></tr><tr><td>3</td><td>4.4 ms</td><td>Index Scan</td></tr><tr><td>4</td><td>5.9 ms</td><td>Index Scan</td></tr><tr><td>5</td><td><strong>153 ms</strong></td><td><strong>Seq Scan</strong></td></tr></tbody></table></figure>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Takeaway:</strong> For IVFFlat with 25 lists, the optimizer flips between probes=4 and probes=5. With <code>sqrt(25) = 5</code>, you&#8217;re right at the tipping point. Use <code>SET LOCAL ivfflat.probes = 3</code> or <code>4</code> for a good recall/speed balance. On larger tables (100K+ rows), the flip happens at much higher probe counts because sequential scans become proportionally more expensive.</p>
</blockquote>



<h3 class="wp-block-heading" id="h-set-local-the-production-pattern">SET LOCAL: The Production Pattern</h3>



<p class="wp-block-paragraph">Never set <code>ivfflat.probes</code> at the session level in production. Use <code>SET LOCAL</code> inside a transaction:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
BEGIN;
    SET LOCAL ivfflat.probes = 3;
    SELECT id, title, content_halfvec &lt;=&gt; (
        SELECT content_halfvec FROM articles WHERE id = 1
    ) AS distance
    FROM articles
    ORDER BY content_halfvec &lt;=&gt; (
        SELECT content_halfvec FROM articles WHERE id = 1
    )
    LIMIT 10;
COMMIT;

-- Verify: probes reverted to default
SHOW ivfflat.probes;  -- 1
</pre></div>


<p class="wp-block-paragraph">The setting reverts automatically after COMMIT/ROLLBACK. No global state leakage. <strong>Do the same for <code>hnsw.ef_search</code>.</strong></p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>DO NOT PLAY AROUND WITH SESSION PARAMETERS ON PRODUCTION.</strong> Use <code>SET LOCAL</code> inside a transaction, or set them at the function/procedure level. Session-level changes persist until disconnect and can affect every query on that connection, including connection pooler reuse. Hinting is not a strategy. </p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-diskann-leveraging-b-tree-index-principle">DiskANN: Leveraging B-TREE index principle </h2>



<p class="wp-block-paragraph">DiskANN is provided by the <a href="https://github.com/timescale/pgvectorscale">pgvectorscale</a> project from Timescale (SQL extension name: <code>vectorscale</code>). It implements the DiskANN algorithm with <strong>Statistical Binary Quantization (SBQ)</strong> compression built in. </p>



<h3 class="wp-block-heading" id="h-no-2-000-dimension-wall">No 2,000-Dimension Wall</h3>



<p class="wp-block-paragraph">Unlike HNSW and IVFFlat, DiskANN supports the <code>vector</code> type natively up to <strong>16,000 dimensions</strong> &#8212; no halfvec workaround needed:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE INDEX idx_content_diskann ON articles USING diskann (content_vector)
WITH (storage_layout = memory_optimized);

NOTICE:  Starting index build with num_neighbors=50, search_list_size=100,
         max_alpha=1.2, storage_layout=SbqCompression.  -- memory_optimized maps to SBQ
NOTICE:  Indexed 24700 tuples
Time: 49140.736 ms (00:49.141)

SELECT pg_size_pretty(pg_relation_size(&#039;idx_content_diskann&#039;));
-- 21 MB
</pre></div>


<p class="wp-block-paragraph">49 seconds to build, but <strong>21 MB</strong>. Compare:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
        indexname         |  size  | size_bytes
--------------------------+--------+------------
 idx_content_diskann      | 21 MB  |   22,511,616
 idx_content_hnsw_halfvec | 193 MB |  202,350,592
 idx_content_ivfflat      | 193 MB |  202,522,624
</pre></div>


<p class="wp-block-paragraph">DiskANN is <strong>9x smaller</strong> than HNSW and IVFFlat on the same data. SBQ compression is the default &#8212; <code>storage_layout = memory_optimized</code> is what you get if you don&#8217;t specify a layout. Specifying it explicitly (as in the <code>CREATE INDEX</code> above) is good practice for readability. The alternative <code>plain</code> layout stores full vectors in the index and does not compress.</p>



<p class="wp-block-paragraph">Query performance is competitive:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
Index Scan using idx_content_diskann on articles
  Order By: (content_vector &lt;=&gt; (InitPlan 1).col1)
  Buffers: shared hit=1437 read=132
Execution Time: 2.915 ms
</pre></div>


<p class="wp-block-paragraph">3 ms for a 3,072-dimension nearest neighbor search on 25K articles. On the same data, HNSW does it in 2-6 ms and IVFFlat in 2-10 ms. All three are in the same ballpark for query speed.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Takeaway:</strong> DiskANN is the right choice when your HNSW index outgrows <code>shared_buffers</code>. At 193 MB for 25K rows, HNSW on halfvec(3072) would reach ~77 GB at 10 million rows, this is well beyond what most buffer pools can keep hot. DiskANN&#8217;s 9x compression keeps the navigational structure cached while full vectors stay in the heap, fetched only for the final rescore of a few dozen candidates. Same access pattern as a B-tree: compact index in memory, selective heap lookups on demand. The trade-off is longer build times and fewer operator class options (vector type only, no halfvec/bit/sparsevec).</p>
</blockquote>



<h3 class="wp-block-heading" id="h-create-index-concurrently">CREATE INDEX CONCURRENTLY</h3>



<p class="wp-block-paragraph">As of pgvectorscale 0.9.0, DiskANN supports <code>CONCURRENTLY</code>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE INDEX CONCURRENTLY idx_content_diskann
ON articles USING diskann (content_vector)
WITH (storage_layout = memory_optimized);
</pre></div>


<p class="wp-block-paragraph">This is critical for production &#8212; you can build the index without locking the table. HNSW and IVFFlat also support <code>CREATE INDEX CONCURRENTLY</code>.</p>



<h3 class="wp-block-heading" id="h-why-these-dimension-limits-exist-buffer-pages-and-bits">Why These Dimension Limits Exist: Buffer Pages and Bits</h3>



<p class="wp-block-paragraph">If the 2,000 / 4,000 / 16,000 dimension limits seem arbitrary, they&#8217;re not. The <strong>intuition</strong> comes from how PostgreSQL stores data: in <strong>8 KB buffer pages</strong> (8,192 bytes). Every index tuple &#8212; including the vector representation in a vector index &#8212; has to fit within a page. The fewer bytes per dimension, the more dimensions you can pack.</p>



<p class="wp-block-paragraph">Here&#8217;s the back-of-the-envelope arithmetic:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Encoding</th><th>Bytes per dimension</th><th>Theoretical max in 8 KB</th></tr></thead><tbody><tr><td><code>vector</code> (float32)</td><td>4 bytes</td><td>8,192 / 4 = <strong>2,048</strong></td></tr><tr><td><code>halfvec</code> (float16)</td><td>2 bytes</td><td>8,192 / 2 = <strong>4,096</strong></td></tr><tr><td>4-bit quantized (PQ)</td><td>0.5 bytes</td><td>8,192 * 2 = <strong>16,384</strong></td></tr><tr><td>1-bit binary (SBQ)</td><td>0.125 bytes</td><td>8,192 * 8 = <strong>65,536</strong></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The theoretical numbers explain the <strong>intuition</strong> &#8212; why halfvec doubles the limit and quantized encodings push it further. The <strong>actual limits</strong> are slightly lower because of page headers, tuple overhead, and the index metadata stored alongside each vector. For HNSW, each page also stores <strong>neighbor connection lists</strong> (up to <code>m * 2</code> neighbor IDs per node); for IVFFlat, each page carries <strong>centroid references and list pointers</strong>. These eat into the available space, which is why pgvector&#8217;s HNSW sets 2,000 (not 2,048) and pgvectorscale&#8217;s DiskANN sets 16,000 (not 16,384). But the pattern is unmistakable: <strong>the fewer bits per dimension, the more dimensions you can fit in a page</strong>.</p>



<p class="wp-block-paragraph">This is why DiskANN can handle 16,000 dimensions where HNSW on <code>vector</code> tops out at 2,000 &#8212; DiskANN stores a compressed representation in the index page, not the full vector. And why halfvec doubled the HNSW/IVFFlat limit from 2,000 to 4,000: half the bytes per dimension, twice the capacity.<br>This is more than enough for the vast majority of use cases. Most embedding models in production today default to 768–1536 dimensions, well within the 2,000-dimension limit. This also proves how future proof the curent vector store implementation is on PostgreSQL. </p>



<h3 class="wp-block-heading" id="h-how-pgvectorscale-compresses-statistical-binary-quantization-sbq">How pgvectorscale Compresses: Statistical Binary Quantization (SBQ)</h3>



<p class="wp-block-paragraph">pgvectorscale&#8217;s DiskANN from Tiger Data uses a method called <strong>Statistical Binary Quantization</strong> (SBQ). The idea is deceptively simple: for each dimension, replace the float value with a 1 or 2-bit code.</p>



<p class="wp-block-paragraph"><strong>1-bit mode</strong> (default for dimensions &gt;= 900): Each dimension is compressed to a single bit. But not naively &#8212; standard binary quantization uses 0.0 as the threshold (positive = 1, negative = 0), which works poorly because real embedding distributions are rarely centered on zero. SBQ instead computes the <strong>per-dimension mean</strong> across all vectors during index build and uses that as the threshold:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
if value &amp;gt; mean_of_this_dimension → 1
else → 0
</pre></div>


<p class="wp-block-paragraph">A 3,072-dimension float32 vector (12,288 bytes) becomes a 3,072-bit string (384 bytes). That&#8217;s <strong>32x compression</strong>. During search, the query vector is also SBQ-encoded, and distances are computed using <strong>XOR + popcount</strong> on the bit strings &#8212; which modern CPUs execute in a single instruction.</p>



<p class="wp-block-paragraph"><strong>2-bit mode</strong> (default for dimensions &lt; 900): Each dimension gets two bits, encoding four &#8220;zones&#8221; based on the z-score (how many standard deviations from the mean). This gives finer granularity at 16x compression instead of 32x.</p>



<p class="wp-block-paragraph">The accuracy loss is real but small. On common benchmarks, SBQ achieves 96-99% recall compared to exact search. The rescore step (controlled by <code>diskann.query_rescore</code>, default 50) compensates: after the graph traversal finds the top-50 candidates using quantized distances, pgvectorscale fetches the <strong>full-precision vectors from the heap</strong> and re-computes exact distances to produce the final top-10.</p>



<p class="wp-block-paragraph"><strong>What&#8217;s stored where:</strong></p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Location</th><th>What&#8217;s stored</th><th>Accessed when</th></tr></thead><tbody><tr><td><strong>Index pages</strong></td><td>SBQ-compressed vectors + graph edges</td><td>Every query (graph traversal)</td></tr><tr><td><strong>Heap (table)</strong></td><td>Full float32 vectors</td><td>Only during rescore (top-N candidates)</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">This two-tier architecture is why DiskANN achieves 21 MB index size: the index only stores 384-byte compressed vectors, not 12 KB originals. The full vectors stay in the table, fetched only for the final re-ranking of a few dozen candidates.</p>



<h3 class="wp-block-heading" id="h-microsoft-s-pg-diskann-a-different-approach">Microsoft&#8217;s pg_diskann: A Different Approach</h3>



<p class="wp-block-paragraph">There&#8217;s a second DiskANN implementation for PostgreSQL: Microsoft&#8217;s <strong>pg_diskann</strong>, currently documented and distributed for <a href="https://learn.microsoft.com/en-us/azure/postgresql/extensions/how-to-use-pgdiskann">Azure Database for PostgreSQL Flexible Server</a>. It uses the same Vamana graph algorithm but a fundamentally different compression strategy: <strong>Product Quantization (PQ)</strong>.</p>



<p class="wp-block-paragraph">Where SBQ asks &#8220;is this dimension above or below the mean?&#8221;, Product Quantization asks &#8220;which of 16 codewords best represents this group of dimensions?&#8221;</p>



<p class="wp-block-paragraph">Here&#8217;s how PQ works, step by step:</p>



<ol class="wp-block-list">
<li><strong>Divide the vector into chunks.</strong> A 3,072-dimension vector is split into, say, 1,024 chunks of 3 dimensions each.</li>



<li><strong>Train a codebook per chunk.</strong> For each chunk, k-means clustering finds 16 representative codewords (centroids). Why 16? Because 16 values fit in <strong>4 bits</strong> &#8212; a single hex digit.</li>



<li><strong>Encode each chunk as a 4-bit symbol.</strong> During index build, each chunk of 3 dimensions is replaced by the index (0-15) of its closest codeword. The entire 3,072-dimension vector becomes 1,024 symbols of 4 bits each = <strong>512 bytes</strong>.</li>



<li><strong>Decode via lookup table at query time.</strong> To compute the distance to a query vector, you pre-compute the distance from the query to all 16 codewords for each chunk, creating a <strong>16-row x 1,024-column lookup table</strong>. Then for each stored vector, you sum up the table entries corresponding to its symbols. No floating-point multiplication needed &#8212; just table lookups and additions.</li>
</ol>



<p class="wp-block-paragraph">The compression is dramatic: 3,072 dimensions * 4 bytes = 12,288 bytes → 512 bytes with PQ. That&#8217;s <strong>24x compression</strong>, in the same ballpark as SBQ&#8217;s 32x.</p>



<h3 class="wp-block-heading" id="h-comparing-the-two-diskann-implementations">Comparing the Two DiskANN Implementations</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th></th><th>pgvectorscale (Timescale)</th><th>pg_diskann (Microsoft)</th></tr></thead><tbody><tr><td><strong>Compression</strong></td><td>SBQ (1-2 bits/dim)</td><td>Product Quantization (4 bits/chunk)</td></tr><tr><td><strong>Compression ratio</strong></td><td>32x (1-bit) or 16x (2-bit)</td><td>~24x (depends on chunks)</td></tr><tr><td><strong>How it works</strong></td><td>Per-dimension thresholding</td><td>Codebook lookup per chunk</td></tr><tr><td><strong>Distance computation</strong></td><td>XOR + popcount (very fast)</td><td>Table lookup + sum</td></tr><tr><td><strong>Trainable</strong></td><td>Minimal (just means + stddev)</td><td>Heavy (k-means per chunk)</td></tr><tr><td><strong>Max dimensions</strong></td><td>16,000</td><td>16,000</td></tr><tr><td><strong>Availability</strong></td><td>Open source, self-hosted</td><td>Azure Database for PostgreSQL</td></tr><tr><td><strong>License</strong></td><td>PostgreSQL License</td><td>Distributed via Azure</td></tr><tr><td><strong>Iterative scan</strong></td><td>No</td><td>Yes (relaxed/strict, ON by default)</td></tr><tr><td><strong>PG version</strong></td><td>PG 14-18 (self-hosted)</td><td>Azure DB for PostgreSQL</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Both implementations share the core DiskANN algorithm (Vamana graph) and the two-phase search pattern (compressed scan + full-precision rescore). The difference is <em>how</em> they compress:</p>



<ul class="wp-block-list">
<li><strong>SBQ</strong> is simpler and faster to build (just compute means). It&#8217;s a blunt instrument &#8212; 1 bit per dimension loses a lot of information, but XOR + popcount is blazingly fast, and the rescore step recovers accuracy.</li>



<li><strong>PQ</strong> is more sophisticated and retains more information per bit (a 4-bit symbol captures relationships between groups of dimensions). It&#8217;s slower to build (k-means training) but can achieve better recall at the same compression ratio, especially for vectors with correlated dimensions.</li>
</ul>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Takeaway:</strong> If you&#8217;re self-hosting PostgreSQL, pgvectorscale is your DiskANN option &#8212; open source, well-maintained, and the SBQ compression is effective. If you&#8217;re on Azure Database for PostgreSQL, you also have pg_diskann, whose PQ compression may give better recall on very high-dimensional data. The underlying algorithm is the same; the compression strategy is the difference.</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-iterative-scans">Iterative Scans</h2>



<p class="wp-block-paragraph">This is the most important query-time feature added to pgvector since HNSW support. If you take one thing from this post, let it be this section.</p>



<h3 class="wp-block-heading" id="h-the-problem">The Problem</h3>



<p class="wp-block-paragraph">Vector search indexes return the K nearest neighbors, then PostgreSQL applies your WHERE clause. If your filter is selective, you get fewer results than requested.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- Our dataset has 9 categories with varying selectivity
  category   | cnt   | % of 25K
-------------+-------+---------
 History     | 8719  | 34.9%
 General     | 6221  | 24.9%
 Science     | 2232  |  8.9%
 Mathematics |  116  |  0.5%
</pre></div>


<p class="wp-block-paragraph">When you search for the 10 nearest &#8220;Science&#8221; articles, the HNSW index returns its ef_search nearest neighbors (say, 40), PostgreSQL filters to keep only Science articles, and you get however many matched. For Science (8.9%), you&#8217;ll usually get your 10 results. For Mathematics (0.5%), you won&#8217;t.</p>



<p class="wp-block-paragraph">Let&#8217;s prove it. Here&#8217;s a search for Mathematics articles with ef_search=40, forcing the HNSW index path. (We disable sequential scan here to force the index path on our small 25K dataset. On production tables with 100K+ rows, the optimizer would naturally choose the index without this hint.)</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET enable_seqscan = off;
SET hnsw.iterative_scan = &#039;off&#039;;
SET hnsw.ef_search = 40;

SELECT id, title, category,
       content_halfvec &lt;=&gt; (SELECT content_halfvec FROM articles WHERE id = 1) AS distance
FROM articles
WHERE category = &#039;Mathematics&#039;
ORDER BY content_halfvec &lt;=&gt; (SELECT content_halfvec FROM articles WHERE id = 1)
LIMIT 10;

Index Scan using idx_content_hnsw_halfvec on articles
  Order By: (content_halfvec &lt;=&gt; (InitPlan 1).col1)
  Filter: (category = &#039;Mathematics&#039;::text)
  Rows Removed by Filter: 40
  Index Searches: 1
Execution Time: 0.766 ms
(0 rows)
</pre></div>


<p class="wp-block-paragraph"><strong>Zero rows returned.</strong> The index fetched 40 candidates, all 40 were filtered out (none were Mathematics), and the query silently returned an empty result set. This is the core problem: your application asked for 10 results and got 0.</p>



<p class="wp-block-paragraph">Before iterative scans, you had two bad options:</p>



<ol class="wp-block-list">
<li><strong>Over-fetch</strong> (LIMIT 1000) and hope enough rows match &#8212; wasteful and unreliable</li>



<li><strong>Sequential scan</strong> &#8212; correct but slow on large tables</li>
</ol>



<h3 class="wp-block-heading" id="h-the-solution-iterative-index-scans">The Solution: Iterative Index Scans</h3>



<p class="wp-block-paragraph">pgvector 0.8.0 introduced iterative scans. Instead of fetching a fixed batch and filtering, the index keeps fetching more candidates until the filter is satisfied.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET enable_seqscan = off;
SET hnsw.iterative_scan = &#039;relaxed_order&#039;;
SET hnsw.ef_search = 40;

SELECT id, title, category,
       content_halfvec &lt;=&gt; (SELECT content_halfvec FROM articles WHERE id = 1) AS distance
FROM articles
WHERE category = &#039;Mathematics&#039;
ORDER BY content_halfvec &lt;=&gt; (SELECT content_halfvec FROM articles WHERE id = 1)
LIMIT 10;

Index Scan using idx_content_hnsw_halfvec on articles
  Order By: (content_halfvec &lt;=&gt; (InitPlan 1).col1)
  Filter: (category = &#039;Mathematics&#039;::text)
  Rows Removed by Filter: 6809
  Index Searches: 171
Execution Time: 4235.805 ms
(10 rows)
</pre></div>


<p class="wp-block-paragraph"><strong>10 rows returned.</strong> The index scanned 171 times, examined 6,819 candidates, filtered out 6,809 non-Mathematics rows, and delivered exactly 10 results. It took 4.2 seconds &#8212; much slower than the 0.8 ms empty result &#8212; but you got a <strong>correct</strong> answer instead of silence.</p>



<p class="wp-block-paragraph">At 6,819 tuples scanned, this was well within the default <code>max_scan_tuples</code> of 20,000. On a table with millions of rows and the same 0.5% selectivity, the scan might hit the 20,000 limit before finding 10 matches &#8212; you&#8217;d get a partial result set. That&#8217;s the trade-off the safety valve makes: bounded latency vs guaranteed result count.</p>



<p class="wp-block-paragraph">That 4.2-second cost reflects the extreme selectivity: Mathematics is only 0.5% of the data, so the index had to traverse deep into the graph. For moderate selectivity like Science (8.9%), the overhead is negligible:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET hnsw.iterative_scan = &#039;relaxed_order&#039;;
SET hnsw.ef_search = 40;

SELECT ... WHERE category = &#039;Science&#039; ORDER BY ... LIMIT 10;

Index Scan using idx_content_hnsw_halfvec on articles
  Order By: (content_halfvec &lt;=&gt; (InitPlan 1).col1)
  Filter: (category = &#039;Science&#039;::text)
  Rows Removed by Filter: 26
  Index Searches: 1
Execution Time: 1.058 ms
</pre></div>


<p class="wp-block-paragraph">Same feature, but for Science the index found 10 matching rows in a single search pass &#8212; no extra work needed.</p>



<h3 class="wp-block-heading" id="h-two-modes">Two Modes</h3>



<ul class="wp-block-list">
<li><strong><code>relaxed_order</code></strong>: Results are approximately ordered by distance. Slightly faster. Good enough for most use cases.</li>



<li><strong><code>strict_order</code></strong>: Results are exactly ordered by distance. Slightly slower. Use when ranking precision matters. SET hnsw.iterative_scan = &#8216;strict_order&#8217;;<br>&#8212; Execution Time: 0.885 ms</li>
</ul>



<h3 class="wp-block-heading" id="h-the-safety-valve-max-scan-tuples">The Safety Valve: max_scan_tuples</h3>



<p class="wp-block-paragraph">To prevent runaway scans on extremely selective filters (imagine filtering for a category that has 1 row in 10 million), there&#8217;s a safety limit:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET hnsw.max_scan_tuples = 500;   -- Restrictive: stop after 500 index tuples
SET hnsw.max_scan_tuples = 20000; -- Default: generous enough for most workloads
SET hnsw.max_scan_tuples = 0;     -- Unlimited (use with caution)
</pre></div>


<h3 class="wp-block-heading" id="h-ivfflat-too">IVFFlat Too</h3>



<p class="wp-block-paragraph">Same concept, different GUC prefix:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET ivfflat.iterative_scan = &#039;relaxed_order&#039;;
SET ivfflat.probes = 3;

SELECT ... WHERE category = &#039;Science&#039; ORDER BY ... LIMIT 10;
-- Execution Time: 2.001 ms
</pre></div>


<h3 class="wp-block-heading" id="h-filtered-results-in-action">Filtered Results in Action</h3>



<p class="wp-block-paragraph">With iterative scan, every result matches the filter:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET hnsw.iterative_scan = &#039;relaxed_order&#039;;
SET hnsw.ef_search = 100;

SELECT id, title, category,
       round((content_halfvec &lt;=&gt; (SELECT content_halfvec FROM articles WHERE id = 1))::numeric, 4) AS distance
FROM articles
WHERE category = &#039;Science&#039;
ORDER BY content_halfvec &lt;=&gt; (SELECT content_halfvec FROM articles WHERE id = 1)
LIMIT 10;

  id   |  title   | category | distance
-------+----------+----------+----------
     1 | April    | Science  |   0.0000
  7862 | April 23 | Science  |   0.2953
  9878 | April 25 | Science  |   0.3076
   469 | May      | Science  |   0.3082
  9880 | April 24 | Science  |   0.3451
   402 | July     | Science  |   0.3453
  5156 | April 4  | Science  |   0.3531
  9530 | April 7  | Science  |   0.3588
 34906 | 2013     | Science  |   0.3674
  9149 | April 8  | Science  |   0.3690
</pre></div>


<p class="wp-block-paragraph">All 10 results are Science. All sorted by cosine distance. No over-fetching, no sequential scan. <em>(The titles are Wikipedia date articles &#8212; &#8220;April&#8221;, &#8220;May&#8221;, etc. &#8212; that happen to be classified under Science in this dataset.)</em></p>



<h3 class="wp-block-heading" id="h-multi-filter-combinations">Multi-Filter Combinations</h3>



<p class="wp-block-paragraph">Iterative scans work with compound WHERE clauses too:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET hnsw.iterative_scan = &#039;relaxed_order&#039;;
SET hnsw.ef_search = 100;

SELECT id, title, category, word_count
FROM articles
WHERE category = &#039;Science&#039; AND word_count &gt; 1000
ORDER BY content_halfvec &lt;=&gt; (SELECT content_halfvec FROM articles WHERE id = 1)
LIMIT 10;

Limit  (actual time=7.098..7.105 rows=10.00 loops=1)
  -&gt;  Sort  (actual time=7.097..7.101 rows=10.00 loops=1)
        Sort Key: ((content_halfvec &lt;=&gt; (InitPlan 1).col1))
        Sort Method: top-N heapsort  Memory: 25kB
        -&gt;  Bitmap Heap Scan on articles  (actual time=0.690..7.027 rows=444.00 loops=1)
              Recheck Cond: ((word_count &gt; 1000) AND (category = &#039;Science&#039;::text))
              -&gt;  BitmapAnd  (actual time=0.641..0.643 rows=0.00 loops=1)
                    -&gt;  Bitmap Index Scan on idx_articles_word_count  (rows=1806.00)
                    -&gt;  Bitmap Index Scan on idx_articles_category    (rows=2232.00)
Execution Time: 7.542 ms
</pre></div>


<p class="wp-block-paragraph">Here the PostgreSQL optimizer did something clever: it combined two B-tree indexes (BitmapAnd) instead of using the HNSW iterative scan. On a 25K-row table that fits in memory, this is often cheaper. At scale (millions of rows), the iterative scan path wins because the bitmap approach requires reading too many heap pages.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Takeaway:</strong> Iterative scans are the answer to &#8220;vector search + WHERE doesn&#8217;t work.&#8221; Enable them with <code>SET LOCAL hnsw.iterative_scan = 'relaxed_order'</code> inside transactions. The safety valve <code>max_scan_tuples = 20000</code> is a sensible default.</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-quantization-and-storage">Quantization and Storage</h2>



<p class="wp-block-paragraph">With 3,072-dimension embeddings, storage is the elephant in the room. Here&#8217;s what each representation costs per row:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT
  pg_size_pretty(avg(pg_column_size(content_vector)))  AS vector_size,
  pg_size_pretty(avg(pg_column_size(content_halfvec))) AS halfvec_size,
  pg_size_pretty(avg(pg_column_size(content_bq)))      AS binary_size
FROM articles WHERE content_vector IS NOT NULL;

 vector_size | halfvec_size | binary_size
-------------+--------------+-------------
 12 kB       | 6148 bytes   | 392 bytes
</pre></div>


<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Type</th><th>Bytes per dimension</th><th>Per-row (3072d)</th><th>Savings</th></tr></thead><tbody><tr><td><code>vector(3072)</code></td><td>4 bytes (float32)</td><td>12,288 bytes</td><td>baseline</td></tr><tr><td><code>halfvec(3072)</code></td><td>2 bytes (float16)</td><td>6,144 bytes</td><td><strong>50%</strong></td></tr><tr><td><code>bit(3072)</code></td><td>1/8 byte (1 bit)</td><td>384 bytes</td><td><strong>97%</strong></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">At 1 million rows:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Type</th><th>Column storage</th><th>HNSW index</th></tr></thead><tbody><tr><td><code>vector(3072)</code></td><td>~12 GB</td><td>N/A (2000-dim limit)</td></tr><tr><td><code>halfvec(3072)</code></td><td>~6 GB</td><td>~7.7 GB</td></tr><tr><td><code>bit(3072)</code></td><td>~0.4 GB</td><td>~0.8 GB (bit_hamming_ops)</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The storage math is brutal for high-dimensional embeddings. This is why DiskANN&#8217;s built-in SBQ compression matters: it gets you to 21 MB where HNSW on halfvec costs 193 MB.</p>



<h3 class="wp-block-heading" id="h-binary-quantize-re-ranking">Binary Quantize + Re-Ranking</h3>



<p class="wp-block-paragraph">Binary quantization crushes each dimension to a single bit (positive = 1, negative = 0). It&#8217;s lossy, but very fast for coarse filtering. The pattern is a two-phase search:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- Phase 1: Hamming distance on binary (fast, coarse) → 100 candidates
-- Phase 2: Cosine distance on full vector (precise) → 10 results

WITH coarse AS (
    SELECT id, title, content_vector
    FROM articles
    WHERE content_bq IS NOT NULL
    ORDER BY content_bq &lt;~&gt; (
        SELECT binary_quantize(content_vector)::bit(3072)
        FROM articles WHERE id = 1
    )
    LIMIT 100
)
SELECT id, title,
       content_vector &lt;=&gt; (SELECT content_vector FROM articles WHERE id = 1) AS distance
FROM coarse
ORDER BY content_vector &lt;=&gt; (SELECT content_vector FROM articles WHERE id = 1)
LIMIT 10;

Limit  (actual time=29.021..29.026 rows=10.00 loops=1)
  -&gt;  Sort on coarse  (actual time=29.020..29.023 rows=10.00 loops=1)
        -&gt;  Subquery Scan  (actual time=27.805..28.999 rows=100.00 loops=1)
              -&gt;  Sort by content_bq &lt;~&gt;  (actual time=27.744..27.754 rows=100.00 loops=1)
                    -&gt;  Seq Scan on articles  (actual time=0.435..24.288 rows=24700.00 loops=1)
Execution Time: 29.127 ms
</pre></div>


<p class="wp-block-paragraph">29 ms without an index on <code>content_bq</code>. With an HNSW index using <code>bit_hamming_ops</code>, Phase 1 would be sub-millisecond. The re-ranking in Phase 2 only touches 100 full vectors instead of 25,000.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>DBA Takeaway:</strong> Use halfvec as your default for 3072-dimension embeddings. Use binary quantize + re-ranking when you need to search billions of rows and can tolerate a two-phase approach. DiskANN&#8217;s SBQ gives you similar compression automatically.</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-operators-and-sargability">Operators and Sargability</h2>



<p class="wp-block-paragraph">pgvector provides four distance operators:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Operator</th><th>Distance</th><th>Operator Class</th><th>Use When</th></tr></thead><tbody><tr><td><code>&lt;=&gt;</code></td><td>Cosine</td><td><code>vector_cosine_ops</code></td><td>Normalized embeddings (most common)</td></tr><tr><td><code>&lt;-&gt;</code></td><td>L2 (Euclidean)</td><td><code>vector_l2_ops</code></td><td>Absolute distance matters</td></tr><tr><td><code>&lt;#&gt;</code></td><td>Inner Product (negative)</td><td><code>vector_ip_ops</code></td><td>Pre-normalized, slight speed edge</td></tr><tr><td><code>&lt;+&gt;</code></td><td>L1 (Manhattan)</td><td><code>vector_l1_ops</code></td><td>Sparse-like behavior on dense vectors</td></tr></tbody></table></figure>



<h3 class="wp-block-heading" id="h-wrong-operator-no-index">Wrong Operator = No Index</h3>



<p class="wp-block-paragraph">This is the single most common mistake. If your index uses <code>halfvec_cosine_ops</code> but your query uses <code>&lt;-&gt;</code> (L2), the index <strong>cannot</strong> be used:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- CORRECT: cosine operator on cosine index → Index Scan
EXPLAIN (COSTS OFF)
SELECT ... ORDER BY content_halfvec &lt;=&gt; (...) LIMIT 10;

 Limit
   -&gt;  Index Scan using idx_content_hnsw_halfvec on articles
         Order By: (content_halfvec &lt;=&gt; (InitPlan 1).col1)

-- WRONG: L2 operator on cosine index → Seq Scan!
EXPLAIN (COSTS OFF)
SELECT ... ORDER BY content_halfvec &lt;-&gt; (...) LIMIT 10;

 Limit
   -&gt;  Sort
         Sort Key: ((articles.content_halfvec &lt;-&gt; (InitPlan 1).col1))
         -&gt;  Seq Scan on articles
</pre></div>


<p class="wp-block-paragraph">The planner can&#8217;t use a cosine-distance index for an L2-distance query. They&#8217;re different metrics with different orderings. If you see an unexpected Seq Scan on a vector query, <strong>check your operator first</strong>.</p>



<h3 class="wp-block-heading" id="h-sargable-queries-the-cross-join-trap">Sargable Queries: The Cross-Join Trap</h3>



<p class="wp-block-paragraph">This is the pattern I see most often in the wild, and it&#8217;s wrong:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- BAD: cross-join prevents index use
SELECT a.id, a.title,
       a.content_halfvec &lt;=&gt; b.content_halfvec AS distance
FROM articles a, articles b
WHERE b.id = 1
ORDER BY a.content_halfvec &lt;=&gt; b.content_halfvec
LIMIT 10;

Limit
  -&gt;  Sort
        Sort Key: ((a.content_halfvec &lt;=&gt; b.content_halfvec))
        -&gt;  Nested Loop
              -&gt;  Index Scan using articles_pkey on articles b
                    Index Cond: (id = 1)
              -&gt;  Seq Scan on articles a     ← NO INDEX!
</pre></div>


<p class="wp-block-paragraph">The planner sees <code>a.content_halfvec &lt;=&gt; b.content_halfvec</code> as a <strong>join condition</strong>, not an index-scan ordering. It can&#8217;t push the ORDER BY into the vector index because the right-hand side comes from a different table reference.</p>



<p class="wp-block-paragraph">The fix: use a <strong>scalar subquery</strong>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
-- GOOD: scalar subquery → index used
SELECT id, title,
       content_halfvec &lt;=&gt; (
           SELECT content_halfvec FROM articles WHERE id = 1
       ) AS distance
FROM articles
ORDER BY content_halfvec &lt;=&gt; (
    SELECT content_halfvec FROM articles WHERE id = 1
)
LIMIT 10;

Limit
  InitPlan 1
    -&gt;  Index Scan using articles_pkey on articles articles_1
          Index Cond: (id = 1)
  -&gt;  Index Scan using idx_content_hnsw_halfvec on articles
        Order By: (content_halfvec &lt;=&gt; (InitPlan 1).col1)
</pre></div>


<p class="wp-block-paragraph">The scalar subquery is evaluated once (InitPlan), then the result is treated as a constant. Now the planner can push the ORDER BY into the HNSW index scan. Same results, but with the index instead of a sequential scan.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Takeaway:</strong> For this nearest-neighbor ORDER BY pattern, always use scalar subqueries for vector distance calculations, never cross-joins. This is the vector-search equivalent of writing sargable WHERE clauses for B-tree indexes.</p>
</blockquote>



<h3 class="wp-block-heading" id="h-partial-indexes">Partial Indexes</h3>



<p class="wp-block-paragraph">If you frequently filter by a specific category, a partial index is dramatically more efficient:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE INDEX idx_content_hnsw_science
ON articles USING hnsw (content_halfvec halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64)
WHERE category = &#039;Science&#039;;

Time: 1420.207 ms (00:01.420)

        indexname         |  size
--------------------------+--------
 idx_content_hnsw_halfvec | 193 MB   ← full table (25,000 rows)
 idx_content_hnsw_science |  17 MB   ← Science only (2,232 rows)
</pre></div>


<p class="wp-block-paragraph"><strong>11x smaller, 20x faster to build.</strong> If your application queries consistently filter by a known set of categories, partial indexes are the single biggest optimization available. Build one per high-value category.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-monitoring">Monitoring</h2>



<h3 class="wp-block-heading" id="h-index-inventory">Index Inventory</h3>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT indexname,
       pg_size_pretty(pg_relation_size(indexname::regclass)) AS size,
       indexdef
FROM pg_indexes
WHERE tablename = &#039;articles&#039;
ORDER BY pg_relation_size(indexname::regclass) DESC;

        indexname        |  size   | indexdef
-------------------------+---------+-------------------------------------------
 idx_content_hnsw_halfvec| 193 MB  | USING hnsw ... WITH (m=&#039;16&#039;, ef_construction=&#039;64&#039;)
 idx_content_ivfflat     | 193 MB  | USING ivfflat ... WITH (lists=&#039;25&#039;)
 idx_content_diskann     | 21 MB   | USING diskann ... WITH (storage_layout=memory_optimized)
 articles_pkey           | 1384 kB | USING btree (id)
 idx_articles_category   | 1192 kB | USING btree (category)
 idx_articles_word_count | 904 kB  | USING btree (word_count)
</pre></div>


<p class="wp-block-paragraph">Adding up the three vector indexes (193 + 193 + 21 = 407 MB) plus B-tree indexes (3 MB), the total index footprint is over <strong>410 MB</strong> for a <strong>90 MB</strong> table. The indexes are ~4.5x the data. This is typical for high-dimensional vector data &#8212; plan your storage accordingly.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Note:</strong> In practice you&#8217;d pick one vector index, not all three. With just HNSW + B-tree indexes, the ratio drops to ~2x.</p>
</blockquote>



<h3 class="wp-block-heading" id="h-settings">Settings</h3>



<p class="wp-block-paragraph">Know what settings exist and what they default to:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT name, setting, short_desc
FROM pg_settings
WHERE name ~ &#039;^(hnsw|ivfflat|diskann)\.&#039;
ORDER BY name;

                    name                    | setting | short_desc
--------------------------------------------+---------+-------------------------------------------
 hnsw.ef_search                             | 40      | Dynamic candidate list size for search
 hnsw.iterative_scan                        | off     | Mode for iterative scans
 hnsw.max_scan_tuples                       | 20000   | Max tuples to visit for iterative scans
 hnsw.scan_mem_multiplier                   | 1       | Multiple of work_mem for iterative scans
 ivfflat.iterative_scan                     | off     | Mode for iterative scans
 ivfflat.max_probes                         | 32768   | Max probes for iterative scans
 ivfflat.probes                             | 1       | Number of probes
 diskann.query_search_list_size             | 100     | Search list size for queries
 diskann.query_rescore                      | 50      | Rescore candidates
</pre></div>


<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Takeaway:</strong> <code>hnsw.iterative_scan</code> and <code>ivfflat.iterative_scan</code> default to <code>off</code>. If your application relies on vector search with WHERE clauses, you need to explicitly enable iterative scans.</p>
</blockquote>



<h3 class="wp-block-heading" id="h-access-method-capabilities">Access Method Capabilities</h3>



<p class="wp-block-paragraph">Not sure which operator class works with which index type? Query the catalog:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT am.amname AS access_method,
       opc.opcname AS operator_class,
       t.typname AS data_type
FROM pg_opclass opc
JOIN pg_am am ON am.oid = opc.opcmethod
JOIN pg_type t ON t.oid = opc.opcintype
WHERE am.amname IN (&#039;hnsw&#039;, &#039;ivfflat&#039;, &#039;diskann&#039;)
ORDER BY am.amname, opc.opcname;
</pre></div>


<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Access Method</th><th>Data Types</th><th>Operator Classes</th></tr></thead><tbody><tr><td><strong>HNSW</strong></td><td>vector, halfvec, bit, sparsevec</td><td>18 classes (broadest support)</td></tr><tr><td><strong>IVFFlat</strong></td><td>vector, halfvec, bit</td><td>7 classes</td></tr><tr><td><strong>DiskANN</strong></td><td>vector only (+ label filtering)</td><td>4 classes</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">HNSW is the most versatile. DiskANN is the most constrained. IVFFlat falls in between.</p>



<h3 class="wp-block-heading" id="h-build-progress-monitoring">Build Progress Monitoring</h3>



<p class="wp-block-paragraph">While building a large index:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT phase,
       round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct_done,
       tuples_done, tuples_total
FROM pg_stat_progress_create_index;
</pre></div>


<p class="wp-block-paragraph">This works for HNSW and IVFFlat builds (pgvector reports progress). DiskANN builds from pgvectorscale don&#8217;t currently report to this view.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-decision-guidelines">Decision Guidelines</h2>



<p class="wp-block-paragraph">Here&#8217;s how to choose:</p>



<p class="wp-block-paragraph"><strong>Start with HNSW</strong> unless you have a reason not to. It has the broadest operator support, good recall, and predictable performance. Use halfvec if your dimensions exceed 2,000.</p>



<p class="wp-block-paragraph"><strong>Choose IVFFlat</strong> if build time matters more than query time. IVFFlat builds 5-6x faster than HNSW. If your data distribution shifts materially, plan a <code>REINDEX</code> to refresh clustering quality (centroid drift). A practical signal: if recall drops without any change in query patterns, or if you&#8217;ve inserted more than ~30% new rows since the last build, the centroids are likely stale. For a deeper look at how embedding model upgrades trigger reindexing at scale, see <a href="https://www.dbi-services.com/blog/rag-series-embedding-versioning-with-pgvector-why-event-driven-architecture-is-a-precondition-to-ai-data-workflows/">Embedding Versioning with pgvector</a>.</p>



<p class="wp-block-paragraph"><strong>Choose DiskANN</strong> if storage is the constraint. The 9x compression is decisive at scale. It handles high dimensions natively (no halfvec needed) and supports <code>CONCURRENTLY</code> for production deployments.</p>



<p class="wp-block-paragraph"><strong>Enable iterative scans</strong> for vector search + filtering in production. The trade-off is potentially higher latency on very selective filters (the index does more work to find matching rows), but that&#8217;s usually the right trade-off for correctness. Tune <code>max_scan_tuples</code> / <code>max_probes</code> to bound worst-case work. Use <code>relaxed_order</code> by default, <code>strict_order</code> when ranking precision matters.</p>



<p class="wp-block-paragraph"><strong>Use partial indexes</strong> for category-specific searches. An 11x size reduction and 20x faster build is hard to argue with.</p>



<p class="wp-block-paragraph"><strong>Use SET LOCAL</strong> for all vector parameter changes in production AFTER having tested them.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading" id="h-summary">Summary</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Feature</th><th>HNSW</th><th>IVFFlat</th><th>DiskANN (pgvectorscale)</th><th>DiskANN (pg_diskann)</th></tr></thead><tbody><tr><td><strong>Provider</strong></td><td>pgvector</td><td>pgvector</td><td>Timescale (open source)</td><td>Microsoft (Azure DB for PG)</td></tr><tr><td><strong>Max dims (vector)</strong></td><td>2,000</td><td>2,000</td><td>16,000</td><td>16,000</td></tr><tr><td><strong>Max dims (halfvec)</strong></td><td>4,000</td><td>4,000</td><td>N/A</td><td>N/A</td></tr><tr><td><strong>Compression</strong></td><td>Via halfvec</td><td>Via halfvec</td><td>SBQ (1-2 bits/dim)</td><td>PQ (4 bits/chunk)</td></tr><tr><td><strong>Build time (25K, 3072d)</strong></td><td>29s</td><td>5s</td><td>49s</td><td>N/A</td></tr><tr><td><strong>Index size</strong></td><td>193 MB</td><td>193 MB</td><td><strong>21 MB</strong></td><td>Similar</td></tr><tr><td><strong>Query time</strong></td><td>2-6 ms</td><td>2-10 ms</td><td>3 ms</td><td>~3 ms</td></tr><tr><td><strong>Key tuning param</strong></td><td>ef_search</td><td>probes</td><td>query_search_list_size</td><td>search list / PQ params</td></tr><tr><td><strong>Iterative scan</strong></td><td>Yes</td><td>Yes</td><td>No</td><td>Yes (ON by default)</td></tr><tr><td><strong>CONCURRENTLY</strong></td><td>Yes</td><td>Yes</td><td>Yes (0.9.0+)</td><td>Yes</td></tr><tr><td><strong>Data types</strong></td><td>vector, halfvec, bit, sparsevec</td><td>vector, halfvec, bit</td><td>vector only</td><td>vector only</td></tr></tbody></table></figure>



<p class="wp-block-paragraph" id="h-dimension-limits-are-largely-explained-by-postgresql-s-8-kb-page-size-and-encoding-density-exact-cutoffs-are-implementation-defined">Dimension limits are largely explained by PostgreSQL&#8217;s 8 KB page size and encoding density (exact cutoffs are implementation-defined):</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Encoding</th><th>Bits/dim</th><th>Theoretical max</th><th>Actual limit</th><th>Who uses it</th></tr></thead><tbody><tr><td>float32 (<code>vector</code>)</td><td>32</td><td>2,048</td><td>2,000</td><td>HNSW, IVFFlat</td></tr><tr><td>float16 (<code>halfvec</code>)</td><td>16</td><td>4,096</td><td>4,000</td><td>HNSW, IVFFlat</td></tr><tr><td>PQ symbol</td><td>4</td><td>16,384</td><td>16,000</td><td>pg_diskann</td></tr><tr><td>SBQ binary</td><td>1</td><td>65,536</td><td>16,000</td><td>pgvectorscale</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The compression story is ultimately a story about <strong>how many bits of information you need per dimension to navigate the index</strong>. pgvector stores full-precision values; DiskANN stores just enough to find the right neighborhood, then goes back to the heap for exact distances. <br>But here&#8217;s the thing: exact results are not necessarily what you want. In a RAG pipeline, the retrieved documents are context for a language model that will synthesize and rephrase an answer — not return rows verbatim. Whether your top-10 results are ranked by exact cosine distance or by a 96%-accurate approximation rarely changes the generated answer. The same is true for recommendations, semantic deduplication, and most classification workflows: the downstream consumer is tolerant of small ranking variations.</p>



<p class="wp-block-paragraph">The real production trade-off is not precision vs approximation — it&#8217;s <strong>the balance between retrieval speed, resource efficiency, and result quality at your scale</strong>. An HNSW index that doesn&#8217;t fit in <code>shared_buffers</code> and hits disk on every query will give you worse <em>effective</em> results than a DiskANN index that stays cached and returns slightly less precise distances in a fraction of the time. The best retrieval is the one that actually runs within your latency budget.</p>



<p class="wp-block-paragraph">The lab with all SQL scripts, and Python embedding pipeline are available here :  <a href="https://github.com/boutaga/pgvector_RAG_search_lab/tree/main/lab/06_pgvector_indexes"><code>lab/06_pgvector_indexes</code></a>.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<p class="wp-block-paragraph"><em>All benchmarks: PostgreSQL 18, pgvector 0.8.1, pgvectorscale 0.9.0, 25K Wikipedia articles, 3072d text-embedding-3-large embeddings, (4 vCPUs, 8 GB RAM).</em></p>
<p>L’article <a href="https://www.dbi-services.com/blog/pgvector-a-guide-for-dba-part-2-indexes-update-march-2026/">pgvector, a guide for DBA &#8211; Part 2: Indexes (update march 2026)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/pgvector-a-guide-for-dba-part-2-indexes-update-march-2026/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 
Lazy Loading (feed)

Served from: www.dbi-services.com @ 2026-11-09 16:52:38 by W3 Total Cache
-->