<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Philip McClarence</title>
    <description>The latest articles on DEV Community by Philip McClarence (@philip_mcclarence_2ef9475).</description>
    <link>https://dev.to/philip_mcclarence_2ef9475</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2690053%2F913499a1-620d-4487-a868-d677f1aca106.png</url>
      <title>DEV Community: Philip McClarence</title>
      <link>https://dev.to/philip_mcclarence_2ef9475</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/philip_mcclarence_2ef9475"/>
    <language>en</language>
    <item>
      <title>pg_cron Tutorial: Schedule Postgres Jobs and Monitor Them</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Tue, 11 Aug 2026 10:00:12 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/pgcron-tutorial-schedule-postgres-jobs-and-monitor-them-1am</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/pgcron-tutorial-schedule-postgres-jobs-and-monitor-them-1am</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;pg_cron is an open-source Postgres extension (Citus Data, now Microsoft), PostgreSQL-licensed, at github.com/citusdata/pg_cron. A background worker wakes every minute and runs SQL on a five-field cron schedule.&lt;/li&gt;
&lt;li&gt;Install is three lines and a restart: &lt;code&gt;shared_preload_libraries = 'pg_cron'&lt;/code&gt;, restart, &lt;code&gt;CREATE EXTENSION pg_cron;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;First three jobs worth scheduling: retention (batched delete or partition drop), partition maintenance, materialized view refresh. A fourth, the hourly rollup, replaces a surprising amount of pipeline tooling.&lt;/li&gt;
&lt;li&gt;You do &lt;strong&gt;not&lt;/strong&gt; need to build your own job logging. &lt;code&gt;cron.log_run&lt;/code&gt; is on by default and every run lands in &lt;code&gt;cron.job_run_details&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Four things that bite: schedules are UTC, &lt;code&gt;cron.database_name&lt;/code&gt; needs a restart, &lt;code&gt;cron.job_run_details&lt;/code&gt; grows forever, and &lt;code&gt;VACUUM&lt;/code&gt; fails inside a multi-statement job body.&lt;/li&gt;
&lt;li&gt;I covered this in a 5-minute whiteboard video: &lt;a href="https://www.youtube.com/watch?v=ku7K7lusTuY" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=ku7K7lusTuY&lt;/a&gt;. This post is the written, deeper version with SQL you can actually paste.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/pg-cron-monitoring-postgres-scheduled-jobs?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=pg-cron-tutorial-schedule-monitor-postgres-jobs" rel="noopener noreferrer"&gt;pg_cron Monitoring: Failures, Staleness, and Bloat&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why schedule inside the database at all
&lt;/h2&gt;

&lt;p&gt;The default answer is a line in the system crontab calling &lt;code&gt;psql&lt;/code&gt;. It works until it doesn't. You now have credentials on a box, a job that keeps firing happily against a demoted standby, and job history in a log file that nobody has read since the person who wrote it left.&lt;/p&gt;

&lt;p&gt;The other default answer is Airflow. Airflow is genuinely good at cross-system pipelines with dependencies and retries. For "delete rows older than 90 days" it is a second production system to patch.&lt;/p&gt;

&lt;p&gt;pg_cron's pitch is narrow and I like it: the schedule is data in a table, it travels with the cluster under physical replication, and the run history is queryable with the same SQL and dashboards you already point at everything else.&lt;/p&gt;

&lt;p&gt;Where pg_cron is the wrong tool: multi-system DAGs, task dependencies, retries with exponential backoff, anything that needs to touch S3 and Postgres in one transactional-ish flow. It has no dependency graph. If you find yourself encoding one in cron expressions, stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install: four lines, and the two settings people get wrong
&lt;/h2&gt;

&lt;p&gt;pg_cron runs as a background worker, so it has to be preloaded before &lt;code&gt;CREATE EXTENSION&lt;/code&gt; will work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;shared_preload_libraries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pg_cron'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- restart the server here. Not reload. Restart.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;pg_cron&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting one people get wrong: &lt;code&gt;cron.database_name&lt;/code&gt;. The pg_cron worker connects to exactly one database, defaulting to &lt;code&gt;postgres&lt;/code&gt;, and changing it requires a restart. If your app lives in &lt;code&gt;appdb&lt;/code&gt; and you installed the extension there but never changed the setting, nothing runs and nothing errors visibly.&lt;/p&gt;

&lt;p&gt;For multi-database clusters, leave the worker where it is and use &lt;code&gt;cron.schedule_in_database()&lt;/code&gt; (pg_cron 1.4+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule_in_database&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'purge-appdb-events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'17 3 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purge_old_events&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'appdb'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'app_maint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting two: &lt;code&gt;cron.use_background_workers&lt;/code&gt;. By default jobs run over a libpq connection to &lt;code&gt;cron.host&lt;/code&gt; (localhost), which eats a regular &lt;code&gt;max_connections&lt;/code&gt; slot. Turn background workers on and they draw from &lt;code&gt;max_worker_processes&lt;/code&gt; instead. Either way, size the pool. &lt;code&gt;cron.max_running_jobs&lt;/code&gt; caps concurrency.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;How to set it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon RDS / Aurora&lt;/td&gt;
&lt;td&gt;DB parameter group: &lt;code&gt;shared_preload_libraries&lt;/code&gt;, &lt;code&gt;cron.database_name&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Flexible Server&lt;/td&gt;
&lt;td&gt;Server parameters blade, then &lt;code&gt;CREATE EXTENSION&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud SQL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cloudsql.enable_pg_cron&lt;/code&gt; flag&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How it actually runs your SQL
&lt;/h2&gt;

&lt;p&gt;The worker ticks once a minute, reads &lt;code&gt;cron.job&lt;/code&gt;, and launches whatever is due. One-minute granularity is the floor. There is no sub-minute scheduling and no catch-up for missed ticks.&lt;/p&gt;

&lt;p&gt;A running job looks like any other backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;query_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;application_name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'pg_cron%'&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%purge_old%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="n"&gt;pid&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="n"&gt;runtime&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt;                    &lt;span class="k"&gt;left&lt;/span&gt;
&lt;span class="c1"&gt;-------+-----------+--------+-----------------+---------------------------------------------&lt;/span&gt;
 &lt;span class="mi"&gt;20481&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;app_maint&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;881203&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purge_old_events&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kill it with &lt;code&gt;SELECT pg_cancel_backend(20481);&lt;/code&gt; and it will show up as failed in the run history, which is what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 1: retention that doesn't lock the table for 40 minutes
&lt;/h2&gt;

&lt;p&gt;The naive version, straight from the video:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightly-purge'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0 2 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'90 days'&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;
&lt;span class="c1"&gt;----------&lt;/span&gt;
        &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns a bigint jobid. Fine for a small table. On 200 million rows it holds one enormous transaction, bloats WAL, and leaves dead tuples for autovacuum to chew through. Deleted space is not returned to the OS, it becomes reusable only after vacuum.&lt;/p&gt;

&lt;p&gt;Batch it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purge_old_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_batch&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'30min'&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5s'&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="n"&gt;removed&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;LOOP&lt;/span&gt;
    &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'90 days'&lt;/span&gt;
      &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="n"&gt;p_batch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;GET&lt;/span&gt; &lt;span class="k"&gt;DIAGNOSTICS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ROW_COUNT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;removed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;removed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;EXIT&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better still: partition by time and drop the old partition. That is a catalog and file operation, not a row-by-row scan, and it leaves nothing for autovacuum. Retention by &lt;code&gt;DROP PARTITION&lt;/code&gt; beats retention by &lt;code&gt;DELETE&lt;/code&gt; every time you can arrange it — this is the pattern behind most postgres data retention jobs that actually scale.&lt;/p&gt;

&lt;p&gt;Inspect and remove jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt; &lt;span class="n"&gt;jobid&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="n"&gt;jobname&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;
&lt;span class="c1"&gt;-------+-----------+------------------+--------&lt;/span&gt;
     &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;nightly&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;purge&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
     &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="o"&gt;*/&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;*|&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;mv&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SELECT cron.unschedule(4);&lt;/code&gt; or &lt;code&gt;SELECT cron.unschedule('nightly-purge');&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 2: partition maintenance (pg_partman does this better than you will)
&lt;/h2&gt;

&lt;p&gt;Hand-rolled works: a function that creates next month's partition and detaches plus drops anything past retention, scheduled &lt;code&gt;0 3 1 * *&lt;/code&gt;. Use &lt;code&gt;ALTER TABLE ... DETACH PARTITION CONCURRENTLY&lt;/code&gt; on PG14+ so you aren't holding ACCESS EXCLUSIVE on the parent while a reporting query finishes.&lt;/p&gt;

&lt;p&gt;The answer most shops land on is pg_partman, whose documented model is to call &lt;code&gt;run_maintenance_proc()&lt;/code&gt; on a schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'partman-maintenance'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'@hourly'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="n"&gt;partman&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_maintenance_proc&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line handles future partition creation and retention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 3: schedule refresh materialized view postgres without blocking readers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;REFRESH MATERIALIZED VIEW CONCURRENTLY&lt;/code&gt; lets &lt;code&gt;SELECT&lt;/code&gt;s keep running against the view during the refresh. Two prerequisites people forget: it needs at least one UNIQUE index covering all rows, and it will not work on a view that has never been populated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_sales&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sale_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;REFRESH&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_sales&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- populate once, non-concurrently&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refresh-daily-sales'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'*/15 * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="n"&gt;REFRESH&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_sales&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CONCURRENTLY is not free. It does a diff against the existing contents and can be dramatically slower on large views. Time both forms before you commit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sale_date&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sale_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;staleness&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_sales&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrap job bodies in functions
&lt;/h2&gt;

&lt;p&gt;Put the job body in a function. Inline SQL in &lt;code&gt;cron.job&lt;/code&gt; is how you end up with a 400-character &lt;code&gt;DELETE&lt;/code&gt; that nobody can read, nobody can test, and nobody can find in version control.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;SET&lt;/code&gt; clause on &lt;code&gt;CREATE FUNCTION&lt;/code&gt; pins execution settings for the duration of the call, which makes it the cleanest place to bound a scheduled job. Add an advisory lock for anything you never want overlapping with itself or with a manual run — a fifteen-minute job scheduled every ten minutes will eventually stack two copies on top of each other without one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rollup_hourly&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'10min'&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5s'&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;application_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'cron:rollup_hourly'&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;pg_try_advisory_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;88101&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;NOTICE&lt;/span&gt; &lt;span class="s1"&gt;'rollup already running, skipping'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;RETURN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;events_hourly&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'-infinity'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events_hourly&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;PERFORM&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_unlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;88101&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That watermark makes it idempotent and safely re-runnable. It is an entire ETL pipeline, and you don't need Airflow for it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  pg_cron monitoring: cron.job_run_details is your job log
&lt;/h2&gt;

&lt;p&gt;The most common misconception I run into is that pg_cron gives you no visibility and you have to build your own logging table. Not true. &lt;code&gt;cron.log_run&lt;/code&gt; is on by default, and every run is recorded in &lt;code&gt;cron.job_run_details&lt;/code&gt; with jobid, runid, job_pid, database, username, command, status, return_message, start_time and end_time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt; &lt;span class="n"&gt;jobid&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;runid&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;                    &lt;span class="n"&gt;return_message&lt;/span&gt;                     &lt;span class="o"&gt;|&lt;/span&gt;         &lt;span class="n"&gt;start_time&lt;/span&gt;
&lt;span class="c1"&gt;-------+-------+--------+-------------------------------------------------------+----------------------------&lt;/span&gt;
    &lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;90422&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;VACUUM&lt;/span&gt; &lt;span class="n"&gt;cannot&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="n"&gt;inside&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt; &lt;span class="mi"&gt;06&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;114&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failures in the last 24 hours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jobname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_time&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s1"&gt;'succeeded'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slowest jobs, p95:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;percentile_cont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WITHIN&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;end_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;p95&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;jobid&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;p95&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stale jobs, meaning scheduled but not succeeding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stale_cron_jobs&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jobname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'succeeded'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_ok&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'succeeded'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'-infinity'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'25 hours'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the mandatory part. Nothing prunes &lt;code&gt;cron.job_run_details&lt;/code&gt;. The README says so plainly, and I once inherited a cluster where it had reached the low millions of rows and was the largest table in the maintenance database — the nightly backup job was slower because it was scanning that table too. Schedule the prune before you schedule anything else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'purge-cron-history'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'5 4 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;end_time&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't want history at all, set &lt;code&gt;cron.log_run = off&lt;/code&gt;, though you lose the audit trail along with it. &lt;code&gt;cron.log_statement&lt;/code&gt; additionally writes the command to the server log if you want it there too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I've been bitten by
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Schedules are UTC.&lt;/strong&gt; Not server local time, not your session &lt;code&gt;TimeZone&lt;/code&gt;. &lt;code&gt;0 2 * * *&lt;/code&gt; fires at 02:00 UTC, which is 21:00 the previous day in America/New_York during EDT and 22:00 during EST. My "2am maintenance window" ran during Tuesday evening peak for three weeks before anyone connected the dots. Write the UTC offset into the job name if it helps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VACUUM in a multi-statement body fails.&lt;/strong&gt; &lt;code&gt;VACUUM ANALYZE t&lt;/code&gt; alone is fine. &lt;code&gt;ANALYZE a; VACUUM b;&lt;/code&gt; in one job body runs as one implicit transaction and errors with &lt;code&gt;VACUUM cannot run inside a transaction block&lt;/code&gt;. One statement per job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long jobs don't double-fire.&lt;/strong&gt; Schedule &lt;code&gt;SELECT pg_sleep(300)&lt;/code&gt; every minute and verify for yourself; you get one run, not five.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jobs run as the role that scheduled them.&lt;/strong&gt; Schedule as a dedicated maintenance role with the grants it needs. Since 1.4 a superuser can &lt;code&gt;GRANT USAGE ON SCHEMA cron&lt;/code&gt; to a non-superuser so that role manages its own jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The extension lives in one database.&lt;/strong&gt; See &lt;code&gt;cron.database_name&lt;/code&gt; above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standbys are read-only.&lt;/strong&gt; &lt;code&gt;cron.job&lt;/code&gt; and &lt;code&gt;cron.job_run_details&lt;/code&gt; replicate physically, but only the primary executes. After a failover, re-verify &lt;code&gt;shared_preload_libraries&lt;/code&gt; and &lt;code&gt;cron.database_name&lt;/code&gt; on the promoted node — a failover with no &lt;code&gt;shared_preload_libraries&lt;/code&gt; entry on the new primary means silence, not an error. I keep that as an explicit step in the runbook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors are invisible unless someone queries for them.&lt;/strong&gt; Point your alerting at the failures query. pg_cron will not page you.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for something else
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Dependencies&lt;/th&gt;
&lt;th&gt;Retries&lt;/th&gt;
&lt;th&gt;Cross-system&lt;/th&gt;
&lt;th&gt;Failover aware&lt;/th&gt;
&lt;th&gt;Observability&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pg_cron&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;follows the primary&lt;/td&gt;
&lt;td&gt;SQL over &lt;code&gt;job_run_details&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cron + psql&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;log files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pgAgent&lt;/td&gt;
&lt;td&gt;steps, sequential&lt;/td&gt;
&lt;td&gt;limited&lt;/td&gt;
&lt;td&gt;shell steps&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;its own tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg_timetable&lt;/td&gt;
&lt;td&gt;chains&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;shell/HTTP&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;its own tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Airflow / Dagster&lt;/td&gt;
&lt;td&gt;full DAG&lt;/td&gt;
&lt;td&gt;yes, backoff&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;external&lt;/td&gt;
&lt;td&gt;rich UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;K8s CronJob&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;pod-level&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;external&lt;/td&gt;
&lt;td&gt;cluster logs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The starter kit
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- postgresql.conf: shared_preload_libraries = 'pg_cron', cron.database_name = 'appdb'; restart&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_cron&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 1. history prune. Do this first, always.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'purge-cron-history'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'5 4 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;end_time&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. retention (function from above), 02:00 UTC&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'purge-old-events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0 2 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purge_old_events&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. matview refresh, needs a unique index and one prior non-concurrent refresh&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refresh-daily-sales'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'*/15 * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="n"&gt;REFRESH&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_sales&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. hourly rollup&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rollup-hourly'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'7 * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rollup_hourly&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 5. monitoring&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cron_failures_24h&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jobname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_time&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s1"&gt;'succeeded'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cron_failures_24h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather not build these dashboards yourself, tools like MyDBA (&lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=pg-cron-tutorial-schedule-monitor-postgres-jobs" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;amp;utm_medium=platform&amp;amp;amp;utm_campaign=pg-cron-tutorial-schedule-monitor-postgres-jobs&lt;/a&gt;) wrap this kind of &lt;code&gt;cron.job_run_details&lt;/code&gt; monitoring into something you can hand to the team.&lt;/p&gt;

&lt;p&gt;What is the worst thing you have scheduled in &lt;code&gt;cron.job&lt;/code&gt;? Mine involved a 400-character &lt;code&gt;DELETE&lt;/code&gt; and a comma in the wrong place.&lt;/p&gt;

&lt;p&gt;Tags: &lt;code&gt;postgres&lt;/code&gt; &lt;code&gt;database&lt;/code&gt; &lt;code&gt;sql&lt;/code&gt; &lt;code&gt;devops&lt;/code&gt; ## Where this leaves you&lt;/p&gt;

&lt;p&gt;pg_cron won't build you a DAG, retry a failed API call, or coordinate a multi-service pipeline — and it shouldn't try to. What it does is take the boring 80% of database housekeeping (retention, partition rotation, refreshes, rollups) and keep it exactly where that logic belongs: in the database, versioned as functions, visible in &lt;code&gt;cron.job_run_details&lt;/code&gt;, replicated with the cluster. Start with the four jobs above, prune your own history table before you forget, and point something at the failures view so you find out about a broken job before your users do.&lt;/p&gt;

&lt;p&gt;pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=pg-cron-tutorial-schedule-monitor-postgres-jobs" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;amp;utm_medium=platform&amp;amp;amp;utm_campaign=pg-cron-tutorial-schedule-monitor-postgres-jobs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're already scheduling jobs with pg_cron, give MyDBA a look — it'll flag stale jobs, bloated &lt;code&gt;cron.job_run_details&lt;/code&gt; tables, and the other things this post just told you to watch for, without you writing the queries by hand.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Aurora PostgreSQL Storage Model: What Actually Changed</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Mon, 10 Aug 2026 10:00:13 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/aurora-postgresql-storage-model-what-actually-changed-62i</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/aurora-postgresql-storage-model-what-actually-changed-62i</guid>
      <description>&lt;p&gt;Short answer: Aurora PostgreSQL never writes heap or index pages to durable storage — only redo log records — so roughly half of your checkpoint and replication tuning playbook is dead weight. The other half (vacuum, &lt;code&gt;work_mem&lt;/code&gt;, connection limits) matters exactly as much as it always did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/aurora-postgresql-storage-model?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=aurora-postgresql-storage-model" rel="noopener noreferrer"&gt;Aurora PostgreSQL Storage Model: What Actually Changes&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've watched teams port a checkpoint-tuning runbook to Aurora and spend a week chasing a parameter that does nothing. They opened a ticket about &lt;code&gt;checkpoint_completion_target&lt;/code&gt; and argued over &lt;code&gt;max_wal_size&lt;/code&gt; while the real problem was an autovacuum that hadn't finished on a 400 GB table since the last deploy.&lt;/p&gt;

&lt;p&gt;The confusion is understandable. Aurora PostgreSQL speaks the wire protocol, runs the same planner, has the same MVCC semantics, and answers &lt;code&gt;SELECT version()&lt;/code&gt; in a way that looks familiar. Underneath, the durability layer is different software entirely — and that gap is where most Aurora vs PostgreSQL architecture confusion comes from.&lt;/p&gt;

&lt;p&gt;There's a companion video that walks through the architecture visually in about ten minutes: &lt;a href="https://www.youtube.com/watch?v=GTFHE5KJSag" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=GTFHE5KJSag&lt;/a&gt;. This post goes further, with the SQL you'd actually run to check any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Aurora writer sends redo log records to storage. It does not write heap or index pages to durable storage.&lt;/li&gt;
&lt;li&gt;Storage nodes apply the log and materialize page versions themselves, across six copies in three AZs.&lt;/li&gt;
&lt;li&gt;Checkpoints stop being an I/O event you can feel, so &lt;code&gt;pg_stat_bgwriter&lt;/code&gt; dashboards go quiet and stay quiet.&lt;/li&gt;
&lt;li&gt;Readers attach to the same volume as the writer, so "replica lag" measures cache application, not WAL replay into a second copy of your data.&lt;/li&gt;
&lt;li&gt;Roughly half of a classic tuning playbook is inert. The half that still matters (vacuum, wraparound, &lt;code&gt;work_mem&lt;/code&gt;, connections) matters exactly as much as it always did.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Aurora vs PostgreSQL Architecture: The Vanilla Write Path
&lt;/h2&gt;

&lt;p&gt;On a self-managed box: a backend dirties a page in &lt;code&gt;shared_buffers&lt;/code&gt;, writes a WAL record into &lt;code&gt;pg_wal&lt;/code&gt;, and fsyncs that WAL at commit. If this is the first touch of the page since the last checkpoint, &lt;code&gt;full_page_writes=on&lt;/code&gt; stuffs a full 8 KB image of the page into the WAL stream, because an 8 KB write isn't atomic against an OS or hardware crash. The checkpointer periodically flushes dirty buffers out to the data files, governed by &lt;code&gt;checkpoint_timeout&lt;/code&gt;, &lt;code&gt;max_wal_size&lt;/code&gt;, and &lt;code&gt;checkpoint_completion_target&lt;/code&gt;. The background writer trickles in between. A physical streaming replica receives the WAL and replays it into its own complete copy of every data file. Ask anyone who's watched &lt;code&gt;iostat&lt;/code&gt; spike the moment &lt;code&gt;checkpoint starting: time&lt;/code&gt; shows up in the logs — that's a real, feelable I/O event.&lt;/p&gt;

&lt;p&gt;Two things to hold onto: there are two full copies of the data, and the checkpoint is a real I/O event with a real cost you tune away from peak traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Aurora PostgreSQL Storage Model Does Instead
&lt;/h2&gt;

&lt;p&gt;The Aurora paper's phrase is "the log is the database" (Verbitski et al., SIGMOD 2017). The database tier ships redo log records to the storage tier. Storage nodes apply those records to their own page versions, in the background and on demand when a read needs a version that hasn't been materialized yet.&lt;/p&gt;

&lt;p&gt;The cluster volume keeps six copies across three Availability Zones, with a 4-of-6 write quorum and a 3-of-6 read quorum, sliced into 10 GB protection groups. The volume autoscales up to 128 TiB for Aurora PostgreSQL.&lt;/p&gt;

&lt;p&gt;Because only log records cross the network, network amplification drops sharply — the paper reports roughly a 7.7x reduction in network IOs per transaction against a mirrored-MySQL configuration in their sysbench comparison. That figure is from the MySQL-flavored benchmark, not a PostgreSQL measurement, so treat it as directional rather than a number to quote in a capacity plan.&lt;/p&gt;

&lt;p&gt;What's published: the quorum scheme, the segment size, the log-only write path. What's inference on my part: exactly when a given storage node decides to materialize a page version versus serve it from the log chain. AWS doesn't document that scheduling, and it doesn't matter much operationally, but I'd rather flag it than pretend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence 1: Aurora Checkpoints Stop Being an Event
&lt;/h2&gt;

&lt;p&gt;If the writer never flushes dirty data pages to durable storage, the parameters that throttle that flush have lost their job. Here's a version-portable query, because PostgreSQL 17 moved the checkpoint counters out of &lt;code&gt;pg_stat_bgwriter&lt;/code&gt; into &lt;code&gt;pg_stat_checkpointer&lt;/code&gt;, and which columns Aurora exposes depends on the engine version you're running.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_views&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;viewname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pg_stat_checkpointer'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;num_timed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sync_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;buffers_written&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stats_reset&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_checkpointer&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;ELSE&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;checkpoints_timed&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;num_timed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;checkpoints_req&lt;/span&gt;     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;num_requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;checkpoint_write_time&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;write_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;checkpoint_sync_time&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sync_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;buffers_checkpoint&lt;/span&gt;    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;buffers_written&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;stats_reset&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_bgwriter&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;
    &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;NOTICE&lt;/span&gt; &lt;span class="s1"&gt;'timed=% req=% write_ms=% sync_ms=% buffers=% since=%'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;num_timed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;num_requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sync_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buffers_written&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats_reset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Self-managed box under a steady OLTP load, one week of uptime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NOTICE:  timed=2016 req=143 write_ms=41883204 sync_ms=98771 buffers=118442901 since=2026-08-02 04:11:07+00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aurora writer, comparable workload and uptime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NOTICE:  timed=2016 req=0 write_ms=612 sync_ms=0 buffers=1174 since=2026-08-02 04:09:55+00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timed count still ticks because the checkpointer process still runs. The buffer and write-time numbers are effectively noise. If you have a Grafana panel plotting &lt;code&gt;buffers_checkpoint&lt;/code&gt; rate or &lt;code&gt;checkpoint_write_time&lt;/code&gt; as your write-pressure signal, that panel is lying to you — &lt;code&gt;pg_stat_bgwriter&lt;/code&gt; on Aurora just doesn't carry the same meaning. Delete it and watch &lt;code&gt;WriteIOPS&lt;/code&gt;, &lt;code&gt;WriteLatency&lt;/code&gt;, and &lt;code&gt;CommitLatency&lt;/code&gt; in CloudWatch instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence 2: full_page_writes Isn't Yours
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;full_page_writes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="n"&gt;full_page_writes&lt;/span&gt;
&lt;span class="c1"&gt;------------------&lt;/span&gt;
 &lt;span class="k"&gt;off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storage handles page materialization, so torn pages aren't a risk the writer has to insure against by doubling its WAL volume after every checkpoint. Try to set it in a cluster parameter group and you'll find it's not a modifiable parameter. This is also why WAL volume per transaction on Aurora looks different from a vanilla instance running the same workload — no periodic full-page-image spike after each checkpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence 3: Replicas Are Compute, Not Copies
&lt;/h2&gt;

&lt;p&gt;Up to 15 Aurora Replicas attach to the same cluster volume as the writer. No &lt;code&gt;pg_basebackup&lt;/code&gt;. No base backup transfer. No second physical copy of your data. Adding a reader is provisioning an instance and pointing it at storage.&lt;/p&gt;

&lt;p&gt;The reader still consumes the log stream, but only to keep its own buffer cache and in-memory structures consistent. A page the writer just modified can be served correctly by a reader only after the reader applies the relevant record. That's why read-after-write against a reader endpoint is typically low single-digit milliseconds rather than zero, and why routing a read-your-own-write flow to the reader endpoint produces the occasional bug report you'll spend two days reproducing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence 4: Measuring Aurora Replica Lag Correctly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_stat_replication&lt;/code&gt; on an Aurora writer doesn't enumerate Aurora Replicas the way it enumerates streaming standbys, because they aren't streaming standbys. Dashboards built on it silently render an empty panel, which people misread as "no lag."&lt;/p&gt;

&lt;p&gt;Use the Aurora function on the writer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;server_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;highest_lsn_rcvd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;cur_replica_lag_in_msec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_update_timestamp&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;aurora_replica_status&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;   &lt;span class="n"&gt;server_id&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt;              &lt;span class="n"&gt;session_id&lt;/span&gt;              &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;highest_lsn_rcvd&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;cur_replica_lag_in_msec&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="n"&gt;last_update_timestamp&lt;/span&gt;
&lt;span class="c1"&gt;----------------+--------------------------------------+------------------+-------------------------+---------------------------&lt;/span&gt;
 &lt;span class="n"&gt;prod&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;MASTER_SESSION_ID&lt;/span&gt;                    &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;412998877123&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;                         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;
 &lt;span class="n"&gt;prod&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="n"&gt;f2c1e77&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;a4b&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;e19&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="n"&gt;d02&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;c6a4e2f8b33&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;412998877098&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;                    &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;199&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;
 &lt;span class="n"&gt;prod&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;b91f043&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="n"&gt;c55&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="n"&gt;aa&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="n"&gt;f6e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="n"&gt;b7c1d4a207&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;412998876940&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;                   &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the reader itself, these still work and are worth keeping in your checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_last_wal_replay_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;pg_last_xact_replay_timestamp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For alerting, &lt;code&gt;AuroraReplicaLag&lt;/code&gt; and &lt;code&gt;AuroraReplicaLagMaximum&lt;/code&gt; in CloudWatch are the metrics to page on. Both are in milliseconds — not the seconds you're used to from &lt;code&gt;pg_stat_replication&lt;/code&gt; lag columns — so set thresholds accordingly or you'll never fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence 5: Aurora Failover and Recovery
&lt;/h2&gt;

&lt;p&gt;With at least one reader present, failover is a promotion plus a cluster-endpoint DNS change. There's no replay of WAL from a checkpoint into a local heap, because storage already holds durable, materializable pages.&lt;/p&gt;

&lt;p&gt;The honest version of the numbers: promotion is fast, and then you wait on DNS TTL, a connection storm from every app pod reconnecting at once, and a cold buffer cache on the new writer serving your worst queries. Failover tiers (0 highest through 15, instance size as tiebreaker) let you control which reader gets promoted. RDS Proxy is the other lever that meaningfully cuts observed downtime, because it absorbs the reconnect stampede.&lt;/p&gt;

&lt;p&gt;A single-instance cluster with no reader doesn't get promotion. Aurora recreates or restarts the writer, which takes considerably longer. If your production cluster is one instance, you don't have HA — you have a good backup story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aurora vs Vanilla PostgreSQL: Key Differences
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Vanilla PostgreSQL&lt;/th&gt;
&lt;th&gt;Aurora PostgreSQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writes to durable storage&lt;/td&gt;
&lt;td&gt;WAL plus dirty data pages&lt;/td&gt;
&lt;td&gt;Redo log records only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkpoints&lt;/td&gt;
&lt;td&gt;Real I/O event, tuned via &lt;code&gt;checkpoint_timeout&lt;/code&gt;, &lt;code&gt;max_wal_size&lt;/code&gt;, &lt;code&gt;checkpoint_completion_target&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Metadata-ish; counters near-flat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;full_page_writes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on&lt;/code&gt;, doubles WAL after each checkpoint&lt;/td&gt;
&lt;td&gt;Storage-managed, not modifiable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replica data&lt;/td&gt;
&lt;td&gt;Own full copy of every file&lt;/td&gt;
&lt;td&gt;Same shared cluster volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replica build&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_basebackup&lt;/code&gt; / base backup transfer&lt;/td&gt;
&lt;td&gt;Provision an instance, minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lag semantics&lt;/td&gt;
&lt;td&gt;WAL replay lag, seconds&lt;/td&gt;
&lt;td&gt;Cache application lag, milliseconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failover&lt;/td&gt;
&lt;td&gt;Manual or Patroni-style promotion + fencing&lt;/td&gt;
&lt;td&gt;Reader promotion + endpoint DNS, tier-controlled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_basebackup&lt;/code&gt;, pgBackRest, &lt;code&gt;archive_command&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Continuous storage-level, PITR to a &lt;strong&gt;new&lt;/strong&gt; cluster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What Still Matters
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;VACUUM, bloat, and transaction ID wraparound behave identically on Aurora PostgreSQL.&lt;/strong&gt; MVCC, dead tuples, table and index bloat, autovacuum tuning, and wraparound are engine-layer concerns, not storage-layer ones. Storage magic does not save you from wraparound. I've seen an Aurora cluster inside 12 million transactions of a forced shutdown, and the storage architecture contributed nothing to the fix. Keep an eye on &lt;code&gt;age(datfrozenxid)&lt;/code&gt; the same way you would on a self-managed box — "managed" doesn't mean "unmonitored."&lt;/p&gt;

&lt;p&gt;Things worth your time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;shared_buffers&lt;/code&gt;. Aurora's default parameter group sets it as a formula tied to &lt;code&gt;DBInstanceClassMemory&lt;/code&gt;, landing around 75%, far above the ~25% heuristic for self-managed. That's deliberate: there's no OS page cache doing useful double-buffering the way there is on a normal box. Don't "fix" it down to 25% out of habit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;work_mem&lt;/code&gt;, per-connection and per-node, same math as always.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_connections&lt;/code&gt; plus a real pooler.&lt;/li&gt;
&lt;li&gt;Autovacuum: &lt;code&gt;autovacuum_vacuum_cost_limit&lt;/code&gt;, &lt;code&gt;autovacuum_max_workers&lt;/code&gt;, per-table thresholds on your hot tables.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;statement_timeout&lt;/code&gt; and &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;. The second one prevents more incidents than any storage feature.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Backups and PITR
&lt;/h2&gt;

&lt;p&gt;Aurora takes continuous automatic backups of the cluster volume and supports point-in-time restore within the retention window. There's no &lt;code&gt;pg_basebackup&lt;/code&gt;, no &lt;code&gt;archive_command&lt;/code&gt;, no pgBackRest against the cluster volume.&lt;/p&gt;

&lt;p&gt;The catch: restore always creates a new DB cluster. There is no restore-in-place. Your RTO includes provisioning a cluster and cutting over connections, which is not thirty seconds.&lt;/p&gt;

&lt;p&gt;Also, PITR doesn't help with the 3 a.m. &lt;code&gt;DELETE&lt;/code&gt; that gets discovered on Tuesday if your retention is short, and it's clumsy for single-table recovery. Keep taking logical dumps of the tables you actually care about, and restore one occasionally. I once inherited a cluster where "managed backups" had been the answer for three years and nobody had ever performed a restore. The restore worked. The application's connection string, secret rotation, and parameter group did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No superuser. The master user gets &lt;code&gt;rds_superuser&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Extensions limited to the AWS allowlist, governed by &lt;code&gt;shared_preload_libraries&lt;/code&gt; and the &lt;code&gt;rds.allowed_extensions&lt;/code&gt; style parameters.&lt;/li&gt;
&lt;li&gt;No filesystem access: &lt;code&gt;COPY FROM PROGRAM&lt;/code&gt; and arbitrary &lt;code&gt;pg_read_file&lt;/code&gt; paths are out.&lt;/li&gt;
&lt;li&gt;Logical replication and pglogical are available with caveats. Physical standbys outside the cluster aren't possible; use logical replication or DMS for cross-system replication.&lt;/li&gt;
&lt;li&gt;Local temp storage is limited on smaller instance classes. Large sorts and hash joins will hit it.&lt;/li&gt;
&lt;li&gt;Storage I/O is billed per request on the standard configuration. A plan regression that flips index lookups to sequential scans costs money as well as latency. Aurora I/O-Optimized removes per-request I/O charges in exchange for higher instance and storage rates; run the arithmetic before assuming either is cheaper.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So Is It Real Postgres?
&lt;/h2&gt;

&lt;p&gt;At the SQL, planner, and MVCC layer, yes. It's the actual PostgreSQL code doing query processing, and your &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; habits transfer intact. At the durability layer, no — and pretending otherwise is how you burn a week on &lt;code&gt;checkpoint_completion_target&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trade is worth it when you want fast reader provisioning, storage that grows on its own, and failover you don't have to build. It's a poor trade when you have a 200 GB database with modest write volume, a working Patroni setup, and someone competent watching it. That workload does fine on a well-tuned RDS instance or your own hardware for a fraction of the bill.&lt;/p&gt;

&lt;p&gt;If it's useful, MyDBA's free health check (&lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=aurora-postgresql-storage-model" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=aurora-postgresql-storage-model&lt;/a&gt;) reads both topologies and won't hand you checkpoint advice on a cluster where checkpoints don't do anything.&lt;/p&gt;




&lt;p&gt;Tags: &lt;code&gt;postgres&lt;/code&gt; &lt;code&gt;aws&lt;/code&gt; &lt;code&gt;database&lt;/code&gt; &lt;code&gt;devops&lt;/code&gt; ## Bringing It Back to the Storage Model&lt;/p&gt;

&lt;p&gt;None of this is an argument for or against Aurora — it's an argument for tuning the layer that's actually yours to tune. The engine-level knobs (vacuum, &lt;code&gt;work_mem&lt;/code&gt;, connection limits, timeouts) do exactly what they've always done, because Aurora didn't touch the engine. The storage-level knobs (checkpoint tuning, &lt;code&gt;full_page_writes&lt;/code&gt;, replica lag interpretation) belong to a layer AWS manages for you now, and pretending otherwise just burns engineering time on parameters that can't move the needle. Know which half of your playbook survived the migration, watch the metrics that actually reflect Aurora's architecture, and don't let a familiar &lt;code&gt;SHOW&lt;/code&gt; command convince you the storage underneath is familiar too.&lt;/p&gt;

&lt;p&gt;pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=aurora-postgresql-storage-model" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=aurora-postgresql-storage-model&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're not sure which of your alerts and dashboards still mean anything on Aurora, run them past &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=aurora-postgresql-storage-model" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; — it's free and it won't waste your time on advice for a layer you don't control.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Postgres Is Down: A 5-Check Triage Tree for Fast Recovery</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Sun, 09 Aug 2026 10:00:10 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/postgres-is-down-a-5-check-triage-tree-for-fast-recovery-43g6</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/postgres-is-down-a-5-check-triage-tree-for-fast-recovery-43g6</guid>
      <description>&lt;p&gt;I have been paged for "postgres is down" maybe two hundred times. Almost every one of those pages resolved into one of five buckets. The fastest recovery I have ever run was not the one where I was cleverest, it was the one where I ran the same checks in the same order and refused to guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/postgres-is-down-triage-runbook?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-is-down-triage-runbook" rel="noopener noreferrer"&gt;Postgres Is Down: A 15-Minute Triage Runbook&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Ok33WKX8GME"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxxbtnfwz9tslnow0g7yi.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxxbtnfwz9tslnow0g7yi.jpg" alt="Postgres Is Down: A 5-Check Triage Tree for Fast Recovery" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So here is the tree. Run it top to bottom. Each check takes under two minutes and eliminates an entire class of failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: the five checks, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Disk.&lt;/strong&gt; Is the PGDATA or pg_wal filesystem full?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connections.&lt;/strong&gt; Are you out of connection slots?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OOM.&lt;/strong&gt; Did the kernel kill the postmaster?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAL and archiving.&lt;/strong&gt; Is a failing archive_command or a stale replication slot pinning WAL?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs and corruption.&lt;/strong&gt; Everything else, read from the server log.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Paste this before you read another word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. state of the world&lt;/span&gt;
systemctl status postgresql&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
pg_isready &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;-p&lt;/span&gt; 5432&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
ps aux | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'[p]ostgres'&lt;/span&gt;

&lt;span class="c"&gt;# 2. disk&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;psql &lt;span class="nt"&gt;-Atc&lt;/span&gt; &lt;span class="s1"&gt;'show data_directory'&lt;/span&gt; 2&amp;amp;gt&lt;span class="p"&gt;;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; /var/lib/postgresql&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/lib/postgresql/&lt;span class="k"&gt;*&lt;/span&gt;/main/pg_wal 2&amp;amp;gt&lt;span class="p"&gt;;&lt;/span&gt;/dev/null

&lt;span class="c"&gt;# 3. oom&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"1 hour ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'out of memory|oom-kill|killed process'&lt;/span&gt;

&lt;span class="c"&gt;# 4. recent server log&lt;/span&gt;
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-100&lt;/span&gt; /var/log/postgresql/postgresql-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;-main&lt;/span&gt;.log 2&amp;amp;gt&lt;span class="p"&gt;;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-100&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;psql &lt;span class="nt"&gt;-Atc&lt;/span&gt; &lt;span class="s1"&gt;'show data_directory'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/log/&lt;span class="k"&gt;*&lt;/span&gt;.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a companion video that walks the first four checks live; this article is the version you can bookmark and grep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you touch anything: the 30-second orientation
&lt;/h2&gt;

&lt;p&gt;You are in exactly one of three states. Figure out which before you do anything else.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Postmaster is dead.&lt;/strong&gt; &lt;code&gt;systemctl status&lt;/code&gt; shows inactive/failed, &lt;code&gt;ps aux | grep postgres&lt;/code&gt; shows nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postmaster is alive but refusing connections.&lt;/strong&gt; Normal during startup/recovery, or something is actively blocking new sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postmaster is crash-looping.&lt;/strong&gt; It starts, dies, restarts, dies again.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;pg_isready &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;-p&lt;/span&gt; 5432
localhost:5432 - no response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pg_isready&lt;/code&gt; distinguishes "accepting connections", "rejecting connections" and "no response". That single line splits the tree. No response means the postmaster is dead or not listening. Rejecting connections means it is alive but in recovery or refusing you. Accepting connections while your app screams means the problem is downstream: connection limits, a lock pileup, or DNS.&lt;/p&gt;

&lt;p&gt;Then check whether it is crash looping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;systemctl status postgresql@16-main &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
   Active: activating &lt;span class="o"&gt;(&lt;/span&gt;auto-restart&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;Result: exit-code&lt;span class="o"&gt;)&lt;/span&gt; since Sat 2026-08-08 03:14:22 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;activating (auto-restart)&lt;/code&gt; in a loop is the tell. The service is starting, PANICking, dying, and systemd is restarting it every few seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not restart it reflexively.&lt;/strong&gt; I know the urge. But if the postmaster is currently alive and in crash recovery, restarting throws away recovery progress and starts it over from the last checkpoint. On a busy cluster with large &lt;code&gt;max_wal_size&lt;/code&gt; that can turn a four-minute recovery into a twelve-minute one, and you will have no idea why. Restarting can also overwrite the exact log lines and WAL state you need to diagnose a disk-full or corruption branch. Let it finish. Watch the log for &lt;code&gt;redo in progress&lt;/code&gt; and &lt;code&gt;consistent recovery state reached&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 1: Disk. Is the data directory full?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /var/lib/postgresql
Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme1n1    500G  500G     0 100% /var/lib/postgresql

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/lib/postgresql/16/main/pg_wal
312G    /var/lib/postgresql/16/main/pg_wal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is your outage. When Postgres cannot write to pg_wal because the filesystem is full, it raises a PANIC and the postmaster shuts down. On restart it tries to write WAL again, fails again, and PANICs again. Crash loop, not corruption. The log looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PANIC:  could not write to file "pg_wal/xlogtemp.2104": No space left on device
LOG:  startup process (PID 2104) was terminated by signal 6: Aborted
LOG:  aborting startup due to startup process failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Work the emergency ladder from safest to most destructive. Stop as soon as the server comes up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 1: delete things that are not Postgres data.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;--vacuum-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100M
find /var/log/postgresql &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.log.[0-9]*'&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +2 &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/lib/postgresql/16/main/pgsql_tmp/&lt;span class="k"&gt;*&lt;/span&gt;   &lt;span class="c"&gt;# only with the server stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rung 2: expand the volume.&lt;/strong&gt; If you are on cloud block storage or LVM, this is a single API call and an online &lt;code&gt;resize2fs&lt;/code&gt; or &lt;code&gt;xfs_growfs&lt;/code&gt; — almost always faster than people think. Do this if you can. It is the only rung with no downside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 3: reclaim the ext4 reserve.&lt;/strong&gt; Ext4 reserves 5% of the filesystem for root by default. On a 500G volume that is 25G of breathing room:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tune2fs &lt;span class="nt"&gt;-m&lt;/span&gt; 1 /dev/nvme1n1     &lt;span class="c"&gt;# drops reserve to 1%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set it back afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 4: neutralise archiving.&lt;/strong&gt; Covered in Check 4, because you need to understand what it costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never delete files from pg_wal by hand.&lt;/strong&gt; Not the oldest ones, not the ones that "look archived", not any of them. WAL segments are removed by the checkpointer when it recycles them, or by &lt;code&gt;pg_archivecleanup&lt;/code&gt; against an &lt;em&gt;archive&lt;/em&gt; directory. Manually removing a segment the server still needs turns a recoverable disk-full into an unrecoverable cluster. I have watched someone do this at 4am and spend the next nine hours on a restore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 2: Connections. Did you hit max_connections?
&lt;/h2&gt;

&lt;p&gt;The symptom in the app log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL:  sorry, too many clients already
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To the application this is indistinguishable from down. The server is fine. It has simply run out of slots.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;max_connections&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        state         | count
----------------------+-------
 idle in transaction  |   287
 idle                 |    94
 active               |    11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can still get in: &lt;code&gt;superuser_reserved_connections&lt;/code&gt; defaults to 3 and holds slots back for superusers. On PG16 and later there is also &lt;code&gt;reserved_connections&lt;/code&gt; plus the &lt;code&gt;pg_use_reserved_connections&lt;/code&gt; role, so you can give a non-superuser break-glass account its own pool. Set that up now, not during the next incident.&lt;/p&gt;

&lt;p&gt;Kill the offenders. &lt;code&gt;pg_terminate_backend&lt;/code&gt; releases the slot; &lt;code&gt;pg_cancel_backend&lt;/code&gt; only cancels the query and leaves the connection sitting there, which does not help you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_terminate_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'idle in transaction'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;state_change&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'5 minutes'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;pg_backend_pid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raising &lt;code&gt;max_connections&lt;/code&gt; is almost always the wrong first move. Every slot costs memory and every additional backend costs you on lock contention and context switching, and you need a restart to apply it. It doesn't fix the leak, it delays the next occurrence and multiplies memory pressure — which walks you straight into Check 3. You will be back here in three weeks with a bigger number and the same problem.&lt;/p&gt;

&lt;p&gt;The durable fix is two settings and one process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;idle_in_transaction_session_timeout = '60s'   # default is 0, disabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then put PgBouncer in transaction pooling mode in front. It multiplexes a few hundred client connections onto twenty or thirty server connections, which is the actual answer to application connection sprawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 3: OOM. Did the kernel kill the postmaster?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"2 hours ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'out of memory'&lt;/span&gt;
kernel: Out of memory: Killed process 1841 &lt;span class="o"&gt;(&lt;/span&gt;postgres&lt;span class="o"&gt;)&lt;/span&gt; total-vm:9124560kB, &lt;span class="se"&gt;\&lt;/span&gt;
  anon-rss:7742108kB, file-rss:0kB, shmem-rss:1048576kB, UID:26 pgtable:16924kB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the server log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LOG:  server process (PID 1841) was terminated by signal 9: Killed
LOG:  terminating any other active server processes
LOG:  all server processes terminated; reinitializing
LOG:  database system is in recovery mode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last sequence is protective behaviour, not corruption. When a backend dies uncleanly the postmaster cannot trust shared memory, so it aborts everything and runs crash recovery. Let it.&lt;/p&gt;

&lt;p&gt;Root causes, in the order I find them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;work_mem times concurrency.&lt;/strong&gt; &lt;code&gt;work_mem&lt;/code&gt; is per sort or hash node, not per connection. One query with three hash joins and a sort can allocate four or five multiples of it. Set 4MB and run 300 connections doing analytics and you have engineered your own OOM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;shared_buffers too high&lt;/strong&gt; relative to RAM, especially with no swap configured to give you a cushion for transient spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overcommit.&lt;/strong&gt; The docs recommend &lt;code&gt;vm.overcommit_memory = 2&lt;/code&gt; with a sane &lt;code&gt;overcommit_ratio&lt;/code&gt; on a dedicated database host, so allocations fail with an honest error instead of the kernel picking a victim later.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; vm.overcommit_memory&lt;span class="o"&gt;=&lt;/span&gt;2
sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; vm.overcommit_ratio&lt;span class="o"&gt;=&lt;/span&gt;80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also protect the postmaster so children die before the parent — losing one bad query is a far better outcome than losing the whole instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/systemd/system/postgresql@.service.d/oom.conf
[Service]
OOMScoreAdjust=-900
Environment=PG_OOM_ADJUST_FILE=/proc/self/oom_score_adj
Environment=PG_OOM_ADJUST_VALUE=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Check 4: WAL and archiving. Is a broken archive_command holding your disk hostage?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;archived_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_archived_wal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_archived_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;failed_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_failed_wal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_failed_time&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_archiver&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; archived_count | last_archived_wal        | failed_count | last_failed_time
----------------+--------------------------+--------------+---------------------------
         418122 | 0000000100000A4C000000B2 |        90441 | 2026-07-19 02:11:07+00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;last_archived_time&lt;/code&gt; from three days ago plus a &lt;code&gt;failed_count&lt;/code&gt; in the tens of thousands tells you the whole story. With &lt;code&gt;archive_mode = on&lt;/code&gt;, Postgres retains every segment that has not been successfully archived. Forever. pg_wal grows until archiving succeeds or you disable it. Common real causes: expired storage credentials, a bucket quota or policy rejection, DNS or network flakiness to the archive target.&lt;/p&gt;

&lt;p&gt;Two other things pin WAL. Check both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wal_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe_wal_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;restart_lsn&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;retained&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An inactive slot from a replica you decommissioned in March will happily hold 400GB of WAL. Drop it with &lt;code&gt;pg_drop_replication_slot('old_replica')&lt;/code&gt; once you are certain nothing needs it. On PG13+, set &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; so a slot can never take the primary down; the slot gets invalidated instead. The third pinner is a base backup that started and never finished, so check for stale &lt;code&gt;pg_basebackup&lt;/code&gt; or pgBackRest processes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The emergency lever, and what it costs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"ALTER SYSTEM SET archive_command = '/bin/true'"&lt;/span&gt;
psql &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT pg_reload_conf()"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;archive_command&lt;/code&gt; takes a reload. (&lt;code&gt;archive_mode&lt;/code&gt; needs a restart, which is why you change the command, not the mode.) The checkpointer will now recycle segments and your disk will drain, often within a minute or two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This breaks PITR continuity.&lt;/strong&gt; Every segment "archived" from that point on is a lie — it's discarded, not stored. Your ability to recover to any point after your last full backup is severed at that moment. The instant the fire is out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fix the real archive target.&lt;/li&gt;
&lt;li&gt;Restore the real &lt;code&gt;archive_command&lt;/code&gt;, reload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Take a fresh full backup.&lt;/strong&gt; Not tomorrow. Now. Your recovery window starts from that backup, not from whenever you think it started.&lt;/li&gt;
&lt;li&gt;Verify &lt;code&gt;pg_stat_archiver.failed_count&lt;/code&gt; stops climbing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The war story
&lt;/h3&gt;

&lt;p&gt;July 2026. Page at 02:40: cluster crash looping, &lt;code&gt;PANIC: could not write to file&lt;/code&gt;. df showed 100%, pg_wal was 312GB on a volume sized for 40. &lt;code&gt;pg_stat_archiver&lt;/code&gt; showed &lt;code&gt;last_archived_time&lt;/code&gt; four days stale and &lt;code&gt;failed_count&lt;/code&gt; past 90,000. First instinct was a broken pgBackRest stanza after a recent upgrade. Wrong. The archive command was fine. The object storage bucket had hit its quota and was returning HTTP 403 on every PUT, and the error was being swallowed into a generic archive failure line. Nobody caught it because the archiver retried quietly and the alert threshold was wrong.&lt;/p&gt;

&lt;p&gt;Neutralised &lt;code&gt;archive_command&lt;/code&gt;, disk drained in about ninety seconds, cluster came up and finished crash recovery. Raised the bucket quota, restored the real command, took a fresh full backup before going back to bed.&lt;/p&gt;

&lt;p&gt;The actual root cause started nineteen days earlier. A missing extension &lt;code&gt;.so&lt;/code&gt; after a package upgrade was causing autovacuum workers to fail cluster-wide. Nothing was being vacuumed. One table bloated to 177GB against maybe 12GB of live data. Bloated tables inflate base backups, and inflated backups filled the bucket, and the full bucket broke archiving, and broken archiving filled the WAL volume. The outage was three weeks old before anyone got paged. Watch your autovacuum failure counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 5: The logs and the scary stuff
&lt;/h2&gt;

&lt;p&gt;On Debian and Ubuntu packages, &lt;code&gt;/var/log/postgresql/&lt;/code&gt;. On RHEL-family, the &lt;code&gt;log/&lt;/code&gt; subdirectory inside PGDATA. If you are not sure, ask the server: &lt;code&gt;SHOW log_directory;&lt;/code&gt; and &lt;code&gt;SHOW logging_collector;&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'PANIC|FATAL|invalid page|checksum|could not'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /var/log/postgresql/postgresql-16-main.log | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crash recovery is normal and can be slow. &lt;code&gt;redo starts at 0/A4C000B2&lt;/code&gt; followed by silence for eight minutes is a healthy server doing its job. Leave it alone.&lt;/p&gt;

&lt;p&gt;These are different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WARNING:  page verification failed, calculated checksum 21847 but expected 9033
ERROR:  invalid page in block 84722 of relation base/16384/24601
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is real damage. Stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Copy PGDATA at the filesystem level before you attempt any repair.&lt;/strong&gt; Stop the server, snapshot the volume or &lt;code&gt;cp -a&lt;/code&gt; the directory somewhere else. Every repair tool below is destructive and one-way. If the repair goes wrong, you want the broken state preserved, not gone.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;zero_damaged_pages&lt;/code&gt; zeroes damaged pages and permanently loses their contents. &lt;code&gt;pg_resetwal&lt;/code&gt; discards WAL and can leave you with silently inconsistent data; the docs are explicit that it is a last resort for a server that will not start, and that you should take a filesystem backup first. Neither of these is a fix. They are a way to get a corpse upright long enough to &lt;code&gt;pg_dump&lt;/code&gt; what survived.&lt;/p&gt;

&lt;p&gt;If you have a tested backup and the damage is more than a page or two, restore. Restoring is boring and predictable. Fixing corruption in place is neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision table: symptom → check → fix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you are staring at&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;First move&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PANIC: could not write to file ... No space left on device&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;df, then the disk ladder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service in &lt;code&gt;activating (auto-restart)&lt;/code&gt; loop&lt;/td&gt;
&lt;td&gt;1 or 4&lt;/td&gt;
&lt;td&gt;df and pg_wal size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Terminate idle-in-transaction, then PgBouncer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FATAL: remaining connection slots are reserved&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Same, connect as superuser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App reports "can't connect" but &lt;code&gt;pg_isready&lt;/code&gt; says accepting&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Check pool exhaustion at app/pooler layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Out of memory: Killed process (postgres)&lt;/code&gt; in dmesg&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;work_mem, overcommit, oom_score_adj&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;terminated by signal 9&lt;/code&gt; in server log&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Confirm with dmesg before assuming corruption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;database system is in recovery mode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3 or 5&lt;/td&gt;
&lt;td&gt;Wait, watch redo progress, do not restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg_wal huge but disk was fine yesterday&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;pg_stat_archiver and pg_replication_slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;archive command failed with exit code 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Test the command by hand as the postgres user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invalid page in block N of relation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Copy PGDATA, then plan a restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_isready&lt;/code&gt; says rejecting connections&lt;/td&gt;
&lt;td&gt;orient&lt;/td&gt;
&lt;td&gt;Read the log, server is alive&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  After the fire: the four things that prevent the repeat
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Forecast disk runway, not disk usage.&lt;/strong&gt; An alert at 85% gives you a number. An alert that says "pg_wal is growing 8GB/hour and you have six hours left" gives you an action — a 30-day trend graph of pg_wal size would have shown the July incident coming a week out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitor the archiver.&lt;/strong&gt; Alert on &lt;code&gt;pg_stat_archiver.failed_count&lt;/code&gt; increasing and on &lt;code&gt;last_archived_time&lt;/code&gt; older than fifteen minutes. That single alert would have caught the July incident on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put a pooler in front.&lt;/strong&gt; Before you need it, not after connection exhaustion becomes an outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the restore.&lt;/strong&gt; A backup you have never restored is a hypothesis. Restore to a scratch host on a schedule, time it, and write the number down so you know your actual RTO.&lt;/p&gt;

&lt;p&gt;If you want the archiver, slot lag and disk runway checks running continuously without building them yourself, that is roughly what we built &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-is-down-triage-runbook" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; to do. But the checks above work fine from a laptop at 3am, and knowing them is the part that matters.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>pgvector in Production: HNSW, Filtering, and Tuning</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Sun, 09 Aug 2026 10:00:09 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/pgvector-in-production-hnsw-filtering-and-tuning-46lj</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/pgvector-in-production-hnsw-filtering-and-tuning-46lj</guid>
      <description>&lt;p&gt;pgvector turns Postgres into a real vector database — no second system, no sync jobs, just an &lt;code&gt;ORDER BY&lt;/code&gt; on a new column type. It scales to tens of millions of rows on a single node, but the defaults, the filtered-search gotchas, and the point where it stops being the right tool all matter more than the "just add an extension" pitch suggests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/pgvector-production-guide?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=pgvector-in-production-guide" rel="noopener noreferrer"&gt;pgvector in Production: What the Quickstart Skips&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/aQV3wqdRMuQ"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;pgvector is a real production vector store for most teams. Tens of millions of rows on one decent node is fine.&lt;/li&gt;
&lt;li&gt;Start with HNSW, and I mean it. IVFFlat only when build time or &lt;code&gt;maintenance_work_mem&lt;/code&gt; forces your hand.&lt;/li&gt;
&lt;li&gt;The index is only used for &lt;code&gt;ORDER BY  ... LIMIT n&lt;/code&gt;, and only when the opclass matches the operator. Mismatch gives you a seq scan with no warning.&lt;/li&gt;
&lt;li&gt;Filtered search is where people get burned: either brute force over a subset, or 3 rows back when you asked for 10.&lt;/li&gt;
&lt;li&gt;Since 0.8.0, iterative index scans fix the over-filtering case. Turn them on deliberately, with the bounds set.&lt;/li&gt;
&lt;li&gt;Measure recall against exact ground truth. Do not eyeball result quality and call it good.&lt;/li&gt;
&lt;li&gt;The ceiling is single-node. There is no built-in sharding of a vector index.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The reason pgvector wins arguments is boring: the embedding sits in the same row as &lt;code&gt;tenant_id&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, and &lt;code&gt;status&lt;/code&gt;. Filtering is a &lt;code&gt;WHERE&lt;/code&gt; clause. No dual write, no sync job, no reconciliation script that someone will have to debug at 3am after a partial failure. I have watched teams bolt on a second datastore before they measured a single query against Postgres, and then spend a quarter keeping two systems consistent.&lt;/p&gt;

&lt;p&gt;There's a companion video that covers this at a whiteboard level (&lt;a href="https://www.youtube.com/@pgdba" rel="noopener noreferrer"&gt;pgdba on YouTube&lt;/a&gt;). This post goes deeper: exact SQL, real &lt;code&gt;EXPLAIN&lt;/code&gt; output, tuning tables, and the edge cases that bite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install, types, and what a vector costs you
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt;   &lt;span class="nb"&gt;int&lt;/span&gt;         &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt;   &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four types exist. &lt;code&gt;vector&lt;/code&gt; (4-byte float), plus &lt;code&gt;halfvec&lt;/code&gt;, &lt;code&gt;sparsevec&lt;/code&gt;, and &lt;code&gt;bit&lt;/code&gt;, all added in 0.7.0. &lt;code&gt;vector&lt;/code&gt; stores up to 16,000 dimensions, but HNSW and IVFFlat indexes on it only support up to 2,000. &lt;code&gt;halfvec&lt;/code&gt; is 2-byte floats, halves storage, and indexes up to 4,000 dimensions. If you're at 3072 dims and want an index, &lt;code&gt;halfvec&lt;/code&gt; is the answer.&lt;/p&gt;

&lt;p&gt;Storage math is simple and worth doing on a napkin before you provision: 4 × dimensions + 8 bytes. At 1536 dims that's &lt;strong&gt;6,152 bytes per row&lt;/strong&gt;. Five million rows is about 30 GB of embedding data alone.&lt;/p&gt;

&lt;p&gt;That data doesn't live where you think:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
                &lt;span class="k"&gt;Table&lt;/span&gt; &lt;span class="nv"&gt;"public.documents"&lt;/span&gt;
   &lt;span class="k"&gt;Column&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt;           &lt;span class="k"&gt;Type&lt;/span&gt;           &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;Storage&lt;/span&gt;
&lt;span class="c1"&gt;------------+--------------------------+----------&lt;/span&gt;
 &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;                   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;plain&lt;/span&gt;
 &lt;span class="n"&gt;tenant_id&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;                  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;plain&lt;/span&gt;
 &lt;span class="n"&gt;content&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;                     &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;extended&lt;/span&gt;
 &lt;span class="n"&gt;embedding&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;external&lt;/span&gt;

&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'documents'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'documents'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;heap&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
&lt;span class="c1"&gt;---------+--------&lt;/span&gt;
 &lt;span class="mi"&gt;412&lt;/span&gt; &lt;span class="n"&gt;MB&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;34&lt;/span&gt; &lt;span class="n"&gt;GB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pgvector sets &lt;code&gt;EXTERNAL&lt;/code&gt; storage on the vector type, so every 6 KB embedding goes out of line into the TOAST relation. &lt;code&gt;pg_relation_size()&lt;/code&gt; on the main table lies to you by a factor of eighty. Always use &lt;code&gt;pg_total_relation_size()&lt;/code&gt; when sizing disk, and watch TOAST growth separately, not just row count in &lt;code&gt;\dt+&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distance operators and opclasses
&lt;/h2&gt;

&lt;p&gt;Get this pair right or your index is decorative.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operator&lt;/th&gt;
&lt;th&gt;Distance&lt;/th&gt;
&lt;th&gt;Opclass&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;lt;-&amp;amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L2 / Euclidean&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vector_l2_ops&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;lt;=&amp;amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cosine&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vector_cosine_ops&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Most common default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;lt;#&amp;amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Negative inner product&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vector_ip_ops&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns negative values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;lt;+&amp;amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L1 / taxicab&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vector_l1_ops&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Added 0.7.0, HNSW only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A &lt;code&gt;vector_cosine_ops&lt;/code&gt; index will not serve a &lt;code&gt;&amp;amp;lt;-&amp;amp;gt;&lt;/code&gt; query. You get a sequential scan, no error, no notice, and a p99 that quietly triples once the table grows. This is the single most common pgvector bug I get called about.&lt;/p&gt;

&lt;p&gt;If your embeddings are unit-normalized (OpenAI's &lt;code&gt;text-embedding-3&lt;/code&gt; models are), cosine distance and negative inner product rank identically, and inner product is cheaper to compute. If that's you, don't just benchmark it as a maybe — build with &lt;code&gt;vector_ip_ops&lt;/code&gt; and query with &lt;code&gt;&amp;amp;lt;#&amp;amp;gt;&lt;/code&gt;. It's free performance with no accuracy cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  HNSW vs IVFFlat: how I choose
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;HNSW&lt;/th&gt;
&lt;th&gt;IVFFlat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Added in&lt;/td&gt;
&lt;td&gt;0.5.0&lt;/td&gt;
&lt;td&gt;0.4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build time&lt;/td&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build memory&lt;/td&gt;
&lt;td&gt;High; falls back to a slower two-pass build if the graph exceeds &lt;code&gt;maintenance_work_mem&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Modest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index size&lt;/td&gt;
&lt;td&gt;Larger&lt;/td&gt;
&lt;td&gt;Smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recall at speed&lt;/td&gt;
&lt;td&gt;Better&lt;/td&gt;
&lt;td&gt;Good, degrades faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build params&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;m&lt;/code&gt; (16), &lt;code&gt;ef_construction&lt;/code&gt; (64)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lists&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query param&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hnsw.ef_search&lt;/code&gt; (40)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ivfflat.probes&lt;/code&gt; (1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Empty table&lt;/td&gt;
&lt;td&gt;Fine, create it before load&lt;/td&gt;
&lt;td&gt;No. Needs representative data for k-means centroids&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data drift&lt;/td&gt;
&lt;td&gt;Tolerates it&lt;/td&gt;
&lt;td&gt;Centroids stale as data shifts; rebuild periodically&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- default choice&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_embedding_hnsw&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ef_construction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- when build time genuinely matters&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_embedding_ivf&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;ivfflat&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pgvector's own sizing guidance for IVFFlat: &lt;code&gt;lists = rows / 1000&lt;/code&gt; up to 1M rows, &lt;code&gt;lists = sqrt(rows)&lt;/code&gt; above that. Start probes at &lt;code&gt;sqrt(lists)&lt;/code&gt;. For 5M rows that's roughly 2,236 lists and 47 probes — do this arithmetic for your actual row count rather than copying a &lt;code&gt;lists&lt;/code&gt; value out of a blog post.&lt;/p&gt;

&lt;p&gt;IVFFlat's centroid requirement also means it can't be built empty and filled incrementally the way HNSW can. If your pipeline bulk-loads first and indexes after, that's a non-issue; if it streams rows in continuously, it's a real workflow constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the build not take all night
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;maintenance_work_mem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'16GB'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;max_parallel_maintenance_workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- 8 workers total&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parallel HNSW builds landed in 0.6.0 and scale reasonably. Bulk load first, then build. Building the index and then inserting 5M rows through it is dramatically slower.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; avoids the write lock but takes two table passes and is meaningfully slower, and it can't run inside a transaction block. On a live system I still use it; on a migration window I don't.&lt;/p&gt;

&lt;p&gt;Watch progress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tuples_done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tuples_total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_progress_create_index&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="n"&gt;phase&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;tuples_done&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;tuples_total&lt;/span&gt;
&lt;span class="c1"&gt;----------------------+-------+-------------+--------------&lt;/span&gt;
 &lt;span class="n"&gt;building&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;3120000&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;      &lt;span class="mi"&gt;5000000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;round&lt;/code&gt; climbs above 0, your graph didn't fit in &lt;code&gt;maintenance_work_mem&lt;/code&gt; and you're in the two-pass path. Cancel, raise it, restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the index is used
&lt;/h2&gt;

&lt;p&gt;The good plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
   &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s1"&gt;'[0.014,-0.221,...]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

 &lt;span class="k"&gt;Limit&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;842&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;061&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="n"&gt;Buffers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2188&lt;/span&gt; &lt;span class="k"&gt;read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;94&lt;/span&gt;
   &lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;idx_embedding_hnsw&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
         &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;839&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;055&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="k"&gt;Order&lt;/span&gt; &lt;span class="k"&gt;By&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s1"&gt;'[0.014,-0.221,...]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="n"&gt;Buffers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2188&lt;/span&gt; &lt;span class="k"&gt;read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;94&lt;/span&gt;
 &lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;238&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the &lt;code&gt;LIMIT&lt;/code&gt;, or swap &lt;code&gt;&amp;amp;lt;=&amp;amp;gt;&lt;/code&gt; for &lt;code&gt;&amp;amp;lt;-&amp;amp;gt;&lt;/code&gt;, and you get this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt; &lt;span class="n"&gt;Sort&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;9412&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;663&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;9680&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;114&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000000&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="n"&gt;Sort&lt;/span&gt; &lt;span class="k"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s1"&gt;'[...]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
   &lt;span class="n"&gt;Sort&lt;/span&gt; &lt;span class="k"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;external&lt;/span&gt; &lt;span class="n"&gt;merge&lt;/span&gt;  &lt;span class="n"&gt;Disk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;412104&lt;/span&gt;&lt;span class="n"&gt;kB&lt;/span&gt;
   &lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="n"&gt;Seq&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;031&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;6119&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;882&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000000&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="n"&gt;Buffers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1204&lt;/span&gt; &lt;span class="k"&gt;read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3821194&lt;/span&gt;
 &lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9844&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;291&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now measure recall instead of trusting it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ef_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'[0.014,-0.221,...]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;ann&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;truth&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;recall_at_10&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ann&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For ground truth, run the &lt;code&gt;truth&lt;/code&gt; half in a separate transaction with &lt;code&gt;SET LOCAL enable_indexscan = off; SET LOCAL enable_bitmapscan = off;&lt;/code&gt; so it brute-forces. Loop over 200 held-out query vectors and average. On a 5M-row corpus I typically see recall@10 around 0.94 at &lt;code&gt;ef_search = 40&lt;/code&gt;, 0.98 at 100, 0.99+ at 200, with latency roughly tripling from the first to the last. Pick your point on that curve with numbers, not vibes.&lt;/p&gt;

&lt;p&gt;One hard rule: &lt;code&gt;hnsw.ef_search&lt;/code&gt; must be at least your &lt;code&gt;LIMIT&lt;/code&gt;, or you won't reliably get the row count you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtered search: the part that bites everyone
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failure mode one, the planner uses the btree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt; &lt;span class="k"&gt;Limit&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;449&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="n"&gt;Sort&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;441&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;444&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="n"&gt;Sort&lt;/span&gt; &lt;span class="k"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s1"&gt;'[...]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
         &lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="n"&gt;Heap&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;418302&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
               &lt;span class="k"&gt;Recheck&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
               &lt;span class="n"&gt;Heap&lt;/span&gt; &lt;span class="n"&gt;Blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exact&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;61044&lt;/span&gt;
               &lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;idx_documents_tenant&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;418302&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exact brute force over the subset. Perfect when the tenant has 800 rows, awful at 400k.&lt;/p&gt;

&lt;p&gt;Failure mode two, post-filtering: the HNSW scan returns its candidates, the &lt;code&gt;WHERE&lt;/code&gt; eliminates most of them, and you get &lt;code&gt;rows=3&lt;/code&gt; back from a &lt;code&gt;LIMIT 10&lt;/code&gt;. No error. Just quietly incomplete results, usually noticed by a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four fixes, ranked
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Iterative scans (0.8.0+)
&lt;/h3&gt;

&lt;p&gt;The index keeps scanning until enough rows survive the filter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'relaxed_order'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_scan_tuples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;-- default 20000&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scan_mem_multiplier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;-- default 1&lt;/span&gt;

&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;ann&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ann&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hnsw.iterative_scan&lt;/code&gt; takes &lt;code&gt;off&lt;/code&gt;, &lt;code&gt;strict_order&lt;/code&gt;, or &lt;code&gt;relaxed_order&lt;/code&gt;. &lt;code&gt;relaxed_order&lt;/code&gt; is faster but can return rows slightly out of distance order, hence the re-sorting CTE. IVFFlat only supports &lt;code&gt;off&lt;/code&gt; and &lt;code&gt;relaxed_order&lt;/code&gt;, bounded by &lt;code&gt;ivfflat.max_probes&lt;/code&gt;. Set &lt;code&gt;max_scan_tuples&lt;/code&gt; higher for very selective filters, or you'll still under-return.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Partial indexes
&lt;/h3&gt;

&lt;p&gt;For known high-traffic filter values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_docs_hnsw_t42&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;True pre-filtering. Cost is one index per value, so reserve it for your five biggest tenants, not ten thousand of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Partitioning
&lt;/h3&gt;

&lt;p&gt;By tenant or time with an HNSW index per partition. Same idea, scalable: the planner prunes partitions before the ANN scan runs, so each partition's index only ever sees its own rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. CTE pre-filter with exact distance
&lt;/h3&gt;

&lt;p&gt;Use when the filter is very selective (a few thousand rows). Skip the index entirely and compute exact distances.&lt;/p&gt;

&lt;p&gt;And the correction I make constantly: you cannot stash the embedding in a btree &lt;code&gt;INCLUDE&lt;/code&gt; column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_tenant_embedding&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt; &lt;span class="mi"&gt;6216&lt;/span&gt; &lt;span class="n"&gt;exceeds&lt;/span&gt; &lt;span class="n"&gt;btree&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="n"&gt;maximum&lt;/span&gt; &lt;span class="mi"&gt;2704&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="nv"&gt;"idx_tenant_embedding"&lt;/span&gt;
&lt;span class="n"&gt;HINT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;Values&lt;/span&gt; &lt;span class="n"&gt;larger&lt;/span&gt; &lt;span class="k"&gt;than&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="n"&gt;cannot&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="n"&gt;indexed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Btree tuples cap at roughly one third of an 8 KB page. 6,152 bytes was never going to fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational reality
&lt;/h2&gt;

&lt;p&gt;Re-generating embeddings is a full row rewrite. Postgres MVCC writes a new row version for any update, even one unrelated to the embedding, and with a 6 KB TOASTed vector that means the TOAST chunks get rewritten too, plus HNSW graph churn absorbing the change. A nightly job that re-embeds 10% of a 5M-row table generates a lot of WAL, which your replicas have to receive and replay. Watch replication lag on the first run.&lt;/p&gt;

&lt;p&gt;If you regenerate embeddings in bulk — new model version, new chunking strategy — expect index bloat and plan a &lt;code&gt;REINDEX CONCURRENTLY&lt;/code&gt; afterward rather than letting autovacuum fight a losing battle. HNSW build work happens on the primary and ships as WAL, so a &lt;code&gt;REINDEX CONCURRENTLY&lt;/code&gt; on a 30 GB index is a replication event, not just a local one. Schedule it accordingly.&lt;/p&gt;

&lt;p&gt;Also worth knowing: pgvector is available as a managed extension on RDS/Aurora, Cloud SQL/AlloyDB, and Azure Database for PostgreSQL. Adopting it rarely means self-hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where pgvector stops being the right answer
&lt;/h2&gt;

&lt;p&gt;There is no distributed vector index. One node, one memory budget, and that budget competes directly with the &lt;code&gt;shared_buffers&lt;/code&gt; your OLTP workload needs. There's no native BM25 or hybrid fusion, and no reranking stage.&lt;/p&gt;

&lt;p&gt;My rule: if you're past roughly 50M vectors at 1536 dims, or you need sub-10ms p99 at hundreds of millions of vectors, or vector work is starving your transactional workload of memory, start looking. Before you leave Postgres entirely, try pgvectorscale, which adds a StreamingDiskANN index for larger-than-memory workloads and keeps the "embedding next to metadata" model. After that, Qdrant, Milvus, and Pinecone exist for exactly this reason. Measure before you migrate — I've watched teams add a second database before they'd run a single recall benchmark on the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-production checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Opclass matches the operator in every query path.&lt;/li&gt;
&lt;li&gt;Every vector query has &lt;code&gt;ORDER BY&lt;/code&gt; and a &lt;code&gt;LIMIT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hnsw.ef_search&lt;/code&gt; &amp;gt;= the largest &lt;code&gt;LIMIT&lt;/code&gt; you issue.&lt;/li&gt;
&lt;li&gt;Recall@10 measured against exact ground truth across at least 100 held-out queries.&lt;/li&gt;
&lt;li&gt;Iterative scan configured, with &lt;code&gt;max_scan_tuples&lt;/code&gt; and &lt;code&gt;scan_mem_multiplier&lt;/code&gt; set explicitly.&lt;/li&gt;
&lt;li&gt;Filtered queries have a plan you've read, not one you assume.&lt;/li&gt;
&lt;li&gt;Partial or partitioned indexes in place for your highest-traffic filter values.&lt;/li&gt;
&lt;li&gt;Index build is scripted, reproducible, and timed on production-sized data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maintenance_work_mem&lt;/code&gt; sized so &lt;code&gt;pg_stat_progress_create_index&lt;/code&gt; never shows &lt;code&gt;round &amp;amp;gt; 0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Monitoring on index size, TOAST growth, &lt;code&gt;pg_total_relation_size&lt;/code&gt;, and vector query p99.&lt;/li&gt;
&lt;li&gt;WAL volume checked after your first bulk re-embedding run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you'd rather have something check the schema side of this for you, &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=pgvector-in-production-guide" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; has a free health check that flags missing or mismatched indexes and prints the exact &lt;code&gt;CREATE INDEX&lt;/code&gt; for your tables.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>PostgreSQL Transaction ID Wraparound: The Slow-Motion Outage You Can See Coming</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Sat, 08 Aug 2026 10:15:04 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/postgresql-transaction-id-wraparound-the-slow-motion-outage-you-can-see-coming-1j7j</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/postgresql-transaction-id-wraparound-the-slow-motion-outage-you-can-see-coming-1j7j</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wraparound is what happens when Postgres's 32-bit transaction ID counter runs out of room and autovacuum hasn't frozen old rows fast enough to make space. It is not a random failure mode — it's the endpoint of weeks of autovacuum falling behind.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;&lt;/code&gt; right now. Anything north of a few hundred million deserves attention; past 1 billion, you need a plan this week.&lt;/li&gt;
&lt;li&gt;Postgres escalates in stages: normal autovacuum, then forced anti-wraparound vacuum at 200 million XIDs from the limit, then failsafe vacuum at 1.6 billion age, then a hard write-block with about 1 million XIDs left.&lt;/li&gt;
&lt;li&gt;The fix is almost never single-user mode. &lt;code&gt;VACUUM&lt;/code&gt; is exempt from the write freeze, so in most cases you vacuum your way out while the app stays up (mostly) online.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7thzbw5ms15dyzj0s9mr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7thzbw5ms15dyzj0s9mr.png" alt="PostgreSQL Transaction ID Wraparound: The Slow-Motion Outage You Can See Coming" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick hit before we start
&lt;/h2&gt;

&lt;p&gt;If you've watched our whiteboard explainer on wraparound, you've got the mental model: a clock that runs out of numbers. This article is the version with the copy-paste SQL, the exact default thresholds, and the recovery nuance the video simplifies — namely that hitting the wall doesn't automatically mean single-user mode. Keep reading if you want the actual queries you'd run at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 32-bit clock: what an XID actually is
&lt;/h2&gt;

&lt;p&gt;Every transaction in Postgres gets a transaction ID — a 32-bit integer, assigned in order, used by MVCC to decide which rows are visible to which transactions. A 32-bit counter gives you roughly 4.2 billion values before it wraps back to 0. That sounds like a lot until you remember every insert, update, delete, and even some reads inside a transaction consumes one. On a busy OLTP system doing a few hundred transactions per second, that's a few years of runway under ideal conditions — and a lot less if things go sideways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's a circle, not a line
&lt;/h2&gt;

&lt;p&gt;Here's the part most people skip past: Postgres doesn't treat XIDs as a simple ascending line. It splits the XID space into a "past half" and a "future half" relative to the current XID, and uses that split to decide whether one transaction happened before or after another. This is what makes wraparound genuinely dangerous rather than just an inconvenient counter reset. If old, unfrozen rows sit around long enough that the counter wraps past them, those rows can suddenly look like they were written in the future from the perspective of current transactions. MVCC visibility logic then treats them as not-yet-visible — and they silently disappear from query results. This is documented plainly in the Postgres manual's section on preventing wraparound failures: it's framed explicitly as a data-loss scenario, not a housekeeping nuisance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres's escalating defenses
&lt;/h2&gt;

&lt;p&gt;Autovacuum is the only thing standing between you and this outcome, and Postgres has built an escalating alarm system on top of it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normal autovacuum&lt;/strong&gt; — runs based on dead tuple thresholds, freezing rows as it goes via &lt;code&gt;vacuum_freeze_min_age&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anti-wraparound vacuum&lt;/strong&gt; — triggered per table once its age exceeds &lt;code&gt;autovacuum_freeze_max_age&lt;/code&gt;, default 200 million. These vacuums can't be canceled by lock conflicts the way ordinary ones can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failsafe vacuum&lt;/strong&gt; — introduced in PG 14, triggered at &lt;code&gt;vacuum_failsafe_age&lt;/code&gt;, default 1.6 billion. This mode disables cost-based delays and skips index vacuuming entirely to race toward freezing tuples before the wall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard stop&lt;/strong&gt; — Postgres emits warnings once you're within about 10 million XIDs of the limit, and refuses to assign new XIDs once you're within roughly 1 million. At that point, new writes are blocked cluster-wide.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is subtle. If autovacuum is keeping pace, you'll never see stage 2, let alone 3 or 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your risk right now
&lt;/h2&gt;

&lt;p&gt;Two queries, thirty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datfrozenxid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_database&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells you, per database, how many XIDs old the freeze horizon is. Danger zone starts getting real around 1.5–2.1 billion — the failsafe kicks in at 1.6 billion, and the hard stop is at roughly 2.1 billion minus a million.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relfrozenxid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'m'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This finds the actual tables dragging the database's age up. Almost always it's one or two large, high-churn tables — not an even distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find what's actually blocking vacuum
&lt;/h2&gt;

&lt;p&gt;Autovacuum age climbing steadily usually means one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idle-in-transaction sessions&lt;/strong&gt; holding an old &lt;code&gt;xmin&lt;/code&gt; open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-running transactions&lt;/strong&gt; — batch jobs, ETL, reporting queries wrapped in &lt;code&gt;BEGIN&lt;/code&gt; and forgotten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abandoned or inactive replication slots&lt;/strong&gt;, which hold back &lt;code&gt;catalog_xmin&lt;/code&gt; even when nothing is consuming them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orphaned prepared (two-phase) transactions&lt;/strong&gt; left by a crashed application or broker that never called &lt;code&gt;COMMIT PREPARED&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check &lt;code&gt;pg_stat_activity&lt;/code&gt; for state = 'idle in transaction' and long &lt;code&gt;xact_start&lt;/code&gt; values, and &lt;code&gt;pg_replication_slots&lt;/code&gt; for slots with no active consumer. Any of these can freeze the effective freeze horizon even while autovacuum looks perfectly healthy in the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when you actually hit the wall
&lt;/h2&gt;

&lt;p&gt;The oversimplified version says "you're stuck in single-user mode." That's mostly wrong. &lt;code&gt;VACUUM&lt;/code&gt;, including a manual &lt;code&gt;VACUUM (FREEZE, VERBOSE)&lt;/code&gt;, is specifically exempt from the wraparound write-block — it needs to run precisely because it's the cure. Most recoveries happen with the database up, reads working, and targeted vacuums running against the worst offenders.&lt;/p&gt;

&lt;p&gt;Single-user mode is genuinely required only in narrower cases: disk full so vacuum can't write, superuser connections exhausted so nobody can even connect to run the fix, or catalog corruption bad enough that normal startup fails. A real incident we reviewed involved a missing PostGIS shared library silently blocking autovacuum cluster-wide for 19 days — the extension load failure meant autovacuum workers kept erroring out on startup, table bloat climbed toward 177 GB on one ingest table, and WAL inflation from the backlog eventually filled the backup bucket. No corruption, no single-user mode needed — just three weeks of nobody noticing the age climbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovery playbook
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run the &lt;code&gt;pg_class&lt;/code&gt; query, sort by age, identify the worst tables.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VACUUM (FREEZE, VERBOSE) tablename;&lt;/code&gt; directly on those tables, largest first.&lt;/li&gt;
&lt;li&gt;Kill idle-in-transaction sessions holding back xmin: &lt;code&gt;SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle in transaction' AND xact_start &amp;amp;lt; now() - interval '1 hour';&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Drop abandoned replication slots you've confirmed are dead.&lt;/li&gt;
&lt;li&gt;Watch &lt;code&gt;pg_stat_progress_vacuum&lt;/code&gt; for live progress on the emergency vacuums.&lt;/li&gt;
&lt;li&gt;Re-run the &lt;code&gt;pg_database&lt;/code&gt; age query every few minutes until the trend reverses.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Preventing the next one
&lt;/h2&gt;

&lt;p&gt;Set an alert on &lt;code&gt;age(datfrozenxid)&lt;/code&gt; well before 1 billion, not at the failsafe threshold. Turn on &lt;code&gt;log_autovacuum_min_duration&lt;/code&gt; so slow or skipped autovacuum runs show up in logs instead of silence. Audit for long-lived transactions and batch jobs that hold connections open across commits. Review replication slots quarterly — a slot with no subscriber is a liability, not a backup plan. If cost-based throttling (&lt;code&gt;autovacuum_vacuum_cost_delay&lt;/code&gt;, &lt;code&gt;autovacuum_vacuum_cost_limit&lt;/code&gt;) is still at conservative defaults on a busy cluster, autovacuum is structurally guaranteed to lose the race eventually.&lt;/p&gt;

&lt;p&gt;If you want a second set of eyes on where your freeze age actually sits, MyDBA's free health check flags this exact metric alongside bloat and replication lag in one pass — worth running before it becomes a page at 2am.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgresql-transaction-id-wraparound-the-slow-motion-outage-you-can-se" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;amp;utm_medium=platform&amp;amp;amp;utm_campaign=postgresql-transaction-id-wraparound-the-slow-motion-outage-you-can-se&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If this piece saved you from finding out about wraparound the hard way, run the two queries above today, and consider pointing MyDBA at your cluster for a standing check on freeze age, bloat, and slot hygiene.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>pg_cancel_backend vs pg_terminate_backend Explained</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Sat, 08 Aug 2026 10:00:14 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/pgcancelbackend-vs-pgterminatebackend-explained-460d</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/pgcancelbackend-vs-pgterminatebackend-explained-460d</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pg_cancel_backend(pid)&lt;/code&gt; sends SIGINT. It kills the running statement; the session stays connected.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_terminate_backend(pid)&lt;/code&gt; sends SIGTERM. It kills the whole backend process and drops the client connection.&lt;/li&gt;
&lt;li&gt;Both are cooperative. They're acted on at &lt;code&gt;CHECK_FOR_INTERRUPTS()&lt;/code&gt; points, so a backend stuck in a blocking syscall will ignore both until it comes up for air.&lt;/li&gt;
&lt;li&gt;The return value means "signal sent", not "query stopped". If the PID isn't a backend you get a warning and &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Rollback in Postgres is cheap because there is no undo log. What it costs you is bloat and the autovacuum work that follows.&lt;/li&gt;
&lt;li&gt;Never &lt;code&gt;kill -9&lt;/code&gt; a backend. Set &lt;code&gt;statement_timeout&lt;/code&gt; instead and stop doing this by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/pg-cancel-backend-vs-pg-terminate-backend?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=pg-cancel-backend-vs-pg-terminate-backend" rel="noopener noreferrer"&gt;pg_cancel_backend vs pg_terminate_backend Explained&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/rC1FjIwU5j8"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzo7xwj65vhmigjmo0lzg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzo7xwj65vhmigjmo0lzg.jpg" alt="pg_cancel_backend vs pg_terminate_backend Explained" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I walked through this whole decision tree with live psql output in a companion video, if you'd rather watch than read: &lt;a href="https://youtube.com/@pgdba" rel="noopener noreferrer"&gt;pgdba on YouTube&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, find the right PID
&lt;/h2&gt;

&lt;p&gt;At 2am the failure mode isn't hesitating. It's killing the wrong session. Here's the triage query I keep in &lt;code&gt;~/.psqlrc&lt;/code&gt; as &lt;code&gt;:runaway&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;xact_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;query_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;query_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocked_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;pg_backend_pid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s1"&gt;'idle'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="n"&gt;NULLS&lt;/span&gt; &lt;span class="k"&gt;LAST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  pid  | usename | application_name |        state        | wait_event_type | wait_event |   xact_age   |  query_age   | blocked_by |                query
-------+---------+------------------+---------------------+-----------------+------------+--------------+--------------+------------+--------------------------------------
 41022 | app_rw  | orders-api       | active              | Lock            | transactio | 00:00:41.2   | 00:00:41.2   | {40988}    | UPDATE orders SET status = 'shipped'
 40988 | app_rw  | psql             | idle in transaction |                 |            | 00:22:07.9   | 00:21:55.1   | {}         | SELECT * FROM orders WHERE id = 91
 41104 | report  | metabase         | active              |                 |            | 00:14:33.0   | 00:14:33.0   | {}         | SELECT o.id, sum(l.qty) FROM orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things that query buys you. &lt;code&gt;backend_type = 'client backend'&lt;/code&gt; filters out walsenders, autovacuum workers and the logical replication launcher, none of which you want to signal casually. &lt;code&gt;pid &amp;amp;lt;&amp;amp;gt; pg_backend_pid()&lt;/code&gt; stops you from cancelling yourself, which is embarrassing but harmless. And &lt;code&gt;pg_blocking_pids(pid)&lt;/code&gt; tells you who the actual culprit is: in that output, 41022 is a victim. The problem is 40988, sitting idle in transaction for 22 minutes.&lt;/p&gt;

&lt;p&gt;One gotcha: &lt;code&gt;pg_stat_activity.query&lt;/code&gt; is truncated to &lt;code&gt;track_activity_query_size&lt;/code&gt; bytes, default 1024. A generated ORM query will look cut off mid-clause. That's the setting, not a bug, and raising it costs shared memory per connection slot.&lt;/p&gt;

&lt;h2&gt;
  
  
  pg_cancel_backend vs pg_terminate_backend: what each does
&lt;/h2&gt;

&lt;p&gt;Both functions do one thing: send a signal and return whether the send succeeded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_cancel_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;41104&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  canceling statement due to user request
SQLSTATE: 57014
report=&amp;amp;gt; SELECT 1;
 ?column?
----------
        1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The session lives. Contrast with terminate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_terminate_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40988&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL:  terminating connection due to administrator command
SQLSTATE: 57P01
server closed the connection unexpectedly
The connection to the server was lost. Attempting reset: Succeeded.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;pg_cancel_backend&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;pg_terminate_backend&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Signal&lt;/td&gt;
&lt;td&gt;SIGINT&lt;/td&gt;
&lt;td&gt;SIGTERM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;current statement&lt;/td&gt;
&lt;td&gt;entire backend process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session survives?&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client sees&lt;/td&gt;
&lt;td&gt;ERROR 57014 &lt;code&gt;query_canceled&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;FATAL 57P01 &lt;code&gt;admin_shutdown&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permission&lt;/td&gt;
&lt;td&gt;superuser, &lt;code&gt;pg_signal_backend&lt;/code&gt;, or member of the owning role&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeout argument&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes, since PG 14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Members of &lt;code&gt;pg_signal_backend&lt;/code&gt; cannot signal a superuser's backend. That bites during incidents when the runaway query is being run by the DBA who went home.&lt;/p&gt;

&lt;p&gt;And in psql, Ctrl+C is not a client-side abort. It sends a cancel request over the protocol, which is exactly &lt;code&gt;pg_cancel_backend&lt;/code&gt; aimed at your own backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The myth I want to kill: "rollback takes as long as the write"
&lt;/h2&gt;

&lt;p&gt;You will read this on Stack Overflow roughly once a week: don't cancel that 40-minute &lt;code&gt;UPDATE&lt;/code&gt;, the rollback will take another 40 minutes replaying it backwards.&lt;/p&gt;

&lt;p&gt;That's Oracle thinking. Postgres has no undo log. Aborting a transaction writes the xid as aborted in &lt;code&gt;pg_xact&lt;/code&gt; and releases its locks. That's it. It doesn't matter whether the transaction touched 12 rows or 120 million; the abort itself is essentially constant time.&lt;/p&gt;

&lt;p&gt;What actually happened is that every row the &lt;code&gt;UPDATE&lt;/code&gt; wrote is now a dead tuple sitting in the heap and the indexes. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You bloated the table and its indexes by the size of the write.&lt;/li&gt;
&lt;li&gt;You handed autovacuum a job it will do later, on its schedule, competing with your production traffic.&lt;/li&gt;
&lt;li&gt;The WAL for all of it was already written and already streamed to your replicas. WAL is generated regardless of eventual commit or abort. Cancelling doesn't un-ship it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So cancel that runaway &lt;code&gt;UPDATE&lt;/code&gt; when you need the locks back. It'll return in seconds. Just know you've traded a long write for a vacuum bill, and postgres cancel query rollback is fast for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  When cancel doesn't work, and why terminate usually won't either
&lt;/h2&gt;

&lt;p&gt;Interrupts are processed at &lt;code&gt;CHECK_FOR_INTERRUPTS()&lt;/code&gt; macros scattered through the executor and utility code. If the backend never reaches one, nothing happens. The real cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Blocked in a syscall against hung storage or a stalled NFS/EBS volume.&lt;/li&gt;
&lt;li&gt;A tight loop inside extension C code that never checks for interrupts. PostGIS geometry work and some FDWs have historically been guilty here.&lt;/li&gt;
&lt;li&gt;A handful of genuine critical sections.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the correction I want to make. A very common claim is that a backend waiting on a heavyweight lock ignores cancel. It doesn't. Lock waits are interruptible latch waits, and &lt;code&gt;pg_cancel_backend&lt;/code&gt; on a session sitting in &lt;code&gt;wait_event_type = 'Lock'&lt;/code&gt; works immediately, every time. Try it yourself with two psql windows and a &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The punchline: if a backend ignored your cancel, escalating to terminate rarely helps, because SIGTERM is checked at the same points. If a session is truly wedged, you're looking at a storage problem, not a signalling problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeout argument (PG 14+)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_terminate_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;41022&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- wait up to 5s for actual exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a non-zero timeout it waits for the process to actually exit and returns &lt;code&gt;true&lt;/code&gt;, or emits a warning and returns &lt;code&gt;false&lt;/code&gt; if the timeout elapsed. That's the difference between "I sent a signal" and "the backend is gone".&lt;/p&gt;

&lt;p&gt;An escalation snippet I use, wrapped in DO for one-shot incident work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
  &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;41022&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;gone&lt;/span&gt;   &lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;PERFORM&lt;/span&gt; &lt;span class="n"&gt;pg_cancel_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;PERFORM&lt;/span&gt; &lt;span class="n"&gt;pg_sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;gone&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pg_terminate_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;gone&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
      &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;WARNING&lt;/span&gt; &lt;span class="s1"&gt;'pid % did not exit; check storage/IO, do NOT kill -9'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The decision tree I actually use
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Runaway read-only SELECT.&lt;/strong&gt; Cancel. Session survives, the app's connection pool doesn't notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Big write you've decided to abandon.&lt;/strong&gt; Cancel. Accept the bloat, then check &lt;code&gt;pg_stat_progress_vacuum&lt;/code&gt; an hour later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle in transaction holding locks.&lt;/strong&gt; Terminate. Cancel is a no-op here: there is no statement to cancel, and it will not roll back the open transaction or release its locks. I've watched people fire cancel three times at an idle-in-transaction session and conclude Postgres is broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocking chain.&lt;/strong&gt; Cancel the root blocker from &lt;code&gt;pg_blocking_pids&lt;/code&gt;, not the victims. Killing victims just makes the app retry into the same wall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not sure how far along it is?&lt;/strong&gt; Check &lt;code&gt;pg_stat_progress_create_index&lt;/code&gt; or &lt;code&gt;pg_stat_progress_copy&lt;/code&gt; first. A &lt;code&gt;CREATE INDEX&lt;/code&gt; at 94% is worth 90 more seconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Edge cases that bite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Concurrent index builds.&lt;/strong&gt; Cancel a &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; or &lt;code&gt;REINDEX CONCURRENTLY&lt;/code&gt; and you get an invalid index: unusable for queries, still maintained on every write. Worst of both worlds. Find and clean them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;indisvalid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;DROP INDEX CONCURRENTLY&lt;/code&gt; each one and restart the build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prepared transactions.&lt;/strong&gt; A transaction that reached &lt;code&gt;PREPARE TRANSACTION&lt;/code&gt; outlives its backend. Terminating the session releases nothing; the locks stay until &lt;code&gt;ROLLBACK PREPARED&lt;/code&gt; or &lt;code&gt;COMMIT PREPARED&lt;/code&gt;. Check &lt;code&gt;pg_prepared_xacts&lt;/code&gt; before you conclude the lock is a ghost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PgBouncer transaction pooling.&lt;/strong&gt; In &lt;code&gt;pool_mode = transaction&lt;/code&gt;, a server connection is leased only for the duration of a transaction. The PID you read from &lt;code&gt;pg_stat_activity&lt;/code&gt; may be serving a completely different client by the time you signal it. Requery immediately before you act, then read fast, act fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autovacuum workers.&lt;/strong&gt; Cancelling one stops that run only; the table gets rescheduled. Habitually killing anti-wraparound vacuums is exactly how clusters end up in wraparound trouble.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry loops.&lt;/strong&gt; Half the apps I've seen will re-issue the identical query within two seconds. Fix the query or add a timeout; killing it is theatre.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never SIGKILL a backend
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;kill -9&lt;/code&gt; on a backend makes the postmaster assume shared memory may be corrupt. It terminates every other backend and forces crash recovery. You took the whole cluster down to stop one report query. There is no situation where this is the right first move. Neither &lt;code&gt;pg_cancel_backend&lt;/code&gt; nor &lt;code&gt;pg_terminate_backend&lt;/code&gt; uses SIGKILL, and that's not an accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The settings that mean you never have to do this by hand
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;SQLSTATE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;statement_timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;cancels the statement&lt;/td&gt;
&lt;td&gt;57014&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lock_timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;aborts statement waiting on a lock&lt;/td&gt;
&lt;td&gt;55P03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;terminates the session&lt;/td&gt;
&lt;td&gt;25P03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;transaction_timeout&lt;/code&gt; (PG 17+)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;terminates the session&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;statement_timeout&lt;/code&gt; does not cover idle time inside an open transaction, which is why &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; exists — the standard fix for a session that shows idle in transaction terminate in monitoring alerts. &lt;code&gt;transaction_timeout&lt;/code&gt; on PG 17 closes the last gap: many short statements interleaved with idle time.&lt;/p&gt;

&lt;p&gt;Scope them per role, not globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_rw&lt;/span&gt;     &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'30s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_rw&lt;/span&gt;     &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'3s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_rw&lt;/span&gt;     &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;idle_in_transaction_session_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'60s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;     &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5min'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;migrations&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;migrations&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;prod&lt;/span&gt;   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;idle_in_transaction_session_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'10min'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- backstop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rough starter values by workload:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OLTP app role: &lt;code&gt;statement_timeout = 5-30s&lt;/code&gt;, &lt;code&gt;lock_timeout = 2-5s&lt;/code&gt;, &lt;code&gt;idle_in_transaction_session_timeout = 1-5min&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reporting/analytics role: &lt;code&gt;statement_timeout = 5-15min&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Migration/maintenance role: leave &lt;code&gt;statement_timeout&lt;/code&gt; unset, but still set &lt;code&gt;lock_timeout&lt;/code&gt; so a migration doesn't sit forever waiting on a lock during business hours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The docs explicitly warn against setting &lt;code&gt;statement_timeout&lt;/code&gt; in &lt;code&gt;postgresql.conf&lt;/code&gt;, and this is why: &lt;code&gt;pg_dump&lt;/code&gt;, a four-hour &lt;code&gt;CREATE INDEX&lt;/code&gt;, and your migration tooling all inherit it. You'll discover this when the nightly backup starts failing at 30 seconds. Role-level settings apply to new sessions; &lt;code&gt;SET LOCAL&lt;/code&gt; inside a transaction overrides them when you genuinely need longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovery conflicts: the standby cancels for you
&lt;/h2&gt;

&lt;p&gt;On a hot standby you'll see cancels you didn't send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  canceling statement due to conflict with recovery
DETAIL:  User query might have needed to see row versions that must be removed.
SQLSTATE: 40001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same mechanism, just triggered by the server itself instead of by you: a query on the standby is holding up WAL replay past &lt;code&gt;max_standby_streaming_delay&lt;/code&gt;. &lt;code&gt;hot_standby_feedback = on&lt;/code&gt; trades that for bloat on the primary, because the standby tells the primary to hold back vacuum on rows it still needs. Pick one deliberately; the default gives you neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- who's the root blocker? (pg_stat_activity long running query check)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;query_start&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'client backend'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="s1"&gt;'idle'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- runaway SELECT / abandoned write -- kill query postgres, session lives&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_cancel_backend&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- idle in transaction, or cancel was ignored&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_terminate_backend&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- never&lt;/span&gt;
&lt;span class="n"&gt;kill&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt; 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather have the long runners and blocking chains handed to you instead of typing that query at 2am, &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=pg-cancel-backend-vs-pg-terminate-backend" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt;'s health check surfaces both with a single ready-to-run command.&lt;/p&gt;

&lt;p&gt;Tags: postgres, database, sql, devops ## Wrapping up&lt;/p&gt;

&lt;p&gt;None of this is exotic — it's a handful of signals, a couple of timeouts, and knowing which one applies to the session in front of you. The mistakes that actually hurt come from skipping the diagnosis step: killing a victim instead of the blocker, cancelling an idle-in-transaction session and expecting locks to release, or reaching for &lt;code&gt;kill -9&lt;/code&gt; because cancel "didn't work fast enough." Set your timeouts per role, keep the triage query handy, and you'll rarely need to make these calls under pressure in the first place.&lt;/p&gt;

&lt;p&gt;pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=pg-cancel-backend-vs-pg-terminate-backend" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;amp;utm_medium=platform&amp;amp;amp;utm_campaign=pg-cancel-backend-vs-pg-terminate-backend&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If 2am triage queries aren't something you want to remember by heart, point &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=pg-cancel-backend-vs-pg-terminate-backend" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; at your cluster and let it surface the blocking chains and long runners for you.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Postgres TOAST Storage: Where Your Big JSONB Really Lives</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Sat, 08 Aug 2026 10:00:13 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/postgres-toast-storage-where-your-big-jsonb-really-lives-45ic</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/postgres-toast-storage-where-your-big-jsonb-really-lives-45ic</guid>
      <description>&lt;p&gt;I got paged once for a "small" table. Twelve million rows, one jsonb column, &lt;code&gt;pg_relation_size&lt;/code&gt; said 2.1GB. The disk said 41GB. The backup job said "still running" at 6am. That table taught me more about TOAST than any doc page ever did, mostly because I was reading the wrong size function for three years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/postgres-toast-storage-bloat-detoast?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-toast-storage-explained" rel="noopener noreferrer"&gt;Postgres TOAST: How It Works, Bloats, and Slows Queries&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the whole thing, with the SQL I actually run.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Postgres pages are 8KB by default (&lt;code&gt;BLCKSZ=8192&lt;/code&gt;), and a heap tuple cannot span pages. Once a row crosses ~2000 bytes, TOAST gets involved.&lt;/li&gt;
&lt;li&gt;TOAST compresses first, then moves values out of line into a hidden &lt;code&gt;pg_toast&lt;/code&gt; table in chunks of roughly 2KB, leaving an 18-byte pointer in the heap.&lt;/li&gt;
&lt;li&gt;Four strategies: PLAIN, EXTENDED, EXTERNAL, MAIN. &lt;code&gt;pg_attribute.attstorage&lt;/code&gt; records them as &lt;code&gt;p&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt;, &lt;code&gt;e&lt;/code&gt;, &lt;code&gt;m&lt;/code&gt;. EXTENDED is the default for text, varchar, jsonb, bytea, arrays.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_relation_size()&lt;/code&gt; excludes the TOAST table. &lt;code&gt;pg_total_relation_size()&lt;/code&gt; includes it. That gap is where 39GB hides.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_column_size()&lt;/code&gt; vs &lt;code&gt;octet_length()&lt;/code&gt; shows you the compression ratio for free.&lt;/li&gt;
&lt;li&gt;TOAST tables live in the &lt;code&gt;pg_toast&lt;/code&gt; schema, so every monitoring query filtered to &lt;code&gt;public&lt;/code&gt; misses their bloat entirely.&lt;/li&gt;
&lt;li&gt;Companion video if you want the visual version: &lt;a href="https://www.youtube.com/watch?v=Eqwf4Yo1R-4" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=Eqwf4Yo1R-4&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsz79jduibrcr9jmg0f1a.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsz79jduibrcr9jmg0f1a.jpg" alt="Postgres TOAST Storage: Where Your Big JSONB Really Lives" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The 8KB wall
&lt;/h2&gt;

&lt;p&gt;Postgres reads and writes in 8KB pages. That is a compile-time constant, and unless you built your own binaries you have 8192. A heap tuple must fit inside one page, no spanning.&lt;/p&gt;

&lt;p&gt;So the practical ceiling for a row before Postgres starts intervening is &lt;code&gt;TOAST_TUPLE_THRESHOLD&lt;/code&gt;, 2000 bytes by default, roughly a quarter of a page. Four fat rows per page is the design target.&lt;/p&gt;

&lt;p&gt;Which raises the obvious question: you have a 50KB JSON document. It does not fit. It does not fit by a factor of six even after compression. Where does it go?&lt;/p&gt;

&lt;h2&gt;
  
  
  What TOAST actually does, in order
&lt;/h2&gt;

&lt;p&gt;TOAST is The Oversized-Attribute Storage Technique. That is the one acronym joke you get from me.&lt;/p&gt;

&lt;p&gt;The algorithm, in the order it runs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the tuple bigger than &lt;code&gt;TOAST_TUPLE_THRESHOLD&lt;/code&gt; (2000 bytes)? If no, stop. Nothing happens.&lt;/li&gt;
&lt;li&gt;Pick the widest TOAST-able attribute in the row.&lt;/li&gt;
&lt;li&gt;If its strategy allows compression (EXTENDED or MAIN), compress it in place.&lt;/li&gt;
&lt;li&gt;If the tuple is still over &lt;code&gt;TOAST_TUPLE_TARGET&lt;/code&gt; (also 2000 by default) and the strategy allows it, move the value out of line into the TOAST table.&lt;/li&gt;
&lt;li&gt;Repeat with the next widest attribute until the tuple fits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Compression happens &lt;strong&gt;first&lt;/strong&gt;. Out-of-line storage is the fallback. Most people I talk to have this backwards and assume any big value goes straight to the TOAST table. A 3KB text field that compresses to 900 bytes stays right there in the heap and never touches TOAST.&lt;/p&gt;

&lt;p&gt;Also: only variable-length (varlena) types are eligible. Your &lt;code&gt;bigint&lt;/code&gt;, &lt;code&gt;timestamptz&lt;/code&gt;, and &lt;code&gt;integer&lt;/code&gt; columns are never TOASTed, no matter how many of them you have.&lt;/p&gt;

&lt;p&gt;When a value does go out of line, it is split into chunks of at most &lt;code&gt;TOAST_MAX_CHUNK_SIZE&lt;/code&gt;, about 2KB, sized so four chunk rows fit per TOAST page. Those chunks land in &lt;code&gt;pg_toast.pg_toast_&lt;/code&gt; with columns &lt;code&gt;chunk_id oid&lt;/code&gt;, &lt;code&gt;chunk_seq int&lt;/code&gt;, &lt;code&gt;chunk_data bytea&lt;/code&gt;, plus a unique index on &lt;code&gt;(chunk_id, chunk_seq)&lt;/code&gt;. What stays in your heap tuple is an 18-byte TOAST pointer holding the total size, compressed size, chunk_id, and the TOAST relation OID.&lt;/p&gt;

&lt;p&gt;Maximum size of a single TOASTed value is 1GB, same as any varlena. If you are near that, we need a different conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four storage strategies
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;&lt;code&gt;attstorage&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Compress?&lt;/th&gt;
&lt;th&gt;Out-of-line?&lt;/th&gt;
&lt;th&gt;Default for&lt;/th&gt;
&lt;th&gt;When I reach for it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PLAIN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;fixed-length types&lt;/td&gt;
&lt;td&gt;Never manually. It errors if the value can't fit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EXTENDED&lt;/td&gt;
&lt;td&gt;&lt;code&gt;x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;text, varchar, jsonb, bytea, arrays&lt;/td&gt;
&lt;td&gt;The default, and correct 90% of the time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EXTERNAL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes-ish (no)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;Big text/bytea where you do &lt;code&gt;substring()&lt;/code&gt; or prefix &lt;code&gt;LIKE&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MAIN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;last resort&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;Values that compress well and are read on every query.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;EXTERNAL exists for a real reason. With compression off, Postgres can fetch only the chunks it needs to satisfy a &lt;code&gt;substring()&lt;/code&gt; on a long text or bytea value instead of pulling and decompressing the entire datum. If you store 200KB documents and routinely read the first 500 bytes, EXTERNAL is a genuine win. It costs you disk. What it will not do is help jsonb key extraction — &lt;code&gt;-&amp;amp;gt;&amp;amp;gt;&lt;/code&gt; has no partial-fetch path regardless of storage strategy, so don't bother flipping a jsonb column to EXTERNAL expecting cheaper key lookups.&lt;/p&gt;

&lt;p&gt;The video keeps this simple; here is the exact nuance: EXTERNAL does not "disable TOAST," it disables compression while still allowing out-of-line storage. MAIN does not "keep it in the heap forever" either. MAIN still goes out of line if the row cannot be made to fit any other way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look at your own database
&lt;/h2&gt;

&lt;p&gt;Per-column strategies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;format_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;atttypid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;atttypmod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attstorage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attstorage&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;'p'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'plain'&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;'x'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'extended'&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;'e'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'external'&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;'m'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'main'&lt;/span&gt;
       &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_attribute&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attrelid&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attnum&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attisdropped&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attnum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  attname  |           type           | attstorage | strategy
-----------+--------------------------+------------+----------
 id        | bigint                   | p          | plain
 status    | text                     | x          | extended
 metadata  | jsonb                    | x          | extended
 created_at| timestamp with time zone | p          | plain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compression in action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;octet_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;raw_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_column_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;on_disk_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pg_column_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;octet_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;88123&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; raw_bytes | on_disk_bytes | pct
-----------+---------------+------
     51234 |          9418 | 18.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is my 50KB document. It compressed to 9,418 bytes, still way over 2000, so it went out of line. Five chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;chunk_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_toast_16419&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;chunk_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; chunk_id | chunks | bytes
----------+--------+-------
    20117 |      5 |  9418
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five index lookups plus five heap fetches, every time something evaluates that column.&lt;/p&gt;

&lt;h2&gt;
  
  
  How big is the TOAST table really
&lt;/h2&gt;

&lt;p&gt;This is the single most useful query in this article. Bookmark it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_indexes_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;               &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;toast_idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reltoastrelid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; relname  |  heap   |  toast  | toast_idx |  total
----------+---------+---------+-----------+---------
 orders   | 2141 MB | 36 GB   | 2380 MB   | 41 GB
 events   | 1802 MB | 118 MB  | 9648 kB   | 2014 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reltoastrelid&lt;/code&gt; is 0 when a table has no TOAST table at all — usually one made entirely of fixed-length columns. If your dashboards graph &lt;code&gt;pg_relation_size&lt;/code&gt;, you are graphing 5% of that table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your jsonb reads got slower
&lt;/h2&gt;

&lt;p&gt;Detoasting is lazy. &lt;code&gt;SELECT id FROM orders&lt;/code&gt; does not touch a single TOAST chunk, because nothing ever evaluates &lt;code&gt;metadata&lt;/code&gt;. I hear the opposite claim constantly and it is wrong: selecting other columns from a wide table pays nothing for the TOASTed one. Same reason &lt;code&gt;COUNT(*)&lt;/code&gt; on a 41GB table is fast.&lt;/p&gt;

&lt;p&gt;But the moment you apply any operator to the value, Postgres fetches and decompresses the whole datum. &lt;code&gt;metadata-&amp;amp;gt;&amp;amp;gt;'status'&lt;/code&gt; does not do a partial read of one key. There is no partial-detoast path for &lt;code&gt;-&amp;amp;gt;&lt;/code&gt; or &lt;code&gt;-&amp;amp;gt;&amp;amp;gt;&lt;/code&gt;. You asked for 12 bytes and paid for 9,418 bytes across five chunk fetches plus decompression, per row.&lt;/p&gt;

&lt;p&gt;That is the difference between a 40ms scan and a 9-second one, and it will not show up as anything obvious in &lt;code&gt;EXPLAIN&lt;/code&gt; output beyond mysteriously high runtime.&lt;/p&gt;

&lt;p&gt;The escape hatch is an expression index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_status_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a predicate on that key is answered from the index without detoasting every document at scan time. If the query only needs that key, an index-only scan skips the heap and TOAST entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your jsonb updates got expensive
&lt;/h2&gt;

&lt;p&gt;Second myth, more damaging than the first: "every UPDATE rewrites the TOAST value." No. If an UPDATE does not modify the TOASTed column, Postgres copies the existing TOAST pointer into the new row version. Eighteen bytes, not nine kilobytes. Updating &lt;code&gt;status&lt;/code&gt; on a row with a 50KB document is cheap.&lt;/p&gt;

&lt;p&gt;Modify the column and everything changes. There's no diffing and no partial chunk update — the new value is compressed from scratch, chunked from scratch, and written as fresh rows in the TOAST table. The old chunks become dead and sit there until vacuum. Change one key in a 50KB document and you have written 9KB of new chunks and orphaned 9KB of old ones.&lt;/p&gt;

&lt;p&gt;Consequence: a high-churn jsonb column bloats its TOAST table faster than anything else in your database. And because the toasted column changed, you cannot get a HOT update, so every index on the table takes a new entry too. If you're updating a jsonb column on every request just to bump one field — a &lt;code&gt;last_seen&lt;/code&gt; timestamp, a status flag — pull that field into its own narrow column. You are otherwise paying full-document recompression on every request to move one value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning knobs I actually use
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;raw_payload&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;STORAGE&lt;/span&gt; &lt;span class="k"&gt;EXTERNAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Critical caveat that bites everyone: this only affects rows inserted or updated &lt;strong&gt;after&lt;/strong&gt; the change. Existing rows keep their current physical representation. To apply it retroactively you need a rewrite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;VACUUM&lt;/span&gt; &lt;span class="k"&gt;FULL&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                              &lt;span class="c1"&gt;-- takes ACCESS EXCLUSIVE&lt;/span&gt;
&lt;span class="c1"&gt;-- or a no-op type change, also a full rewrite:&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;raw_payload&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both take heavy locks. Plan the window, or use &lt;code&gt;pg_repack&lt;/code&gt; if you cannot.&lt;/p&gt;

&lt;p&gt;Compression method: since PG14, &lt;code&gt;default_toast_compression = lz4&lt;/code&gt; if your build has &lt;code&gt;--with-lz4&lt;/code&gt;. I switch to lz4 on anything write-heavy. Lower ratio than pglz, considerably faster both directions. The method is recorded per value, so old pglz values and new lz4 values coexist happily in the same column. No rewrite required to start benefiting.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;toast_tuple_target&lt;/code&gt; (128 bytes up to &lt;code&gt;TOAST_TUPLE_THRESHOLD&lt;/code&gt;) lets you push values out of line more aggressively per table. I have used this exactly twice, both times on tables where the wide column was almost never read and I wanted the heap dense for sequential scans.&lt;/p&gt;

&lt;p&gt;And the option nobody wants to hear: if the blob is a 2MB PDF, put it in object storage and keep a URL in Postgres. TOAST is good engineering, but it is not a file server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bloat trap nobody watches
&lt;/h2&gt;

&lt;p&gt;TOAST tables are autovacuumed as part of the parent's autovacuum. They also accept independent settings via &lt;code&gt;toast.autovacuum_*&lt;/code&gt; storage parameters on the parent. And they show up in &lt;code&gt;pg_stat_all_tables&lt;/code&gt; under the &lt;code&gt;pg_toast&lt;/code&gt; schema, which is exactly why the monitoring query you inherited (&lt;code&gt;WHERE schemaname = 'public'&lt;/code&gt;) has never once reported them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_dead_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_autovacuum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;autovacuum_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_all_tables&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pg_toast'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     relname     | n_dead_tup |        last_autovacuum        | count | size
-----------------+------------+-------------------------------+-------+-------
 pg_toast_16419  |   48211903 | 2026-06-19 04:11:02.338+00     |    12 | 36 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven weeks since the last autovacuum on the table that holds 88% of the cluster's data. That is the incident. Autovacuum was starved by cost limits and a long-running replication slot holding back the horizon, TOAST bloat grew unbounded, base backups doubled in wall time and size, WAL archive volume climbed, and the disk alert fired at 3am on the standby first because it had the smaller volume.&lt;/p&gt;

&lt;p&gt;Tune it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;autovacuum_vacuum_scale_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;autovacuum_vacuum_cost_limit&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want this kind of thing surfaced without writing the queries yourself, &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-toast-storage-explained" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; covers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for wide-column tables
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;I always look at &lt;code&gt;pg_total_relation_size&lt;/code&gt;, never &lt;code&gt;pg_relation_size&lt;/code&gt;, when sizing anything with a text or jsonb column.&lt;/li&gt;
&lt;li&gt;I add &lt;code&gt;pg_stat_all_tables&lt;/code&gt; rows from the &lt;code&gt;pg_toast&lt;/code&gt; schema to bloat monitoring on day one.&lt;/li&gt;
&lt;li&gt;I never assume &lt;code&gt;SELECT id&lt;/code&gt; is expensive on a wide table. It is not.&lt;/li&gt;
&lt;li&gt;I do assume &lt;code&gt;metadata-&amp;amp;gt;&amp;amp;gt;'k'&lt;/code&gt; in a WHERE clause on a large table is a full detoast per row, and I add an expression index.&lt;/li&gt;
&lt;li&gt;I do not split a jsonb column into a side table for read performance before checking whether the queries touch it at all.&lt;/li&gt;
&lt;li&gt;If I'm updating one small field on every request, I pull it out of the wide jsonb column into its own column rather than rewriting the whole document.&lt;/li&gt;
&lt;li&gt;I set &lt;code&gt;toast.autovacuum_vacuum_scale_factor&lt;/code&gt; explicitly on any table with a high-churn large column. The default 0.2 on a 36GB TOAST table is absurd.&lt;/li&gt;
&lt;li&gt;I switch &lt;code&gt;default_toast_compression&lt;/code&gt; to lz4 on write-heavy clusters and leave read-mostly archives on pglz.&lt;/li&gt;
&lt;li&gt;When I change SET STORAGE, I schedule the rewrite in the same maintenance window, or I write down that I did not and why.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL docs, &lt;a href="https://www.postgresql.org/docs/current/storage-toast.html" rel="noopener noreferrer"&gt;Database Physical Storage: TOAST&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL docs, &lt;a href="https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS" rel="noopener noreferrer"&gt;CREATE TABLE storage parameters&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL docs, &lt;a href="https://www.postgresql.org/docs/current/sql-altertable.html" rel="noopener noreferrer"&gt;ALTER TABLE ... SET STORAGE&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Companion video: &lt;a href="https://www.youtube.com/watch?v=Eqwf4Yo1R-4" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=Eqwf4Yo1R-4&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 3am lesson, distilled
&lt;/h2&gt;

&lt;p&gt;TOAST is not an edge case you'll hit someday — it's the default fate of any text or jsonb column past 2000 bytes, and the gap between &lt;code&gt;pg_relation_size&lt;/code&gt; and reality is where most storage surprises live. Read the pointer arithmetic once, and jsonb bloat, mystery-slow queries, and starved autovacuum jobs stop being mysterious. Check &lt;code&gt;pg_total_relation_size&lt;/code&gt;, watch the &lt;code&gt;pg_toast&lt;/code&gt; schema, and index the keys you actually query instead of detoasting the whole document every time.&lt;/p&gt;

&lt;p&gt;pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-toast-storage-explained" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;amp;utm_medium=platform&amp;amp;amp;utm_campaign=postgres-toast-storage-explained&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you'd rather not write these queries by hand every incident, point &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-toast-storage-explained" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; at your cluster and let it flag TOAST bloat and dead tuples before they page you.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Postgres Row-Level Security: 5 Policies That Actually Work</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Fri, 07 Aug 2026 10:00:28 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/postgres-row-level-security-5-policies-that-actually-work-42be</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/postgres-row-level-security-5-policies-that-actually-work-42be</guid>
      <description>&lt;p&gt;I've been called into the same incident twice: a multi-tenant app where "we have RLS" turned out to mean RLS was enabled and the application connected as the table owner. Zero policies ran. Both times the fix was one &lt;code&gt;ALTER TABLE&lt;/code&gt; away, and both times nobody had a test that would have caught it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/postgres-row-level-security-guide?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-row-level-security-multi-tenant" rel="noopener noreferrer"&gt;Postgres Row-Level Security: Policies That Actually Work&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Gz3X6bkzIHc"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0k3p3yeo4jk25fba1t0u.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0k3p3yeo4jk25fba1t0u.jpg" alt="Postgres Row-Level Security: 5 Policies That Actually Work" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Postgres row-level security is a small rulebook. Deny by default once enabled. Permissive policies OR together, restrictive ones AND. &lt;code&gt;USING&lt;/code&gt; filters what you can see, &lt;code&gt;WITH CHECK&lt;/code&gt; validates what you write. And three roles walk straight past all of it: superusers, anything with &lt;code&gt;BYPASSRLS&lt;/code&gt;, and the table owner unless you force it.&lt;/p&gt;

&lt;p&gt;I walked through these five policies on the whiteboard in the companion video; this post is the copy-paste version with the edge cases the video didn't have room for.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt; means deny by default. No policy, no rows, no error.&lt;/li&gt;
&lt;li&gt;The table owner bypasses its own policies until you add &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Superuser and &lt;code&gt;BYPASSRLS&lt;/code&gt; always bypass. &lt;code&gt;FORCE&lt;/code&gt; does not override that.&lt;/li&gt;
&lt;li&gt;Permissive policies combine with OR; restrictive with AND. Restrictive policies alone grant nothing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;USING&lt;/code&gt; applies to existing rows (SELECT/UPDATE/DELETE), &lt;code&gt;WITH CHECK&lt;/code&gt; to new row contents (INSERT/UPDATE). Omit &lt;code&gt;WITH CHECK&lt;/code&gt; on UPDATE and the &lt;code&gt;USING&lt;/code&gt; expression gets applied to the new row too — that's a fallback, not a guarantee you should rely on.&lt;/li&gt;
&lt;li&gt;RLS sits on top of GRANTs, not instead of them.&lt;/li&gt;
&lt;li&gt;Index the columns your policies filter on, and &lt;code&gt;EXPLAIN&lt;/code&gt; as the app role, because as superuser the quals vanish.&lt;/li&gt;
&lt;li&gt;GUC-based tenancy (&lt;code&gt;current_setting('app.tenant_id')&lt;/code&gt;) stops application bugs. It does not stop someone who can run arbitrary SQL on that connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 90 seconds of setup that decide whether RLS does anything
&lt;/h2&gt;

&lt;p&gt;Roles first. One role owns the schema, a different role runs the app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_owner&lt;/span&gt; &lt;span class="n"&gt;NOLOGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'redacted'&lt;/span&gt; &lt;span class="n"&gt;NOBYPASSRLS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'redacted'&lt;/span&gt; &lt;span class="n"&gt;NOBYPASSRLS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt;   &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;customer&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;deleted_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;OWNER&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_owner&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;SEQUENCE&lt;/span&gt; &lt;span class="n"&gt;orders_id_seq&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;GRANT&lt;/code&gt; line matters. RLS narrows what a privilege reaches; it never hands out a privilege. A role with no &lt;code&gt;SELECT&lt;/code&gt; grant gets a permission error regardless of how generous your policies are.&lt;/p&gt;

&lt;p&gt;Now the switch, and the second switch people forget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt;  &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ENABLE&lt;/code&gt; turns policies on for everyone except the exempt roles. &lt;code&gt;FORCE&lt;/code&gt; adds the table owner to the list of roles that must obey. If your migration tool, your background jobs, or your app connect as the owner, &lt;code&gt;ENABLE&lt;/code&gt; alone buys you nothing.&lt;/p&gt;

&lt;p&gt;Sanity check, run it as the role your app actually connects with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;rolsuper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rolbypassrls&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; rolsuper | rolbypassrls
----------+--------------
 f        | f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;f&lt;/code&gt; values or you are not testing RLS, you are testing your own optimism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres RLS multi-tenant isolation on one shared table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt;      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;true&lt;/code&gt; second argument to &lt;code&gt;current_setting&lt;/code&gt; is doing real work. Without it, an unset &lt;code&gt;app.tenant_id&lt;/code&gt; raises an error. With it, you get NULL, the comparison yields NULL, and no rows qualify. Fails closed, which is what you want when a connection escapes the pooler without setup.&lt;/p&gt;

&lt;p&gt;Usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'42'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SET LOCAL&lt;/code&gt; scopes the value to the transaction. Under PgBouncer transaction pooling, a plain &lt;code&gt;SET&lt;/code&gt; sticks to the server connection after your transaction ends and the next client inherits it. I have watched tenant 7 read tenant 3's dashboard because of exactly one missing &lt;code&gt;LOCAL&lt;/code&gt;. Wire the &lt;code&gt;SET LOCAL&lt;/code&gt; into your pool checkout or your ORM's transaction hook, never into "connection init".&lt;/p&gt;

&lt;p&gt;Now the honest part. &lt;code&gt;app.tenant_id&lt;/code&gt; is a customized option, and any session can change it with a plain &lt;code&gt;SET&lt;/code&gt;. If an attacker reaches arbitrary SQL execution on that connection, they set the GUC to 1 and read tenant 1. This design defends against forgotten &lt;code&gt;WHERE&lt;/code&gt; clauses, a misconfigured ORM scope, a raw SQL report someone pasted in. Against SQL injection it does nothing at all.&lt;/p&gt;

&lt;p&gt;If you need the stronger version, two options. Set the tenant from the pooler or a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; login function that derives it from a signed token, so the value never comes from client-controlled SQL. Or issue one database role per tenant and write the policy against &lt;code&gt;current_user&lt;/code&gt;, which the session cannot change without the password.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-user rows, and the WITH CHECK you forgot
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;OWNER&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_owner&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;notes_owner&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt;      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the failure mode I've seen twice in code review — someone writes only &lt;code&gt;USING&lt;/code&gt; and skips &lt;code&gt;WITH CHECK&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;notes_owner_broken&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For UPDATE, if &lt;code&gt;WITH CHECK&lt;/code&gt; is omitted, Postgres reuses the &lt;code&gt;USING&lt;/code&gt; expression against the &lt;em&gt;new&lt;/em&gt; row. That sounds safe until you notice what it actually verifies: the row you started with, not the row you're leaving behind. I can see my row, and I can update it to say &lt;code&gt;owner = 'alice'&lt;/code&gt;, giving away a row I no longer control. Don't rely on the fallback as your actual defense — write &lt;code&gt;WITH CHECK&lt;/code&gt; explicitly whenever ownership is a mutable column.&lt;/p&gt;

&lt;p&gt;With the correct policy above, the violation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app=&amp;gt; UPDATE notes SET owner = 'alice' WHERE id = 9;
ERROR:  new row violates row-level security policy for table "notes"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLSTATE 42501, &lt;code&gt;insufficient_privilege&lt;/code&gt;. Same error you get on a cross-tenant insert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app=&amp;gt; BEGIN; SET LOCAL app.tenant_id = '42';
app=&amp;gt; INSERT INTO orders (tenant_id, customer, amount_cents) VALUES (43, 'acme', 1000);
ERROR:  new row violates row-level security policy for table "orders"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SELECT&lt;/th&gt;
&lt;th&gt;INSERT&lt;/th&gt;
&lt;th&gt;UPDATE&lt;/th&gt;
&lt;th&gt;DELETE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;USING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;filters visible rows&lt;/td&gt;
&lt;td&gt;not used&lt;/td&gt;
&lt;td&gt;picks which rows may be updated&lt;/td&gt;
&lt;td&gt;picks which rows may be deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WITH CHECK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not used&lt;/td&gt;
&lt;td&gt;validates the new row&lt;/td&gt;
&lt;td&gt;validates the resulting row (falls back to &lt;code&gt;USING&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;not used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PERMISSIVE&lt;/td&gt;
&lt;td&gt;OR'd with other permissive policies&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RESTRICTIVE&lt;/td&gt;
&lt;td&gt;AND'd on top of the permissive result&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Admin override, because permissive policies OR
&lt;/h2&gt;

&lt;p&gt;Never edit the tenant policy to carve out exceptions. Add a second one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;support_read_all&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A policy with no &lt;code&gt;TO&lt;/code&gt; clause applies to PUBLIC. Scoping to &lt;code&gt;TO support&lt;/code&gt; means the support role gets &lt;code&gt;tenant_isolation OR support_read_all&lt;/code&gt;, which is &lt;code&gt;true&lt;/code&gt;, while the app role is unaffected. If you'd rather not name roles in the policy, &lt;code&gt;USING (pg_has_role(current_user, 'support', 'member'))&lt;/code&gt; gets you the same effect through group membership.&lt;/p&gt;

&lt;p&gt;Check your work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permissive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   policyname    | permissive | roles     |  cmd   | qual
-----------------+------------+-----------+--------+---------------------------------
 tenant_isolation| PERMISSIVE | {public}  | ALL    | (tenant_id = (current_setting(...
 support_read_all| PERMISSIVE | {support} | SELECT | true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;\d+ orders&lt;/code&gt; shows the same thing in psql if you prefer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Team rows via a membership table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;doc_membership&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;doc_members&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;
  &lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one reads well and plans badly if you skip the index. As the app role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on documents  (cost=0.00..48210.00 rows=3333 width=64)
                       (actual time=0.4..612.9 rows=41 loops=1)
  Filter: (SubPlan 1)
  Rows Removed by Filter: 99959
  Buffers: shared hit=812 read=41520
  SubPlan 1
    -&amp;gt;  Seq Scan on doc_members m ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the index the subquery actually wants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;doc_members&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subplan turns into an index-only lookup and the runtime drops off a cliff. When the join stays hot at scale, the escape hatch is denormalization: carry a &lt;code&gt;team_id&lt;/code&gt; on &lt;code&gt;documents&lt;/code&gt; and write the policy against that column, keeping the membership table for the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restrictive policies for soft delete and an append-only log
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;hide_deleted&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;RESTRICTIVE&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restrictive policies AND with everything else, so no future permissive policy, including one added at 2am during an incident, can expose tombstoned rows. Remember the rule: a table with only restrictive policies grants nobody anything, because access still requires at least one permissive policy to pass.&lt;/p&gt;

&lt;p&gt;Write-only audit log, same idea from the other direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;audit_append_only&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No SELECT/UPDATE/DELETE policy means the app role can write and never read back. One warning: there is no TRUNCATE policy in RLS at all. Any role holding the TRUNCATE privilege empties the table regardless. Don't grant it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres RLS performance: what it actually costs
&lt;/h2&gt;

&lt;p&gt;Three real costs, in the order they bite.&lt;/p&gt;

&lt;p&gt;The invisible predicate needs an index. Every query against &lt;code&gt;orders&lt;/code&gt; now carries &lt;code&gt;tenant_id = ...&lt;/code&gt;, so your indexes should lead with &lt;code&gt;tenant_id&lt;/code&gt;: &lt;code&gt;(tenant_id, created_at)&lt;/code&gt;, not &lt;code&gt;(created_at)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Policy expressions are security-barrier conditions. Any user-supplied condition using a non-leakproof function or operator is evaluated only after the policy quals, which can block an index or pushdown the planner would otherwise use. Plans get worse in ways that look mysterious until you remember this.&lt;/p&gt;

&lt;p&gt;The GUC lookup is a per-row function call. Wrap it in a scalar subquery so it becomes a one-time InitPlan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt;      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Index Scan using orders_tenant_created_idx on orders
  Index Cond: (tenant_id = $0)
  InitPlan 1 (returns $0)
    -&amp;gt;  Result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; as the app role. Run it as superuser and the quals disappear, and you tune a query that never runs in production. This is also where a second set of eyes helps — I've had a MyDBA review catch a missing composite index on a policy column before it turned into a slow-query page at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases that bite
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A plain view over an RLS table executes with the view owner's permissions, so the owner's policies apply, not the caller's. PostgreSQL 15 added &lt;code&gt;CREATE VIEW ... WITH (security_invoker = true)&lt;/code&gt; to flip that.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions run as the function owner. If the owner bypasses RLS, so does the function. Useful on purpose, dangerous by accident.&lt;/li&gt;
&lt;li&gt;Foreign key and unique constraint checks always bypass row security. A duplicate-key error can confirm a row exists that the querying role cannot see.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COPY orders TO&lt;/code&gt; applies SELECT policies for a non-exempt role, so an "export everything" script quietly exports a subset.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_dump&lt;/code&gt; sets &lt;code&gt;row_security = off&lt;/code&gt; and errors out if the dumping role cannot bypass RLS. Passing &lt;code&gt;--enable-row-security&lt;/code&gt; makes the dump succeed and contain only visible rows, which is an incomplete backup. Document which role takes backups.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;row_security = off&lt;/code&gt;, a query that needs a policy errors rather than silently returning fewer rows. That's a feature for batch jobs.&lt;/li&gt;
&lt;li&gt;Policies are per-table. A policy on the partitioned parent does not protect a partition queried directly by name, and the same goes for inheritance children.&lt;/li&gt;
&lt;li&gt;Logical replication operates at the WAL level and ignores RLS entirely — a publication built off an RLS-protected table replicates every row to the subscriber, policies or not. Don't assume a replica inherits your row security.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;RLS landed in 9.5 and &lt;code&gt;AS RESTRICTIVE&lt;/code&gt; in 10, so all of this works on every supported major version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-flight checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;App role: &lt;code&gt;rolsuper = f&lt;/code&gt;, &lt;code&gt;rolbypassrls = f&lt;/code&gt;, verified in production.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt; on every table the owner role touches.&lt;/li&gt;
&lt;li&gt;At least one permissive policy for every command the app issues.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET LOCAL&lt;/code&gt; wired into the pooler or transaction hook, never &lt;code&gt;SET&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Composite indexes led by the policy column.&lt;/li&gt;
&lt;li&gt;A CI test asserting tenant B sees zero of tenant A's rows.&lt;/li&gt;
&lt;li&gt;Backup role documented, with its RLS exemption explicit.&lt;/li&gt;
&lt;li&gt;Migration role documented (it usually needs to bypass; know that it does).&lt;/li&gt;
&lt;li&gt;No TRUNCATE grant on RLS-protected tables.&lt;/li&gt;
&lt;li&gt;Views audited for &lt;code&gt;security_invoker&lt;/code&gt;, functions audited for &lt;code&gt;SECURITY DEFINER&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Test it or it isn't real
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tenant1_ok&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;no_leak_ok&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'tenant1-acme'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;SAVEPOINT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;-- expect: ERROR: new row violates row-level security policy for table "orders"&lt;/span&gt;
  &lt;span class="k"&gt;ROLLBACK&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;SAVEPOINT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;RESET&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it in CI on every migration. The failure mode I keep meeting: a policy that was fine on the day it shipped, then quietly stopped applying six months later when someone changed the connection role and nobody re-ran this test.&lt;/p&gt;




&lt;p&gt;Tags: &lt;code&gt;postgres&lt;/code&gt; &lt;code&gt;database&lt;/code&gt; &lt;code&gt;sql&lt;/code&gt; &lt;code&gt;security&lt;/code&gt; ## Wrapping up&lt;/p&gt;

&lt;p&gt;Row-level security earns its keep the moment you stop treating it as a checkbox and start treating it as code: it needs the same review, the same indexes, and the same tests as anything else that decides who sees what. The five policies above cover almost every shape of multi-tenant, per-user, and append-only access I've run into, and the edge cases are the ones that turn "we have RLS" into an incident report. Enable it, force it, index it, and — most importantly — write the CI test that fails loudly when someone quietly breaks it later.&lt;/p&gt;

&lt;p&gt;If you want a second set of eyes on whether your policies are actually doing what you think, checks like the ones in &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-row-level-security-multi-tenant" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; are a fast way to catch a missing &lt;code&gt;FORCE&lt;/code&gt;, an unindexed policy column, or a role that shouldn't have &lt;code&gt;BYPASSRLS&lt;/code&gt; before it becomes a postmortem.&lt;/p&gt;

&lt;p&gt;pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-row-level-security-multi-tenant" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-row-level-security-multi-tenant&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If this saved you a debugging session, give your RLS setup a quick audit with &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-row-level-security-multi-tenant" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; — it's a lot cheaper than the incident.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Postgres Full-Text Search: The Setup You Should Ship</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Fri, 07 Aug 2026 10:00:18 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/postgres-full-text-search-the-setup-you-should-ship-3635</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/postgres-full-text-search-the-setup-you-should-ship-3635</guid>
      <description>&lt;p&gt;Postgres full-text search works out of the box for most apps, and you probably don't need Elasticsearch to get good results. I've built app search on Postgres four times and torn it out for Elasticsearch exactly once. That one time was justified — the product grew a facet-heavy search UI that Postgres was never meant to serve. That distinction matters more than row count, and it's the whole thesis of this article. The other three times, moving would have cost a team a cluster, a sync pipeline, and a permanent class of "the search index is stale" bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/postgres-full-text-search-tsvector-gin?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-full-text-search-guide" rel="noopener noreferrer"&gt;Postgres Full-Text Search: tsvector, GIN, and Real Limits&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/AtVpxSG80Ko"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnww8jqg4ogugyujs0nr4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnww8jqg4ogugyujs0nr4.jpg" alt="Postgres Full-Text Search: The Setup You Should Ship" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The companion video walks the mental model at a whiteboard pace: &lt;a href="https://www.youtube.com/@pgdba" rel="noopener noreferrer"&gt;watch it here&lt;/a&gt;. This article is the copy-paste version with the edge cases that bite in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Store a &lt;code&gt;tsvector&lt;/code&gt; in a &lt;strong&gt;stored generated column&lt;/strong&gt;, index it with &lt;strong&gt;GIN&lt;/strong&gt;. That's the default shape.&lt;/li&gt;
&lt;li&gt;Parse user input with &lt;strong&gt;&lt;code&gt;websearch_to_tsquery&lt;/code&gt;&lt;/strong&gt;. I default to it and have never regretted it.&lt;/li&gt;
&lt;li&gt;Order with &lt;strong&gt;&lt;code&gt;ts_rank_cd&lt;/code&gt;&lt;/strong&gt;, blended with recency or popularity. Raw rank alone is a weak signal.&lt;/li&gt;
&lt;li&gt;Snippet with &lt;strong&gt;&lt;code&gt;ts_headline&lt;/code&gt;&lt;/strong&gt;, applied only to the page you're about to render.&lt;/li&gt;
&lt;li&gt;Typos and accents are &lt;strong&gt;&lt;code&gt;pg_trgm&lt;/code&gt;&lt;/strong&gt; plus &lt;strong&gt;&lt;code&gt;unaccent&lt;/code&gt;&lt;/strong&gt;, not core FTS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're under roughly ten million documents in one language, you probably don't need Elasticsearch. Treat that number as a smell test, not a law — I've seen twenty million rows behave fine on decent hardware, and I've seen three million rows struggle because of pathological &lt;code&gt;ts_rank_cd&lt;/code&gt; over huge documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The primitives, in 90 seconds
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tsvector&lt;/code&gt; is a sorted list of distinct normalized lexemes, optionally carrying positions and weight labels. &lt;code&gt;tsquery&lt;/code&gt; holds search terms joined by &lt;code&gt;&amp;amp;amp;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt;, &lt;code&gt;!&lt;/code&gt;, and the phrase operator &lt;code&gt;&amp;amp;lt;-&amp;amp;gt;&lt;/code&gt;. The &lt;code&gt;@@&lt;/code&gt; operator asks whether one matches the other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'The quick brown foxes jumped over the lazy dogs'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                        to_tsvector
------------------------------------------------------------
 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
(1 row)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what happened. &lt;code&gt;the&lt;/code&gt; and &lt;code&gt;over&lt;/code&gt; vanished as stop words. &lt;code&gt;foxes&lt;/code&gt; became &lt;code&gt;fox&lt;/code&gt;, &lt;code&gt;jumped&lt;/code&gt; became &lt;code&gt;jump&lt;/code&gt;, &lt;code&gt;lazy&lt;/code&gt; became &lt;code&gt;lazi&lt;/code&gt;. The integers are token positions in the original stream, which is why position 1 is missing.&lt;/p&gt;

&lt;p&gt;That stemming is why a search for &lt;code&gt;runs&lt;/code&gt; finds a document containing &lt;code&gt;running&lt;/code&gt;. It's dictionary normalization at index time, not a &lt;code&gt;LIKE '%run%'&lt;/code&gt; scan pretending to be search. And those position integers aren't decoration — they're what makes phrase search and &lt;code&gt;ts_rank_cd&lt;/code&gt; possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  tsquery syntax: &amp;amp; | ! &amp;lt;-&amp;gt; and prefixes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index &amp;amp;amp; !gist'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bitmap &amp;amp;lt;-&amp;amp;gt; heap'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index &amp;amp;lt;2&amp;amp;gt; scan'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'run:*'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;`&lt;code&gt;means "N tokens apart."&lt;/code&gt;run:*&lt;code&gt;is a prefix match. All useful. All dangerous if you wire them to a text box, because&lt;/code&gt;to_tsquery` demands syntactically valid input and throws a syntax error on anything else:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`sql&lt;br&gt;
SELECT to_tsquery('english', 'fast "index scan" or bitmap -gist');&lt;br&gt;
ERROR:  syntax error in tsquery: "fast "index scan" or bitmap -gist"&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Here's the same input through the three safe parsers:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`sql&lt;br&gt;
SELECT plainto_tsquery('english',    'fast "index scan" or bitmap -gist') AS plain,&lt;br&gt;
       phraseto_tsquery('english',   'fast "index scan" or bitmap -gist') AS phrase,&lt;br&gt;
       websearch_to_tsquery('english','fast "index scan" or bitmap -gist') AS websearch \gx&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`&lt;br&gt;
-[ RECORD 1 ]------------------------------------------------------&lt;br&gt;
plain     | 'fast' &amp;amp;amp; 'index' &amp;amp;amp; 'scan' &amp;amp;amp; 'bitmap' &amp;amp;amp; 'gist'&lt;br&gt;
phrase    | 'fast' &amp;amp;lt;-&amp;amp;gt; 'index' &amp;amp;lt;-&amp;amp;gt; 'scan' &amp;amp;lt;2&amp;amp;gt; 'bitmap' &amp;amp;lt;-&amp;amp;gt; 'gist'&lt;br&gt;
websearch | 'fast' &amp;amp;amp; 'index' &amp;amp;lt;-&amp;amp;gt; 'scan' | 'bitmap' &amp;amp;amp; !'gist'&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;plainto_tsquery&lt;/code&gt; ANDs everything. &lt;code&gt;phraseto_tsquery&lt;/code&gt; forces one rigid phrase (the &lt;code&gt;&amp;amp;lt;2&amp;amp;gt;&lt;/code&gt; is the stop word &lt;code&gt;or&lt;/code&gt; leaving a positional hole). &lt;code&gt;websearch_to_tsquery&lt;/code&gt; honors quotes, &lt;code&gt;or&lt;/code&gt;, and a leading minus — exactly what users already expect from a search box. It landed in PostgreSQL 11. Use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it fast: generated column + GIN index
&lt;/h2&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;sql&lt;br&gt;
CREATE TABLE article (&lt;br&gt;
  id          bigserial PRIMARY KEY,&lt;br&gt;
  title       text NOT NULL,&lt;br&gt;
  body        text NOT NULL,&lt;br&gt;
  published_at timestamptz NOT NULL DEFAULT now(),&lt;br&gt;
  search_vec  tsvector GENERATED ALWAYS AS (&lt;br&gt;
      setweight(to_tsvector('english', coalesce(title, '')), 'A') ||&lt;br&gt;
      setweight(to_tsvector('english', coalesce(body,  '')), 'B')&lt;br&gt;
  ) STORED&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;CREATE INDEX article_search_vec_gin ON article USING GIN (search_vec);&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Read that generated expression carefully, because this is the most common mistake I see: &lt;strong&gt;you must pass the configuration as a literal&lt;/strong&gt;. The one-argument &lt;code&gt;to_tsvector(text)&lt;/code&gt; is only STABLE, since it reads the &lt;code&gt;default_text_search_config&lt;/code&gt; GUC. Generated columns and expression indexes require IMMUTABLE. Write &lt;code&gt;to_tsvector('english', body)&lt;/code&gt; or Postgres rejects the DDL.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setweight&lt;/code&gt; labels every lexeme A/B/C/D. Title gets A, body gets B, and the ranking functions weight them &lt;code&gt;{D, C, B, A}&lt;/code&gt; = &lt;code&gt;{0.1, 0.2, 0.4, 1.0}&lt;/code&gt; by default — a title hit ends up outweighing a body hit by 2.5x in &lt;code&gt;ts_rank_cd&lt;/code&gt;. Also note &lt;code&gt;coalesce&lt;/code&gt;: a NULL anywhere in the expression nulls the whole vector, and NULL never matches &lt;code&gt;@@&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Stored generated columns arrived in PG12. On PG11, or when you need per-row language (a &lt;code&gt;lang&lt;/code&gt; column feeding a &lt;code&gt;regconfig&lt;/code&gt;), you still need the old &lt;code&gt;BEFORE INSERT OR UPDATE&lt;/code&gt; trigger calling &lt;code&gt;tsvector_update_trigger&lt;/code&gt;. It works fine. It's just more moving parts.&lt;/p&gt;

&lt;p&gt;GIN is the right index type here. It builds around three times slower than GiST and it's bigger, but it searches faster and suits read-heavy data — which is what a search table is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the GIN index is used
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;`sql&lt;br&gt;
EXPLAIN (ANALYZE, BUFFERS)&lt;br&gt;
SELECT id, title&lt;br&gt;
FROM article&lt;br&gt;
WHERE search_vec @@ websearch_to_tsquery('english', 'bitmap heap scan');&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`&lt;br&gt;
 Bitmap Heap Scan on article  (cost=44.29..1893.55 rows=489 width=42)&lt;br&gt;
                              (actual time=0.612..3.887 rows=471 loops=1)&lt;br&gt;
   Recheck Cond: (search_vec @@ websearch_to_tsquery('english'::text, 'bitmap heap scan'::text))&lt;br&gt;
   Heap Blocks: exact=433&lt;br&gt;
   Buffers: shared hit=449&lt;br&gt;
   -&amp;amp;gt;  Bitmap Index Scan on article_search_vec_gin  (cost=0.00..44.17 rows=489 width=0)&lt;br&gt;
                                                    (actual time=0.531..0.531 rows=471 loops=1)&lt;br&gt;
         Index Cond: (search_vec @@ websearch_to_tsquery('english'::text, 'bitmap heap scan'::text))&lt;br&gt;
         Buffers: shared hit=16&lt;br&gt;
 Planning Time: 0.214 ms&lt;br&gt;
 Execution Time: 4.019 ms&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;A GIN scan always produces a bitmap, so you get &lt;code&gt;Bitmap Index Scan&lt;/code&gt; feeding &lt;code&gt;Bitmap Heap Scan&lt;/code&gt;. The &lt;code&gt;Recheck Cond&lt;/code&gt; line isn't a failure. GIN hands back candidate rows and the heap confirms them; if the bitmap goes lossy it degrades to page granularity and rechecks every tuple on those pages. Skip the GIN index entirely and you get a &lt;code&gt;Seq Scan&lt;/code&gt; that scales linearly with table size and gets ugly fast.&lt;/p&gt;

&lt;p&gt;One gotcha for freshly loaded tables: GIN's fastupdate buffers new entries in a pending list sized by &lt;code&gt;gin_pending_list_limit&lt;/code&gt;. Until vacuum or a limit overflow flushes it, scans have to read that unsorted list and timings look erratic. Run &lt;code&gt;VACUUM ANALYZE&lt;/code&gt; after a bulk load before you benchmark anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranking with ts_rank and ts_rank_cd
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;`sql&lt;br&gt;
SELECT id, title,&lt;br&gt;
       ts_rank_cd('{0.1, 0.2, 0.4, 1.0}', search_vec, q, 32) AS rank&lt;br&gt;
FROM article, websearch_to_tsquery('english', '"index scan" or bitmap') q&lt;br&gt;
WHERE search_vec @@ q&lt;br&gt;
ORDER BY rank DESC, published_at DESC&lt;br&gt;
LIMIT 10;&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ts_rank&lt;/code&gt; scores on term frequency. &lt;code&gt;ts_rank_cd&lt;/code&gt; does cover density, which accounts for how close the matched lexemes sit, and therefore needs the positional data in your vector. I use &lt;code&gt;ts_rank_cd&lt;/code&gt; for prose and &lt;code&gt;ts_rank&lt;/code&gt; for short catalog fields.&lt;/p&gt;

&lt;p&gt;That trailing &lt;code&gt;32&lt;/code&gt; is the normalization bitmask: &lt;code&gt;rank/(rank+1)&lt;/code&gt;, squashing scores into 0..1 so I can blend them. &lt;code&gt;1&lt;/code&gt; divides by &lt;code&gt;1 + log(length)&lt;/code&gt;, &lt;code&gt;2&lt;/code&gt; by raw length. Pick a normalization on purpose or long documents win everything.&lt;/p&gt;

&lt;p&gt;Raw rank is a weak relevance signal on its own. Real ordering looks more like &lt;code&gt;ORDER BY (0.7 * rank + 0.3 * popularity_score) DESC&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cost you must know: ranking touches the &lt;code&gt;tsvector&lt;/code&gt; of every matching row, so &lt;code&gt;ORDER BY rank LIMIT 10&lt;/code&gt; on a query matching 400k rows ranks 400k rows before truncating. Narrow the match set first with filters, or look at the &lt;a href="https://github.com/postgrespro/rum" rel="noopener noreferrer"&gt;RUM extension&lt;/a&gt;, which stores positions inside the index and can return ranked results without sorting the whole match set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Highlighting with ts_headline
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;`sql&lt;br&gt;
WITH hits AS (&lt;br&gt;
  SELECT id, title, body,&lt;br&gt;
         ts_rank_cd(search_vec, q, 32) AS rank&lt;br&gt;
  FROM article, websearch_to_tsquery('english', 'bitmap heap scan') q&lt;br&gt;
  WHERE search_vec @@ q&lt;br&gt;
  ORDER BY rank DESC&lt;br&gt;
  LIMIT 10&lt;br&gt;
)&lt;br&gt;
SELECT id, title, rank,&lt;br&gt;
       ts_headline('english', body,&lt;br&gt;
                   websearch_to_tsquery('english', 'bitmap heap scan'),&lt;br&gt;
                   'StartSel=, StopSel=, MaxWords=35, MinWords=15,&lt;br&gt;
                    MaxFragments=2, FragmentDelimiter= … ') AS snippet&lt;br&gt;
FROM hits;&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The CTE exists for one reason. &lt;code&gt;ts_headline&lt;/code&gt; re-parses the original document text and cannot use the index at all. The docs say plainly to use it only on the rows you're displaying. Apply it to an unbounded match set and you'll parse a million bodies to render ten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typos, autocomplete, and pg_trgm fuzzy search
&lt;/h2&gt;

&lt;p&gt;Core full-text search has no fuzzy matching inside &lt;code&gt;tsquery&lt;/code&gt;. None. If a user types "fxo" instead of "fox," &lt;code&gt;websearch_to_tsquery&lt;/code&gt; won't save you. That's &lt;code&gt;pg_trgm&lt;/code&gt;'s job:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;sql&lt;br&gt;
CREATE EXTENSION IF NOT EXISTS pg_trgm;&lt;br&gt;
CREATE INDEX article_title_trgm ON article USING GIN (title gin_trgm_ops);&lt;/p&gt;

&lt;p&gt;SELECT title, similarity(title, 'postgers vaccum') AS sim&lt;br&gt;
FROM article&lt;br&gt;
WHERE title % 'postgers vaccum'&lt;br&gt;
ORDER BY sim DESC LIMIT 5;&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Trigrams work on raw text independently of your text search config, which makes them a clean fallback: run FTS, and if it returns nothing, retry with &lt;code&gt;%&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For accents, &lt;code&gt;unaccent&lt;/code&gt; strips diacritics, but &lt;code&gt;unaccent(text)&lt;/code&gt; is STABLE because it depends on the default dictionary. To index it you need a wrapper:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`sql&lt;br&gt;
CREATE FUNCTION f_unaccent(text) RETURNS text&lt;br&gt;
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT AS&lt;br&gt;
$$ SELECT public.unaccent('public.unaccent', $1) $$;&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Autocomplete: prefix &lt;code&gt;tsquery&lt;/code&gt; (&lt;code&gt;'foo:*'&lt;/code&gt;) is cheaper for a single leading token; trigram similarity handles mid-word and misspelled prefixes, since it doesn't care about word boundaries the way FTS prefix matching does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres vs Elasticsearch: the honest line
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Postgres FTS&lt;/th&gt;
&lt;th&gt;Elasticsearch / OpenSearch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Relevance tuning&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;setweight&lt;/code&gt; A–D, normalization bitmask, hand-blended SQL&lt;/td&gt;
&lt;td&gt;BM25 by default, per-field boosts, iterated by non-DBAs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typo tolerance&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_trgm&lt;/code&gt; bolted alongside, not inside &lt;code&gt;tsquery&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Fuzzy queries built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facets / aggregations&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GROUP BY&lt;/code&gt; over the match set, fine until it isn't&lt;/td&gt;
&lt;td&gt;Aggregations designed for it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale&lt;/td&gt;
&lt;td&gt;Single-digit millions is comfortable&lt;/td&gt;
&lt;td&gt;Horizontal sharding across nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ops burden&lt;/td&gt;
&lt;td&gt;Zero new infrastructure&lt;/td&gt;
&lt;td&gt;A cluster, a sync pipeline, its own on-call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consistency with source data&lt;/td&gt;
&lt;td&gt;Same transaction, always current&lt;/td&gt;
&lt;td&gt;Eventually consistent by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The breaking point is feature demand, not row count. Dozens of language analyzers, index-time A/B testing of analyzers, search-as-you-type at massive scale, product managers who want to tune boosts weekly — that's when you move.&lt;/p&gt;

&lt;p&gt;Middle ground before you run two datastores: ParadeDB's &lt;code&gt;pg_search&lt;/code&gt; embeds a Tantivy BM25 index inside Postgres, ZomboDB bridges to Elasticsearch from SQL, and logical decoding into OpenSearch is the well-trodden replication path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas nobody mentions until 2am
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GIN bloat.&lt;/strong&gt; &lt;code&gt;REINDEX INDEX CONCURRENTLY article_search_vec_gin;&lt;/code&gt; (PG12+) rebuilds without blocking writes. Schedule it proactively on high-churn tables rather than waiting for symptoms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size and WAL.&lt;/strong&gt; A stored &lt;code&gt;tsvector&lt;/code&gt; roughly doubles a text-heavy table and is replicated like any other column. Expect more disk and more replication lag under load; your standbys will notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config changes are migrations.&lt;/strong&gt; Switching &lt;code&gt;'english'&lt;/code&gt; to &lt;code&gt;'simple'&lt;/code&gt; means regenerating every vector and rebuilding the index. There's no in-place migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maintenance_work_mem&lt;/code&gt;&lt;/strong&gt; dominates GIN build time. Raise it in the session doing the build or a multi-million-row build will crawl.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 MB limit&lt;/strong&gt; on a &lt;code&gt;tsvector&lt;/code&gt;, with 16383 as the maximum position. Truncate huge documents deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;default_text_search_config&lt;/code&gt; is a session setting.&lt;/strong&gt; Which is precisely why your generated column hardcodes &lt;code&gt;'english'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The copy-paste starter
&lt;/h2&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;sql&lt;br&gt;
CREATE EXTENSION IF NOT EXISTS pg_trgm;&lt;/p&gt;

&lt;p&gt;CREATE TABLE article (&lt;br&gt;
  id           bigserial PRIMARY KEY,&lt;br&gt;
  title        text NOT NULL,&lt;br&gt;
  body         text NOT NULL,&lt;br&gt;
  published_at timestamptz NOT NULL DEFAULT now(),&lt;br&gt;
  search_vec   tsvector GENERATED ALWAYS AS (&lt;br&gt;
      setweight(to_tsvector('english', coalesce(title, '')), 'A') ||&lt;br&gt;
      setweight(to_tsvector('english', coalesce(body,  '')), 'B')&lt;br&gt;
  ) STORED&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;CREATE INDEX article_search_vec_gin ON article USING GIN (search_vec);&lt;br&gt;
CREATE INDEX article_title_trgm     ON article USING GIN (title gin_trgm_ops);&lt;/p&gt;

&lt;p&gt;INSERT INTO article (title, body) VALUES&lt;br&gt;
 ('Bitmap heap scans explained', 'A GIN index scan builds a bitmap, then the heap rechecks each candidate row.'),&lt;br&gt;
 ('Tuning maintenance_work_mem', 'Raising maintenance_work_mem substantially reduces GIN index build time.'),&lt;br&gt;
 ('Phrase search with tsquery',  'Positional information enables the &amp;lt;-&amp;gt; operator and cover density ranking.');&lt;/p&gt;

&lt;p&gt;VACUUM ANALYZE article;&lt;/p&gt;

&lt;p&gt;WITH q AS (SELECT websearch_to_tsquery('english', 'gin "index scan"') AS tsq),&lt;br&gt;
hits AS (&lt;br&gt;
  SELECT a.id, a.title, a.body,&lt;br&gt;
         ts_rank_cd(a.search_vec, q.tsq, 32) AS rank&lt;br&gt;
  FROM article a, q&lt;br&gt;
  WHERE a.search_vec @@ q.tsq&lt;br&gt;
  ORDER BY rank DESC, a.published_at DESC&lt;br&gt;
  LIMIT 10&lt;br&gt;
)&lt;br&gt;
SELECT h.id, h.title, round(h.rank::numeric, 4) AS rank,&lt;br&gt;
       ts_headline('english', h.body, q.tsq,&lt;br&gt;
                   'StartSel=, StopSel=, MaxWords=30, MinWords=10') AS snippet&lt;br&gt;
FROM hits h, q;&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Then run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; on the inner query and confirm you see &lt;code&gt;Bitmap Index Scan on article_search_vec_gin&lt;/code&gt;. If you see a Seq Scan, your &lt;code&gt;tsquery&lt;/code&gt; config literal doesn't match the column's, or the table is too small for the planner to care.&lt;/p&gt;

&lt;p&gt;That's the whole thing. Ship it, watch p95, and revisit when a product requirement — not a row count — forces your hand.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tags: postgres, database, sql, performance&lt;/em&gt; ## Keep an eye on it after you ship&lt;/p&gt;

&lt;p&gt;The starter above gets you a correct, fast implementation on day one, but full-text search tends to degrade quietly. GIN indexes bloat under heavy update churn, &lt;code&gt;search_vec&lt;/code&gt; recalculates on every write to &lt;code&gt;title&lt;/code&gt; or &lt;code&gt;body&lt;/code&gt;, and nobody notices until a dashboard shows p95 creeping from 4ms to 400ms over a few months. Whatever you use to watch autovacuum lag, index bloat, and slow queries on the rest of your database should be watching this table too — it's not a special case, it's just another write-heavy index that needs the same attention as your primary keys. I run &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-full-text-search-guide" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; alongside the apps I build this way specifically because bloat and autovacuum drift are the two things that silently wreck FTS performance, and catching them before they show up in query latency is a lot cheaper than debugging a "why did search get slow" ticket three months from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship the boring version first
&lt;/h2&gt;

&lt;p&gt;Postgres full-text search isn't a compromise you make until you can afford Elasticsearch — for the overwhelming majority of apps, it's the correct final answer. You get relevance ranking, phrase search, typo tolerance via &lt;code&gt;pg_trgm&lt;/code&gt;, and results that are always transactionally consistent with the row you just wrote, all without a second datastore, a sync pipeline, or a new class of on-call pages. Reach for something heavier only when a real product requirement demands it: deep facets, dozens of language analyzers, or search-as-you-type at a scale Postgres genuinely can't hold. Until then, the setup in this article is the version worth shipping.&lt;/p&gt;

&lt;p&gt;pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-full-text-search-guide" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;amp;utm_medium=platform&amp;amp;amp;utm_campaign=postgres-full-text-search-guide&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're running Postgres in production, it's worth five minutes to point &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-full-text-search-guide" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; at it and see what your indexes and autovacuum settings are actually doing.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Postgres Wait Events: Stop Guessing, Start Counting</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Thu, 06 Aug 2026 10:00:42 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/postgres-wait-events-stop-guessing-start-counting-ghn</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/postgres-wait-events-stop-guessing-start-counting-ghn</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wait_event_type&lt;/code&gt; is the bucket (nine of them), &lt;code&gt;wait_event&lt;/code&gt; is the specific detail. Read both or you learn nothing.&lt;/li&gt;
&lt;li&gt;Postgres reports &lt;strong&gt;state&lt;/strong&gt;, never accumulated wait duration. One &lt;code&gt;SELECT * FROM pg_stat_activity&lt;/code&gt; is a single frame of a movie. Sample it.&lt;/li&gt;
&lt;li&gt;Filter out &lt;code&gt;state='idle'&lt;/code&gt; and &lt;code&gt;wait_event_type='Activity'&lt;/code&gt; before you count anything, or background processes sitting in their main loop will drown your profile.&lt;/li&gt;
&lt;li&gt;Rough ownership: &lt;code&gt;Lock&lt;/code&gt; is your transactions, &lt;code&gt;LWLock&lt;/code&gt; is Postgres fighting itself, &lt;code&gt;IO&lt;/code&gt; is storage or a missing index, &lt;code&gt;Client&lt;/code&gt; is your application, &lt;code&gt;Timeout&lt;/code&gt; is usually vacuum throttling.&lt;/li&gt;
&lt;li&gt;The sampler and rollup SQL are below. Copy them, run them for sixty seconds, then argue about &lt;code&gt;shared_buffers&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/postgres-wait-events-guide?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-wait-events-guide" rel="noopener noreferrer"&gt;Postgres Wait Events: What Every Backend Is Blocked On&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/8_7iDBquAjA"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4mobgt8novv7teixttfl.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4mobgt8novv7teixttfl.jpg" alt="Postgres Wait Events: Stop Guessing, Start Counting" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The video version
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/5bDaC0kNRlY"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;That clip is the three-minute mental model: two columns, nine buckets, sample don't snapshot. This post is the toolkit. Sampler script, rollup query with the averaging math spelled out, a per-event decoder table, the blocking-tree query, and the version gotchas that make ten-year-old blog queries return zero rows on a modern server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two columns, nine buckets
&lt;/h2&gt;

&lt;p&gt;The wait columns landed in 9.6, replacing the old boolean &lt;code&gt;waiting&lt;/code&gt; flag. &lt;code&gt;wait_event_type&lt;/code&gt; tells you the category, &lt;code&gt;wait_event&lt;/code&gt; names the exact thing. The documented types, with who owns the problem:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;wait_event_type&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Who owns the fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Activity&lt;/td&gt;
&lt;td&gt;Background process idling in its main loop&lt;/td&gt;
&lt;td&gt;Nobody. Noise. Exclude it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BufferPin&lt;/td&gt;
&lt;td&gt;Waiting for exclusive access to a pinned buffer&lt;/td&gt;
&lt;td&gt;Rare — usually vacuum vs. a long-running scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;Waiting on your application over the socket&lt;/td&gt;
&lt;td&gt;Your app team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extension&lt;/td&gt;
&lt;td&gt;Whatever extension author registered it&lt;/td&gt;
&lt;td&gt;Read their docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IO&lt;/td&gt;
&lt;td&gt;Reading or writing files&lt;/td&gt;
&lt;td&gt;Storage, or a query reading pages it shouldn't need&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPC&lt;/td&gt;
&lt;td&gt;Waiting on another Postgres process&lt;/td&gt;
&lt;td&gt;Parallel workers, sync replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock&lt;/td&gt;
&lt;td&gt;Heavyweight locks&lt;/td&gt;
&lt;td&gt;Application-level contention, always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LWLock&lt;/td&gt;
&lt;td&gt;Internal shared memory contention&lt;/td&gt;
&lt;td&gt;Postgres versus Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeout&lt;/td&gt;
&lt;td&gt;A deliberate sleep&lt;/td&gt;
&lt;td&gt;Vacuum cost delay, mostly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On PG 17 and later, &lt;code&gt;SELECT * FROM pg_wait_events&lt;/code&gt; lists every event name and description for the exact version you're running. Stop searching blog posts for what &lt;code&gt;WalSync&lt;/code&gt; means. Ask the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: NULL does not mean CPU
&lt;/h2&gt;

&lt;p&gt;Every wait-event tutorial I've read says "NULL wait event means the backend is on CPU." The docs are more careful, and so should you be: a NULL &lt;code&gt;wait_event&lt;/code&gt; means the backend is not currently waiting on a &lt;em&gt;tracked&lt;/em&gt; wait event. That's usually on-CPU. It is not a guarantee. Untracked waits exist, and a backend can be runnable but not scheduled.&lt;/p&gt;

&lt;p&gt;Practically: if your profile shows 12 active sessions with &lt;code&gt;wait_event IS NULL&lt;/code&gt; on a 4-core box, do not immediately go tune the planner. Look at the OS first. &lt;code&gt;top&lt;/code&gt;, &lt;code&gt;vmstat 1&lt;/code&gt;, whatever your monitoring gives you. If system CPU is at 30% and load average is 40, you have a scheduler or noisy-neighbour problem, not a query problem. I've watched a team spend a day rewriting a join because the wait profile "showed CPU," when the actual answer was a hypervisor stealing 60% of the cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  One snapshot tells you nothing
&lt;/h2&gt;

&lt;p&gt;Postgres core does not accumulate per-event wait time. There is no &lt;code&gt;total_wait_ms&lt;/code&gt; column, anywhere. That is the entire reason the &lt;code&gt;pg_wait_sampling&lt;/code&gt; extension exists.&lt;/p&gt;

&lt;p&gt;So you sample. The arithmetic is simple enough to do in your head. Take N samples over a window. If a given &lt;code&gt;wait_event&lt;/code&gt; shows up in M rows across those samples, then M/N is the average number of sessions sitting on that event during the window. That number has a name in other database communities: &lt;strong&gt;average active sessions (AAS)&lt;/strong&gt;. Compare it to your core count and you know instantly whether the box is saturated.&lt;/p&gt;

&lt;p&gt;Concretely: 600 samples at 100ms intervals is a 60-second window. If &lt;code&gt;Lock:transactionid&lt;/code&gt; appears in 5,400 rows, that's 5400/600 = 9 sessions, on average, doing nothing but waiting for other transactions to commit. For a whole minute. That's not a tuning opportunity, that's an outage.&lt;/p&gt;

&lt;p&gt;100ms is my default. It's cheap, and it catches anything lasting longer than a tenth of a second, which is everything that matters during an incident. Going to 10ms is fine on a quiet box and starts to cost you on a busy one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10-line sampler you can run right now
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;UNLOGGED&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;wait_samples&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;ts&lt;/span&gt;              &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;pid&lt;/span&gt;             &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;leader_pid&lt;/span&gt;      &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;state&lt;/span&gt;           &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;wait_event_type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;wait_event&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;query_id&lt;/span&gt;        &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;datname&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;usename&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;query&lt;/span&gt;           &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UNLOGGED&lt;/code&gt; matters. You are writing a few hundred rows a second during an incident and you do not want that in WAL, competing with the thing you're diagnosing.&lt;/p&gt;

&lt;p&gt;Now the collector. Put this in &lt;code&gt;sample.sql&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;wait_samples&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leader_pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;query_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leader_pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;query_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;pg_backend_pid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;-- don't sample the sampler&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s1"&gt;'idle'&lt;/span&gt;                  &lt;span class="c1"&gt;-- idle backends are not waiting on anything you care about&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'Activity'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- walwriter et al. idling in their main loop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;psql -d prod -f - &amp;amp;lt;&amp;amp;lt;'EOF'
\i sample.sql
\watch 0.1
EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, if &lt;code&gt;\watch&lt;/code&gt; with a sub-second interval isn't available on your client version, a shell loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;SECONDS+60&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$SECONDS&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="nv"&gt;$end&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;psql &lt;span class="nt"&gt;-qAt&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; prod &lt;span class="nt"&gt;-f&lt;/span&gt; sample.sql &amp;amp;gt&lt;span class="p"&gt;;&lt;/span&gt;/dev/null
  &lt;span class="nb"&gt;sleep &lt;/span&gt;0.1
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip the &lt;code&gt;Activity&lt;/code&gt; filter once and you'll see why it's there. &lt;code&gt;WalWriterMain&lt;/code&gt;, &lt;code&gt;CheckpointerMain&lt;/code&gt;, &lt;code&gt;AutoVacuumMain&lt;/code&gt; and &lt;code&gt;LogicalLauncherMain&lt;/code&gt; will be present in essentially every sample, and your top wait will be "background workers successfully doing nothing."&lt;/p&gt;

&lt;p&gt;Permissions: &lt;code&gt;track_activities&lt;/code&gt; must be on (it is by default), and a non-superuser needs &lt;code&gt;pg_monitor&lt;/code&gt; or &lt;code&gt;pg_read_all_stats&lt;/code&gt; to see other users' query text and wait details. Without it those columns come back NULL and you will misread the entire profile as idle. &lt;code&gt;GRANT pg_monitor TO app_dba;&lt;/code&gt; and move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rollup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wait_samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CPU/untracked'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_active_sessions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wait_samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real output from a payments box last month, 600 samples, 8 vCPU:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      type      |      event       | hits | avg_active_sessions | pct
----------------+------------------+------+---------------------+------
 Lock           | transactionid    | 5412 |                9.02 | 63.1
 CPU/untracked  | -                | 1188 |                1.98 | 13.9
 Client         | ClientRead       |  901 |                1.50 | 10.5
 LWLock         | LockManager      |  402 |                0.67 |  4.7
 IO             | DataFileRead     |  331 |                0.55 |  3.9
 Lock           | tuple            |  208 |                0.35 |  2.4
 IO             | WALSync          |   77 |                0.13 |  0.9
 Timeout        | VacuumDelay      |   41 |                0.07 |  0.5
(8 rows)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it out loud. Eight cores, 14.3 AAS total, and 9 of those sessions are parked on &lt;code&gt;Lock:transactionid&lt;/code&gt;. Nobody is CPU-bound: 1.98 AAS of CPU on an 8-core box is 25% busy. This database is not slow. It is &lt;em&gt;blocked&lt;/em&gt;. Every millisecond spent on &lt;code&gt;work_mem&lt;/code&gt; or &lt;code&gt;random_page_cost&lt;/code&gt; here is wasted. Go find the transaction everyone is queued behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoder ring
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;First thing to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Lock:transactionid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Waiting for another txn to commit or roll back (row conflict)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_blocking_pids&lt;/code&gt;, transaction length&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Lock:relation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Table/index-level lock. Someone ran DDL, &lt;code&gt;VACUUM FULL&lt;/code&gt;, or &lt;code&gt;REINDEX&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Who holds the AccessExclusiveLock, right now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Lock:tuple&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Queued behind other waiters for one hot row&lt;/td&gt;
&lt;td&gt;Counter/sequence-in-a-table patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LWLock:WALInsert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Contention inserting into WAL buffers under write load&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wal_buffers&lt;/code&gt;, commit rate, &lt;code&gt;synchronous_commit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LWLock:BufferMapping&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared buffer lookup table contention, high eviction churn&lt;/td&gt;
&lt;td&gt;Working set vs &lt;code&gt;shared_buffers&lt;/code&gt;, seq scans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LWLock:BufferContent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Multiple backends fighting over one page's content&lt;/td&gt;
&lt;td&gt;Hot index root/leaf pages, update patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LWLock:LockManager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fast-path lock slots exhausted, locks spilled to shared manager&lt;/td&gt;
&lt;td&gt;Partition count per query, prepared statements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IO:DataFileRead&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Buffer miss, reading a relation page from the filesystem&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;, missing index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IO:DataFileWrite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Backend writing dirty pages itself&lt;/td&gt;
&lt;td&gt;Checkpoint settings, bgwriter, write burst&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;IO:WALWrite&lt;/code&gt; / &lt;code&gt;IO:WALSync&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Commit-path WAL write and fsync&lt;/td&gt;
&lt;td&gt;Commit rate, fsync latency, storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;IO:BufFileRead&lt;/code&gt; / &lt;code&gt;IO:BufFileWrite&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Temp file spill from sorts, hashes, materialize&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;work_mem&lt;/code&gt;, &lt;code&gt;log_temp_files = 0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Client:ClientRead&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Waiting for the client to send the next command&lt;/td&gt;
&lt;td&gt;Your app. Round trips, pooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Client:ClientWrite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Client isn't reading results fast enough&lt;/td&gt;
&lt;td&gt;Result set size, network, cursor use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IPC:SyncRep&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Waiting for a synchronous standby to acknowledge commit&lt;/td&gt;
&lt;td&gt;Replication RTT, &lt;code&gt;synchronous_standby_names&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Timeout:VacuumDelay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cost-based vacuum throttling&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vacuum_cost_delay&lt;/code&gt;, &lt;code&gt;vacuum_cost_limit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BufferPin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Waiting for exclusive access to a pinned buffer&lt;/td&gt;
&lt;td&gt;Long-running scans overlapping vacuum&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Lock waits: find the blocker, not the victim
&lt;/h2&gt;

&lt;p&gt;Your profile says &lt;code&gt;Lock:transactionid&lt;/code&gt;. Every session you look at is a victim. You need the root of the tree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;RECURSIVE&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;cardinality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
                &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'  '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pid_tree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;xact_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pg_blocking_pids()&lt;/code&gt; arrived in 9.6 and is the supported way to do this. Stop hand-joining &lt;code&gt;pg_locks&lt;/code&gt; to itself; that query is wrong in edge cases and you will not notice.&lt;/p&gt;

&lt;p&gt;The depth-0 row is your culprit. Nine times out of ten it's in &lt;code&gt;state = 'idle in transaction'&lt;/code&gt; with an &lt;code&gt;xact_age&lt;/code&gt; of four minutes, because someone opened a transaction, called an external API, and the API is timing out.&lt;/p&gt;

&lt;p&gt;Fixes, in order of how much I trust them: shorten transactions (do the API call outside the transaction), batch updates in a consistent key order to avoid pile-ups, set &lt;code&gt;lock_timeout&lt;/code&gt; on migration sessions so DDL fails fast instead of freezing the table, and turn on &lt;code&gt;log_lock_waits&lt;/code&gt;. That last one is off by default and logs any wait longer than &lt;code&gt;deadlock_timeout&lt;/code&gt; (1s default). Turn it on everywhere. It costs nothing and it gives you the post-mortem you'll want at 3am.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Lock:relation&lt;/code&gt; is a different animal. That's table-level, which means DDL, &lt;code&gt;VACUUM FULL&lt;/code&gt;, or an unlucky &lt;code&gt;ALTER TABLE&lt;/code&gt; sitting behind a long read and blocking everything behind &lt;em&gt;it&lt;/em&gt;. &lt;code&gt;Lock:tuple&lt;/code&gt; means a queue on one specific row, which almost always means a counter table.&lt;/p&gt;

&lt;h2&gt;
  
  
  LWLock waits: when Postgres fights itself
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LWLock:WALInsert&lt;/code&gt; is a write-throughput ceiling. Backends are queuing to copy their WAL records into shared buffers. Look at &lt;code&gt;wal_buffers&lt;/code&gt;, at whether you're committing one tiny row at a time from a hundred connections, and at whether &lt;code&gt;synchronous_commit = off&lt;/code&gt; is acceptable for some of your workload (it is, for anything you'd be willing to lose the last 200ms of).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LWLock:BufferMapping&lt;/code&gt; and &lt;code&gt;BufferContent&lt;/code&gt; mean shared buffer churn or a genuinely hot page. Bigger &lt;code&gt;shared_buffers&lt;/code&gt; sometimes helps the first, never helps the second.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LWLock:LockManager&lt;/code&gt; is the one that catches people out. Each backend gets a fixed number of fast-path lock slots (16 per backend before PG 18). Blow past that and every lock goes through the shared lock manager. The classic trigger is a query touching a partitioned table with 200 partitions: even with pruning, planning can grab locks on more relations than you'd think. PG 18 scales those slots with &lt;code&gt;max_locks_per_transaction&lt;/code&gt;, which helps, but the real fix is fewer partitions per query.&lt;/p&gt;

&lt;p&gt;Here's my position: LWLock waits are almost never fixed by a config knob alone. They're a symptom of a workload shape. Change the shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  IO waits: disk, or a missing index pretending to be disk
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;IO:DataFileRead&lt;/code&gt; means a page wasn't in &lt;code&gt;shared_buffers&lt;/code&gt;. That's all it means. The read may well have been served from the OS page cache in 20 microseconds. I have watched people buy faster NVMe because of a &lt;code&gt;DataFileRead&lt;/code&gt;-heavy profile, when the actual problem was a sequential scan on a 40GB table that needed one index.&lt;/p&gt;

&lt;p&gt;Cross-check before you conclude anything. Turn on &lt;code&gt;track_io_timing&lt;/code&gt; (off by default) so &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; and &lt;code&gt;pg_stat_statements&lt;/code&gt; report real I/O time. On PG 16+, &lt;code&gt;pg_stat_io&lt;/code&gt; breaks reads and writes down by backend type and context, which tells you whether it's client backends, autovacuum, or the checkpointer. If &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; shows a million shared reads and near-zero I/O time, your storage is fine and your query plan is not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IO:BufFileRead&lt;/code&gt; / &lt;code&gt;BufFileWrite&lt;/code&gt; means temp files: sorts, hashes, or materialization spilling past &lt;code&gt;work_mem&lt;/code&gt;. Set &lt;code&gt;log_temp_files = 0&lt;/code&gt;, find the queries, then decide whether to raise &lt;code&gt;work_mem&lt;/code&gt; (per-session, not globally) or fix the plan.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IO:WALWrite&lt;/code&gt; and &lt;code&gt;IO:WALSync&lt;/code&gt; are commit-bound. Query tuning won't touch them. Look at fsync latency on the WAL device and at your commit rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Client waits are almost always your application's fault
&lt;/h2&gt;

&lt;p&gt;If your profile is 70% &lt;code&gt;Client:ClientRead&lt;/code&gt;, no amount of &lt;code&gt;shared_buffers&lt;/code&gt; tuning will save you. Go fix your ORM.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Client:ClientRead&lt;/code&gt; means the backend has finished and is waiting for the client to say something. Dominant &lt;code&gt;ClientRead&lt;/code&gt; means chatty round trips, no connection pooler, or sessions holding a transaction open while doing nothing. That last one is the actual bug, and this query separates it from the harmless case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;wait_event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ClientRead'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;client_read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                           &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;oldest_xact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;state_change&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;oldest_idle&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        state        | client_read |   oldest_xact   |   oldest_idle
---------------------+-------------+-----------------+-----------------
 idle                |         214 |                 | 00:41:12.339
 idle in transaction |          17 | 00:06:48.771    | 00:06:44.102
 active              |           3 | 00:00:00.412    | 00:00:00.400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;214 plain-idle sessions on &lt;code&gt;ClientRead&lt;/code&gt; is a pool at rest. Ignore them, which is exactly what the &lt;code&gt;state &amp;amp;lt;&amp;amp;gt; 'idle'&lt;/code&gt; filter in the sampler does. The 17 sessions &lt;strong&gt;idle in transaction&lt;/strong&gt; for nearly seven minutes are holding locks and pinning old snapshots so vacuum can't clean up. Set &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; and make the application fail loudly instead of quietly bloating your tables.&lt;/p&gt;

&lt;p&gt;Conflating a plain-idle pooled connection with an idle-in-transaction one is why people panic-tune connection pools that were never the problem. The state column, not the wait event alone, is what tells them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going continuous
&lt;/h2&gt;

&lt;p&gt;Manual sampling is an incident tool. For trending, either install &lt;code&gt;pg_wait_sampling&lt;/code&gt;, which runs a background worker doing exactly this and exposes both a live profile and per-process history without you polling, or keep your own table and store &lt;code&gt;query_id&lt;/code&gt; with every sample.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;query_id&lt;/code&gt; landed in &lt;code&gt;pg_stat_activity&lt;/code&gt; in PG 14 and is populated when &lt;code&gt;compute_query_id&lt;/code&gt; is on. It's already in my sampler DDL above. With it you can answer the question that actually ends arguments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wait_samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queryid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;aas_lock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wait_samples&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queryid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Lock'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queryid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Which query burns the most &lt;code&gt;Lock:transactionid&lt;/code&gt; time" now has a numeric answer instead of a hunch.&lt;/p&gt;

&lt;p&gt;Disclosure: I write for pgdba, and we run &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-wait-events-guide" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt;, which does a free health check if you'd rather have someone else read the profile. The queries above work fine without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version gotchas that will bite you
&lt;/h2&gt;

&lt;p&gt;PG 13 standardized LWLock event names (&lt;code&gt;buffer_mapping&lt;/code&gt; became &lt;code&gt;BufferMapping&lt;/code&gt;) and merged &lt;code&gt;LWLockNamed&lt;/code&gt; and &lt;code&gt;LWLockTranche&lt;/code&gt; into a single &lt;code&gt;LWLock&lt;/code&gt; type. Any monitoring query you copied from a 2018 blog post will silently return zero rows on a modern server — which is worse than an error, because it reads as "no contention" instead of "your query is stale." PG 13 also added &lt;code&gt;leader_pid&lt;/code&gt;, so parallel workers stop looking like mystery sessions with no query text — join on it to roll workers back up to their query. PG 16 reworked relation extension locking. PG 17 gave you &lt;code&gt;pg_wait_events&lt;/code&gt; so you can look events up locally.&lt;/p&gt;

&lt;p&gt;And again: &lt;code&gt;pg_monitor&lt;/code&gt; or &lt;code&gt;pg_read_all_stats&lt;/code&gt;, or you're profiling NULLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  60-second triage runbook
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create the unlogged table, start the sampler at &lt;code&gt;\watch 0.1&lt;/code&gt;, wait 60 seconds.&lt;/li&gt;
&lt;li&gt;Run the rollup. Sum the &lt;code&gt;avg_active_sessions&lt;/code&gt; column.&lt;/li&gt;
&lt;li&gt;Compare that total to your core count. Above it means saturation, below it means you have a latency problem, not a capacity one.&lt;/li&gt;
&lt;li&gt;Dominant &lt;code&gt;Lock&lt;/code&gt; → run the blocking tree, kill or fix the depth-0 session, turn on &lt;code&gt;log_lock_waits&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Dominant &lt;code&gt;Client:ClientRead&lt;/code&gt; → check the idle-in-transaction breakdown, set &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;, talk to the app team.&lt;/li&gt;
&lt;li&gt;Dominant &lt;code&gt;IO&lt;/code&gt; → &lt;code&gt;track_io_timing = on&lt;/code&gt;, check &lt;code&gt;pg_stat_io&lt;/code&gt;, then &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; the top query before touching storage.&lt;/li&gt;
&lt;li&gt;Dominant &lt;code&gt;LWLock&lt;/code&gt; → identify the specific lock, look at workload shape (partition count, commit rate, buffer churn) before any config change.&lt;/li&gt;
&lt;li&gt;Dominant NULL → verify against OS CPU before you blame the planner.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DROP TABLE wait_samples;&lt;/code&gt; when you're done. It's unlogged, but it's still disk.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nine buckets, one sampler, one rollup. Write down the numbers. Not the vibe. Stop guessing, count something.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>No pg_hba.conf Entry for Host: 5 Real Causes &amp; Fixes</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Thu, 06 Aug 2026 10:00:35 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/no-pghbaconf-entry-for-host-5-real-causes-fixes-1ocm</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/no-pghbaconf-entry-for-host-5-real-causes-fixes-1ocm</guid>
      <description>&lt;p&gt;I've been paged for this error at 2am more times than I care to count. I've also watched a well-meaning junior "fix" it at 2:07am by appending &lt;code&gt;host all all 0.0.0.0/0 trust&lt;/code&gt; to a cluster with a public IP, then going back to bed. The connection worked. So did everyone else's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/no-pg-hba-conf-entry-for-host-fix?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=no-pg-hba-conf-entry-for-host-fix" rel="noopener noreferrer"&gt;Fix "no pg_hba.conf entry for host" (Full Cause Map)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/CD5RzhtZagI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4v8p7egbkzqsnsvc6bm3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4v8p7egbkzqsnsvc6bm3.jpg" alt="No pg_hba.conf Entry for Host: 5 Real Causes &amp;amp; Fixes" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the short version: &lt;code&gt;no pg_hba.conf entry for host&lt;/code&gt; means your TCP connection reached Postgres fine — the failure is at the authentication-matching step, not the network step. Postgres tells you exactly which rule it needed, in the server log, on one line. Most advice you'll find skips reading that line and jumps straight to editing the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR — the 60-second triage
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the server log line, not the client error.&lt;/strong&gt; It names the host address, user, database, and encryption state Postgres matched against. That's your entire diagnosis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm where the server is listening:&lt;/strong&gt; &lt;code&gt;SHOW listen_addresses;&lt;/code&gt; and &lt;code&gt;ss -ltnp 'sport = :5432'&lt;/code&gt;. (If you got the pg_hba error at all, this is already fine — more on that below.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the file as Postgres parsed it:&lt;/strong&gt; &lt;code&gt;SELECT * FROM pg_hba_file_rules;&lt;/code&gt; — check the &lt;code&gt;error&lt;/code&gt; column before you trust anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the narrowest rule that matches, then reload:&lt;/strong&gt; &lt;code&gt;SELECT pg_reload_conf();&lt;/code&gt; No restart needed for pg_hba.conf changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this error actually means (and what it doesn't)
&lt;/h2&gt;

&lt;p&gt;pg_hba is the second gate, not the first. The sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;TCP connect to the postmaster — governed by &lt;code&gt;listen_addresses&lt;/code&gt;, the OS firewall, cloud security groups, and routing.&lt;/li&gt;
&lt;li&gt;The postmaster matches the incoming connection against pg_hba.conf and either authenticates or refuses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If you're seeing &lt;code&gt;no pg_hba.conf entry for host&lt;/code&gt;, stage one already succeeded.&lt;/strong&gt; The client opened a TCP session, the postmaster read the startup packet, and the server decided it had nothing to say to you. Your firewall is fine. &lt;code&gt;listen_addresses&lt;/code&gt; is fine. The port is open.&lt;/p&gt;

&lt;p&gt;When those &lt;em&gt;are&lt;/em&gt; broken, you get a completely different failure — a postgres remote connection error that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;psql: error: connection to server at "db01" (10.0.3.10), port 5432 failed:
        Connection refused
        Is the server running on that host and accepting TCP/IP connections?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or a hang followed by a timeout. Neither of those is an HBA problem, and neither will ever produce the pg_hba error text. Roughly half the answers I read online respond to "no pg_hba.conf entry" with "did you set &lt;code&gt;listen_addresses = '*'&lt;/code&gt;?" If that mattered, the asker wouldn't have gotten this error in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the log line before you touch anything
&lt;/h2&gt;

&lt;p&gt;Here's the thing you actually need, straight out of the server log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-08-04 02:14:07.118 UTC [21873] FATAL:  no pg_hba.conf entry for host "10.0.3.42", user "app", database "orders", no encryption
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decompose it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.3.42&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The source address &lt;strong&gt;the server sees&lt;/strong&gt;. Not what the client thinks it is.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The role from the startup packet, after any client-side mapping.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;database&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orders&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The requested database. Note: not &lt;code&gt;all&lt;/code&gt;, not the default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;encryption&lt;/td&gt;
&lt;td&gt;&lt;code&gt;no encryption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The connection isn't SSL, so &lt;code&gt;hostssl&lt;/code&gt; lines can't match it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last field is the one people skim past. &lt;code&gt;no encryption&lt;/code&gt; means a &lt;code&gt;hostssl&lt;/code&gt; rule will never match this connection no matter how perfect the CIDR is. If it said &lt;code&gt;SSL on&lt;/code&gt;, a &lt;code&gt;hostnossl&lt;/code&gt; rule is excluded instead.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;10.0.3.42&lt;/code&gt; is the single most useful fact in the whole investigation, and it's the value you should be grepping your pg_hba.conf for — not the client's &lt;code&gt;ip addr&lt;/code&gt; output. The server's view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause 1: Postgres isn't listening where you think
&lt;/h2&gt;

&lt;p&gt;This is the pre-error case — you never get the HBA message, you get "Connection refused."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;listen_addresses&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;hba_file&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;listen_addresses&lt;/code&gt; defaults to &lt;code&gt;localhost&lt;/code&gt; in a source build. &lt;code&gt;*&lt;/code&gt; binds all interfaces, &lt;code&gt;0.0.0.0&lt;/code&gt; binds all IPv4, &lt;code&gt;::&lt;/code&gt; binds all IPv6, and an empty string disables TCP entirely (Unix sockets only). Confirm at the OS level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ss &lt;span class="nt"&gt;-ltnp&lt;/span&gt; &lt;span class="s1"&gt;'sport = :5432'&lt;/span&gt;
&lt;span class="go"&gt;State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN 0      244        127.0.0.1:5432       0.0.0.0:*     users:(("postgres",pid=1041,fd=6))
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bound to loopback only. No amount of pg_hba editing will help.&lt;/p&gt;

&lt;p&gt;Two traps here. First, &lt;strong&gt;&lt;code&gt;listen_addresses&lt;/code&gt; requires a full restart&lt;/strong&gt;, not a reload — it's a postmaster-level parameter. Second, &lt;code&gt;0.0.0.0&lt;/code&gt; covers IPv4 only; if your client resolves the hostname to an AAAA record you'll try to connect over IPv6 to nothing, and vice versa with &lt;code&gt;::&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also check &lt;code&gt;SHOW hba_file;&lt;/code&gt; before you edit anything. On Debian and Ubuntu packages it's &lt;code&gt;/etc/postgresql/16/main/pg_hba.conf&lt;/code&gt;, not inside &lt;code&gt;$PGDATA&lt;/code&gt;, and I have absolutely watched someone edit the wrong file for twenty minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause 2: no rule matches at all
&lt;/h2&gt;

&lt;p&gt;A host record has five fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TYPE&lt;/th&gt;
&lt;th&gt;DATABASE&lt;/th&gt;
&lt;th&gt;USER&lt;/th&gt;
&lt;th&gt;ADDRESS&lt;/th&gt;
&lt;th&gt;METHOD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orders&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.3.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;scram-sha-256&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Postgres reads the file top to bottom and &lt;strong&gt;the first matching record wins&lt;/strong&gt;. There's no fall-through and no backup. If the first matching rule's authentication fails, the connection fails — even if a more permissive rule sits three lines below. Placement matters as much as content.&lt;/p&gt;

&lt;p&gt;Verify the file parsed the way you think, via &lt;code&gt;pg_hba_file_rules&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;line_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;netmask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth_method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_hba_file_rules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; line_number | type  |   database    | user_name |  address  |    netmask     |  auth_method   | error
-------------+-------+---------------+-----------+-----------+----------------+----------------+-------
          89 | local | {all}         | {all}     |           |                | peer           |
          91 | host  | {all}         | {all}     | 127.0.0.1 | 255.255.255.255| scram-sha-256  |
          93 | host  | {all}         | {all}     | ::1       | ffff:...:ffff  | scram-sha-256  |
          97 | host  | {orders}      | {app}     | 10.0.1.5  |                |                | invalid CIDR mask in address "10.0.1.5/24"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;error&lt;/code&gt; column is why this view exists. Non-null means the line didn't parse, and Postgres is still running on whatever was loaded before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause 3: the rule matches the host but not the rest
&lt;/h2&gt;

&lt;p&gt;The subtle one, and the one that costs the most time.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;all&lt;/code&gt; doesn't include &lt;code&gt;replication&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;all&lt;/code&gt; keyword in the database column matches every database &lt;em&gt;except&lt;/em&gt; physical replication connections. If your standby is failing to connect and your log shows &lt;code&gt;database "replication"&lt;/code&gt;, you need an explicit line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;host    replication     repl    10.0.3.0/24    scram-sha-256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I've seen a failover rehearsal die on exactly this, with a perfectly good &lt;code&gt;host all all 10.0.3.0/24 scram-sha-256&lt;/code&gt; sitting right there. Logical replication connects to a named database and is matched by &lt;code&gt;all&lt;/code&gt;; physical replication is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connection type must match the encryption state
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;host&lt;/code&gt; matches both encrypted and unencrypted TCP. &lt;code&gt;hostssl&lt;/code&gt; matches SSL-only. &lt;code&gt;hostnossl&lt;/code&gt; matches non-SSL only. &lt;code&gt;hostgssenc&lt;/code&gt; and &lt;code&gt;hostnogssenc&lt;/code&gt; do the same for GSSAPI encryption. Cross-reference with the encryption clue in the log line — if it says "no encryption" and your only matching line is &lt;code&gt;hostssl&lt;/code&gt;, Postgres skips past it as if it weren't there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Role membership needs a &lt;code&gt;+&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A user column of &lt;code&gt;app&lt;/code&gt; matches only the role literally named &lt;code&gt;app&lt;/code&gt;. &lt;code&gt;+app_users&lt;/code&gt; matches any member of &lt;code&gt;app_users&lt;/code&gt;, directly or indirectly. &lt;code&gt;@filename&lt;/code&gt; reads a list from an include file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method mismatch after PG14
&lt;/h3&gt;

&lt;p&gt;Since PostgreSQL 14, &lt;code&gt;password_encryption&lt;/code&gt; defaults to &lt;code&gt;scram-sha-256&lt;/code&gt;. An &lt;code&gt;md5&lt;/code&gt; HBA line can still authenticate a role whose password is stored as a SCRAM hash — Postgres negotiates the right protocol underneath. But a &lt;code&gt;scram-sha-256&lt;/code&gt; line cannot authenticate a role still holding an old MD5 hash. It's a one-way door: once you migrate a password to SCRAM, the &lt;code&gt;md5&lt;/code&gt; line becomes unnecessary, but tightening a line to &lt;code&gt;scram-sha-256&lt;/code&gt; before every password is rotated will lock out anyone who hasn't reset theirs yet. Rotate the password, then tighten the line.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;peer&lt;/code&gt; is local-only
&lt;/h3&gt;

&lt;p&gt;It matches the OS username to the role name over Unix sockets. On a &lt;code&gt;host&lt;/code&gt; line it's invalid and will never authenticate anyone.&lt;/p&gt;

&lt;p&gt;You can also use &lt;code&gt;reject&lt;/code&gt; deliberately, high in the file, to shut a subnet out regardless of what follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause 4: CIDR arithmetic and address family
&lt;/h2&gt;

&lt;p&gt;The most common typo I see is &lt;code&gt;/32&lt;/code&gt; where someone meant &lt;code&gt;/24&lt;/code&gt;, or vice versa — a &lt;code&gt;/32&lt;/code&gt; matches exactly one address, and writing it when you meant a subnet locks out everyone except the one IP you happened to test with.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CIDR&lt;/th&gt;
&lt;th&gt;IPv4 hosts covered&lt;/th&gt;
&lt;th&gt;IPv6 equivalent&lt;/th&gt;
&lt;th&gt;IPv6 hosts covered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/32&lt;/td&gt;
&lt;td&gt;1 (single host)&lt;/td&gt;
&lt;td&gt;/128&lt;/td&gt;
&lt;td&gt;1 (single host)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/24&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;/64&lt;/td&gt;
&lt;td&gt;typical LAN subnet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/16&lt;/td&gt;
&lt;td&gt;65,536&lt;/td&gt;
&lt;td&gt;/48&lt;/td&gt;
&lt;td&gt;typical site allocation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/0&lt;/td&gt;
&lt;td&gt;everything&lt;/td&gt;
&lt;td&gt;/0&lt;/td&gt;
&lt;td&gt;everything (never use this)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In concrete terms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Notation&lt;/th&gt;
&lt;th&gt;Covers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;10.0.3.42/32&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;exactly that one IPv4 host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;10.0.3.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10.0.3.0 – 10.0.3.255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;10.0.0.0/16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10.0.0.0 – 10.0.255.255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0/0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every IPv4 address on earth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;::1/128&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;exactly that one IPv6 host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fd00:dead:beef::/64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;that IPv6 subnet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two failure modes. First, non-zero bits to the right of the mask are an error in modern versions: &lt;code&gt;10.0.1.5/24&lt;/code&gt; gets flagged, not silently rounded to &lt;code&gt;10.0.1.0/24&lt;/code&gt;. &lt;code&gt;pg_hba_file_rules.error&lt;/code&gt; will show it.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;an IPv4 entry doesn't match an IPv6 connection.&lt;/strong&gt; &lt;code&gt;127.0.0.1/32&lt;/code&gt; won't authorize a client arriving from &lt;code&gt;::1&lt;/code&gt;. That's why the shipped default file has both lines. If your app host resolves &lt;code&gt;localhost&lt;/code&gt; to &lt;code&gt;::1&lt;/code&gt;, you need the IPv6 entry too.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;samehost&lt;/code&gt; and &lt;code&gt;samenet&lt;/code&gt; are decent shorthands: they match the server's own addresses and the subnets it's directly attached to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause 5: containers, NAT, and proxies
&lt;/h2&gt;

&lt;p&gt;The address in the log is the last hop, not the original client — this is the classic pg_hba.conf docker connection refused scenario in disguise.&lt;/p&gt;

&lt;p&gt;Docker bridge networks NAT everything through the gateway. Find the real numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker inspect &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'{{range .NetworkSettings.Networks}}{{.Gateway}} {{.IPAddress}}{{end}}'&lt;/span&gt; app_web_1
&lt;span class="go"&gt;172.18.0.1 172.18.0.7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write your rule against &lt;code&gt;172.18.0.0/16&lt;/code&gt;, or the compose network's subnet, and be aware that this range is shared with anything else on that bridge. In Kubernetes, the pod CIDR is not the node IP — decide which one you're actually seeing before writing anything, and match your rule to what the log line says, not to what you assume the topology looks like.&lt;/p&gt;

&lt;p&gt;PgBouncer and HAProxy collapse every client into a single source address. Your HBA rule authorizes the pooler; user-level control has to move into the pooler's own auth configuration.&lt;/p&gt;

&lt;p&gt;The official &lt;code&gt;postgres&lt;/code&gt; Docker image ships a permissive host rule and exposes &lt;code&gt;POSTGRES_HOST_AUTH_METHOD&lt;/code&gt; to set it. Setting it to &lt;code&gt;trust&lt;/code&gt; disables password checking for all host connections. Fine for a throwaway test container, unacceptable anywhere else. Set it to &lt;code&gt;scram-sha-256&lt;/code&gt; and provide a password.&lt;/p&gt;

&lt;p&gt;Sanity check the exact path the server sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;psql &lt;span class="s2"&gt;"host=10.0.3.10 port=5432 user=app dbname=orders sslmode=disable"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Applying changes safely: reload vs restart
&lt;/h2&gt;

&lt;p&gt;pg_hba.conf takes effect on SIGHUP. No restart, no dropped sessions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_reload_conf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pg_ctl &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="nv"&gt;$PGDATA&lt;/span&gt; reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the file has a syntax error at reload time, the server logs it and &lt;strong&gt;keeps the previously loaded rules&lt;/strong&gt;. That's a feature, and it's also why &lt;code&gt;pg_hba_file_rules&lt;/code&gt; (which reads the file on disk) can disagree with what's actually enforced. Always check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;line_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_hba_file_rules&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty result, then reload, then retest. &lt;code&gt;listen_addresses&lt;/code&gt; and &lt;code&gt;port&lt;/code&gt; still need a full restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lines I actually use
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# TYPE  DATABASE      USER        ADDRESS           METHOD

# local admin via unix socket, OS user must match role
local   all           all                           peer

# loopback: both families, always both
host    all           all         127.0.0.1/32      scram-sha-256
host    all           all         ::1/128           scram-sha-256

# application subnet, scoped to one db and one role
host    orders        +app_users  10.0.3.0/24       scram-sha-256

# physical replication: 'all' above does NOT cover this
hostssl replication   repl        10.0.3.0/24       scram-sha-256

# admin access from the bastion only, single host, SSL required
hostssl all           dbadmin     10.0.9.15/32      scram-sha-256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never &lt;code&gt;trust&lt;/code&gt;. Never &lt;code&gt;0.0.0.0/0&lt;/code&gt;. Not temporarily. "Temporary" HBA lines are the most durable objects in our industry, and the incident review is worse than the outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit before someone locks you out (or lets everyone in)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;line_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth_method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;auth_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'trust'&lt;/span&gt;                    &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'CRITICAL: no authentication'&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'0.0.0.0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'::'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'CRITICAL: open to the world'&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;auth_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'md5'&lt;/span&gt;                      &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'WARN: legacy md5, move to scram-sha-256'&lt;/span&gt;
       &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;finding&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_hba_file_rules&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;auth_method&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'trust'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'md5'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'0.0.0.0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'::'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;line_number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that on every cluster you own. Then look for shadowing: because first match wins, a broad rule near the top silently disables every narrower rule below it, and nothing warns you.&lt;/p&gt;

&lt;p&gt;This is the check I got tired of writing by hand on every cluster. &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=no-pg-hba-conf-entry-for-host-fix" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; runs this class of check continuously and flags rules that shadow or over-permit — that's the whole pitch.&lt;/p&gt;

&lt;p&gt;If you'd rather see this walked through on a whiteboard, the companion video is up at &lt;a href="https://www.youtube.com/@pgdba" rel="noopener noreferrer"&gt;https://www.youtube.com/@pgdba&lt;/a&gt;. Otherwise, you now have everything you need to fix a &lt;code&gt;no pg_hba.conf entry for host&lt;/code&gt; error without guessing.&lt;/p&gt;

&lt;p&gt;Tags: &lt;code&gt;postgres&lt;/code&gt; &lt;code&gt;database&lt;/code&gt; &lt;code&gt;devops&lt;/code&gt; &lt;code&gt;security&lt;/code&gt; ## One line to remember&lt;/p&gt;

&lt;p&gt;Every one of these five failure modes produces the exact same eleven words on the client. The server log doesn't lie, and it doesn't summarize — it hands you host, user, database, and encryption state on a plate. Read that line first, every time, before you touch a single CIDR block or auth method. Nine times out of ten the fix is one narrow line added in the right place, reloaded, and retested. The tenth time is the one where someone already added &lt;code&gt;trust&lt;/code&gt; at 2am, and now you're doing an access audit instead of a quick config fix.&lt;/p&gt;

&lt;p&gt;pg_hba.conf isn't complicated once you stop treating it as a firewall and start treating it as what it is — a first-match ordered list of authentication contracts. Keep it narrow, keep it explicit about SSL and role membership, and check &lt;code&gt;pg_hba_file_rules&lt;/code&gt; after every change instead of trusting that a reload did what you meant it to.&lt;/p&gt;

&lt;p&gt;pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=no-pg-hba-conf-entry-for-host-fix" rel="noopener noreferrer"&gt;https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=no-pg-hba-conf-entry-for-host-fix&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're maintaining more than a couple of clusters, it's worth having something watch your pg_hba rules for shadowing and over-permissive entries so you're not the one finding out at 2am. That's what &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=no-pg-hba-conf-entry-for-host-fix" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt; is for — give it a look.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Postgres max_connections: The Real Memory Math</title>
      <dc:creator>Philip McClarence</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:00:37 +0000</pubDate>
      <link>https://dev.to/philip_mcclarence_2ef9475/postgres-maxconnections-the-real-memory-math-287a</link>
      <guid>https://dev.to/philip_mcclarence_2ef9475/postgres-maxconnections-the-real-memory-math-287a</guid>
      <description>&lt;p&gt;I watched someone go from &lt;code&gt;max_connections = 100&lt;/code&gt; to &lt;code&gt;2000&lt;/code&gt; at 4pm and take the box down the same night. The change itself took ten seconds. The 2am crash recovery took considerably longer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📖 Read the full guide:&lt;/strong&gt; &lt;a href="https://mydba.dev/blog/postgres-max-connections-memory-guide?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-max-connections-guide" rel="noopener noreferrer"&gt;max_connections in Postgres: A Memory Decision in Disguise&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/I4kvuC-8F9g"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4hfnz3jjlpdsw3dyzun.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4hfnz3jjlpdsw3dyzun.jpg" alt="Postgres max_connections: The Real Memory Math" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;postgres max_connections&lt;/code&gt; isn't a knob that means "allow more clients." It resizes shared memory at startup and multiplies your worst-case per-backend memory exposure by a bigger number. Here's how to check your real usage, do the memory math, and decide whether a new value actually fits — before you touch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;SHOW max_connections&lt;/code&gt;, then count &lt;strong&gt;only&lt;/strong&gt; &lt;code&gt;backend_type = 'client backend'&lt;/code&gt; in &lt;code&gt;pg_stat_activity&lt;/code&gt;. Autovacuum workers and walsenders aren't your app.&lt;/li&gt;
&lt;li&gt;Sample that count over hours, not once. A single &lt;code&gt;count(*)&lt;/code&gt; tells you nothing about peak load.&lt;/li&gt;
&lt;li&gt;Compute the ceiling first: &lt;code&gt;shared_buffers&lt;/code&gt; + (per-backend private memory × max_connections) + concurrent &lt;code&gt;work_mem&lt;/code&gt; allocations + &lt;code&gt;maintenance_work_mem&lt;/code&gt; × autovacuum workers + OS headroom.&lt;/li&gt;
&lt;li&gt;For most teams, a connection pooler solves this faster than a config change. PgBouncer in transaction mode lets 2000 clients share 40 server connections.&lt;/li&gt;
&lt;li&gt;If you genuinely need to raise it: &lt;code&gt;ALTER SYSTEM SET&lt;/code&gt;, check &lt;code&gt;pending_restart&lt;/code&gt;, pre-flight &lt;code&gt;shared_memory_size&lt;/code&gt;, raise standbys first, restart in a window with logs tailing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What max_connections actually reserves
&lt;/h2&gt;

&lt;p&gt;Postgres forks a dedicated backend process per client connection. Each connection is an OS process with its own address space and catalog caches before it runs a single query. There's no thread pool underneath — one process per connection, full stop.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max_connections&lt;/code&gt; has a &lt;code&gt;postmaster&lt;/code&gt; context, meaning a reload won't apply it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pending_restart&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_settings&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'max_connections'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="n"&gt;context&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;pending_restart&lt;/span&gt;
&lt;span class="c1"&gt;-----------------+---------+------------+-----------------&lt;/span&gt;
 &lt;span class="n"&gt;max_connections&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;postmaster&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs a restart because the value sizes fixed shared memory structures at startup. The lock table is sized from &lt;code&gt;max_locks_per_transaction × (max_connections + max_prepared_transactions)&lt;/code&gt;. The predicate lock table works the same way. Those are allocated once, up front.&lt;/p&gt;

&lt;p&gt;Since PG 15 you can see the total without restarting, and even run this against a stopped or separate data directory to pre-flight a value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;postgres &lt;span class="nt"&gt;-C&lt;/span&gt; shared_memory_size &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="nv"&gt;$PGDATA&lt;/span&gt;
4489
&lt;span class="nv"&gt;$ &lt;/span&gt;postgres &lt;span class="nt"&gt;-C&lt;/span&gt; shared_memory_size_in_huge_pages &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="nv"&gt;$PGDATA&lt;/span&gt;
2245
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since PG 13, &lt;code&gt;pg_shmem_allocations&lt;/code&gt; shows where it went:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allocated_size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_shmem_allocations&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;allocated_size&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Postgres connection limit check: find out what you're actually using
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'max_connections'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'superuser_reserved_connections'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;su_reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;client_backends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'max_connections'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'superuser_reserved_connections'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;superuser_reserved_connections&lt;/code&gt; defaults to 3 and comes off the top for ordinary users. PG 16 added &lt;code&gt;reserved_connections&lt;/code&gt; (default 0) for roles granted &lt;code&gt;pg_use_reserved_connections&lt;/code&gt;. Since PG 12, &lt;code&gt;max_wal_senders&lt;/code&gt; no longer counts against &lt;code&gt;max_connections&lt;/code&gt;, so replication slots aren't eating your budget.&lt;/p&gt;

&lt;p&gt;Now break it down by type. &lt;code&gt;backend_type&lt;/code&gt; was added in PostgreSQL 10 so you stop conflating autovacuum workers and walsenders with real client load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

   &lt;span class="n"&gt;backend_type&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt;        &lt;span class="k"&gt;state&lt;/span&gt;        &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;
&lt;span class="c1"&gt;-------------------+---------------------+-------&lt;/span&gt;
 &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt;                &lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;61&lt;/span&gt;
 &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;              &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;9&lt;/span&gt;
 &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;7&lt;/span&gt;
 &lt;span class="n"&gt;autovacuum&lt;/span&gt; &lt;span class="n"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;              &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;3&lt;/span&gt;
 &lt;span class="n"&gt;walsender&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;              &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;2&lt;/span&gt;
 &lt;span class="n"&gt;background&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;                     &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventy client backends, and 61 are doing nothing. Run this on a cron every 30 seconds into a table for a day before you decide anything. One sample at 11am isn't a capacity plan.&lt;/p&gt;

&lt;p&gt;If you're not superuser, &lt;code&gt;query&lt;/code&gt; and &lt;code&gt;state&lt;/code&gt; come back NULL for other roles unless you have &lt;code&gt;pg_read_all_stats&lt;/code&gt; or &lt;code&gt;pg_monitor&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you're seeing "sorry, too many clients already"
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Diagnostic&lt;/th&gt;
&lt;th&gt;Fix that isn't "raise the number"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Idle-in-transaction leak&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;state = 'idle in transaction'&lt;/code&gt; with old &lt;code&gt;xact_start&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; plus fix the app's transaction scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App pool × pod count&lt;/td&gt;
&lt;td&gt;pool size × replica count &amp;gt; max_connections&lt;/td&gt;
&lt;td&gt;Shrink per-pod pool; 5 per pod × 40 pods is 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No pooler&lt;/td&gt;
&lt;td&gt;Connection count tracks request rate exactly&lt;/td&gt;
&lt;td&gt;PgBouncer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long analytics queries holding slots&lt;/td&gt;
&lt;td&gt;&lt;code&gt;now() - query_start &amp;gt; '5 min'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Separate read replica, &lt;code&gt;statement_timeout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy-time storms&lt;/td&gt;
&lt;td&gt;Spike at rollout, settles in 60s&lt;/td&gt;
&lt;td&gt;Staggered rollout, pooler absorbs it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;xact_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'idle in transaction'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; defaults to 0 (disabled). Setting it to &lt;code&gt;'5min'&lt;/code&gt; is almost always a better first move than a restart — and it applies with a reload, no downtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The work_mem memory calculation before you raise anything
&lt;/h2&gt;

&lt;p&gt;Concrete box: 16 GB RAM, dedicated Postgres, &lt;code&gt;shared_buffers = 4GB&lt;/code&gt;, &lt;code&gt;work_mem = 16MB&lt;/code&gt;, &lt;code&gt;hash_mem_multiplier = 2.0&lt;/code&gt; (the PG 15 default), &lt;code&gt;maintenance_work_mem = 512MB&lt;/code&gt;, three autovacuum workers.&lt;/p&gt;

&lt;p&gt;First, measure real per-backend private memory. Don't use &lt;code&gt;ps&lt;/code&gt; RSS — it counts shared_buffers pages the backend has touched and will tell you every backend uses 900 MB. &lt;code&gt;smaps_rollup&lt;/code&gt; gives you the honest private footprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"postgres: app"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; Private_ /proc/&lt;span class="nv"&gt;$p&lt;/span&gt;/smaps_rollup 2&amp;gt;/dev/null | &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'{s+=$3} END {print s/1024" MB"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
41.2 MB
12.8 MB
9.6 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it 10 MB typical. Now the arithmetic at 100 vs 400:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;At max_connections = 100
  shared_buffers                4.0 GB
  private (100 × 10 MB)         1.0 GB
  work_mem: 25 active × 2 nodes × 16 MB × 2.0   1.6 GB
  maintenance (3 × 512 MB)      1.5 GB
  OS + page cache headroom      1.0 GB
                              --------
                                9.1 GB   fits in 16 GB

At max_connections = 400 (same active ratio)
  shared_buffers                4.0 GB
  private (400 × 10 MB)         4.0 GB
  work_mem: 100 active × 2 × 16 MB × 2.0        6.4 GB
  maintenance                   1.5 GB
  headroom                      1.0 GB
                              --------
                               16.9 GB   over budget
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;work_mem&lt;/code&gt; is a per-node limit, not per-connection. One query with two sorts and a hash join can allocate several multiples of it, and every parallel worker gets its own allowance. That 2-node assumption is conservative for OLTP and wildly optimistic for a reporting workload.&lt;/p&gt;

&lt;p&gt;The 400 number doesn't fit — and that's before anyone runs a bad report.&lt;/p&gt;

&lt;h2&gt;
  
  
  PgBouncer vs raising max_connections
&lt;/h2&gt;

&lt;p&gt;For most people reading this, PgBouncer in transaction mode ends the conversation. A server connection is held only for the duration of a transaction, so idle clients cost nothing on the database side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[databases]&lt;/span&gt;
&lt;span class="py"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;host=10.0.1.20 port=5432 dbname=app&lt;/span&gt;

&lt;span class="nn"&gt;[pgbouncer]&lt;/span&gt;
&lt;span class="py"&gt;listen_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0&lt;/span&gt;
&lt;span class="py"&gt;listen_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6432&lt;/span&gt;
&lt;span class="py"&gt;auth_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;scram-sha-256&lt;/span&gt;
&lt;span class="py"&gt;auth_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/etc/pgbouncer/userlist.txt&lt;/span&gt;
&lt;span class="py"&gt;pool_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;transaction&lt;/span&gt;
&lt;span class="py"&gt;max_client_conn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2000&lt;/span&gt;
&lt;span class="py"&gt;default_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;40&lt;/span&gt;
&lt;span class="py"&gt;server_idle_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Defaults are &lt;code&gt;max_client_conn = 100&lt;/code&gt; and &lt;code&gt;default_pool_size = 20&lt;/code&gt;, so both need raising deliberately, tuned against the real usage numbers from the connection check above — not guesses.&lt;/p&gt;

&lt;p&gt;What transaction mode breaks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Works in transaction mode?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Session-level &lt;code&gt;SET&lt;/code&gt; / &lt;code&gt;RESET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;LISTEN&lt;/code&gt; / &lt;code&gt;NOTIFY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WITH HOLD&lt;/code&gt; cursors&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session-level advisory locks&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temporary tables&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Named prepared statements&lt;/td&gt;
&lt;td&gt;Only from PgBouncer 1.21+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction-scoped advisory locks&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PgBouncer publishes a full SQL feature map by pooling mode. Read it before you flip the switch. If your app leans on any "No" row, the honest options are session pooling (which gives back most of the connection savings) or a genuinely larger &lt;code&gt;max_connections&lt;/code&gt;, not a workaround.&lt;/p&gt;

&lt;p&gt;One operational detail: PgBouncer is a single-threaded event loop. To use more than one core you run multiple instances behind &lt;code&gt;SO_REUSEPORT&lt;/code&gt;, supported since 1.19.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When raising max_connections is still the right call:&lt;/strong&gt; your app depends on &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; or session advisory locks and can't be refactored; a fleet of stateless services each needs a handful of direct connections and a pooler adds latency you can't afford; you already run a pooler and the &lt;em&gt;server-side&lt;/em&gt; pool needs to be bigger under genuinely measured load. PG 14's snapshot scalability work in &lt;code&gt;GetSnapshotData&lt;/code&gt; cut the overhead idle connections impose on active ones, but an idle connection still costs a process slot and its private memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to increase max_connections in PostgreSQL safely
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;max_connections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pending_restart&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_settings&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'max_connections'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;pending_restart&lt;/span&gt;
&lt;span class="c1"&gt;-----------------+---------+-----------------&lt;/span&gt;
 &lt;span class="n"&gt;max_connections&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pre-flight the new shared memory size before restarting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;postgres &lt;span class="nt"&gt;-C&lt;/span&gt; shared_memory_size &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="nv"&gt;$PGDATA&lt;/span&gt;
5761
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;huge_pages = on&lt;/code&gt;, recount now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;postgres &lt;span class="nt"&gt;-C&lt;/span&gt; shared_memory_size_in_huge_pages &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="nv"&gt;$PGDATA&lt;/span&gt;
2881
&lt;span class="nv"&gt;$ &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; vm.nr_hugepages&lt;span class="o"&gt;=&lt;/span&gt;3000
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"vm.nr_hugepages = 3000"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/sysctl.d/99-postgres.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Raise every standby before the primary.&lt;/strong&gt; A hot standby refuses to continue recovery if &lt;code&gt;max_connections&lt;/code&gt;, &lt;code&gt;max_worker_processes&lt;/code&gt;, &lt;code&gt;max_prepared_transactions&lt;/code&gt;, or &lt;code&gt;max_locks_per_transaction&lt;/code&gt; are lower than on the primary. Standby first, primary second, no exceptions.&lt;/p&gt;

&lt;p&gt;A healthy restart looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LOG:  database system was shut down at 2026-08-04 02:14:07 UTC
LOG:  database system is ready to accept connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Three ways this bites you after restart
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Huge pages shortfall.&lt;/strong&gt; You bumped the setting but forgot &lt;code&gt;vm.nr_hugepages&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL:  could not map anonymous shared memory: Cannot allocate memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recount as above, or set &lt;code&gt;huge_pages = try&lt;/code&gt; so it falls back to normal pages instead of refusing to start — safer during a change window even if it costs some TLB efficiency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standby refuses recovery.&lt;/strong&gt; You did the primary first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL:  hot standby is not possible because max_connections = 100 is a lower
        setting than on the primary server (its value was 200)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raise and restart the standby, ordering as above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OOM killer.&lt;/strong&gt; The box fits at idle and doesn't fit under load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LOG:  server process (PID 28841) was terminated by signal 9: Killed
LOG:  terminating any other active server processes
LOG:  all server processes terminated; reinitializing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres treats an OOM kill as a crash and forces every other session into crash recovery, so one killed backend costs you the whole cluster's availability for the recovery window. Mitigations: &lt;code&gt;PG_OOM_ADJUST_FILE&lt;/code&gt; and &lt;code&gt;PG_OOM_ADJUST_VALUE&lt;/code&gt; to protect the postmaster while leaving backends killable, and &lt;code&gt;vm.overcommit_memory = 2&lt;/code&gt; with a tuned &lt;code&gt;overcommit_ratio&lt;/code&gt; on dedicated hosts so the kernel refuses the overcommit instead of killing you later.&lt;/p&gt;

&lt;p&gt;And the two errors clients will actually see at the ceiling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL:  sorry, too many clients already
FATAL:  remaining connection slots are reserved for non-replication
        superuser connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Managed Postgres (RDS, Aurora, Cloud SQL)
&lt;/h2&gt;

&lt;p&gt;On RDS and Aurora PostgreSQL the default is &lt;code&gt;LEAST({DBInstanceClassMemory/9531392}, 5000)&lt;/code&gt;, derived from instance memory. It's a static parameter, so changing it in the parameter group requires a DB instance reboot. There's no &lt;code&gt;postgresql.conf&lt;/code&gt; to edit and no &lt;code&gt;ALTER SYSTEM&lt;/code&gt;. Cloud SQL follows the same memory-derived, reboot-to-apply pattern. For pooling, RDS Proxy is the AWS-native option; on Supabase it's Supavisor. Same tradeoffs as PgBouncer transaction mode apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order of fixes that actually works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;idle_in_transaction_session_timeout = '5min'&lt;/code&gt;. Costs nothing, ships as a reload, catches the most common cause.&lt;/li&gt;
&lt;li&gt;Fix app-side pool sizing. Per-pod pool × replica count is the real number, and most teams have never multiplied it out.&lt;/li&gt;
&lt;li&gt;PgBouncer in transaction mode. This is the correct answer for the large majority of connection-exhaustion tickets.&lt;/li&gt;
&lt;li&gt;Only then, &lt;code&gt;max_connections&lt;/code&gt; — with the memory arithmetic written down and the standby restarted first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want a second opinion on where your instance sits today, &lt;a href="https://mydba.dev/?utm_source=devto&amp;amp;utm_medium=platform&amp;amp;utm_campaign=postgres-max-connections-guide" rel="noopener noreferrer"&gt;MyDBA&lt;/a&gt;'s free health check looks at connection distribution and memory settings together, which is the pairing that matters here.&lt;/p&gt;

&lt;p&gt;The value in &lt;code&gt;postgresql.conf&lt;/code&gt; is easy to change. The RAM on the box is not.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
