<?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: Pranay Ravi</title>
    <description>The latest articles on DEV Community by Pranay Ravi (@pranay_raavi).</description>
    <link>https://dev.to/pranay_raavi</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%2F3372066%2Fdab53ee7-0e9d-4dab-8313-32931f0fa124.jpg</url>
      <title>DEV Community: Pranay Ravi</title>
      <link>https://dev.to/pranay_raavi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pranay_raavi"/>
    <language>en</language>
    <item>
      <title>What to Keep in Mind for a Cloud Migration</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Thu, 12 Nov 2026 07:09:07 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/what-to-keep-in-mind-for-a-cloud-migration-56mg</link>
      <guid>https://dev.to/pranay_raavi/what-to-keep-in-mind-for-a-cloud-migration-56mg</guid>
      <description>&lt;p&gt;I have led a couple of platform migrations, from on-premises to AWS and from one AWS account structure to another. Each time my job was to coordinate the application, database, DevOps and network teams from scoping to cutover.&lt;/p&gt;

&lt;p&gt;This is the list I would want you to keep in mind before you start one. It is grouped into seven categories. Under each I say what to think about and, where it helps, what we did.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Scope: decide what changes and what waits
&lt;/h2&gt;

&lt;p&gt;A migration is a chance to modernize, and it is also the easiest place to over-engineer. Start from the rules of the target environment. Ours were no static credentials, TLS on database connections, nothing public by default, autoscaling, and tags on every resource so cost splits by application.&lt;/p&gt;

&lt;p&gt;Then list every improvement someone wants and make a call on each one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Think about&lt;/th&gt;
&lt;th&gt;What we did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;Move to a managed engine? Upgrade the major version in the same move?&lt;/td&gt;
&lt;td&gt;Moved to Aurora with an engine upgrade and TLS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Can VMs, ECS and EKS collapse into one?&lt;/td&gt;
&lt;td&gt;Consolidated on EKS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;Use a secrets manager and take passwords out of code and config files&lt;/td&gt;
&lt;td&gt;Secrets moved into AWS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration&lt;/td&gt;
&lt;td&gt;Externalize parameters so the same build deploys to every environment&lt;/td&gt;
&lt;td&gt;Config maps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logging&lt;/td&gt;
&lt;td&gt;Send application logs to a central store instead of local disk&lt;/td&gt;
&lt;td&gt;Central search cluster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access&lt;/td&gt;
&lt;td&gt;Use roles between services and drop long-lived keys&lt;/td&gt;
&lt;td&gt;Role-based access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipeline&lt;/td&gt;
&lt;td&gt;Can one pipeline deploy everything? Is GitOps in reach?&lt;/td&gt;
&lt;td&gt;One CI server in a shared account. GitOps came later, once the runtime was standard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search and loaders&lt;/td&gt;
&lt;td&gt;Upgrade or rewrite now, or carry them over as they are?&lt;/td&gt;
&lt;td&gt;Deferred. Both needed application code changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backups&lt;/td&gt;
&lt;td&gt;Does going live need a new backup design?&lt;/td&gt;
&lt;td&gt;Deferred. Snapshots were enough&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two columns in the task tracker kept this honest: "key project goal" and "blocker for the first environment". A task with a no in both waited.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Discovery: get the complete picture
&lt;/h2&gt;

&lt;p&gt;Get a complete application architecture diagram before anyone builds. Expect the first version to be partial, and keep asking. A platform that has run for years has connections nobody wrote down.&lt;/p&gt;

&lt;p&gt;Build an inventory for every environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which component runs where, and why it runs there.&lt;/li&gt;
&lt;li&gt;Every DNS name, with its zone, load balancer, protocol, target group and the resource behind it.&lt;/li&gt;
&lt;li&gt;Every inbound caller and every outbound dependency.&lt;/li&gt;
&lt;li&gt;Every loader, job and script that reads or writes the data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check your assumptions two and three times, including the convenient ones. We worried that bucket names were stored in the database, which would have made renaming buckets painful. We checked, and they were in a properties file. We also assumed a transit gateway would cover traffic between accounts, and we needed VPC peering as well.&lt;/p&gt;

&lt;p&gt;Compare the environments against each other. Production and non-production are rarely built the same way, and the differences are where the cutover steps change.&lt;/p&gt;

&lt;p&gt;Even with all of this, three things found us late: another product's web client that read our object storage directly, a data warehouse job that read a logging table we planned to retire, and a monitoring script that counted documents in the old search cluster. Leave room in the plan for your own version of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. People and calendar: one scrum, one plan
&lt;/h2&gt;

&lt;p&gt;Each team has its own backlog, its own way of working and other projects competing for the same people. Someone has to keep those plans moving as one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run one scrum across all teams.&lt;/strong&gt; Ours started weekly, went to twice a week to clear blockers, and became daily near the first cutover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask every team the same three questions.&lt;/strong&gt; What are you working on, and what is the estimate? What is blocked or likely to slip, and what is the alternative? What still has to happen for the next environment to cut over on its date?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep one board.&lt;/strong&gt; One epic, with each team's own tickets linked in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spell out requirements early.&lt;/strong&gt; When one team settles on a solution, share what it needs from the others the same day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post outcomes in the shared channel.&lt;/strong&gt; Two-person working sessions are fine, but the result belongs where every team sees it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Follow up instead of waiting.&lt;/strong&gt; It never hurts to over-communicate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan around the calendar.&lt;/strong&gt; Change freezes, public holidays and time off for key people all punch holes in a quarter. Keep a tab for who is out and when, and set each environment window around it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dates will still move, and ours did on the first environment. Build slack around the known gaps so one slip does not push every later window.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Data: the part that decides the cutover
&lt;/h2&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%2F0o8qshp8sg6rzhm6mjh7.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%2F0o8qshp8sg6rzhm6mjh7.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Compute can be redeployed. Data has to be moved, kept in sync and proven correct, so it shapes the whole cutover. There is no single right method, only questions you have to answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you take downtime?&lt;/strong&gt; Decide this first, with the product owner. A short window removes a whole class of design. We could take one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One direction or two?&lt;/strong&gt; Bidirectional replication buys an easy rollback and costs you conflict handling and weeks of testing. We tested replicating back from the upgraded engine to the old one and hit problems. We chose one direction: seed the new database from a shared snapshot, replicate old to new with native replication, and roll back by export and import. That rollback carries downtime, so tell the product team before they agree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native or heterogeneous tooling?&lt;/strong&gt; Every engine has its own replication. Tools such as AWS DMS or Oracle GoldenGate cover moves across engines. Pick the option with the fewest steps to script and test. For us that was native.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where else does the data live?&lt;/strong&gt; Rarely in one place. Ours was in three, each with its own method:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Store&lt;/th&gt;
&lt;th&gt;How it moved&lt;/th&gt;
&lt;th&gt;Rollback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Relational database&lt;/td&gt;
&lt;td&gt;Snapshot seed, then one-way native replication&lt;/td&gt;
&lt;td&gt;Export and import&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search cluster&lt;/td&gt;
&lt;td&gt;Snapshot to a shared bucket and restore, then a remote reindex of the current day during the window&lt;/td&gt;
&lt;td&gt;Reindex back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object storage&lt;/td&gt;
&lt;td&gt;Bidirectional bucket replication, left on for days after cutover&lt;/td&gt;
&lt;td&gt;Files already in both accounts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What feeds it and what reads it?&lt;/strong&gt; List every inbound loader and every downstream reader. We stopped the loaders before the window and repointed them after. The downstream warehouse stayed on the old database for one more day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What can testing break?&lt;/strong&gt; Readiness tests that write to the new database while replication is running can break the replication. Plan which tests write, and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Network and edges: where it usually breaks
&lt;/h2&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%2Fen1ywhhpfgyqf1lfkz4p.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%2Fen1ywhhpfgyqf1lfkz4p.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The application and the database get the attention. The surprises come from DNS, load balancers and allowlists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS.&lt;/strong&gt; If the domain's hosting moves too, stage it so a rollback never touches the registrar. We moved ours into Route 53 like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the new hosted zone as an empty shell. Not live, and no records created early.&lt;/li&gt;
&lt;li&gt;Add temporary names in the old zone that point at the new load balancers, so the new stack can be tested before cutover.&lt;/li&gt;
&lt;li&gt;At cutover, repoint the old zone's records to the new load balancers.&lt;/li&gt;
&lt;li&gt;The next day, sync the records into the new zone.&lt;/li&gt;
&lt;li&gt;After 24 hours, publish the new name servers at the registrar next to the old ones.&lt;/li&gt;
&lt;li&gt;After another 24 hours, remove the old name servers and watch the URL monitors.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Load balancers.&lt;/strong&gt; Decide where TLS terminates, what the listener rules are, and whether each endpoint gets its own load balancer or shares one with host and path routing. Have services call each other through a load balancer or a DNS name, not through the cluster's internal networking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowlists.&lt;/strong&gt; A new account means new outbound addresses. Every partner and internal service that allowlisted the old ones needs the new ones before cutover. Give every outbound call (email delivery, analytics, partner APIs) its own line in the inventory. Check inbound routes too, such as loaders that still run on-premises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Object storage.&lt;/strong&gt; If browser or mobile clients read buckets directly, the CORS rules move with the buckets. If bucket names change, find every place a name is stored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edge protection.&lt;/strong&gt; Check what sits in front of production that the lower environments lack. One of our APIs was behind a web application firewall only in production, and a deployment that rewrote its DNS record would have broken it.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Cutover: rehearse it the same way every time
&lt;/h2&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%2F8q8chmy2u2752hfq4cko.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%2F8q8chmy2u2752hfq4cko.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Cut over the lowest environment exactly the way you will cut over production. People will ask why QA needs this much ceremony. QA is the only rehearsal production gets.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use one runbook format for every environment.&lt;/strong&gt; Ours had a preparation tab, a cutover tab, a rollback tab and a DNS tab. Every row had a date, time, duration, team, owner and status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the application test cases before the first environment.&lt;/strong&gt; Run the same set in every environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get a sign-off from each team.&lt;/strong&gt; Once before the window and again after it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Script every cutover and rollback command.&lt;/strong&gt; Store the scripts before the night.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say ahead of time what a lower environment cannot prove.&lt;/strong&gt; A production-only firewall, a registrar change and a disaster-recovery copy are typical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give cleanup its own change request.&lt;/strong&gt; We had two people present for every bucket deletion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our rehearsals showed that deployments had to finish before the window, that published DNS had to be checked against expected results, and that the cutover needed an application redeploy. Each of those would have been a surprise in production.&lt;/p&gt;

&lt;p&gt;For a sense of scale, our production runbook budgeted about forty minutes from cutting traffic to sign-off.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Monitoring, retention and cost: decide early
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Monitoring.&lt;/strong&gt; Set up monitoring and central logging in the new environment before the cutover. Put full coverage of databases and application URLs on the production readiness list. Stay on one approved logging path. If the cluster auto-instruments applications, a new service comes up already covered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retention.&lt;/strong&gt; Autoscaling without retention is a bill that grows quietly. Set retention at every layer as it is built: log indices, database logging tables, buckets and container images. We left some of these until after cutover, and I would not do that again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; The cost lessons are mostly about deciding early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pick instance classes before the build, so reserved capacity can be planned.&lt;/li&gt;
&lt;li&gt;Run the consolidated cluster on reserved instances.&lt;/li&gt;
&lt;li&gt;Create temporary instances with volumes that delete on termination.&lt;/li&gt;
&lt;li&gt;Question any design that copies data between accounts more than it needs to.&lt;/li&gt;
&lt;li&gt;Ask whether every log line is needed before paying to ship and store it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;A migration is mostly coverage. Know what you have, decide what changes now and what waits, move the data with a rollback you have tested, and rehearse the cutover in every environment. In my experience the hard moments come from things nobody wrote down far more often than from the technology.&lt;/p&gt;

&lt;p&gt;It is also never one person's work. Application, database, DevOps and network engineers build it. The lead's job is to keep the scope honest, the plan visible and the teams talking to each other.&lt;/p&gt;

&lt;p&gt;With a clear scope, a complete architecture diagram, a regular scrum and a firm timeline, and with AI tools to speed up the inventory and runbook work, three to five months is a fair plan for most platform migrations.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>devops</category>
      <category>database</category>
    </item>
    <item>
      <title>Auditing Aurora PostgreSQL Without Drowning in Logs (or Leaking PII Into Them)</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Fri, 06 Nov 2026 05:46:56 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/auditing-aurora-postgresql-without-drowning-in-logs-or-leaking-pii-into-them-2fnl</link>
      <guid>https://dev.to/pranay_raavi/auditing-aurora-postgresql-without-drowning-in-logs-or-leaking-pii-into-them-2fnl</guid>
      <description>&lt;p&gt;When we moved a set of Oracle workloads to Aurora PostgreSQL, the compliance ask sounded simple: audit every statement that touches sensitive tables. Set &lt;code&gt;pgaudit.log = 'all'&lt;/code&gt; everywhere and you have it covered, right?&lt;/p&gt;

&lt;p&gt;Not quite. Turning auditing all the way up creates two new problems. The log stream gets so noisy that nobody reads it, so a real incident hides among thousands of driver and IDE catalog queries. And with the wrong parameter, the audit log quietly becomes another copy of the sensitive data you were trying to protect.&lt;/p&gt;

&lt;p&gt;This is a follow-up to part 1, which covered the alerting pipeline: per-user pgAudit, the Logs Insights noise filter, and the Lambda that turns query results into a CloudWatch alarm.&lt;/p&gt;


&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/pranay_raavi/what-it-actually-takes-to-audit-aurora-postgresql-on-aws-l7j" class="crayons-story__hidden-navigation-link"&gt;What It Actually Takes to Audit Aurora PostgreSQL on AWS&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/pranay_raavi" class="crayons-avatar  crayons-avatar--l  "&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3372066%2Fdab53ee7-0e9d-4dab-8313-32931f0fa124.jpg" alt="pranay_raavi profile" class="crayons-avatar__image" width="800" height="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/pranay_raavi" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Pranay Ravi
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Pranay Ravi
                
                
              
              &lt;div id="story-author-preview-content-3720708" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/pranay_raavi" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3372066%2Fdab53ee7-0e9d-4dab-8313-32931f0fa124.jpg" class="crayons-avatar__image" alt="" width="800" height="800"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Pranay Ravi&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/pranay_raavi/what-it-actually-takes-to-audit-aurora-postgresql-on-aws-l7j" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;May 21&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/pranay_raavi/what-it-actually-takes-to-audit-aurora-postgresql-on-aws-l7j" id="article-link-3720708"&gt;
          What It Actually Takes to Audit Aurora PostgreSQL on AWS
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/aws"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;aws&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/postgres"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;postgres&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/security"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;security&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/pranay_raavi/what-it-actually-takes-to-audit-aurora-postgresql-on-aws-l7j#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            7 min read
          &lt;/small&gt;
          
          &lt;div class="only-sidebar-menu-item trusted-visible-block print-hidden align-center"&gt;
                
                    
                
          &lt;/div&gt;

        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


&lt;p&gt;This post covers what came next: testing two pgAudit configurations side by side, four parameters that behaved differently than we expected, and the layers that protect the data itself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cluster names, accounts, users and data in every example are placeholders.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The architecture at a glance
&lt;/h2&gt;

&lt;p&gt;No single tool both logs everything and protects sensitive text, so the design has three layers: &lt;strong&gt;capture&lt;/strong&gt; (pgAudit), &lt;strong&gt;protect&lt;/strong&gt; (masking and access control) and &lt;strong&gt;store and alert&lt;/strong&gt; (CloudWatch, S3 and friends).&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%2Faxn4wni7640j4jklvb5z.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%2Faxn4wni7640j4jklvb5z.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    Aurora PostgreSQL cluster
          (pgAudit writes AUDIT lines · pg_columnmask masks reads)
                 │                                   │
            log export                        activity stream
                 ▼                                   ▼
          CloudWatch Logs                 Database Activity Streams
                 │                        (Kinesis, KMS, DBAs can't alter)
        ┌────────┴─────────┐                         │
        ▼                  ▼                         ▼
 Lambda (every 5 min)   Firehose                  Firehose
        │                  │                         │
        ▼                  ▼                         ▼
 Metric + alarm      S3 audit archive         S3 activity archive
        │            (SSE-KMS, Glacier)       (separate KMS key)
        ▼                  │
       SNS                 ▼
                     Athena + Macie
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The left two columns share one CloudWatch log group: one path turns it into alerts, the other into a long-term archive. The right column runs alongside and depends on nothing a database user can configure.&lt;/p&gt;


&lt;h2&gt;
  
  
  Setup, briefly
&lt;/h2&gt;

&lt;p&gt;Part 1 walks through enabling pgAudit, including the silent failure when &lt;code&gt;CREATE EXTENSION pgaudit&lt;/code&gt; is missing. The short version: preload &lt;code&gt;pgaudit&lt;/code&gt; in the cluster parameter group, reboot, create the extension in each database, set &lt;code&gt;pgaudit.log&lt;/code&gt; per role, and export the &lt;code&gt;postgresql&lt;/code&gt; log to CloudWatch Logs.&lt;/p&gt;

&lt;p&gt;Audit lines land in the log group &lt;code&gt;/aws/rds/cluster/&amp;lt;cluster&amp;gt;/postgresql&lt;/code&gt;, one stream per instance. Each one starts with &lt;code&gt;AUDIT: SESSION&lt;/code&gt;, followed by the statement class (READ, WRITE, DDL, ROLE, FUNCTION, MISC), the command, the object and the statement text.&lt;/p&gt;


&lt;h2&gt;
  
  
  Opt-in or baseline? We tested both
&lt;/h2&gt;

&lt;p&gt;A cluster-wide baseline of &lt;code&gt;ddl, role, write, function&lt;/code&gt; plus &lt;code&gt;all&lt;/code&gt; for a few named roles beat opt-in auditing in our tests. Part 1 scoped auditing to named human accounts only. Another team's cluster used a cluster-wide baseline instead, so we ran the same script against both.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opt-in:&lt;/strong&gt; &lt;code&gt;pgaudit.log = none&lt;/code&gt; in the parameter group; &lt;code&gt;ALTER ROLE ... SET pgaudit.log = 'all'&lt;/code&gt; for selected roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baseline:&lt;/strong&gt; &lt;code&gt;pgaudit.log = 'ddl,role,write,function'&lt;/code&gt; in the parameter group; the same &lt;code&gt;all&lt;/code&gt; override for selected roles.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script created a schema and table, ran INSERT/UPDATE/DELETE, a SELECT, a CREATE/ALTER/DROP, an anonymous &lt;code&gt;DO&lt;/code&gt; block, a GRANT and &lt;code&gt;DISCARD PLANS&lt;/code&gt;. We ran it as a flagged role and as a brand-new application role nobody had configured.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Statement class&lt;/th&gt;
&lt;th&gt;Flagged role (&lt;code&gt;all&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;New role, opt-in&lt;/th&gt;
&lt;th&gt;New role, baseline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DDL (CREATE / ALTER / DROP)&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;td&gt;❌ Not logged&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WRITE (INSERT / UPDATE / DELETE)&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;td&gt;❌ Not logged&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ROLE (GRANT)&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;td&gt;❌ Not logged&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FUNCTION (&lt;code&gt;DO&lt;/code&gt; block)&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;td&gt;❌ Not logged&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;READ (SELECT)&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;td&gt;❌ Not logged&lt;/td&gt;
&lt;td&gt;❌ Not logged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MISC (&lt;code&gt;DISCARD PLANS&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;✅ Logged&lt;/td&gt;
&lt;td&gt;❌ Not logged&lt;/td&gt;
&lt;td&gt;❌ Not logged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Opt-in has a blind spot exactly where you least want one: any account nobody remembered to flag is invisible, including one created yesterday. The baseline still records every schema change, data change and privilege change from every role, and keeps high-volume SELECT logging to the roles you name.&lt;/p&gt;

&lt;p&gt;One caveat applies to both: these are role and session settings. Anyone with enough privilege to change role settings can turn their own auditing down, which is the gap Database Activity Streams (covered below) closes.&lt;/p&gt;


&lt;h2&gt;
  
  
  Four parameters that changed our design
&lt;/h2&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;pgaudit.log_parameter&lt;/code&gt;: the audit log becomes a data store
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;log_parameter = on&lt;/code&gt;, pgAudit writes bind values into the last field of each line. That is what makes the audit trail useful for reconstructing who changed what. It is also how a patient name or card number ends up in CloudWatch Logs. The same prepared INSERT, logged both ways:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# log_parameter = on
AUDIT: SESSION,17,1,WRITE,INSERT,,,"PREPARE test_insert(INT, TEXT) AS
    INSERT INTO pgaudit_test.audit_test (id, name) VALUES ($1, $2);","101,Jane Example"

# log_parameter = off
AUDIT: SESSION,17,1,WRITE,INSERT,,,"PREPARE test_insert(INT, TEXT) AS
    INSERT INTO pgaudit_test.audit_test (id, name) VALUES ($1, $2);",&amp;lt;not logged&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;We keep it off by default. Where an auditor needs values, we enable it per role with &lt;code&gt;ALTER ROLE ... SET pgaudit.log_parameter = on&lt;/code&gt;, and treat that log group like the source table: KMS-encrypted, narrow IAM read access, short retention.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Statements written with inline literals carry their values in the statement text regardless of this setting.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;pgaudit.log_relation&lt;/code&gt;: answering "who touched this table?"
&lt;/h3&gt;

&lt;p&gt;With it off, the object-type and object-name fields of a SELECT or DML line are empty (&lt;code&gt;WRITE,INSERT,,,&lt;/code&gt;). With it on, pgAudit fills them (&lt;code&gt;WRITE,INSERT,TABLE,pgaudit_test.customer&lt;/code&gt;) and logs a line per relation the statement references.&lt;/p&gt;

&lt;p&gt;"Who touched this table in the last 24 hours" becomes a field filter instead of a regex over SQL text. It can be scoped per database (&lt;code&gt;ALTER DATABASE app_db SET pgaudit.log_relation = on&lt;/code&gt;); the cost is extra lines for joins and multi-table statements.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;log_statement&lt;/code&gt;: everything logged twice
&lt;/h3&gt;

&lt;p&gt;Native &lt;code&gt;log_statement&lt;/code&gt; and pgAudit run independently. On a cluster with &lt;code&gt;log_statement = 'ddl'&lt;/code&gt;, every CREATE and DROP appeared twice: once as a plain &lt;code&gt;LOG: statement:&lt;/code&gt; line and once as an &lt;code&gt;AUDIT:&lt;/code&gt; line. Set it to &lt;code&gt;none&lt;/code&gt; in the cluster parameter group. For a debugging session, scope it and clean up afterwards:&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_admin&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;app_db&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;log_statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'all'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ...debug...&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_admin&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;app_db&lt;/span&gt; &lt;span class="k"&gt;RESET&lt;/span&gt; &lt;span class="n"&gt;log_statement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;log_connections&lt;/code&gt; and &lt;code&gt;log_disconnections&lt;/code&gt;: cluster level only
&lt;/h3&gt;

&lt;p&gt;These are read when a session starts, so in our tests per-database and per-role settings had no effect. They have to be on in the cluster parameter group. One of our two clusters had them off, which meant its audit trail showed statements but not session start, end or duration.&lt;/p&gt;


&lt;h2&gt;
  
  
  What a baseline means for alerting
&lt;/h2&gt;

&lt;p&gt;The alerting path is the one from part 1: EventBridge runs a Lambda every 5 minutes, the Lambda runs the Logs Insights noise filter and publishes a count, and a CloudWatch alarm notifies SNS. Code, IAM and SNS/KMS policies and a Terraform module are in the repo:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/pcraavi" rel="noopener noreferrer"&gt;
        pcraavi
      &lt;/a&gt; / &lt;a href="https://github.com/pcraavi/PostgreSQL-Audit" rel="noopener noreferrer"&gt;
        PostgreSQL-Audit
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Setup PostgreSQL Audit in AWS and get alerted based on Cloudwatch ,Lambda and SNS 
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Aurora PostgreSQL User Audit Monitor&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Detect and alert on individual user DML activity on Aurora PostgreSQL clusters using pgAudit, Lambda, CloudWatch, and SNS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit scope:&lt;/strong&gt; Individual human users making direct DML changes (SELECT, INSERT, UPDATE, DELETE) on application tables. Application service accounts are deliberately excluded — their DML is expected. The risk surface is direct human access: a developer connected via psql, a support engineer running an ad-hoc update, a credential that should never have had direct database access.&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Repository Structure&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;├── README.md
├── deploy-all.sh                        # Full CLI deployment sequence (single account)
│
├── lambda/
│   ├── lambda_function.py               # Core audit monitor — Log Insights query, metric publish
│   └── deploy-lambda.sh                 # Package and deploy / update Lambda
│
├── iam/
│   ├── lambda-trust-policy.json         # Lambda execution role trust policy
│   ├── lambda-permissions-policy.json   # Least-privilege: Log Insights read + CW metric write
│   └── deploy-iam.sh                    # Create role and attach&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/pcraavi/PostgreSQL-Audit" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;One thing changes if you adopt the baseline: &lt;strong&gt;application accounts now log their writes too.&lt;/strong&gt; A &lt;code&gt;count &amp;gt; 0&lt;/code&gt; alarm built for human-only auditing would fire on normal traffic. Either filter on the human accounts' user names (they appear in each line's &lt;code&gt;user@database&lt;/code&gt; prefix), or alert on DDL and ROLE statements only for everyone else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Protecting the data and keeping the trail
&lt;/h2&gt;

&lt;p&gt;pgAudit answers "what happened." Three more pieces cover "who could see it" and "can we prove it later."&lt;/p&gt;

&lt;h3&gt;
  
  
  Mask at query time with &lt;code&gt;pg_columnmask&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Aurora PostgreSQL 16.10+ and 17.6+ ship &lt;code&gt;pg_columnmask&lt;/code&gt;, which masks column values at query time based on the caller's role and leaves stored data unchanged (&lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraPostgreSQL.Security.DynamicMasking.html" rel="noopener noreferrer"&gt;AWS docs&lt;/a&gt;). A support role can see a partial email while the application role sees the real value, and weights decide which policy wins when several apply. Pair it with PostgreSQL row-level security when you also need to limit which rows a role sees.&lt;/p&gt;

&lt;p&gt;Three details from the &lt;a href="https://www.repost.aws/knowledge-center/aurora-dynamic-data-masking" rel="noopener noreferrer"&gt;AWS troubleshooting guide&lt;/a&gt; are worth knowing before rollout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Table owners and &lt;code&gt;rds_superuser&lt;/code&gt; members are exempt, so test as the actual target role.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_dump&lt;/code&gt; run as a masked role produces masked data, which can break constraints on restore.&lt;/li&gt;
&lt;li&gt;A materialized view is masked according to its owner's policy at refresh time, regardless of who reads it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Close the insider gap with Database Activity Streams
&lt;/h3&gt;

&lt;p&gt;Anyone who can change role settings can turn pgAudit down for themselves. Part 1 explained why we passed on &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/DBActivityStreams.html" rel="noopener noreferrer"&gt;Database Activity Streams&lt;/a&gt; for alerting: Kinesis as a dependency, per-event cost and a consumer layer to build. For the insider case they are still the answer, because the stream is KMS-encrypted and DBAs can't read or alter it.&lt;/p&gt;

&lt;p&gt;Aurora PostgreSQL supports synchronous mode, where a session waits until its event is durable, trading latency for completeness. Before turning it on, check that your instance class is supported, and plan for a restart: starting the stream with "apply immediately" restarts the cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep the trail queryable, and scan it
&lt;/h3&gt;

&lt;p&gt;CloudWatch Logs is a poor long-term archive. A subscription filter sends the PostgreSQL log through Firehose to an S3 bucket encrypted with a dedicated KMS key, where Athena can query years of history and a lifecycle rule moves old objects to Glacier.&lt;/p&gt;

&lt;p&gt;Point an Amazon Macie classification job at that bucket on a schedule. If sensitive values slip into the logs through &lt;code&gt;log_parameter&lt;/code&gt; or inline literals, Macie is how you find out before an auditor does.&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;# Bucket policy essentials: deny unencrypted writes and non-TLS access&lt;/span&gt;
&lt;span class="c"&gt;# "Condition": {"StringNotEquals": {"s3:x-amz-server-side-encryption": "aws:kms"}}&lt;/span&gt;
&lt;span class="c"&gt;# "Condition": {"Bool": {"aws:SecureTransport": "false"}}&lt;/span&gt;

aws macie2 create-classification-job &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; audit-log-pii-scan &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--job-type&lt;/span&gt; SCHEDULED &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--schedule-frequency&lt;/span&gt; &lt;span class="s1"&gt;'{"weeklySchedule":{"dayOfWeek":"MONDAY"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--s3-job-definition&lt;/span&gt; &lt;span class="s1"&gt;'{"bucketDefinitions":[{"accountId":"111122223333","buckets":["my-audit-log-bucket"]}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;p&gt;If you are about to turn on auditing for Aurora PostgreSQL, this is the checklist we would hand you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Preload &lt;code&gt;pgaudit&lt;/code&gt; and set a cluster-wide baseline of &lt;code&gt;ddl, role, write, function&lt;/code&gt;, not &lt;code&gt;none&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Reserve &lt;code&gt;pgaudit.log = 'all'&lt;/code&gt; for the human and break-glass roles you name.&lt;/li&gt;
&lt;li&gt;Leave &lt;code&gt;pgaudit.log_parameter&lt;/code&gt; off unless an auditor needs values, and then scope it to a role.&lt;/li&gt;
&lt;li&gt;Turn on &lt;code&gt;pgaudit.log_relation&lt;/code&gt; where you need per-table answers.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;log_statement = none&lt;/code&gt;; set &lt;code&gt;log_connections&lt;/code&gt; and &lt;code&gt;log_disconnections&lt;/code&gt; on, at the cluster level.&lt;/li&gt;
&lt;li&gt;If you move to a baseline, scope alerts to human accounts or to DDL and ROLE statements, or application writes will page you.&lt;/li&gt;
&lt;li&gt;Encrypt, retain and scan the archive; add masking for readers and Activity Streams for the insider case.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Auditing is easy to switch on and hard to make useful. Most of the work is deciding what not to log, and making sure the log itself doesn't become the next thing you need to protect.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you run pgAudit with a cluster-wide baseline, or put &lt;code&gt;pg_columnmask&lt;/code&gt; in front of a support team yet? Drop a comment. I'm curious how others are handling &lt;code&gt;log_parameter&lt;/code&gt; in regulated environments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>postgres</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Not Every Slow Query Needs a New Index</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:53:03 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/not-every-slow-query-needs-a-new-index-5cn8</link>
      <guid>https://dev.to/pranay_raavi/not-every-slow-query-needs-a-new-index-5cn8</guid>
      <description>&lt;p&gt;&lt;em&gt;What a SQL review taught me about assumptions, AI-written queries, and common-sense tuning&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A development team asked me to review the SQL behind a change-data publishing job before they moved it from once a day to once an hour. In QA, one full run took 10 to 12 minutes. After the review, the same run took under 30 seconds. In production, most of its queries now finish in under a millisecond.&lt;/p&gt;

&lt;p&gt;Almost none of that came from adding indexes. It came from reading the queries, asking what the job actually needed, and checking each assumption against real data. This post walks through what we found, in the order we found it, and what I would tell any team (or any AI assistant) writing SQL against a busy production database.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You don't always need to tune the database. Often you need to understand the use case and explain how the database behaves.&lt;/li&gt;
&lt;li&gt;Functions on indexed columns and "this column might be null" assumptions are the most common silent performance killers.&lt;/li&gt;
&lt;li&gt;What is harmless in QA can be dangerous in production, because the data is shaped differently.&lt;/li&gt;
&lt;li&gt;An index can make a query slower. Measure after every change.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The job polls a PostgreSQL read replica for rows that changed since its last run and publishes them as events. It is a classic checkpoint pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read a ceiling: the newest change timestamp in the source tables.&lt;/li&gt;
&lt;li&gt;Count the rows changed between the last checkpoint and that ceiling.&lt;/li&gt;
&lt;li&gt;Page through them with keyset pagination, 500 rows at a time.&lt;/li&gt;
&lt;li&gt;Store the ceiling as the next checkpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'll use a generic schema for the examples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Rows (largest)&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;accounts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~1M&lt;/td&gt;
&lt;td&gt;Users, organizations and partners, with an &lt;code&gt;account_type&lt;/code&gt; column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contracts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~1,100&lt;/td&gt;
&lt;td&gt;Partner contracts, keyed by the owning account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;memberships&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~650k&lt;/td&gt;
&lt;td&gt;Which accounts belong to which organization, with a &lt;code&gt;membership_type&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org_members&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~9M&lt;/td&gt;
&lt;td&gt;Account-to-location assignments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;locations&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~hundreds of k&lt;/td&gt;
&lt;td&gt;Organization addresses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every table has &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt;. The source system also maintains &lt;code&gt;updated_at_num&lt;/code&gt;, a numeric &lt;code&gt;YYYYMMDDHHMISS&lt;/code&gt; copy of &lt;code&gt;updated_at&lt;/code&gt;, and that column is indexed on every table. Keep that last detail in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: A function on an indexed column throws the index away
&lt;/h2&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%2F9x5exekdjj0mup17m2ws.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%2F9x5exekdjj0mup17m2ws.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every change-window filter in the job looked like 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="k"&gt;WHERE&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;checkpoint&lt;/span&gt;
  &lt;span class="k"&gt;AND&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent is reasonable: if a row was never updated, fall back to its creation time. But PostgreSQL can only use a B-tree index when the predicate matches the indexed expression. Wrap the column in &lt;code&gt;COALESCE&lt;/code&gt;, and the index on &lt;code&gt;updated_at&lt;/code&gt; (or &lt;code&gt;updated_at_num&lt;/code&gt;) is no longer a candidate. Every window query became a sequential scan of the whole table.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;COALESCE&lt;/code&gt; also encoded an assumption: that &lt;code&gt;updated_at&lt;/code&gt; can be null. So before changing anything, we checked:&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;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;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;updated_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;span class="c1"&gt;-- 0&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;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;updated_at_num&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;span class="c1"&gt;-- 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero on every table. The source system sets both timestamps when a row is created, so the fallback could never fire. The fix was to drop the function and filter on the indexed column 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;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;updated_at_num&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;checkpoint&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;updated_at_num&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one change turned every window query into an index range scan.&lt;/p&gt;

&lt;p&gt;Two things to take from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't defend against a condition until you've checked whether it exists.&lt;/strong&gt; A one-line &lt;code&gt;count(*)&lt;/code&gt; settled a question the code had been paying for on every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you really do need the fallback, write it so it can use an index.&lt;/strong&gt; Either &lt;code&gt;(updated_at &amp;gt; x AND updated_at &amp;lt;= y) OR (updated_at IS NULL AND created_at &amp;gt; x AND created_at &amp;lt;= y)&lt;/code&gt;, or an expression index on exactly &lt;code&gt;COALESCE(updated_at, created_at)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lesson 2: What is fine in QA can bite in production
&lt;/h2&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%2Fljay09x6gj3obcq021ur.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%2Fljay09x6gj3obcq021ur.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;COALESCE&lt;/code&gt; wasn't the only defensive habit. One query filtered on &lt;code&gt;TRIM(a.account_type) = 'STAFF'&lt;/code&gt;. In QA that is harmless: small tables, fast scans, correct results. In production it does two quiet things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It stops any index on &lt;code&gt;account_type&lt;/code&gt; from being used.&lt;/li&gt;
&lt;li&gt;It hints that some values might be padded with spaces. If that were true, every &lt;em&gt;other&lt;/em&gt; query that compares &lt;code&gt;account_type&lt;/code&gt; without &lt;code&gt;TRIM&lt;/code&gt; would silently miss those rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Again, the answer was in the data:&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;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;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_type&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;TRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No padded values, so the &lt;code&gt;TRIM&lt;/code&gt; came out, and the queries became consistent with each other.&lt;/p&gt;

&lt;p&gt;I want to be clear about where this comes from. The developers I work with write good SQL. But we are now in an era where a lot of SQL is drafted by AI assistants, and they are very good at producing queries that return the right answer. They are much less good at knowing your data. A model will happily add &lt;code&gt;COALESCE&lt;/code&gt;, &lt;code&gt;TRIM&lt;/code&gt;, &lt;code&gt;LOWER&lt;/code&gt; or &lt;code&gt;DISTINCT&lt;/code&gt; "just in case", because in the abstract each one is safer. On a 10,000-row QA table you'll never notice. On a 10-million-row production table, each one can turn an index lookup into a full scan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result being correct does not mean the assumptions behind it are.&lt;/strong&gt; Before a query ships, check it against the data's real shape: null counts, value distributions, duplicates, and how skewed the busiest keys are. Or ask a database engineer to review it. It never hurts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: Carry every filter you know into the subquery
&lt;/h2&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%2Fgk86w4wbaiwqe92gwjk6.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%2Fgk86w4wbaiwqe92gwjk6.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The partner-count query looked like 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="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;contracts&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;accounts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&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;account_id&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;account_id&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;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;org&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;updated_at_num&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;checkpoint&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;updated_at_num&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;snapshot&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;account_id&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="k"&gt;DISTINCT&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;parent_account_id&lt;/span&gt;
        &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;memberships&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;membership_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PRIMARY'&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;is_deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
          &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&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;end_date&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&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;end_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;as_of&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outer query was narrowed to one organization and a one-hour window: 59 candidate rows. The subquery wasn't narrowed at all. PostgreSQL read &lt;strong&gt;1.77 million&lt;/strong&gt; membership rows, built a distinct list of 831 IDs, then matched that list against the 59 candidates. That was 31,395 buffers and 83 ms to return a count of zero.&lt;/p&gt;

&lt;p&gt;Two changes fixed 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;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;memberships&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;parent_account_id&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;account_id&lt;/span&gt;   &lt;span class="c1"&gt;-- correlate to the outer row&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;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;                      &lt;span class="c1"&gt;-- the filter the outer query already had&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;membership_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PRIMARY'&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;is_deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
          &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&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;end_date&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&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;end_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;as_of&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&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;IN (SELECT DISTINCT …)&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Correlated &lt;code&gt;EXISTS&lt;/code&gt; with filters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Execution time&lt;/td&gt;
&lt;td&gt;83 ms&lt;/td&gt;
&lt;td&gt;0.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buffers&lt;/td&gt;
&lt;td&gt;31,395&lt;/td&gt;
&lt;td&gt;181&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memberships access&lt;/td&gt;
&lt;td&gt;Parallel sequential scan&lt;/td&gt;
&lt;td&gt;Index lookup per candidate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A note on &lt;code&gt;IN&lt;/code&gt; versus &lt;code&gt;EXISTS&lt;/code&gt;: PostgreSQL already turns &lt;code&gt;IN (subquery)&lt;/code&gt; into a semi-join, so the two often plan the same way. The &lt;code&gt;DISTINCT&lt;/code&gt; adds nothing, since a semi-join never returns a row twice, and it can push the planner toward building the full list first. Switching to &lt;code&gt;EXISTS&lt;/code&gt; helps. &lt;strong&gt;The bigger win is adding the filter.&lt;/strong&gt; If the outer query is limited to something you also know about the subquery's rows, say so in the subquery too. Don't rely on the planner to infer it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: Ask what a query is for before you tune it
&lt;/h2&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%2Fmox4oggazayvlivvme9r.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%2Fmox4oggazayvlivvme9r.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The slowest query in the job didn't return any data. It computed the run's ceiling:&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;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated_at_num&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;ts&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_type&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;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="n"&gt;to_char&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;password_changed_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'YYYYMMDDHH24MISS'&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;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_type&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;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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated_at_num&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;org_members&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;max&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;updated_at_num&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;staff_links&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&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;account_id&lt;/span&gt; &lt;span class="o"&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;account_id&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;account_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'STAFF'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It took 853 ms and about 1 million buffers to return one number. The plan showed three different behaviours:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;How PostgreSQL ran it&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;max(updated_at_num)&lt;/code&gt; on one table&lt;/td&gt;
&lt;td&gt;Backward index scan, stops at the first row&lt;/td&gt;
&lt;td&gt;0.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max(password_changed_at)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sequential scan; the column has no index&lt;/td&gt;
&lt;td&gt;~590 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;max(...)&lt;/code&gt; over a join&lt;/td&gt;
&lt;td&gt;Full join of every staff account, then aggregate&lt;/td&gt;
&lt;td&gt;~270 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PostgreSQL has a neat shortcut here. As the &lt;code&gt;CREATE AGGREGATE&lt;/code&gt; documentation explains, a MIN- or MAX-like aggregate can be answered from an index, because &lt;code&gt;SELECT max(col) FROM tab&lt;/code&gt; is equivalent to &lt;code&gt;SELECT col FROM tab ORDER BY col DESC LIMIT 1&lt;/code&gt;. The planner walks the index backward and takes the first non-null value. It also works on partitioned tables and &lt;code&gt;UNION ALL&lt;/code&gt; views, by merging each child's index. &lt;strong&gt;It does not work through a join.&lt;/strong&gt; The planner source (&lt;code&gt;planagg.c&lt;/code&gt;) limits the shortcut to queries that reference exactly one table, so the join branch had to build the whole join just to find its largest value.&lt;/p&gt;

&lt;p&gt;That is where understanding the use case matters more than tuning. This value is an upper bound for the next change window. It doesn't have to be the exact newest staff row; it only has to be at least as high. Dropping the join and taking &lt;code&gt;max(updated_at_num)&lt;/code&gt; over all of &lt;code&gt;staff_links&lt;/code&gt; gives a bound that is never lower, and costs a single index probe. The join filter still applies in the query that reads the window.&lt;/p&gt;

&lt;p&gt;The same review turned up a second easy win. The ceiling was global, but the job ran it once per scope, inside a loop, so it returned the same value several times per run. Computing it once per run cost nothing to change.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;password_changed_at&lt;/code&gt; branch I left alone. More on that in Lesson 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: A test that returns zero rows proves nothing
&lt;/h2&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%2F9ggqkf3ah67xakxzdf0s.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%2F9ggqkf3ah67xakxzdf0s.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My first round of &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; used a one-hour window, the job's steady state. Most queries looked great: under a millisecond. Then I noticed how often the plans said &lt;code&gt;(never executed)&lt;/code&gt;. In that hour, no organization had changed, so every join after the first step was skipped. The plan was cheap because there was no work, not because the work was cheap.&lt;/p&gt;

&lt;p&gt;Widening the window to 30 days told a different story. One query that ran in 0.7 ms on the hourly window chose a completely different plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It started from the membership table instead of the changed rows.&lt;/li&gt;
&lt;li&gt;It read every one of the 15,368 primary memberships, then probed &lt;code&gt;accounts&lt;/code&gt; for each.&lt;/li&gt;
&lt;li&gt;It found 31 changed rows, after 72,522 buffers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cause was a row estimate. The planner expected 188 primary memberships and got 15,368, about 80 times more. With 188, starting from memberships looks cheap. A likely reason is how PostgreSQL combines filters: by default it multiplies each condition's selectivity as if they were independent, so correlated filters (type, deleted flag, end date) can be badly underestimated. Check that statistics are fresh with &lt;code&gt;ANALYZE&lt;/code&gt; first. If the columns really are correlated, &lt;code&gt;CREATE STATISTICS&lt;/code&gt; can tell the planner how they relate.&lt;/p&gt;

&lt;p&gt;So I now test every change-data query with three windows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Window&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;Normal (one hour)&lt;/td&gt;
&lt;td&gt;Steady-state cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wide (30 days)&lt;/td&gt;
&lt;td&gt;What happens after an outage or a missed run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full history&lt;/td&gt;
&lt;td&gt;The very first run, when the checkpoint is empty&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first run is easy to forget. Here it was fine: page one of the full-history query took about 20 ms, because the planner could stop as soon as it had 500 rows. But you only know that by running it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 6: "Good enough" is a valid result. Index only what doesn't scale
&lt;/h2&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%2F2z9nmuhfsnzfkidxfl5r.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%2F2z9nmuhfsnzfkidxfl5r.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the rewrites, I had a list of index candidates that would have made every remaining query near-instant: one on &lt;code&gt;password_changed_at&lt;/code&gt;, a partial index for a rare account type, and a few more. Before building any of them, I added up what an hourly run actually cost without them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part of the run&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ceiling queries, once per run&lt;/td&gt;
&lt;td&gt;~1.5–1.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change-window queries, per scope&lt;/td&gt;
&lt;td&gt;~1.2 s for the largest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Whole run&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~6–7 s, every buffer from cache&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The slowest single statement was under a second, far from any timeout. Every index you add costs writes, storage and maintenance forever, and here the database was a replication target that would pay for each one on every write. So most of the candidates were dropped. That is a fine outcome. Not every slow-looking query needs an index; sometimes it needs nothing.&lt;/p&gt;

&lt;p&gt;One query was different, because its cost multiplied. It loaded full account details in chunks of 100 IDs, and each chunk joined &lt;code&gt;org_members&lt;/code&gt; on &lt;code&gt;account_id&lt;/code&gt;, a column with no index. Every chunk scanned a 9-million-row table: about 0.7 s.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Accounts to load&lt;/th&gt;
&lt;th&gt;Chunks&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hourly&lt;/td&gt;
&lt;td&gt;~50&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~0.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30-day catch-up&lt;/td&gt;
&lt;td&gt;~20,000&lt;/td&gt;
&lt;td&gt;~200&lt;/td&gt;
&lt;td&gt;~2.5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First run&lt;/td&gt;
&lt;td&gt;~870,000&lt;/td&gt;
&lt;td&gt;~8,700&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1.7 hours&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the test I use now: &lt;strong&gt;don't ask whether a query is slow, ask whether its cost grows with the data or the backlog.&lt;/strong&gt; Fixed costs can usually be tolerated. Costs that multiply need an index. We added exactly one: &lt;code&gt;org_members (account_id)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 7: A new index can make a query slower
&lt;/h2&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%2Ftzc5bretg0yb9fxh7cnc.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%2Ftzc5bretg0yb9fxh7cnc.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The new index worked exactly as planned: the &lt;code&gt;org_members&lt;/code&gt; step went from scanning 5.4 million rows to reading 2,217 buffers. Then I looked at the total for the chunk query. It had gone from &lt;strong&gt;723 ms to 26.9 seconds&lt;/strong&gt;.&lt;/p&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;Before the index&lt;/th&gt;
&lt;th&gt;After the index&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org_members&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parallel sequential scan, 174k buffers&lt;/td&gt;
&lt;td&gt;Index scan, 2,217 buffers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;memberships&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One scan of the 15k primary rows, hashed&lt;/td&gt;
&lt;td&gt;7,191 index probes, each discarding ~14,000 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;723 ms, 243k buffers&lt;/td&gt;
&lt;td&gt;26,872 ms, 22.7M buffers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With &lt;code&gt;org_members&lt;/code&gt; suddenly cheap, the planner switched to a nested loop and probed &lt;code&gt;memberships&lt;/code&gt; once per organization row through an index on &lt;code&gt;org_id&lt;/code&gt;. That index holds every membership in the organization, one per member account. The accounts in this chunk belonged to very large organizations, so each probe walked about 14,000 entries to find the one primary membership it needed.&lt;/p&gt;

&lt;p&gt;The planner expected roughly one row per probe. When it knows a value in advance, PostgreSQL can look it up in its most-common-values statistics. Here each probe's value came from the outer row at run time, so the planner estimated an average number of rows per organization. The handful of very large organizations didn't fit that average, and re-running &lt;code&gt;ANALYZE&lt;/code&gt; doesn't change how the estimate is made.&lt;/p&gt;

&lt;p&gt;The fix was another index, but a very small one: a partial index containing only the rows the query asks for.&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;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;memberships_primary_org_idx&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;memberships&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_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;parent_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_date&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;membership_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PRIMARY'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It holds about 15,000 rows instead of 650,000, so it is small and cheap to maintain, and each probe now lands directly on the row it needs. PostgreSQL uses a partial index only when it can prove, at planning time, that the query's &lt;code&gt;WHERE&lt;/code&gt; clause implies the index predicate. The docs point out that a bind parameter such as &lt;code&gt;membership_type = $1&lt;/code&gt; can't prove that. Because this query uses &lt;code&gt;membership_type = 'PRIMARY' AND is_deleted = false&lt;/code&gt; as literal values, the planner can match the partial index without any code change.&lt;/p&gt;

&lt;p&gt;The lesson isn't "avoid indexes". It's that &lt;strong&gt;an index changes the plan around it&lt;/strong&gt;, not just the step it targets. Re-run the full &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; after every index, and read the total, not just the line you were fixing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the indexes online
&lt;/h2&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%2Fndpp1u4xw4cgwcls6ulw.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%2Fndpp1u4xw4cgwcls6ulw.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both indexes went in with &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;, so writes kept flowing. A few things I check every time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Look for old transactions first.&lt;/strong&gt; Per the &lt;code&gt;CREATE INDEX&lt;/code&gt; docs, a concurrent build waits before each table scan for transactions that have modified the table, and after the second scan for any transaction holding a snapshot older than that scan. My pre-check found 65 sessions from a replication tool sitting &lt;code&gt;idle in transaction&lt;/code&gt; for 28 days. Before cancelling anything, check whether they actually matter:&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;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;sessions&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="n"&gt;backend_xmin&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;holding_snapshot&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="n"&gt;backend_xid&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;holding_xid&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;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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ours held no snapshot, no transaction ID and no locks on the target tables, so they couldn't block the build or hold back vacuum. That's a cleanup item, not a blocker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Run with autocommit on.&lt;/strong&gt; &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; cannot run inside a transaction block, and some SQL clients wrap scripts in one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Decide on &lt;code&gt;lock_timeout&lt;/code&gt; deliberately.&lt;/strong&gt; It lets a build fail fast instead of queueing behind a long transaction, but a build that fails partway leaves an invalid index behind. I left it unset and watched the progress view instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check for invalid indexes afterwards.&lt;/strong&gt; &lt;code&gt;CREATE INDEX ... IF NOT EXISTS&lt;/code&gt; sees an invalid leftover's name and skips it with only a NOTICE. Drop it with &lt;code&gt;DROP INDEX CONCURRENTLY&lt;/code&gt; and build again, or rebuild it with &lt;code&gt;REINDEX INDEX CONCURRENTLY&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;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;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;&lt;strong&gt;5. Build smallest to largest&lt;/strong&gt;, and watch replication lag during the big ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  A review checklist
&lt;/h2&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%2Fb6cycfzd3zctcjl7q4et.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%2Fb6cycfzd3zctcjl7q4et.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is what I now run through for any SQL headed to a large production table, whether a person or an AI wrote it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Predicates&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No functions wrapped around indexed columns in &lt;code&gt;WHERE&lt;/code&gt; or &lt;code&gt;JOIN&lt;/code&gt; (&lt;code&gt;COALESCE&lt;/code&gt;, &lt;code&gt;TRIM&lt;/code&gt;, &lt;code&gt;LOWER&lt;/code&gt;, casts, date math).&lt;/li&gt;
&lt;li&gt;Every "defensive" expression is backed by a data check: null counts, padded values, duplicate keys.&lt;/li&gt;
&lt;li&gt;Filters the outer query knows are repeated inside subqueries and on every joined table.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IN (SELECT DISTINCT ...)&lt;/code&gt; replaced with a correlated &lt;code&gt;EXISTS&lt;/code&gt;, or at least the &lt;code&gt;DISTINCT&lt;/code&gt; dropped.&lt;/li&gt;
&lt;li&gt;Partial-index conditions written as literals, not bind parameters, so the planner can match them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Purpose&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For each query, what is it &lt;em&gt;for&lt;/em&gt;? A ceiling, a count or a page may not need the precision it was written with.&lt;/li&gt;
&lt;li&gt;Values that don't change within a run are computed once per run, not inside a loop.&lt;/li&gt;
&lt;li&gt;Keyset pagination sorts on a unique key, or ends with a unique tie-breaker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Evidence&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; on production-sized data, for normal, wide and first-run windows.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;(never executed)&lt;/code&gt; hiding the parts you care about.&lt;/li&gt;
&lt;li&gt;Estimated versus actual rows compared; a gap of 10x or more is worth chasing.&lt;/li&gt;
&lt;li&gt;Costs that multiply (per chunk, per page, per backlog hour) indexed; fixed costs judged against the schedule.&lt;/li&gt;
&lt;li&gt;The full plan re-run after every new index.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing: tuning is mostly a conversation
&lt;/h2&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%2F9xpr9c9bemuukuhdadcy.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%2F9xpr9c9bemuukuhdadcy.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Looking back, the biggest gains came from questions, not configuration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Can this column actually be null?" No, so the &lt;code&gt;COALESCE&lt;/code&gt; went, and the existing index started working.&lt;/li&gt;
&lt;li&gt;"What is this &lt;code&gt;MAX&lt;/code&gt; used for?" An upper bound, so it didn't need the expensive join.&lt;/li&gt;
&lt;li&gt;"Does this cost grow with the backlog?" Only in one place, so we added one index instead of six.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that needed database expertise the developers couldn't learn. It needed someone to explain how the planner reads a predicate, why a function hides an index, and why QA data isn't evidence for production. Once that was clear, the developer rewrote the queries himself, and the run time in QA went from 30–50 minutes to under 30 seconds.&lt;/p&gt;

&lt;p&gt;Some tuning really is just common sense and basic troubleshooting: read the plan, check the data, and ask what the query is for. If your team writes SQL, especially with an AI assistant drafting it, get a database engineer to look at it before it meets production data. It never hurts, and sometimes it saves you an hour-long first run, or a 27-second query you didn't know you had.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/sql-createaggregate.html" rel="noopener noreferrer"&gt;CREATE AGGREGATE&lt;/a&gt;: how MIN/MAX-like aggregates use an index&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://doxygen.postgresql.org/planagg_8c_source.html" rel="noopener noreferrer"&gt;planagg.c&lt;/a&gt;: the MIN/MAX optimization and its single-table restriction&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/indexes-expressional.html" rel="noopener noreferrer"&gt;Indexes on Expressions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/indexes-partial.html" rel="noopener noreferrer"&gt;Partial Indexes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;Index-Only Scans and Covering Indexes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/row-estimation-examples.html" rel="noopener noreferrer"&gt;Row Estimation Examples&lt;/a&gt;: MCV lists, averages and combining conditions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/planner-stats.html" rel="noopener noreferrer"&gt;Statistics Used by the Planner&lt;/a&gt;: including extended statistics&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/using-explain.html" rel="noopener noreferrer"&gt;Using EXPLAIN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY" rel="noopener noreferrer"&gt;CREATE INDEX: Building Indexes Concurrently&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>performance</category>
      <category>database</category>
    </item>
    <item>
      <title>Aurora PostgreSQL Backups at Scale, Part 1: Designing AWS Backup Across Multiple Accounts</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Mon, 02 Nov 2026 17:52:46 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/aurora-postgresql-backups-at-scale-part-1-designing-aws-backup-across-80-accounts-3ehi</link>
      <guid>https://dev.to/pranay_raavi/aurora-postgresql-backups-at-scale-part-1-designing-aws-backup-across-80-accounts-3ehi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;AWS Backup + AWS Organizations lets you define Aurora PostgreSQL backups once and stamp them into every account. But the org policy won't create your vaults or IAM roles, continuous backup quietly takes over RDS's native backups, Aurora silently ignores cold-storage settings, the default &lt;code&gt;aws/rds&lt;/code&gt; key blocks cross-account and air-gapped copies, and your 5-6 year old snapshot will outlive its PostgreSQL version. Here's how we designed around all of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every Aurora cluster has backups turned on from day one. That's the trap: "it's backed up" becomes a sentence nobody verifies.&lt;/p&gt;

&lt;p&gt;When you run Aurora PostgreSQL across 80+ AWS accounts, multiple regions, and retention measured in years, the real questions are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who&lt;/strong&gt; owns the backup configuration?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where&lt;/strong&gt; does the copy live if a region goes dark?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can&lt;/strong&gt; an admin (or an attacker with admin) delete it?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Will&lt;/strong&gt; a seven-year-old snapshot actually restore?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is part 1 of a series. &lt;strong&gt;Part 1 = design.&lt;/strong&gt; Part 2 covers monitoring and reporting failures. Part 3 covers restores.&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%2Ffsiiww7n5hftms28g0iq.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%2Ffsiiww7n5hftms28g0iq.png" alt="Org-wide backup architecture" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The five nouns of AWS Backup
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Noun&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vault&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Encrypted container for recovery points&lt;/td&gt;
&lt;td&gt;Each member account, each region you back up to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A named bundle of rules&lt;/td&gt;
&lt;td&gt;Generated in member accounts by the org policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rule&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Schedule + retention + target vault + copy actions + continuous on/off&lt;/td&gt;
&lt;td&gt;Inside the plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Selection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which resources get protected (we use a tag)&lt;/td&gt;
&lt;td&gt;Inside the plan; references an IAM role in the member account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Policy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AWS Organizations backup policy that generates the plan&lt;/td&gt;
&lt;td&gt;Management (or delegated admin) account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Key insight: &lt;strong&gt;retention, target vault and replication are per &lt;em&gt;rule&lt;/em&gt;, not per plan.&lt;/strong&gt; The plan is just a container.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Account-level vs organization-level settings
&lt;/h2&gt;

&lt;p&gt;Think of it as &lt;strong&gt;governing&lt;/strong&gt; vs &lt;strong&gt;holding&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Management account (governs)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Let AWS Backup act across the organization&lt;/span&gt;
aws organizations enable-aws-service-access &lt;span class="nt"&gt;--service-principal&lt;/span&gt; backup.amazonaws.com

&lt;span class="c"&gt;# 2. Turn on the backup policy type on the org root&lt;/span&gt;
aws organizations enable-policy-type &lt;span class="nt"&gt;--root-id&lt;/span&gt; r-xxxx &lt;span class="nt"&gt;--policy-type&lt;/span&gt; BACKUP_POLICY

&lt;span class="c"&gt;# 3. Allow cross-account backup copies&lt;/span&gt;
aws backup update-global-settings &lt;span class="nt"&gt;--global-settings&lt;/span&gt; &lt;span class="nv"&gt;isCrossAccountBackupEnabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the AWS Backup console (Settings), also enable &lt;strong&gt;backup policies&lt;/strong&gt; and &lt;strong&gt;cross-account monitoring&lt;/strong&gt;. If you'd rather not run this from the management account itself, register a &lt;strong&gt;delegated administrator&lt;/strong&gt; for AWS Backup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Member accounts (hold)
&lt;/h3&gt;

&lt;p&gt;Each account that owns databases needs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A vault&lt;/strong&gt; in every region the policy targets (and in the DR region if you copy there).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An IAM role&lt;/strong&gt; that the policy's selection references, trusted by &lt;code&gt;backup.amazonaws.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aurora opted in&lt;/strong&gt; for that region:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws backup update-region-settings &lt;span class="nt"&gt;--resource-type-opt-in-preference&lt;/span&gt; &lt;span class="nv"&gt;Aurora&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The tag&lt;/strong&gt; on the cluster, e.g. &lt;code&gt;backup-enable = yes&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The sentence I wish I'd read on day one:&lt;/strong&gt; an org backup policy creates the &lt;strong&gt;plan&lt;/strong&gt; in the member account, but &lt;strong&gt;not the vault and not the IAM role&lt;/strong&gt;. If either is missing, the job never starts, and nothing shouts about it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Policy inheritance
&lt;/h3&gt;

&lt;p&gt;Backup policies attach to the &lt;strong&gt;org root, an OU, or a single account&lt;/strong&gt;, and AWS merges them into an &lt;strong&gt;effective policy&lt;/strong&gt; per account using inheritance operators like &lt;code&gt;@@assign&lt;/code&gt;. A common layout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Root:&lt;/strong&gt; baseline policy (e.g. continuous-only for everything tagged)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production OU:&lt;/strong&gt; the full ladder with DR copy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specific account:&lt;/strong&gt; an override, if you really must&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can stop child policies from overriding the parts you care about with &lt;code&gt;@@operators_allowed_for_child_policies&lt;/code&gt;. To see what an account actually ended up with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws organizations describe-effective-policy &lt;span class="nt"&gt;--policy-type&lt;/span&gt; BACKUP_POLICY &lt;span class="nt"&gt;--target-id&lt;/span&gt; 111122223333
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  3. Vaults: standard vs logically air-gapped
&lt;/h2&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%2Fnxg8mbqzlvl5zjr122ts.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%2Fnxg8mbqzlvl5zjr122ts.png" alt="Vault types and lock modes" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Standard vault
&lt;/h3&gt;

&lt;p&gt;Lives in your own account, encrypted with the KMS key you choose. You can optionally add &lt;strong&gt;Vault Lock&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Who can delete early / remove lock&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Governance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Principals with the right IAM permissions can bypass&lt;/td&gt;
&lt;td&gt;Day one, while you tune retention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compliance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nobody after the grace period (minimum 72 hours): not admins, not root, not AWS&lt;/td&gt;
&lt;td&gt;Regulated / ransomware-proof retention&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 hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_backup_vault_lock_configuration"&lt;/span&gt; &lt;span class="s2"&gt;"primary"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backup_vault_name&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_backup_vault&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;min_retention_days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
  &lt;span class="nx"&gt;max_retention_days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2555&lt;/span&gt; &lt;span class="c1"&gt;# 7 years&lt;/span&gt;

  &lt;span class="c1"&gt;# Uncomment to switch to COMPLIANCE mode. Omitted = governance mode.&lt;/span&gt;
  &lt;span class="c1"&gt;# After this grace period the lock is permanent.&lt;/span&gt;
  &lt;span class="c1"&gt;# changeable_for_days = 3&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Start in governance mode, prove your min/max retention against real recovery points, then flip to compliance. A compliance lock with a mistake in it is a mistake you keep.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Logically air-gapped vault
&lt;/h3&gt;

&lt;p&gt;A separate vault type that is &lt;strong&gt;always compliance-locked&lt;/strong&gt;, encrypted with an AWS-owned key by default, and &lt;strong&gt;shareable to a recovery account via AWS RAM&lt;/strong&gt;, even an account in a different organization. The goal is logical separation: owning your production account (or even your org) isn't enough to reach those copies.&lt;/p&gt;

&lt;p&gt;Two Aurora-specific notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Aurora can't back up &lt;em&gt;directly&lt;/em&gt; into an air-gapped vault; AWS Backup takes a temporary recovery point in a standard vault and copies it across.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clusters encrypted with the AWS-managed &lt;code&gt;aws/rds&lt;/code&gt; key can't be copied into an air-gapped vault.&lt;/strong&gt; You need a customer-managed KMS key (more on this in the traps section).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4. Continuous (PITR) vs snapshot backups
&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;&lt;strong&gt;Continuous (PITR)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Snapshot&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Restore to&lt;/td&gt;
&lt;td&gt;Any second in the window&lt;/td&gt;
&lt;td&gt;The moment it ran&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max retention&lt;/td&gt;
&lt;td&gt;35 days&lt;/td&gt;
&lt;td&gt;Whatever the rule says&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per cluster&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;One&lt;/strong&gt; continuous recovery point&lt;/td&gt;
&lt;td&gt;One per scheduled run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-region copy&lt;/td&gt;
&lt;td&gt;Arrives as a &lt;strong&gt;snapshot&lt;/strong&gt; (no PITR in DR)&lt;/td&gt;
&lt;td&gt;Copies as a snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appears in RDS as&lt;/td&gt;
&lt;td&gt;Automated backups&lt;/td&gt;
&lt;td&gt;Manual snapshots&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The handover nobody tells you about
&lt;/h3&gt;

&lt;p&gt;Once AWS Backup enables continuous backup on an Aurora cluster, &lt;strong&gt;AWS Backup takes over Aurora's native automated backups.&lt;/strong&gt; You can no longer change the backup window or retention from the RDS console or API. In the RDS console, those recovery points are named like &lt;code&gt;continuous:cluster-&amp;lt;job-id&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Lessons from production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One owner for PITR.&lt;/strong&gt; Two org policies that both enable continuous backup on the same cluster will conflict. To move a cluster between policies, exclude it from the old policy first (&lt;code&gt;NotResources&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero jobs can be healthy.&lt;/strong&gt; A continuous-only plan doesn't create a job per scheduled run once it's set up. An empty job list is not a failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full snapshot first.&lt;/strong&gt; Make sure a full snapshot exists alongside the continuous recovery point before you rely on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RDS console ≠ AWS Backup status.&lt;/strong&gt; &lt;code&gt;EarliestRestorableTime&lt;/code&gt; on the RDS console reflects native PITR, not your backup policy's activity.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  5. Several policies and respective jobs
&lt;/h2&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%2Foy1t0t1raxh3ntelhhqz.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%2Foy1t0t1raxh3ntelhhqz.png" alt="Protection layers across several regions" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of one giant policy that tries to do everything, we can split protection into &lt;strong&gt;several org policies&lt;/strong&gt;, each with a single job and its own tag to opt a cluster in.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;Runs in&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Retention idea&lt;/th&gt;
&lt;th&gt;Leaves the region?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Primary site&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Primary region (&lt;code&gt;us-east-1&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Continuous PITR, the fast "undo" button&lt;/td&gt;
&lt;td&gt;Short rolling window (days)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Long-term ladder&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Primary region&lt;/td&gt;
&lt;td&gt;Weekly / monthly / yearly snapshots&lt;/td&gt;
&lt;td&gt;5 weeks → 1 year → 7 years&lt;/td&gt;
&lt;td&gt;Every rule copies to a vault in &lt;code&gt;us-west-2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DR site&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;DR region (&lt;code&gt;us-east-2&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Weekly / monthly / yearly snapshots of the &lt;strong&gt;headless&lt;/strong&gt; global-database secondary&lt;/td&gt;
&lt;td&gt;5 weeks → ~13 months → 7 years&lt;/td&gt;
&lt;td&gt;Already in the DR region&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why split it this way
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Independent layers.&lt;/strong&gt; Tuning the long-term ladder never risks your PITR window, and vice versa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opt in per layer, by tag.&lt;/strong&gt; A dev cluster can take PITR only; production takes all three. A new database is protected the moment it's tagged. No ARN lists to maintain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No accidental cross-matching.&lt;/strong&gt; The DR policy matches a DR-only tag value, so it can never pick up a primary cluster. The primary-site policy uses a different tag &lt;em&gt;key&lt;/em&gt;, so it's independent of the ladder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The selections are the whole trick. Trimmed down, the three policies differ mainly here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Primary site policy (us-east-1)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"selections"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tag_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"backup-enable-site"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tag_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"primary-only"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;// Long-term ladder policy (us-east-1, copies to us-west-2)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"selections"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ladder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tag_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"backup-enable"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tag_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"yes"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;// DR site policy (us-east-2) when you have a DR Global cluster / headless/serverless&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"selections"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tag_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"backup-enable"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tag_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dr-e2-only"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A production database ends up with &lt;strong&gt;three independent histories&lt;/strong&gt;: a short PITR window in its own region, long-term snapshots copied to a second region where DR cluster is not available, or a separate snapshot history built in a DR region where there is a DR Global cluster (headless/serverless) from replicated storage. Lose any one region and the other remain.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Click for a trimmed long-term ladder rule with a cross-region copy
  &lt;br&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plans"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plan-e1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"regions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"yearly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"schedule_expression"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cron(0 5 1 1 ? *)"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"lifecycle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"delete_after_days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2555"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"target_backup_vault_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vault-e1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"recovery_point_tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"backup-rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"tag_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"backup-rule"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"tag_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yearly"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"copy_actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"arn:aws:backup:us-west-2:$account:backup-vault:vault-w2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"target_backup_vault_arn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:backup:us-west-2:$account:backup-vault:vault-w2"&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"lifecycle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"delete_after_days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2555"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selections"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"ladder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"iam_role_arn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::$account:role/org-backup-role"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"tag_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"backup-enable"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"tag_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"yes"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;$account&lt;/code&gt; is substituted with each member account's ID, so one policy works everywhere. An empty &lt;code&gt;lifecycle&lt;/code&gt; on a copy action means the copy inherits the source rule's retention. Set it explicitly only when the remote copy should live longer or shorter than the original.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Watch out when you split
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only one policy should own PITR.&lt;/strong&gt; Continuous backup can be enabled in more than one policy. If a cluster carries tags that match two policies with &lt;code&gt;enable_continuous_backup: true&lt;/code&gt;, two plans claim the single continuous recovery point a cluster is allowed. Pick the owner (we use the primary-site policy) and keep continuous off everywhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag recovery points.&lt;/strong&gt; Every rule stamps &lt;code&gt;backup-policy&lt;/code&gt; and &lt;code&gt;backup-rule&lt;/code&gt; tags on what it creates, so during an incident someone can filter for "the yearly from January" instead of guessing from timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next up: air gap.&lt;/strong&gt; A logically air-gapped copy in a separate recovery account is the next layer, for backups that must survive even a compromised organization.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  6. Backing up a headless/serverless DR cluster
&lt;/h2&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%2Foxbudd7ierehgwg436nq.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%2Foxbudd7ierehgwg436nq.png" alt="Headless DR with region-local backups" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;Aurora Global Database&lt;/strong&gt;, the primary region replicates to a secondary region at the storage layer. A &lt;strong&gt;headless&lt;/strong&gt; secondary is that cluster with &lt;strong&gt;zero DB instances&lt;/strong&gt;: storage keeps up, compute costs nothing until you need it. In our tests, adding an instance to a headless cluster took under 10 minutes.&lt;/p&gt;

&lt;p&gt;Instead of copying every snapshot across regions, we point the &lt;strong&gt;DR-site policy&lt;/strong&gt; at the headless cluster itself, in &lt;code&gt;us-east-2&lt;/code&gt;. The DR region builds its own backup history from its own replicated storage, with no cross-region copy charges, and becomes a genuinely separate recovery site.&lt;/p&gt;

&lt;p&gt;Creating a headless Aurora PostgreSQL secondary (the console can't do zero instances for PostgreSQL):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds create-db-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-cluster-identifier&lt;/span&gt; mydb-e2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global-cluster-identifier&lt;/span&gt; mydb-global &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--engine&lt;/span&gt; aurora-postgresql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--engine-version&lt;/span&gt; 16.4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-subnet-group-name&lt;/span&gt; mydb-subnets-e2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vpc-security-group-ids&lt;/span&gt; sg-0123456789abcdef0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--storage-encrypted&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kms-key-id&lt;/span&gt; arn:aws:kms:us-east-2:111122223333:key/your-cmk-id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gotchas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No burstable classes&lt;/strong&gt; (&lt;code&gt;db.t3&lt;/code&gt;, &lt;code&gt;db.t4g&lt;/code&gt;) in a global database. Your emergency instance is r- or m-class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate Terraform state for DR backup infra.&lt;/strong&gt; Running the same backup module from the same directory for a second region will plan to destroy the first region's vault, and the account-global IAM role will collide with &lt;code&gt;EntityAlreadyExists&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On PostgreSQL, creating and deleting secondary readers can affect vacuum on the primary; watch it after DR tests.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  7. The traps that show up years later
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🕰️ Trap 1: Your yearly backup will outlive its PostgreSQL version
&lt;/h3&gt;

&lt;p&gt;A 5-6 year old snapshot taken on today's major version will almost certainly be restored (if ever) after that version leaves standard support. When that happens, Aurora tries to &lt;strong&gt;upgrade the snapshot during restore&lt;/strong&gt; to a version still in standard support. If that fails, it &lt;strong&gt;enrolls the restored cluster in RDS Extended Support&lt;/strong&gt;, which is billed.&lt;/p&gt;

&lt;p&gt;What we do about it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Know what you're holding.&lt;/strong&gt; We tag every cluster with &lt;code&gt;Engine&lt;/code&gt; and &lt;code&gt;EngineVersion&lt;/code&gt; when it's enrolled (section 7b), and snapshots record their engine version too:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds describe-db-cluster-snapshots &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"DBClusterSnapshots[].[DBClusterSnapshotIdentifier,EngineVersion,SnapshotCreateTime]"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yearly restore drill.&lt;/strong&gt; Restore the oldest long-term snapshots, upgrade them, and confirm the data is usable, before an auditor asks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logical safety net.&lt;/strong&gt; For archives that truly matter, keep a &lt;code&gt;pg_dump&lt;/code&gt; alongside the snapshot. A dump doesn't care which engine version produced it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🧊 Trap 2: Cold storage settings that silently do nothing
&lt;/h3&gt;

&lt;p&gt;A rule can say &lt;code&gt;move_to_cold_storage_after_days: 30&lt;/code&gt;, and the policy will happily accept it. It looks like a cost win. But AWS Backup's cold tier covers services like EBS, EFS and DynamoDB, &lt;strong&gt;not Aurora&lt;/strong&gt;, and AWS Backup &lt;strong&gt;ignores the transition for resource types that don't support it&lt;/strong&gt;. No error, no warning: seven years of yearlies stay at warm-storage prices.&lt;/p&gt;

&lt;p&gt;If you see cold-storage settings in a policy that only selects Aurora clusters, they're documentation, not behavior. If long-term cost matters, a &lt;code&gt;pg_dump&lt;/code&gt; in S3 with a lifecycle rule to Glacier is the real cheap archive (and doubles as the Trap 1 safety net).&lt;/p&gt;

&lt;h3&gt;
  
  
  🔑 Trap 3: The default KMS key blocks your exit doors
&lt;/h3&gt;

&lt;p&gt;Clusters encrypted with the AWS-managed &lt;code&gt;aws/rds&lt;/code&gt; key &lt;strong&gt;can't be shared or copied to another account&lt;/strong&gt;, and &lt;strong&gt;can't be copied into a logically air-gapped vault&lt;/strong&gt;. Use a customer-managed key from day one. Changing an existing cluster's key means snapshot → copy with new key → restore.&lt;/p&gt;

&lt;h3&gt;
  
  
  🏚️ Trap 4: Missing vaults fail silently
&lt;/h3&gt;

&lt;p&gt;The org policy happily creates a plan that targets a vault that doesn't exist. Audit every member account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws backup list-backup-vaults &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"BackupVaultList[].BackupVaultName"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  ⏱️ Trap 5: "Last runtime" is not success
&lt;/h3&gt;

&lt;p&gt;The console's last-runtime timestamp moves when the scheduler evaluates the plan, not when a job succeeds. Real health comes from job states, which is exactly what Part 2 is about.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 2 — Monitoring &amp;amp; reporting:&lt;/strong&gt; EventBridge + SNS for failures, and consolidating AWS Backup reports from every account into one queryable place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3 — Restores:&lt;/strong&gt; snapshot vs point-in-time restores, the parameter-group gotcha, and a scripted, interactive restore flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run Aurora at scale, which trap got you first? Drop it in the comments 👇&lt;/p&gt;




&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/aws-backup/latest/devguide/vault-lock.html" rel="noopener noreferrer"&gt;AWS Backup Vault Lock&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/aws-backup/latest/devguide/logicallyairgappedvault.html" rel="noopener noreferrer"&gt;Logically air-gapped vaults&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/aws-backup/latest/devguide/lag-vault-primary-backup.html" rel="noopener noreferrer"&gt;Primary backups to logically air-gapped vaults&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/aws-backup/latest/devguide/point-in-time-recovery.html" rel="noopener noreferrer"&gt;Continuous backups and PITR&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/extended-support-overview.html" rel="noopener noreferrer"&gt;RDS Extended Support for Aurora&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/backup/pricing" rel="noopener noreferrer"&gt;AWS Backup pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>postgres</category>
      <category>devops</category>
      <category>terraform</category>
    </item>
    <item>
      <title>15-Minute RPO to 7-Year Retention: Elasticsearch Backups on S3 with SLM (No Scripts, No Keys)</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Wed, 28 Oct 2026 16:03:47 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/15-minute-rpo-to-7-year-retention-elasticsearch-backups-on-s3-with-slm-no-scripts-no-keys-43pe</link>
      <guid>https://dev.to/pranay_raavi/15-minute-rpo-to-7-year-retention-elasticsearch-backups-on-s3-with-slm-no-scripts-no-keys-43pe</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Four Elasticsearch SLM policies (15-min, hourly, monthly, yearly) write to four repositories in one S3 bucket.&lt;/li&gt;
&lt;li&gt;Nodes authenticate with an EC2 instance profile. Zero access keys in the keystore.&lt;/li&gt;
&lt;li&gt;SLM handles retention, from 7 days up to ~7 years. S3 lifecycle rules stay off, on purpose.&lt;/li&gt;
&lt;li&gt;The whole thing is about 30 lines of JSON and a handful of AWS CLI commands.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three questions every backup should answer
&lt;/h2&gt;

&lt;p&gt;Elasticsearch cluster runs on EC2 in a regulated environment. I wanted backups that answer three questions in one sentence each: how much data can we lose, how far back can we go, and what credentials does the job use?&lt;/p&gt;

&lt;p&gt;Elasticsearch already ships a scheduler for this: Snapshot Lifecycle Management (SLM). So I moved everything into SLM and let S3 be dumb, durable storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&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%2Fzg45be26b3wuqk0isxlh.jpeg" 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%2Fzg45be26b3wuqk0isxlh.jpeg" alt="Architecture diagram: Elasticsearch on EC2 with an IAM instance profile, SLM policies, S3 repository plugin and an encrypted S3 bucket with four tier folders" width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read it left to right. Nodes get temporary credentials from an IAM role. SLM fires snapshots on cron schedules. The S3 repository writes objects into an SSE-S3 encrypted bucket, one folder per tier. SLM comes back around to delete expired snapshots, and restores go the other way.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Keeps&lt;/th&gt;
&lt;th&gt;What's in it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;15min/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every 15 min&lt;/td&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;td&gt;critical app indices only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;daily/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hourly&lt;/td&gt;
&lt;td&gt;35 days&lt;/td&gt;
&lt;td&gt;everything + cluster state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;monthly/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1st of month&lt;/td&gt;
&lt;td&gt;~13 months&lt;/td&gt;
&lt;td&gt;everything + cluster state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;yearly/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Jan 1&lt;/td&gt;
&lt;td&gt;~7 years&lt;/td&gt;
&lt;td&gt;everything + cluster state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fvyzi408mc3ivmoxwxlz0.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%2Fvyzi408mc3ivmoxwxlz0.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;daily/&lt;/code&gt; folder holds the hourly snapshots. It's the day-to-day recovery tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: IAM, the keyless way
&lt;/h2&gt;

&lt;p&gt;One role, one instance profile, attached to &lt;strong&gt;every&lt;/strong&gt; node: data, master and Kibana. The permissions policy needs bucket-level and object-level actions in separate statements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetBucketLocation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucketMultipartUploads"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucketVersions"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-es-snapshots"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:DeleteObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:AbortMultipartUpload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:ListMultipartUploadParts"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-es-snapshots/*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the role with an EC2 trust policy, then wire it to each node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam create-role &lt;span class="nt"&gt;--role-name&lt;/span&gt; es-snapshot-role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assume-role-policy-document&lt;/span&gt; &lt;span class="s1"&gt;'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}'&lt;/span&gt;

aws iam put-role-policy &lt;span class="nt"&gt;--role-name&lt;/span&gt; es-snapshot-role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-name&lt;/span&gt; es-snapshot-s3 &lt;span class="nt"&gt;--policy-document&lt;/span&gt; file://s3-policy.json

aws iam create-instance-profile &lt;span class="nt"&gt;--instance-profile-name&lt;/span&gt; es-snapshot-profile
aws iam add-role-to-instance-profile &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-profile-name&lt;/span&gt; es-snapshot-profile &lt;span class="nt"&gt;--role-name&lt;/span&gt; es-snapshot-role

&lt;span class="c"&gt;# Repeat for every data, master and Kibana node&lt;/span&gt;
aws ec2 associate-iam-instance-profile &lt;span class="nt"&gt;--instance-id&lt;/span&gt; i-0123456789abcdef0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--iam-instance-profile&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;es-snapshot-profile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a node already has an instance profile, add the S3 policy to that role instead. An instance can only have one profile.&lt;/p&gt;

&lt;p&gt;Then leave the Elasticsearch keystore alone. With no &lt;code&gt;s3.client.default.access_key&lt;/code&gt; configured, the S3 client uses the instance profile automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Repositories
&lt;/h2&gt;

&lt;p&gt;On 7.x, run this on each node and do a rolling restart. On 8.x, S3 support is built in, so skip it.&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;sudo&lt;/span&gt; /usr/share/elasticsearch/bin/elasticsearch-plugin &lt;span class="nb"&gt;install &lt;/span&gt;repository-s3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then register one repository per tier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_snapshot/es-backup&lt;/span&gt;&lt;span class="mi"&gt;-15&lt;/span&gt;&lt;span class="err"&gt;min&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"settings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-es-snapshots"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"base_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"15min"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"server_side_encryption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do the same for &lt;code&gt;daily&lt;/code&gt;, &lt;code&gt;monthly&lt;/code&gt; and &lt;code&gt;yearly&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: SLM policies
&lt;/h2&gt;

&lt;p&gt;The 15-minute policy covers only the critical indices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_slm/policy/snap&lt;/span&gt;&lt;span class="mi"&gt;-15&lt;/span&gt;&lt;span class="err"&gt;min&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schedule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 5/15 * * * ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;snap-15min-{now/d}&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"repository"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"es-backup-15min"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"indices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"app-critical-*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ignore_unavailable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"include_global_state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retention"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expire_after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"min_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hourly policy snapshots everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_slm/policy/snap-hourly&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schedule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 0 * * * ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;snap-hourly-{now/d}&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"repository"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"es-backup-daily"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"indices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"include_global_state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retention"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expire_after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"35d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"min_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The monthly and yearly policies are the same shape, with schedules like &lt;code&gt;0 0 2 1 * ?&lt;/code&gt; and &lt;code&gt;0 0 3 1 1 ?&lt;/code&gt;, and &lt;code&gt;expire_after&lt;/code&gt; values of &lt;code&gt;396d&lt;/code&gt; and &lt;code&gt;2575d&lt;/code&gt;. SLM appends a unique suffix to every snapshot name, so the date-based names never collide.&lt;/p&gt;

&lt;p&gt;Kick the tires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_slm/policy/snap&lt;/span&gt;&lt;span class="mi"&gt;-15&lt;/span&gt;&lt;span class="err"&gt;min/_execute&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_slm/policy/snap&lt;/span&gt;&lt;span class="mi"&gt;-15&lt;/span&gt;&lt;span class="err"&gt;min?human&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_slm/stats&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_cat/snapshots/es-backup&lt;/span&gt;&lt;span class="mi"&gt;-15&lt;/span&gt;&lt;span class="err"&gt;min?v&amp;amp;s=end_epoch:desc&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy response shows &lt;code&gt;last_success&lt;/code&gt;, &lt;code&gt;last_failure&lt;/code&gt; and &lt;code&gt;next_execution&lt;/code&gt;. Those are the fields to alert on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things that bit me (so they don't bite you)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. "Cannot create snapshot repository" on a healthy cluster.&lt;/strong&gt;&lt;br&gt;
Repository registration is verified from every master and data node. One node without the instance profile fails the whole request. Check every node, not just the one you're curling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Access denied with a policy that looked right.&lt;/strong&gt;&lt;br&gt;
I had object permissions on &lt;code&gt;bucket/*&lt;/code&gt; but forgot the bucket ARN itself. &lt;code&gt;ListBucket&lt;/code&gt; and &lt;code&gt;GetBucketLocation&lt;/code&gt; live at the bucket level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. I wanted a 5-minute RPO.&lt;/strong&gt;&lt;br&gt;
SLM refuses schedules under 15 minutes by default (&lt;code&gt;slm.minimum_interval&lt;/code&gt;). The fix was scoping: a 15-minute policy for only the critical indices, and hourly for everything else. If you need tighter than 15 minutes, you need replication, not snapshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. I almost added an S3 lifecycle rule.&lt;/strong&gt;&lt;br&gt;
Don't. Snapshots are incremental and share files, so expiring objects by age can corrupt the repository. SLM retention knows which files are still referenced. If cleanup seems slow, remember it runs once a day by default; force it with &lt;code&gt;POST _slm/_execute_retention&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Schedules colliding at the top of the hour.&lt;/strong&gt;&lt;br&gt;
Starting the 15-minute policy at minute 5 (&lt;code&gt;0 5/15 * * * ?&lt;/code&gt;) keeps it out of the hourly run's way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Bonus check:&lt;/strong&gt; the 15-minute tier keeps about 672 snapshots (96 a day for 7 days). Make sure your version's repository snapshot limit can hold that.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Restoring (the part that actually matters)
&lt;/h2&gt;

&lt;p&gt;Restore to new names first, compare, then swap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_snapshot/es-backup-daily/&amp;lt;snapshot-name&amp;gt;/_restore&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"indices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app-critical-orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rename_pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"(.+)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rename_replacement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"restored-$1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"include_global_state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a full-cluster restore, close or delete the target indices first, then restore without the rename.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd add next
&lt;/h2&gt;

&lt;p&gt;Alerting on &lt;code&gt;last_failure&lt;/code&gt; and on the failure counts in &lt;code&gt;_slm/stats&lt;/code&gt;, plus a monthly restore drill into a scratch cluster. A backup you haven't restored is a hypothesis.&lt;/p&gt;

&lt;p&gt;If you run self-managed Elasticsearch on EC2, I'd love to hear how you handle long-term retention. Drop it in the comments.&lt;/p&gt;

</description>
      <category>elasticsearch</category>
      <category>aws</category>
      <category>devops</category>
      <category>sre</category>
    </item>
    <item>
      <title>Stop Running psql Commands by Hand — Build a REST API for PostgreSQL User Management</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Fri, 29 May 2026 21:45:12 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/stop-running-psql-commands-by-hand-build-a-rest-api-for-postgresql-user-management-1bba</link>
      <guid>https://dev.to/pranay_raavi/stop-running-psql-commands-by-hand-build-a-rest-api-for-postgresql-user-management-1bba</guid>
      <description>&lt;p&gt;If you manage PostgreSQL databases across multiple environments, you've probably done this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SSH to the DB host (or connect via &lt;code&gt;psql&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;CREATE USER jsmith CONNECTION LIMIT 20 PASSWORD '...'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Slack the password to the developer&lt;/li&gt;
&lt;li&gt;Forget to log it anywhere&lt;/li&gt;
&lt;li&gt;Repeat for every environment, every onboarding, every access request&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's tedious, error-prone, and leaves zero audit trail. Here's a better way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;pg-user-api&lt;/strong&gt; is a lightweight Flask REST API that wraps PostgreSQL user provisioning in clean HTTP endpoints. You register your databases once in a SQLite inventory, then any tooling — CI pipelines, internal portals, Ansible playbooks, or a plain &lt;code&gt;curl&lt;/code&gt; — can create and manage users across environments without ever touching &lt;code&gt;psql&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/pcraavi/PostgreSQL-user-creation-API" rel="noopener noreferrer"&gt;pcraavi/PostgreSQL-user-creation-API&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem It Solves
&lt;/h2&gt;

&lt;p&gt;In teams that span dev, QA, UAT, and prod, you end up with different patterns of users:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App service accounts&lt;/strong&gt; — named after the host/port combo (&lt;code&gt;web01_8080&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes workload accounts&lt;/strong&gt; — named after env prefix + farm (&lt;code&gt;dv_gearservice&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Individual dev/QA accounts&lt;/strong&gt; — low connection limits, scoped to non-prod&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only analyst accounts&lt;/strong&gt; — prod only, no DDL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DBA accounts&lt;/strong&gt; — &lt;code&gt;CREATEDB CREATEROLE LOGIN&lt;/code&gt;, rarely provisioned&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each type has different &lt;code&gt;CONNECTION LIMIT&lt;/code&gt; values, privilege levels, and naming conventions. Encoding these patterns in an API means the rules are consistent, repeatable, and auditable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The project is intentionally small — five Python files and a requirements list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pg_user_api/
├── app.py              # Flask app — all endpoints
├── auth.py             # HTTP Basic Auth (constant-time compare)
├── database.py         # SQLite registry + audit log
├── notifications.py    # Notification stubs (Webex / Slack / Email)
├── seed_db.py          # One-time setup: creates DB + sample records
└── requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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.amazonaws.com%2Fuploads%2Farticles%2Ff3sjt72008n6q9zv3tka.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.amazonaws.com%2Fuploads%2Farticles%2Ff3sjt72008n6q9zv3tka.png" alt=" " width="800" height="1011"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two credential pairs, clearly separated:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PG_API_USER&lt;/code&gt; / &lt;code&gt;PG_API_PASS&lt;/code&gt; — who can call this API (your team/tooling)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PG_ADMIN_USER&lt;/code&gt; / &lt;code&gt;PG_ADMIN_PASS&lt;/code&gt; — the PostgreSQL DBA role that executes DDL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The DBA credentials never appear in API URLs or response bodies. Callers only need the API credentials plus &lt;code&gt;env&lt;/code&gt; and &lt;code&gt;database&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite as a config-free registry:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rather than a static YAML or environment file listing every hostname, databases are registered once in a &lt;code&gt;db_registry&lt;/code&gt; table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dev&lt;/code&gt; / &lt;code&gt;qa&lt;/code&gt; / &lt;code&gt;uat&lt;/code&gt; / &lt;code&gt;prod&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;db_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL database name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hostname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FQDN or IP of the host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;port&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL port (default 5432)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;1&lt;/code&gt; = active, &lt;code&gt;0&lt;/code&gt; = skip&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every endpoint looks up the hostname dynamically from this registry. No hardcoded connection strings anywhere in application code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Endpoints
&lt;/h2&gt;

&lt;p&gt;All endpoints are &lt;code&gt;GET&lt;/code&gt; with query params (by design — simple to &lt;code&gt;curl&lt;/code&gt;, simple to call from automation scripts).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /                            # Health check, no auth required
GET /api/v1/registry             # List registered databases
GET /api/v1/users/all            # List all PostgreSQL roles on a database
GET /api/v1/users/app            # Create VM/container service account
GET /api/v1/users/app-k8s        # Create Kubernetes workload account
GET /api/v1/users/devqa          # Create individual dev/QA user
GET /api/v1/users/devlead        # Create dev-lead user
GET /api/v1/users/readonly       # Create read-only user
GET /api/v1/users/dba            # Create DBA user (CREATEDB + CREATEROLE)
GET /api/v1/users/reset          # Reset a user's password
GET /api/v1/users/search-path    # Update search_path for a user
GET /api/v1/users/find           # Look up a specific user
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every user-creation endpoint returns the same structured response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"web01_8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"generatedSecurePassword"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hostname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pg-dev-01.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"database"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"myapp_dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5432"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dev"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Password is returned once at creation time. The API uses &lt;code&gt;secrets.token_urlsafe(16)&lt;/code&gt; for generation — no insecure &lt;code&gt;random&lt;/code&gt; module.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Clone and install
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/pcraavi/PostgreSQL-user-creation-API.git
&lt;span class="nb"&gt;cd &lt;/span&gt;PostgreSQL-user-creation-API
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Seed the registry
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python seed_db.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates &lt;code&gt;pg_registry.db&lt;/code&gt; with sample entries. Edit &lt;code&gt;SAMPLE_RECORDS&lt;/code&gt; in &lt;code&gt;seed_db.py&lt;/code&gt; to point at your real PostgreSQL hosts.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Set credentials via environment variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Windows PowerShell&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;PG_API_USER&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pgadmin"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;PG_API_PASS&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ch@ngeMe2024!"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;PG_ADMIN_USER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"role_create"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;PG_ADMIN_PASS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_pg_password"&lt;/span&gt;&lt;span class="w"&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 shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / macOS&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PG_API_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pgadmin"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PG_API_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Ch@ngeMe2024!"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PG_ADMIN_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"role_create"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PG_ADMIN_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_pg_password"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Start
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python app.py
&lt;span class="c"&gt;# Listening on http://localhost:5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Example Calls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Create a service account for a VM running on port 8080:&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;curl &lt;span class="nt"&gt;-u&lt;/span&gt; pgadmin:Ch@ngeMe2024! &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost:5000/api/v1/users/app?env=dev&amp;amp;database=myapp_dev&amp;amp;servername=web01&amp;amp;port=8080"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creates user &lt;code&gt;web01_8080&lt;/code&gt; with &lt;code&gt;CONNECTION LIMIT 200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create a Kubernetes workload account:&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;curl &lt;span class="nt"&gt;-u&lt;/span&gt; pgadmin:Ch@ngeMe2024! &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost:5000/api/v1/users/app-k8s?env=dev&amp;amp;database=myapp_dev&amp;amp;env_prefix=dv&amp;amp;farmname=gearservice"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creates user &lt;code&gt;dv_gearservice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reset a forgotten password:&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;curl &lt;span class="nt"&gt;-u&lt;/span&gt; pgadmin:Ch@ngeMe2024! &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost:5000/api/v1/users/reset?env=prod&amp;amp;database=myapp_prod&amp;amp;username=analyst01"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generates a new &lt;code&gt;secrets.token_urlsafe(16)&lt;/code&gt; password and applies it immediately. Returns the new password in the response.&lt;/p&gt;




&lt;h2&gt;
  
  
  Idempotency
&lt;/h2&gt;

&lt;p&gt;All create endpoints check &lt;code&gt;pg_catalog.pg_roles&lt;/code&gt; before issuing &lt;code&gt;CREATE USER&lt;/code&gt;. If the role already exists, the API returns &lt;code&gt;"status": "user already exists"&lt;/code&gt; and exits cleanly. Safe to call from automation without worrying about duplicate creation errors.&lt;/p&gt;




&lt;h2&gt;
  
  
  Audit Log
&lt;/h2&gt;

&lt;p&gt;Every operation (create, reset, search_path change) is written to a &lt;code&gt;audit_log&lt;/code&gt; table in the same &lt;code&gt;pg_registry.db&lt;/code&gt; SQLite file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 pg_registry.db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"SELECT * FROM audit_log ORDER BY performed_at DESC LIMIT 10;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a timestamped record of who called what, on which database, with what outcome. Useful for access reviews and incident investigations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Notification Hooks
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;notifications.py&lt;/code&gt; ships with ready-to-uncomment stubs for Webex Teams, Slack, and email. Wire in your webhook URL or SMTP config, then call &lt;code&gt;send_notification()&lt;/code&gt; from any endpoint to push alerts to your team when accounts are created or passwords reset.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Intended for &lt;strong&gt;internal / intranet&lt;/strong&gt; use — put it behind a VPN or API gateway, not on the open internet.&lt;/li&gt;
&lt;li&gt;For internet-facing deployments, swap HTTP Basic Auth for JWT or an API key header.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_registry.db&lt;/code&gt; contains your real hostnames — it's in &lt;code&gt;.gitignore&lt;/code&gt; and should stay off version control.&lt;/li&gt;
&lt;li&gt;The DBA password never appears in URLs, query strings, or logs.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I'd Add Next
&lt;/h2&gt;

&lt;p&gt;A few things on the roadmap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GRANT/REVOKE endpoints&lt;/strong&gt; — privilege management beyond account creation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema-level grants&lt;/strong&gt; — &lt;code&gt;GRANT SELECT ON ALL TABLES IN SCHEMA&lt;/code&gt; patterns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token-based auth&lt;/strong&gt; — drop-in replacement for Basic Auth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker packaging&lt;/strong&gt; — &lt;code&gt;docker run&lt;/code&gt; for teams that don't want to manage Python deps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured audit export&lt;/strong&gt; — JSON or CSV export of the audit log for compliance workflows&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;If your team provisions PostgreSQL users more than a few times a month, wrapping it in an HTTP interface pays for itself quickly. The audit trail alone is worth it.&lt;/p&gt;

&lt;p&gt;The full source is at &lt;a href="https://github.com/pcraavi/PostgreSQL-user-creation-API" rel="noopener noreferrer"&gt;github.com/pcraavi/PostgreSQL-user-creation-API&lt;/a&gt;. It's MIT-licensed — fork it, adapt the user types to your org's naming conventions, and wire in your notification channels.&lt;/p&gt;

&lt;p&gt;Questions or suggestions? Drop them in the comments or open an issue on GitHub.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>python</category>
      <category>devops</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>PostgreSQL VACUUM Tuning: A Technical Deep Dive Into Autovacuum Configuration</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Sun, 24 May 2026 09:37:29 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/postgresql-vacuum-tuning-a-technical-deep-dive-into-autovacuum-configuration-3gf0</link>
      <guid>https://dev.to/pranay_raavi/postgresql-vacuum-tuning-a-technical-deep-dive-into-autovacuum-configuration-3gf0</guid>
      <description>&lt;p&gt;&lt;strong&gt;Author's Note:&lt;/strong&gt; This article documents a production incident investigation and the technical findings that emerged from returning to foundational documentation. The fix was implemented by a colleague; this article captures the learning journey through proper documentation review.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Incident: High CPU Due to Autovacuum Contention
&lt;/h2&gt;

&lt;p&gt;A production Aurora PostgreSQL cluster experienced sustained CPU utilization between 85-90% over a 3-4 hour window. CloudWatch Performance Insights identified the primary wait event as CPU (not I/O or lock contention), and the top consuming operation was autovacuum VACUUM processes running on two large tables.&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.amazonaws.com%2Fuploads%2Farticles%2Fimpgkzayofraehnyhe21.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.amazonaws.com%2Fuploads%2Farticles%2Fimpgkzayofraehnyhe21.png" alt="CloudWatch CPU Spike Dashboard" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observed State:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Table A (593 GB, 623M rows): 124 million dead tuples (16.6% dead ratio)&lt;/li&gt;
&lt;li&gt;Table B (465M rows): 74 million dead tuples (13.7% dead ratio)&lt;/li&gt;
&lt;li&gt;Autovacuum workers: 2 running concurrently&lt;/li&gt;
&lt;li&gt;CPU utilization: 85-90%&lt;/li&gt;
&lt;li&gt;Autovacuum frequency: Single massive vacuum operation per 4-6 hours&lt;/li&gt;
&lt;li&gt;Status of tables: Never manually vacuumed since instance creation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Root Cause:&lt;/strong&gt; Autovacuum thresholds were configured at system defaults, which were inappropriate for high-churn tables receiving bulk updates every 2-3 hours via scheduled data loader processes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understanding MVCC: PostgreSQL vs. Oracle's Undo Architecture
&lt;/h2&gt;

&lt;p&gt;Before tuning can be effective, the fundamental difference in how PostgreSQL and Oracle manage concurrent access must be understood.&lt;/p&gt;

&lt;h3&gt;
  
  
  Oracle's Approach: Automatic Undo Retention Management (AUM)
&lt;/h3&gt;

&lt;p&gt;In Oracle, Multi-Version Concurrency Control (MVCC) is implemented via &lt;strong&gt;undo tablespace segments&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Update operation:&lt;/strong&gt; When a row is updated, the old version is written to undo tablespace (not to the table itself).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undo retention:&lt;/strong&gt; Oracle's Automatic Undo Retention Management (AUM) manages undo tablespace as a circular buffer. Undo extents are recycled automatically based on the &lt;code&gt;UNDO_RETENTION&lt;/code&gt; parameter and available tablespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Space reclamation:&lt;/strong&gt; Undo space is automatically freed when either (a) the retention period expires, or (b) tablespace pressure forces rollback of older undo data. The DBA's responsibility is limited to allocating sufficient undo tablespace upfront.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Key characteristic:&lt;/strong&gt; The DBA tunes this mechanism once (setting retention period and tablespace size) and then relies on Oracle's background processes to manage undo lifecycle automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[IMAGE 2: Oracle vs PostgreSQL MVCC Architecture Diagram]&lt;/strong&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.amazonaws.com%2Fuploads%2Farticles%2Fi7efyd3nzn1m277c7hj4.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.amazonaws.com%2Fuploads%2Farticles%2Fi7efyd3nzn1m277c7hj4.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"&lt;/p&gt;

&lt;h3&gt;
  
  
  PostgreSQL's Approach: Heap-Based MVCC with Explicit VACUUM
&lt;/h3&gt;

&lt;p&gt;In PostgreSQL, MVCC is implemented at the &lt;strong&gt;table (heap) level&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Update operation:&lt;/strong&gt; When a row is updated, a new version of the row is inserted into the same table. The old version is marked as "dead" but remains physically in the table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Space reclamation:&lt;/strong&gt; VACUUM must scan the table, identify dead tuples, and mark their space as reusable. Dead tuples are not automatically removed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autovacuum trigger:&lt;/strong&gt; Autovacuum is a background process that decides &lt;em&gt;when&lt;/em&gt; to vacuum based on tunable thresholds. Unlike Oracle's automatic undo recycling, PostgreSQL requires explicit configuration of when vacuum should trigger.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Key characteristic:&lt;/strong&gt; The DBA must actively tune VACUUM parameters based on table churn patterns. There is no "set it and forget it" mechanism comparable to Oracle's AUM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication:&lt;/strong&gt; On Oracle, a table with high UPDATE volume simply generates more undo, which Oracle's AUM handles. On PostgreSQL, the same UPDATE volume generates more dead tuples, and if autovacuum thresholds are too conservative, dead tuples accumulate until autovacuum finally triggers—often at high volume, causing CPU spikes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Vacuum Threshold Formula: The Mathematical Foundation
&lt;/h2&gt;

&lt;p&gt;When autovacuum decides to run, it evaluates the following formula for each table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VACUUM_TRIGGER_THRESHOLD = autovacuum_vacuum_threshold + 
                           (autovacuum_vacuum_scale_factor × n_live_tup)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;autovacuum_vacuum_threshold&lt;/code&gt;: Absolute minimum dead tuples (default: 50)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;autovacuum_vacuum_scale_factor&lt;/code&gt;: Percentage of table size (default: 0.1 = 10%)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;n_live_tup&lt;/code&gt;: Current number of live tuples in the table&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pre-Investigation Configuration
&lt;/h3&gt;

&lt;p&gt;System defaults on the cluster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;autovacuum_vacuum_threshold&lt;/code&gt; = 50&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;autovacuum_vacuum_scale_factor&lt;/code&gt; = 0.1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Calculation for Table A (725M rows):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;THRESHOLD = 50 + (0.1 × 725,000,000)
          = 50 + 72,500,000
          = 72.5 million dead tuples
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Interpretation:&lt;/strong&gt; Autovacuum would not trigger on Table A until 72.5 million dead tuples accumulated. This is the critical misconfiguration.&lt;/p&gt;

&lt;p&gt;For comparison, Table A actually accumulated 124 million dead tuples before the vacuum completed—well beyond this threshold, indicating autovacuum had already triggered much earlier in the lifecycle but was running continuously against an accumulating workload.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Root Cause: Loader Pattern and Threshold Mismatch
&lt;/h2&gt;

&lt;p&gt;The data loader process (running every 2-3 hours with 4 parallel workers) updated rows using a COALESCE merge pattern:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;table_a&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; 
  &lt;span class="n"&gt;column_1&lt;/span&gt; &lt;span class="o"&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_1&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;column_1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;column_2&lt;/span&gt; &lt;span class="o"&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_2&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;column_2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;column_3&lt;/span&gt; &lt;span class="o"&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_3&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;column_3&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;staging_table&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&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;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern creates a dead tuple for every row touched, regardless of whether values actually changed. Over a 2-3 hour window with millions of rows, dead tuple generation rate significantly exceeded autovacuum's ability to reclaim space given the high threshold values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The collision:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High dead tuple generation rate from bulk UPDATE operations&lt;/li&gt;
&lt;li&gt;Autovacuum thresholds calibrated for the default use case (moderate churn on tables of typical size)&lt;/li&gt;
&lt;li&gt;No table-level overrides to account for this specific workload pattern&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result: Dead tuples accumulated to 16.6% of table size before stabilizing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Investigation: Diagnostic Queries and Findings
&lt;/h2&gt;

&lt;p&gt;Three queries provided diagnostic clarity:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query 1: Current dead tuple status&lt;/strong&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
  &lt;span class="n"&gt;n_live_tup&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;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;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt; &lt;span class="o"&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="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;dead_ratio_pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&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;'table_a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'table_b'&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;n_dead_tup&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;Result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Table A: 623M live, 124M dead (16.6%)&lt;/li&gt;
&lt;li&gt;Table B: 465M live, 74M dead (13.7%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Query 2: Active autovacuum processes&lt;/strong&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;query&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;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EPOCH&lt;/span&gt; &lt;span class="k"&gt;FROM&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="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;runtime_seconds&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;query&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%VACUUM%'&lt;/span&gt; 
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%pg_stat%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: Two autovacuum workers running concurrently, one on each table, both active for 55+ minutes and 3+ minutes respectively at time of investigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query 3: Cumulative churn analysis&lt;/strong&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_live_tup&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;n_tup_upd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n_tup_del&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_modifications&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_tup_upd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n_tup_del&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_live_tup&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;churn_ratio_pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&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;'table_a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'table_b'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Table A: 16 billion total modifications on 725M rows (2203% cumulative churn)&lt;/li&gt;
&lt;li&gt;Table B: 11.9 billion total modifications on 465M rows (2566% cumulative churn)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Interpretation:&lt;/strong&gt; These represent lifetime cumulative statistics since instance creation. The 2200%+ ratio indicates every row has been touched approximately 22 times on average over the instance lifetime.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Configuration Adjustment: Formula-Based Approach
&lt;/h2&gt;

&lt;p&gt;Rather than implementing ad-hoc changes, a formula-based approach was used to calculate appropriate thresholds.&lt;/p&gt;

&lt;p&gt;The colleague conducting the fix referenced textbook formulas for maintenance memory allocation and threshold calculation, confirming that the system defaults were inappropriate for tables with this churn profile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Applied changes (table-level only):&lt;/strong&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;table_a&lt;/span&gt; &lt;span class="k"&gt;SET&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;01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_analyze_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;005&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_analyze_threshold&lt;/span&gt; &lt;span class="o"&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;autovacuum_vacuum_cost_delay&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="n"&gt;autovacuum_vacuum_cost_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&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;table_b&lt;/span&gt; &lt;span class="k"&gt;SET&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;01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_analyze_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;005&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_analyze_threshold&lt;/span&gt; &lt;span class="o"&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;autovacuum_vacuum_cost_delay&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="n"&gt;autovacuum_vacuum_cost_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rationale for each parameter:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Scale factor (0.1 → 0.01):&lt;/strong&gt; Lowers the percentage-based trigger from 10% to 1% of table size, causing autovacuum to start at 7.25 million dead tuples instead of 72.5 million. This increases vacuum frequency but reduces per-operation work volume.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Threshold (50 → 10000):&lt;/strong&gt; Sets an explicit minimum to prevent excessive vacuum triggering on very small tables, but still allows reasonable triggering on large tables.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Analyze scale factor (0.05 → 0.005):&lt;/strong&gt; ANALYZE (which updates table statistics for the query planner) triggers more frequently, preventing stale statistics during high-churn periods.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cost parameters:&lt;/strong&gt; &lt;code&gt;cost_delay = 2ms&lt;/code&gt; and &lt;code&gt;cost_limit = 5000&lt;/code&gt; distribute vacuum work into smaller increments with longer pauses, reducing per-operation CPU spike while still completing the work.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;New threshold calculation for Table A:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;THRESHOLD = 10,000 + (0.01 × 725,000,000)
          = 10,000 + 7,250,000
          = 7.26 million dead tuples
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This represents a &lt;strong&gt;10× reduction&lt;/strong&gt; in the threshold value, causing autovacuum to trigger 10× more frequently but with proportionally smaller work loads.&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.amazonaws.com%2Fuploads%2Farticles%2Fbyuky1v3222vyg5o3i1t.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.amazonaws.com%2Fuploads%2Farticles%2Fbyuky1v3222vyg5o3i1t.png" alt="Vacuum Threshold Comparison - Before vs After" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Critical Design Decision: Table-Level Configuration, Not Cluster-Level
&lt;/h2&gt;

&lt;p&gt;A deliberate choice was made to apply all tuning parameters &lt;strong&gt;at the table level only&lt;/strong&gt;, not at the system/cluster level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why table-level configuration is necessary:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Heterogeneous workloads:&lt;/strong&gt; Not all tables in a cluster have the same churn pattern. Table A and Table B are high-churn bulk-update targets. Other tables in the same cluster may be mostly static or read-heavy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Preventing cascade effects:&lt;/strong&gt; A cluster-wide reduction in &lt;code&gt;autovacuum_vacuum_scale_factor&lt;/code&gt; would cause autovacuum to trigger more frequently on &lt;em&gt;all&lt;/em&gt; tables, including those with minimal churn. This could result in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unnecessary vacuum operations consuming CPU and I/O&lt;/li&gt;
&lt;li&gt;More frequent ANALYZE operations on stable tables&lt;/li&gt;
&lt;li&gt;Increased lock contention if many vacuums run concurrently&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Isolation of risk:&lt;/strong&gt; By tuning only the affected tables, the change is isolated to the problem source and does not introduce unexpected side effects on other database objects or applications.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is a deliberate engineering discipline: tune at the smallest scope that solves the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results: Before and After Metrics
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before Configuration (April 13, 09:51 AM):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Table A&lt;/th&gt;
&lt;th&gt;Table B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dead tuples&lt;/td&gt;
&lt;td&gt;124M&lt;/td&gt;
&lt;td&gt;74M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead ratio&lt;/td&gt;
&lt;td&gt;16.6%&lt;/td&gt;
&lt;td&gt;13.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last autovacuum&lt;/td&gt;
&lt;td&gt;N/A (first vacuum)&lt;/td&gt;
&lt;td&gt;N/A (first vacuum)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU utilization&lt;/td&gt;
&lt;td&gt;85-90%&lt;/td&gt;
&lt;td&gt;85-90%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vacuum frequency&lt;/td&gt;
&lt;td&gt;1 per 4-6 hours&lt;/td&gt;
&lt;td&gt;1 per 4-6 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;After Configuration (April 13, 12:07 PM - 13:45 PM):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Table A&lt;/th&gt;
&lt;th&gt;Table B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dead tuples&lt;/td&gt;
&lt;td&gt;0 (vacuum completed)&lt;/td&gt;
&lt;td&gt;21M (declining)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead ratio&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;7.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last autovacuum&lt;/td&gt;
&lt;td&gt;2026-04-13 12:07:08&lt;/td&gt;
&lt;td&gt;2026-04-13 13:45:44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU utilization&lt;/td&gt;
&lt;td&gt;30-40%&lt;/td&gt;
&lt;td&gt;30-40%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vacuum frequency&lt;/td&gt;
&lt;td&gt;Multiple per loader cycle&lt;/td&gt;
&lt;td&gt;Multiple per loader cycle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The vacuum operations completed naturally without manual intervention. Dead tuple levels stabilized well below the new thresholds. CPU alerts cleared.&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.amazonaws.com%2Fuploads%2Farticles%2F2l5cg0agi01eaqacbinn.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.amazonaws.com%2Fuploads%2Farticles%2F2l5cg0agi01eaqacbinn.png" alt="Incident Timeline and Recovery " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring: Proactive Visibility
&lt;/h2&gt;

&lt;p&gt;To prevent recurrence, a monitoring table was implemented to capture dead tuple trends:&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;pg_table_stats_history&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;captured_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&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="k"&gt;table_name&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;n_live_tup&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;n_dead_tup&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;dead_ratio_percent&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_upd&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;n_tup_del&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;last_autovacuum&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&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;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;'capture_table_stats'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'*/30 * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&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;pg_table_stats_history&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_live_tup&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;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;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt; &lt;span class="o"&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="mi"&gt;0&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_tup_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_tup_del&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&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;'table_a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'table_b'&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;This captures a snapshot every 30 minutes, allowing observation of daily patterns: "During loader window (12:00-14:00), dead tuples climb to 5-8M, then autovacuum brings them back to 0.5M."&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.amazonaws.com%2Fuploads%2Farticles%2F9d9n1nw14ci779wl1b0c.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.amazonaws.com%2Fuploads%2Farticles%2F9d9n1nw14ci779wl1b0c.png" alt="Dead Tuple Trend Monitoring Over 24 Hours" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Concepts: What the Investigation Revealed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The Autovacuum Cost Mechanism
&lt;/h3&gt;

&lt;p&gt;VACUUM has two cost-control parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;autovacuum_vacuum_cost_limit&lt;/code&gt;&lt;/strong&gt;: Units of work (reading a page = 1 unit, writing a page = 20 units) allowed before autovacuum pauses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;autovacuum_vacuum_cost_delay&lt;/code&gt;&lt;/strong&gt;: Milliseconds to pause when cost limit is reached&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lower cost_limit + higher delay = slower vacuum, less CPU spike&lt;br&gt;&lt;br&gt;
Higher cost_limit + lower delay = faster vacuum, more CPU spike&lt;/p&gt;

&lt;p&gt;The pre-incident configuration had &lt;code&gt;cost_delay = 5ms&lt;/code&gt; and &lt;code&gt;cost_limit = 1800&lt;/code&gt; (already aggressive). The post-incident configuration used &lt;code&gt;cost_delay = 2ms&lt;/code&gt; and &lt;code&gt;cost_limit = 5000&lt;/code&gt;, allocating higher work budgets but still enforcing frequent pauses for distribution.&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.amazonaws.com%2Fuploads%2Farticles%2Fitmiimix23toanwn1r5a.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.amazonaws.com%2Fuploads%2Farticles%2Fitmiimix23toanwn1r5a.png" alt="Autovacuum Cost Mechanism - Work Distribution Pattern" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When autovacuum triggers, it scans the entire table. If dead tuples are being created faster than they're being reclaimed (because the loader is still running), the vacuum operation takes longer and consumes sustained CPU.&lt;/p&gt;

&lt;p&gt;In the incident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Table A vacuum started at 09:51:54 and completed at 12:07:08 (2h 15m)&lt;/li&gt;
&lt;li&gt;Table B vacuum started at 08:59:39 and completed at 13:45:44 (4h 46m)&lt;/li&gt;
&lt;li&gt;During this window, the loader was also running (starting 12:37:27), creating additional dead tuples&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The concurrent activity created contention for CPU and I/O resources, explaining the 85-90% CPU utilization.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Wraparound Protection: A Critical Background Mechanism
&lt;/h3&gt;

&lt;p&gt;While not the active problem in this incident, understanding wraparound protection is essential context:&lt;/p&gt;

&lt;p&gt;PostgreSQL uses 4-byte transaction IDs (2^32 = 4.3 billion possible values). For MVCC visibility comparison to work correctly, only a 2-billion value range is usable at any given time. If autovacuum falls so far behind that unfrozen tuples approach the 2-billion transaction boundary, PostgreSQL will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Log warnings at 200M transactions remaining&lt;/li&gt;
&lt;li&gt;Shift to READ-ONLY mode at 1M transactions remaining
&lt;/li&gt;
&lt;li&gt;Force shutdown if the limit is reached&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is not a theoretical concern—it's a safety mechanism that has forced database shutdowns in under-monitored systems. Autovacuum must keep up, or the database fails.&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.amazonaws.com%2Fuploads%2Farticles%2Fknpunbwv37i29rs3hwbl.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.amazonaws.com%2Fuploads%2Farticles%2Fknpunbwv37i29rs3hwbl.png" alt="Transaction ID Wraparound Protection Lifecycle" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Documentation Recovery: Why AI Alone Is Insufficient
&lt;/h2&gt;

&lt;p&gt;When the incident was first encountered, AI-based diagnostic tools provided generic suggestions: "lower cost_delay," "increase cost_limit," "check maintenance_work_mem."&lt;/p&gt;

&lt;p&gt;These suggestions were not wrong, but they were not calibrated to the specific situation. The threshold formula, the rationale for scale factor adjustment, and the risks of cluster-level configuration changes were not apparent from AI output alone.&lt;/p&gt;

&lt;p&gt;The fix required returning to foundational documentation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL VACUUM documentation&lt;/strong&gt; (official): Explained the cost mechanism and threshold formula&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Aurora PostgreSQL tuning guide&lt;/strong&gt;: Provided context-specific guidance for managed Aurora instances&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Textbook references on MVCC&lt;/strong&gt;: Clarified why dead tuples accumulate and how autovacuum prevents wraparound&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The colleague's insistence on reading the documentation forced a deeper investigation that revealed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The mathematical formula driving autovacuum trigger decisions&lt;/li&gt;
&lt;li&gt;The specific interaction between bulk update workloads and default thresholds&lt;/li&gt;
&lt;li&gt;The risks and benefits of table-level vs. cluster-level configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Base concepts—MVCC, dead tuples, autovacuum thresholds, wraparound protection—are non-negotiable foundations. Understanding these requires reading documentation. Once the foundations are understood, AI tools can accelerate diagnosis and suggest configurations. But without foundations, suggestions are just knobs to turn without understanding consequences.&lt;/p&gt;

&lt;p&gt;Combining both—foundational reading with AI-assisted diagnosis—yields better outcomes than either alone.&lt;/p&gt;


&lt;h2&gt;
  
  
  Diagnostic Queries for Future Incidents
&lt;/h2&gt;

&lt;p&gt;When autovacuum CPU spikes occur, these queries provide immediate visibility:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dead tuple status:&lt;/strong&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_live_tup&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;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;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt; &lt;span class="o"&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="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;dead_pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&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;n_dead_tup&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;&lt;strong&gt;Active autovacuum activity:&lt;/strong&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;query&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;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EPOCH&lt;/span&gt; &lt;span class="k"&gt;FROM&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="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;runtime_sec&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;query&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%VACUUM%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Churn rate analysis:&lt;/strong&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
  &lt;span class="n"&gt;n_tup_upd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n_tup_del&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_mods&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_tup_upd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n_tup_del&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_live_tup&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;churn_pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&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;n_dead_tup&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;&lt;strong&gt;Transaction age (wraparound risk):&lt;/strong&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;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;AS&lt;/span&gt; &lt;span class="n"&gt;txid_age&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;setting&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;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;'autovacuum_freeze_max_age'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;AS&lt;/span&gt; &lt;span class="n"&gt;txid_remaining&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;datallowconn&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;txid_age&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;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This incident demonstrated that PostgreSQL's VACUUM mechanism requires active tuning based on workload patterns. Unlike Oracle's Automatic Undo Retention Management—which automatically recycles undo tablespace based on retention policies—PostgreSQL requires explicit configuration of autovacuum thresholds calibrated to specific table churn patterns.&lt;/p&gt;

&lt;p&gt;The resolution came not from tweaking random parameters, but from understanding the mathematical formulas governing autovacuum behavior and applying them methodically at table scope.&lt;/p&gt;

&lt;p&gt;The broader lesson is methodological: when facing production database performance issues, foundational understanding of mechanisms (documented in official sources) combined with diagnostic data yields better outcomes than parameter suggestions alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL Official: &lt;a href="https://www.postgresql.org/docs/current/sql-vacuum.html" rel="noopener noreferrer"&gt;VACUUM&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL Official: &lt;a href="https://www.postgresql.org/docs/current/runtime-config-autovacuum.html" rel="noopener noreferrer"&gt;Autovacuum&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS Database Blog: &lt;a href="https://aws.amazon.com/blogs/database/understanding-autovacuum-in-amazon-rds-for-postgresql-environments/" rel="noopener noreferrer"&gt;Understanding autovacuum in Amazon RDS for PostgreSQL&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Percona: &lt;a href="https://www.percona.com/blog/overcoming-vacuum-wraparound/" rel="noopener noreferrer"&gt;Overcoming VACUUM WRAPAROUND&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CYBERTEC: &lt;a href="https://www.cybertec-postgresql.com/en/autovacuum-wraparound-protection-in-postgresql/" rel="noopener noreferrer"&gt;Autovacuum wraparound protection&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
    </item>
    <item>
      <title>From Zombie Connections to XactSync: A Post-Mortem on Aurora Postgres CPU Spikes</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Sat, 23 May 2026 20:54:26 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/from-zombie-connections-to-xactsync-a-post-mortem-on-aurora-postgres-cpu-spikes-55ek</link>
      <guid>https://dev.to/pranay_raavi/from-zombie-connections-to-xactsync-a-post-mortem-on-aurora-postgres-cpu-spikes-55ek</guid>
      <description>&lt;p&gt;The alert fires at 1:28 PM. By the time you open CloudWatch, CPU is already back to normal. No application errors. No user complaints. Nothing obviously broken.&lt;/p&gt;

&lt;p&gt;Do you close the ticket and move on, or dig in?&lt;/p&gt;

&lt;p&gt;This post is about why you should always dig in — and exactly how we traced a "self-resolving" CPU spike on Aurora PostgreSQL 16 all the way back to a mismatch between pipeline commit logic and the database storage engine.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Engine:&lt;/strong&gt; Aurora PostgreSQL 16.1 on &lt;code&gt;db.x2g.xlarge&lt;/code&gt; (4 vCPUs, 128GB RAM)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workload:&lt;/strong&gt; Kafka stream-based pipeline inserting ~365 rows/sec average into a daily-partitioned table, with burst peaks near 825/sec&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication:&lt;/strong&gt; Oracle GoldenGate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability:&lt;/strong&gt; AWS CloudWatch Database Insights + AppDynamics Database Agent&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What the Dashboard Showed
&lt;/h2&gt;

&lt;p&gt;The first stop is always &lt;strong&gt;CloudWatch&lt;/strong&gt; $\rightarrow$ &lt;strong&gt;Database Insights&lt;/strong&gt; $\rightarrow$ &lt;strong&gt;Database Load&lt;/strong&gt;. This chart shows Average Active Sessions (AAS) broken down by wait type:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Color&lt;/th&gt;
&lt;th&gt;Wait Type&lt;/th&gt;
&lt;th&gt;What It Means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🟢 &lt;strong&gt;Green&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CPU&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Active computation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🟠 &lt;strong&gt;Orange&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IO:AuroraStorageLogAllocate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;WAL/storage log writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🟣 &lt;strong&gt;Purple&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LWLock:BufferContent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Buffer manager contention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🟤 &lt;strong&gt;Brown&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Timeout:SpinDelay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Spinlock spin waits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🩷 &lt;strong&gt;Pink&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LWLock:WALInsert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;WAL insert lock contention&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our spike was &lt;strong&gt;pure green&lt;/strong&gt;—CPU only, no I/O or lock contention. The &lt;em&gt;Top SQL&lt;/em&gt; tab showed a high-volume &lt;code&gt;INSERT&lt;/code&gt; into a partitioned event log table running at ~825 calls/sec with 0.17ms average latency. Fast and normal individually. The volume was the question.&lt;/p&gt;

&lt;p&gt;However, the &lt;em&gt;Database Telemetry&lt;/em&gt; tab revealed two ominous signals that often catch a developer's attention:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Max time idle in transaction:&lt;/strong&gt; Climbing linearly for 3.2 hours straight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vacuum: Max used transaction IDs:&lt;/strong&gt; On a steady linear climb.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When you see both of those linear trends on a single chart, it usually points to a common suspect in relational databases: &lt;em&gt;something has been holding an open transaction for a very long time, threatening your autovacuum health.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Background: The Core Mechanics of Postgres MVCC
&lt;/h2&gt;

&lt;p&gt;To understand why long-running transactions are so closely tracked, it helps to look at how PostgreSQL manages Multi-Version Concurrency Control (MVCC).&lt;/p&gt;

&lt;p&gt;Unlike database engines that keep old record data in a separate utility tablespace (like Oracle's Undo Tablespace), PostgreSQL relies on a &lt;strong&gt;Heap-Based Approach&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Postgres Heap Reality:&lt;/strong&gt; There is no separate undo space. When a row is updated or deleted, the old version (called a "dead tuple") remains physically stored inside the exact same table page. It stays there until the background &lt;code&gt;VACUUM&lt;/code&gt; process scans the table and marks that dead space as reusable for future inserts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If an application leaves a transaction open, it anchors the &lt;code&gt;xmin&lt;/code&gt; (the baseline transaction ID floor for that snapshot). &lt;strong&gt;&lt;code&gt;VACUUM&lt;/code&gt; cannot safely clean up any dead tuples that were generated after the oldest active transaction's `xmin&lt;/strong&gt;`. This can trigger a problematic domino effect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Session opens → acquires xmin snapshot
       ↓
Session goes idle (no explicit commits or rollbacks)
       ↓
Dead tuples accumulate → VACUUM cannot clear them out
       ↓
Autovacuum loops continuously, wasting CPU scanning bloated tables
       ↓
Transient CPU spikes alert operations

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because autovacuum periodically wakes up to evaluate tables, the resulting CPU spikes look completely intermittent. The alert clears, engineers close the ticket as a glitch, and the underlying architectural friction persists.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Find the Stuck Sessions
&lt;/h2&gt;

&lt;p&gt;To hunt down the culprit, we ran a quick diagnostic query against &lt;code&gt;pg_stat_activity&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="n"&gt;client_addr&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;query&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;idle_duration&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;transaction_age&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;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The results:&lt;/strong&gt; 35 sessions, all originating from the same connection IP, belonging to the replication user. The oldest transaction had been open for &lt;strong&gt;133 days&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The queries they were idling on:&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;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_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;'ansi_force_foreign_key_checks'&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;pg_type&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;typtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'e'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are classic &lt;strong&gt;compatibility probe queries&lt;/strong&gt; issued during connection initialization by integration tooling like GoldenGate. The integration platform had been connecting, executing its basic discovery probes, and abandoning the sessions without explicitly closing them every time it restarted or reconnected over a four-month period.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Verify the Vacuum Blocker (The Twist)
&lt;/h2&gt;

&lt;p&gt;Finding &lt;code&gt;idle in transaction&lt;/code&gt; sessions is a major red flag, but we had to verify if they were genuinely blocking data cleanup. Not all idle sessions hold an active database snapshot.&lt;/p&gt;

&lt;p&gt;We executed the source-of-truth query to find actual vacuum blockers:&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;backend_xmin&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;backend_xmin&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;xmin_age_in_transactions&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;session_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;state&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_xmin&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="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&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;backend_xmin&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;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;strong&gt;This is where our initial MVCC-bloat hypothesis collapsed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The zombie integration sessions did &lt;em&gt;not&lt;/em&gt; appear in this list; their &lt;code&gt;backend_xmin&lt;/code&gt; fields were entirely &lt;code&gt;NULL&lt;/code&gt;. Because they had only executed basic, read-only catalog queries during initialization, they never acquired a persistent data snapshot transaction ID. They were &lt;em&gt;not&lt;/em&gt; blocking vacuum. The actual oldest &lt;code&gt;xmin&lt;/code&gt; holders were normal application sessions with transaction ages under 3,000—completely standard for a busy transaction engine.&lt;/p&gt;

&lt;p&gt;While these zombie connections were a clear operational hazard (consuming connection slots and wasting system memory), they weren't driving our CPU alert.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Check the Partitions
&lt;/h2&gt;

&lt;p&gt;With zombie connections ruled out as vacuum blockers, we turned our focus to the physical table health. Because our target event log table is a &lt;strong&gt;partitioned table&lt;/strong&gt;, statistics are tracked natively at the individual child partition level, not the parent logical table. We targeted the table prefix:&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;schemaname&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;n_live_tup&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;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;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt; &lt;span class="o"&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="mi"&gt;0&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;dead_ratio_pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'event_log%'&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;n_dead_tup&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;&lt;strong&gt;Result:&lt;/strong&gt; &lt;code&gt;0&lt;/code&gt; dead tuples across all active partitions.&lt;/p&gt;

&lt;p&gt;The partitioned strategy was working flawlessly. The application used an insert-only pattern on the active daily partition, and old partitions were dropped entirely rather than vacuumed. This meant &lt;code&gt;VACUUM&lt;/code&gt; bloat was completely ruled out.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Finding the Real Culprit via Wait States
&lt;/h2&gt;

&lt;p&gt;With MVCC bloat off the table, we pivoted to the APM wait state drill-down for the intensive &lt;code&gt;INSERT&lt;/code&gt; query during the exact minutes of the CPU spike:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ClientRead&lt;/code&gt; (54%):&lt;/strong&gt; The database engine spent more than half its time completely idle, waiting for the application client to send the next query over the network wire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Active&lt;/code&gt; (24.7%):&lt;/strong&gt; Time spent physically executing the raw insert statements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;XactSync&lt;/code&gt; (17.9%):&lt;/strong&gt; The engine waiting for the Write-Ahead Log (WAL) to be flushed and synchronized to durable storage upon a &lt;code&gt;COMMIT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AuroraStorageLogAllocate&lt;/code&gt; (2.4%):&lt;/strong&gt; Storage block layer allocation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two metrics tell the final story here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;XactSync&lt;/code&gt; at 17.9%:&lt;/strong&gt; This wait state occurs when an explicit &lt;code&gt;COMMIT&lt;/code&gt; forces PostgreSQL to flush WAL records. At a peak volume of 825 inserts per second, the application was issuing &lt;strong&gt;825 individual commits per second&lt;/strong&gt;. This meant 825 distinct sync operations forcing the storage layer to acknowledge writes sequentially.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ClientRead&lt;/code&gt; at 54%:&lt;/strong&gt; This is the classic signature of single-row, one-at-a-time application database calls. The application loop was executing: &lt;em&gt;Send row $\rightarrow$ Execute $\rightarrow$ Commit $\rightarrow$ Wait for network Ack $\rightarrow$ Send next row.&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shape of the CPU spike closely mirrored a &lt;strong&gt;backlog flush pattern&lt;/strong&gt;. Messages would pile up briefly in the upstream Kafka topic, and then the consumer would drain them all at once in a massive burst of single-row, high-frequency synchronous commits.&lt;/p&gt;




&lt;h2&gt;
  
  
  Root Cause Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hypothesis&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Technical Proof&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dead Tuple Accumulation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;Ruled Out&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_stat_user_tables&lt;/code&gt; showed a 0% dead tuple ratio across all database partitions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zombie Connections Blocking Vacuum&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;Ruled Out&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;backend_xmin&lt;/code&gt; was explicitly &lt;code&gt;NULL&lt;/code&gt; for all zombie replication sessions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Burst Insert Flush + Per-Row Commits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Root Cause&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High &lt;code&gt;XactSync&lt;/code&gt; (17.9%) paired with a massive &lt;code&gt;ClientRead&lt;/code&gt; (54%) network wait pattern.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Action Plan
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fix 1: Terminate and Block the Zombie Connections
&lt;/h3&gt;

&lt;p&gt;Even though they didn't block vacuum in this specific instance, leaving 35 abandoned sessions active for 133 days is risky. They leak connection resources and backend memory.&lt;/p&gt;

&lt;p&gt;First, we safely terminated the dangling backends:&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="n"&gt;pid&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="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;usename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'repl_agent'&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;'idle in transaction'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, we implemented an automated defense line. We updated the cluster configuration parameters to enforce a strict timeout globally:&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="py"&gt;idle_in_transaction_session_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;600000 # 10 minutes in milliseconds&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any session sits idle inside an uncommitted transaction block for more than 10 minutes, Postgres will now automatically drop the connection and free up system resources.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2: Implement Micro-Batching in the Consumer Pipeline
&lt;/h3&gt;

&lt;p&gt;The true fix for the CPU spike required changing how the application interacted with the storage engine. Instead of committing every single row individually, we modified the JDBC producer configuration to use a batch size of &lt;code&gt;500&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="c1"&gt;-- Old Pattern (825 WAL Syncs/sec):&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="k"&gt;COMMIT&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;WAL&lt;/span&gt; &lt;span class="n"&gt;fsync&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="k"&gt;COMMIT&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;WAL&lt;/span&gt; &lt;span class="n"&gt;fsync&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;

&lt;span class="c1"&gt;-- New Pattern (1.7 WAL Syncs/sec):&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;row&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;row&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="k"&gt;COMMIT&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;WAL&lt;/span&gt; &lt;span class="n"&gt;fsync&lt;/span&gt;

&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;Metric&lt;/th&gt;
&lt;th&gt;Before Batching&lt;/th&gt;
&lt;th&gt;After Batching (Est.)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Commits/sec (Burst)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~825&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1.7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WAL Fsyncs/sec&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~825&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1.7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;XactSync&lt;/code&gt; Wait Time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;17.9%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 1.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Transient CPU Spikes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Frequent&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Eliminated&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Essential Checklist Queries for Triage
&lt;/h2&gt;

&lt;p&gt;Add these queries to your incident response runbook for analyzing database load:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Identify Long-Running Snapshot Blockers
&lt;/h3&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;backend_xmin&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;backend_xmin&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;xmin_age_transactions&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;session_age&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_xmin&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="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&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;backend_xmin&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;h3&gt;
  
  
  2. Spot Dangerous &lt;code&gt;Idle in Transaction&lt;/code&gt; Sessions
&lt;/h3&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;client_addr&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;transaction_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="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;state_change&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;idle_duration&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;AS&lt;/span&gt; &lt;span class="n"&gt;last_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="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;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="o"&gt;&amp;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;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;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Check Table Churn &amp;amp; Dead Tuple Ratios (Including Partitions)
&lt;/h3&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;schemaname&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;n_live_tup&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;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;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt; &lt;span class="o"&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="mi"&gt;0&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;dead_ratio_pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'your_table_prefix%'&lt;/span&gt;  &lt;span class="c1"&gt;-- Handles partitioned child tables&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;n_dead_tup&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;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; A &lt;code&gt;dead_ratio_pct&lt;/code&gt; &amp;gt; 10% indicates a strong need for space recovery; &amp;gt; 20% means the table is critically bloated and query execution performance will likely degrade.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  What's Next?
&lt;/h3&gt;

&lt;p&gt;If you found this breakdown helpful, keep an eye out for our next post. We will be diving into the opposite side of database optimization: &lt;strong&gt;When Autovacuum goes wrong.&lt;/strong&gt; We will walk through a separate production incident where a high-churn table choked on over &lt;strong&gt;124 million dead tuples&lt;/strong&gt; (a brutal 16.6% dead ratio) due to misconfigured autovacuum cost thresholds. Stay tuned!&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>aws</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>What It Actually Takes to Audit Aurora PostgreSQL on AWS</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Thu, 21 May 2026 23:28:33 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/what-it-actually-takes-to-audit-aurora-postgresql-on-aws-l7j</link>
      <guid>https://dev.to/pranay_raavi/what-it-actually-takes-to-audit-aurora-postgresql-on-aws-l7j</guid>
      <description>&lt;p&gt;Most operational infrastructure starts this way: a requirement appears before the architecture does.&lt;/p&gt;

&lt;p&gt;One day the team needed a database audit solution. Not in a planning doc — someone asked, and I had to build something. I'd owned the Oracle audit pipeline already, so I knew what the destination looked like. The question was what it would take to get there on Aurora PostgreSQL and AWS.&lt;/p&gt;

&lt;p&gt;The short answer: more than you'd expect. The longer answer is this article.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We're Actually Auditing — and Why It Matters
&lt;/h2&gt;

&lt;p&gt;The scope here is specific: &lt;strong&gt;individual human users making direct DML changes&lt;/strong&gt; (SELECT, INSERT, UPDATE, DELETE) to application tables.&lt;/p&gt;

&lt;p&gt;Application service accounts are expected to modify data — that's their function. The risk surface is direct human access. A developer connected via psql. A support engineer running an ad-hoc update. A credential that shouldn't have had access to begin with. Those are the actions that need an audit trail in a regulated environment.&lt;/p&gt;

&lt;p&gt;This distinction shapes every architectural decision: we enable pgAudit per individual user account, not cluster-wide. Application accounts are excluded entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Oracle Baseline
&lt;/h2&gt;

&lt;p&gt;Before getting into the build, it's worth framing the comparison.&lt;/p&gt;

&lt;p&gt;On Oracle, Unified Auditing (an Enterprise-tier feature) handles this with a single policy definition at the intersection of user, action, and object:&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;AUDIT&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;user_dml_activity&lt;/span&gt;
  &lt;span class="n"&gt;ACTIONS&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;app_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;'SYS_CONTEXT(&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;USERENV&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;SESSION_USER&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;) != &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;APP_SVC_ACCOUNT&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;
  &lt;span class="n"&gt;EVALUATE&lt;/span&gt; &lt;span class="n"&gt;PER&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;AUDIT&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;user_dml_activity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Records land in &lt;code&gt;UNIFIED_AUDIT_TRAIL&lt;/code&gt; — a structured, SQL-queryable audit view. From there, the data can be exported on a schedule to any downstream analytics platform such as Elasticsearch, Splunk, OpenSearch, or a dedicated audit store. Scheduled queries or alerting rules can then detect patterns like bulk deletes, after-hours access, or unexpected DDL changes and trigger notifications through PagerDuty, Opsgenie, or an observability platform via HTTP webhook. Once the audit policy is defined, the downstream detection and alerting pipeline is relatively small because the audit data is already structured and queryable.&lt;/p&gt;

&lt;p&gt;One important operational caveat with Oracle's approach: if the audit tablespace fills up, Oracle halts the database rather than silently dropping audit records — it guarantees completeness at the cost of availability. Tablespace monitoring becomes a hard operational requirement. pgAudit writing to the log stream instead of a table sidesteps this entirely: if log delivery has issues, the database keeps running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pgAudit is different.&lt;/strong&gt; There's no policy-based object/action targeting. You set a log class (&lt;code&gt;read&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;ddl&lt;/code&gt;, &lt;code&gt;all&lt;/code&gt;) per user. Records go to the PostgreSQL log stream — on Aurora, that means CloudWatch Logs. There's no queryable view. You're working with text:&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;AUDIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SESSION&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;WRITE&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;TABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nv"&gt;"INSERT INTO orders (customer_id, amount) VALUES ($1, $2)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. But extracting structured, alertable signal from a log stream requires deliberate engineering.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fm45v45s1kz0hyv67e6hr.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.amazonaws.com%2Fuploads%2Farticles%2Fm45v45s1kz0hyv67e6hr.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Aurora PostgreSQL  (pgAudit per-user)
        │
        ▼
CloudWatch Logs
        │
EventBridge  rate(5 min)
        │
        ▼
Lambda  → runs Log Insights query → filters noise → publishes count metric
        │
        ▼
CloudWatch Alarm  (count &amp;gt; 0)
        │
        ▼
SNS → incident platform + email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Lambda exists because &lt;strong&gt;CloudWatch Alarms cannot evaluate Log Insights query results directly&lt;/strong&gt; — there's no native bridge. Lambda runs the query, counts filtered results, and publishes a standard CloudWatch metric. The alarm evaluates that metric. Lambda invocation logs also give you an independent timestamped record of every detection event — useful when compliance asks "when was this first noticed?"&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Full code, IAM policies, SNS configurations, and Terraform module:&lt;/strong&gt; &lt;a href="https://github.com/pcraavi/PostgreSQL-Audit" rel="noopener noreferrer"&gt;github.com/pcraavi/PostgreSQL-Audit&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Gotcha 1: The Silent pgAudit Setup Failure
&lt;/h2&gt;

&lt;p&gt;Enable &lt;code&gt;pgaudit&lt;/code&gt; in &lt;code&gt;shared_preload_libraries&lt;/code&gt;, reboot the cluster. Simple.&lt;/p&gt;

&lt;p&gt;Except — &lt;code&gt;shared_preload_libraries&lt;/code&gt; only loads the binary into shared memory. You also need:&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;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;pgaudit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it: zero audit records. Zero error messages. Nothing in CloudWatch. The cluster had been rebooted, the parameter was confirmed, logs were being exported — and there was nothing to show for it.&lt;/p&gt;

&lt;p&gt;The diagnostic:&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_extension&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;extname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pgaudit'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 0 rows returned&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the entire problem. One missing statement.&lt;/p&gt;

&lt;p&gt;Then enable per-user logging:&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;USER&lt;/span&gt; &lt;span class="n"&gt;john_doe&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;pgaudit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;'all'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Verify&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;useconfig&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_user&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'john_doe'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Gotcha 2: The Noise Problem
&lt;/h2&gt;

&lt;p&gt;The first time you look at raw audit logs from a production cluster, the signal-to-noise ratio is genuinely bad:&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;AUDIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt;&lt;span class="p"&gt;,...,&lt;/span&gt;&lt;span class="k"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,,,&lt;/span&gt;&lt;span class="nv"&gt;"SELECT version()"&lt;/span&gt;
&lt;span class="n"&gt;AUDIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt;&lt;span class="p"&gt;,...,&lt;/span&gt;&lt;span class="k"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,,,&lt;/span&gt;&lt;span class="nv"&gt;"SELECT * FROM pg_shdescription..."&lt;/span&gt;
&lt;span class="n"&gt;AUDIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt;&lt;span class="p"&gt;,...,&lt;/span&gt;&lt;span class="k"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,,,&lt;/span&gt;&lt;span class="nv"&gt;"SET application_name='DBeaver 23.2.0'"&lt;/span&gt;
&lt;span class="n"&gt;AUDIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt;&lt;span class="p"&gt;,...,&lt;/span&gt;&lt;span class="k"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,,,&lt;/span&gt;&lt;span class="nv"&gt;"SELECT oid, typarray FROM pg_type WHERE typname=$1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every database tool (DBeaver, DataGrip, pgAdmin) fires a catalog query sequence on connect. Every JDBC driver runs initialization SELECTs. Every connection pool runs health checks. Alert on this raw stream and you're alerting on noise constantly — which means you stop paying attention, which defeats the audit entirely.&lt;/p&gt;

&lt;p&gt;The filter query was built incrementally by watching actual production traffic:&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;fields&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;logStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;AUDIT&lt;/span&gt;&lt;span class="p"&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;filter&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&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;or&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt;
    &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;DELETE&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;filter&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt;&lt;span class="p"&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;filter&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;pg_shdescription&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&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="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;DBeaver&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;PostgreSQL&lt;/span&gt; &lt;span class="n"&gt;JDBC&lt;/span&gt; &lt;span class="n"&gt;Driver&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;datname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;\$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your exclusion list will grow. Every application stack generates its own connection init patterns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotcha 3: The KMS + CloudWatch Alarms Incompatibility
&lt;/h2&gt;

&lt;p&gt;Encrypt the SNS topic at rest. Straightforward — use &lt;code&gt;alias/aws/sns&lt;/code&gt;, the AWS-managed SNS key. Done.&lt;/p&gt;

&lt;p&gt;Except the alarm stopped delivering.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CloudWatch Alarms does not have authorization to access the SNS topic encryption key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AWS-managed keys have immutable key policies. You cannot grant CloudWatch &lt;code&gt;kms:GenerateDataKey&lt;/code&gt;. The fix is a customer-managed KMS key with an explicit CloudWatch service grant in the key policy. The managed key works fine if Lambda is publishing directly to SNS — the limitation is specific to the &lt;strong&gt;CloudWatch Alarms → SNS&lt;/strong&gt; delivery path.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key policy and full SNS security configuration (HTTPS enforcement, cross-account lockdown): &lt;a href="https://github.com/pcraavi/PostgreSQL-Audit/tree/main/sns" rel="noopener noreferrer"&gt;github.com/pcraavi/PostgreSQL-Audit/tree/main/sns&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Gotcha 4: The Incident Platform Deduplication Problem
&lt;/h2&gt;

&lt;p&gt;First alert: fired correctly.&lt;br&gt;
Second alert, third, fourth: nothing.&lt;/p&gt;

&lt;p&gt;The incident platform logs showed requests arriving and the "Create Alert" action starting — but no alert created. The issue: platforms like Opsgenie, PagerDuty, and VictorOps deduplicate by alarm name (used as the alert alias). If that alert is already open or acknowledged, subsequent triggers are suppressed.&lt;/p&gt;

&lt;p&gt;Correct behavior for most alarms. For an audit alert that should fire every detection window, it needs handling.&lt;/p&gt;

&lt;p&gt;Fix options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Timestamp the alarm name&lt;/strong&gt; at deploy time (&lt;code&gt;$(date +%s)&lt;/code&gt; suffix) — unique alias per deployment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure auto-close&lt;/strong&gt; when the alarm returns to OK — fresh alert on each ALARM transition&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Override the alias template&lt;/strong&gt; in the integration to include a timestamp from the alert payload&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why Not Database Activity Streams?
&lt;/h2&gt;

&lt;p&gt;Experienced AWS engineers will immediately ask this. DAS provides near-real-time activity streaming to Kinesis with structured output. It's a real solution.&lt;/p&gt;

&lt;p&gt;The reason we didn't use it: it introduces Kinesis as a required dependency, adds per-event cost, and requires a consumer layer for decryption and processing. For teams without an existing Kinesis pipeline, that's meaningful operational surface area.&lt;/p&gt;

&lt;p&gt;pgAudit + CloudWatch uses infrastructure Aurora already depends on. Lambda and SNS are general-purpose services. There's no proprietary tooling, no specialized operational knowledge required. Any engineer with standard AWS experience can maintain it. That portability and low learning curve matters when you're deploying across multiple accounts.&lt;/p&gt;

&lt;p&gt;GuardDuty RDS Protection is complementary — it handles threat detection, not structured audit trails. OpenSearch subscription filters would work but introduce an OpenSearch cluster as a dependency, which adds cost and operational overhead.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Note on pgAudit Overhead
&lt;/h2&gt;

&lt;p&gt;Overhead is minimal at this audit scope. Individual human users in a regulated production environment are not expected to generate high transaction volumes — the audit target is ad-hoc access, not application traffic. The per-session overhead of writing to the PostgreSQL log is negligible when audit scope is limited to non-application users.&lt;/p&gt;

&lt;p&gt;For high-volume clusters where audit is a compliance requirement: account for log overhead in instance sizing. Set CloudWatch log retention to match your compliance window rather than keeping logs indefinitely — use &lt;code&gt;aws logs put-retention-policy&lt;/code&gt; to enforce it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ship filtered audit records to a structured store.&lt;/strong&gt; CloudWatch Log Insights is a query tool, not a data store. Extending the Lambda to write each filtered record to DynamoDB or an RDS audit table gives you the closest equivalent to Oracle's &lt;code&gt;UNIFIED_AUDIT_TRAIL&lt;/code&gt;: indexed, queryable, long-term audit history. Kinesis Firehose → S3 → Athena is another path for columnar query performance over large windows. The Lambda architecture makes this extension natural — the query and filtering logic is already there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terraform from day one.&lt;/strong&gt; The full stack — Lambda, EventBridge rule, SNS topic, IAM role, CloudWatch alarm, KMS key — fits in a single reusable module. Deploying to additional accounts should be &lt;code&gt;terraform apply&lt;/code&gt; with environment variables, not a re-run of CLI commands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tag every resource.&lt;/strong&gt; &lt;code&gt;Environment&lt;/code&gt;, &lt;code&gt;ClusterName&lt;/code&gt;, &lt;code&gt;Owner&lt;/code&gt;. Untagged audit infrastructure across multiple accounts becomes unauditable infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;Each component here has a clean interface and behaves predictably in isolation. The engineering is in the integration contracts: Log Insights results don't flow to alarms without mediation; AWS-managed KMS keys don't work with CloudWatch delivery; incident platform deduplication suppresses repeated alarm transitions. None of these are documented prominently — they surface when components are wired together under production conditions.&lt;/p&gt;

&lt;p&gt;Start with the Log Insights filter query. Validate the signal before building anything around it. The filtering logic is the foundation. The rest is plumbing.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Full implementation:&lt;/strong&gt; Lambda code, IAM policies, SNS topic policy, KMS key policy, CloudWatch alarm, Terraform module → &lt;a href="https://github.com/pcraavi/PostgreSQL-Audit" rel="noopener noreferrer"&gt;github.com/pcraavi/PostgreSQL-Audit&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Drop a comment if you've hit different noise patterns in your environment, or if you've implemented the Lambda → structured audit table extension — curious how others have approached long-term audit retention on Aurora.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>postgres</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Automated 25 Minutes of My Morning With a Prompt (Not a Script)</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Thu, 21 May 2026 03:07:21 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/automated-25-minutes-of-my-morning-with-a-prompt-not-a-script-2d4k</link>
      <guid>https://dev.to/pranay_raavi/automated-25-minutes-of-my-morning-with-a-prompt-not-a-script-2d4k</guid>
      <description>&lt;p&gt;Every serious engineering org I've worked in has the same split personality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One side:&lt;/strong&gt; A modern observability stack. AppDynamics, Datadog, whatever the current favorite is. Real-time metrics, distributed traces, beautiful dashboards, alert routing. Years of investment. It works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other side:&lt;/strong&gt; A long tail of legacy monitoring tools that predate the current stack by a decade. Tools that don't speak OpenTelemetry. Tools that can't push to a webhook. Tools whose output format is, and will remain, a 73-row HTML table emailed at 11:15 UTC every morning.&lt;/p&gt;

&lt;p&gt;These two sides don't talk to each other. And in most orgs, someone fills that gap manually for 25–30 minutes every morning.&lt;/p&gt;

&lt;p&gt;That someone was me.&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.amazonaws.com%2Fuploads%2Farticles%2Fykro6x1odvcxp36qn7us.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.amazonaws.com%2Fuploads%2Farticles%2Fykro6x1odvcxp36qn7us.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Gap Nobody Draws on the Architecture Diagram
&lt;/h2&gt;

&lt;p&gt;The box that should exist on every enterprise architecture diagram but never does:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Dave reads his email for 25 minutes"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is what I call an &lt;strong&gt;observability seam&lt;/strong&gt; — the boundary between your modern monitoring stack and legacy outputs it can't ingest. These seams exist because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Legacy tools were built before modern observability conventions&lt;/li&gt;
&lt;li&gt;Integration work is permanently lower priority than feature work&lt;/li&gt;
&lt;li&gt;The cost is diffuse (distributed across many humans' mornings) rather than concentrated, so it never hits "fix it" priority&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The insight I kept coming back to: this isn't a technology problem. It's an &lt;strong&gt;architectural surface area nobody drew&lt;/strong&gt;. Once you see it that way, the solution becomes tractable.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Legacy Tail Actually Looked Like
&lt;/h2&gt;

&lt;p&gt;My specific environment had four data sources, none of which fed the observability stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Frequency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Replication latency alerts&lt;/td&gt;
&lt;td&gt;Email (body text)&lt;/td&gt;
&lt;td&gt;Multiple times daily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB backup status report&lt;/td&gt;
&lt;td&gt;Email (73-row HTML table)&lt;/td&gt;
&lt;td&gt;Daily at 11:15 UTC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure/AWS notifications&lt;/td&gt;
&lt;td&gt;Email (subject-line pattern)&lt;/td&gt;
&lt;td&gt;20–50 per day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment Review&lt;/td&gt;
&lt;td&gt;Confluence page backed by Jira macros&lt;/td&gt;
&lt;td&gt;Checked manually&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of them have APIs. None of them feed my SIEM or APM. The only integration surface available was: &lt;em&gt;it arrives as email, or it lives at a known URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That constraint is also the opportunity.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: Agent as Integration Layer
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2F8oyo8rns3fjim6ogetyo.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.amazonaws.com%2Fuploads%2Farticles%2F8oyo8rns3fjim6ogetyo.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The core decision was to treat an LLM agent not as a productivity tool, but as a &lt;strong&gt;seam-closing integration layer&lt;/strong&gt; — something that sits between heterogeneous, unstructured legacy outputs and a human who needs a consistent, actionable daily summary.&lt;/p&gt;

&lt;p&gt;The agent does four things each morning:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Replication Latency — Signal extraction from noise
&lt;/h3&gt;

&lt;p&gt;Find the latest alert, read the body, and report whether the body is actually populated.&lt;/p&gt;

&lt;p&gt;This sounds trivial. It isn't. Replication alerts sometimes fire with &lt;em&gt;empty bodies&lt;/em&gt; — the email arrived, but the content didn't. Manually, this is easy to miss because you see the email and assume it's fine. The agent makes "body is empty" an explicit flagged state. That's catching a &lt;strong&gt;monitoring failure&lt;/strong&gt;, not just monitoring an alert.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Backup Report — Structured extraction from semi-structured HTML
&lt;/h3&gt;

&lt;p&gt;Parse the 73-row HTML table, tally the &lt;code&gt;BACKUP_STATUS&lt;/code&gt; column, surface only the rows that didn't complete.&lt;/p&gt;

&lt;p&gt;Output: &lt;em&gt;"69 completed, 0 failed, 3 with no backup — db01, db02, db05."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Reading time: three seconds. A human eyeballing 73 rows is slower and occasionally misses things.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Infrastructure Notifications — Classification at scale
&lt;/h3&gt;

&lt;p&gt;Twenty to fifty emails daily, each following the pattern &lt;code&gt;STATUS - Alert (Server Name : X and DB Name : Y)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The agent classifies every subject line, counts by status, and surfaces only the non-SUCCESS entries with a direct link to each email.&lt;/p&gt;

&lt;p&gt;Output: &lt;em&gt;"21 SUCCESS, 1 WARNING on SERVER04U / DB09."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That one line is the entire actionable output of fifty emails.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Deployment Review — Live data from a known URL
&lt;/h3&gt;

&lt;p&gt;Read the Confluence page, identify the Jira macro structure, query the underlying filters directly, and report live issue counts. Bridges the gap between "someone updated a Confluence page" and "here's the actual current state of production deployments."&lt;/p&gt;




&lt;h2&gt;
  
  
  Why an LLM Instead of a Python Script?
&lt;/h2&gt;

&lt;p&gt;A reasonable engineer will ask: couldn't this be IMAP + regex + cron?&lt;/p&gt;

&lt;p&gt;Yes — and in many cases that's the right answer. Large enterprises already run Airflow, Power Automate, Splunk SOAR, Logic Apps. This isn't automation entering a vacuum.&lt;/p&gt;

&lt;p&gt;The honest comparison:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Strength&lt;/th&gt;
&lt;th&gt;Breaks when…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python + regex + cron&lt;/td&gt;
&lt;td&gt;Deterministic, auditable, fast&lt;/td&gt;
&lt;td&gt;Email format changes, new alert pattern appears&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ETL / SOAR pipeline&lt;/td&gt;
&lt;td&gt;Scalable, governed, integrated&lt;/td&gt;
&lt;td&gt;Requires schema agreement upstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM agent&lt;/td&gt;
&lt;td&gt;Tolerates format variance, low setup cost&lt;/td&gt;
&lt;td&gt;Output is nondeterministic, harder to audit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What the LLM reduces is &lt;strong&gt;integration friction&lt;/strong&gt; — not the need for integration.&lt;/p&gt;

&lt;p&gt;The backup report column order can shift. The AWS notification subject-line pattern can vary. A new data source can be added in a sentence of English rather than a week of schema work.&lt;/p&gt;

&lt;p&gt;That flexibility has a cost: you trade determinism for adaptability. For a morning triage digest where a missed edge case surfaces in the next run, that tradeoff is acceptable. For a system that triggers automated remediation, it isn't.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Right mental model:&lt;/strong&gt; LLM agents lower the cost of closing observability seams. They don't replace deterministic pipelines where correctness guarantees matter.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Design Principles Worth Keeping
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Independent section isolation
&lt;/h3&gt;

&lt;p&gt;Each source is handled independently. A connector outage in one section doesn't abort the others — the agent renders an "unavailable" note and continues. A partial report is vastly more valuable than a silent failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Absence as a signal
&lt;/h3&gt;

&lt;p&gt;Each section includes a sanity check that fires if no emails are found for that source.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;"No replication alerts received today"&lt;/code&gt; is &lt;strong&gt;bolded as a warning&lt;/strong&gt;, not silently skipped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This paid off in month two, when the backup report email silently stopped arriving after a mail routing change. The sanity check flagged it immediately. Manual triage would have assumed a clean run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delta orientation
&lt;/h3&gt;

&lt;p&gt;The report answers &lt;em&gt;"what changed or failed since yesterday"&lt;/em&gt;, not &lt;em&gt;"what is the current state of everything."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Confirming sameness has no information value but costs the same attention as confirming change. A delta-oriented report routes attention only where it's needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Runtime date resolution
&lt;/h3&gt;

&lt;p&gt;The prompt never hardcodes a date. "Today" is resolved against the local clock at execution time. Small thing. Prevents a class of subtle bugs where a stale prompt runs with yesterday's scope.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Prompt (Sanitized)
&lt;/h2&gt;

&lt;p&gt;The prompt itself is the deliverable worth preserving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;You are producing the daily 9 AM operations report. Run silently — this prompt is self-contained.

REQUIRED CONNECTORS
&lt;span class="p"&gt;-&lt;/span&gt; Microsoft 365 / Outlook (email search + read_resource on mail:// URIs)
&lt;span class="p"&gt;-&lt;/span&gt; Atlassian (getConfluencePage; optionally searchJiraIssuesUsingJql)

If any connector is missing or errors out, render that section with a clear
"⚠ connector unavailable" note and continue. Never abort the whole report.

SCOPE OF "TODAY"
"Today" = the calendar day the task fires, in local time.
Always pass afterDateTime: "today" to outlook_email_search.

====
&lt;span class="gu"&gt;SECTION 1 — Replication Latency Monitoring (latest alert today)
====
&lt;/span&gt;&lt;span class="p"&gt;1.&lt;/span&gt; Search for latest alert today.
&lt;span class="p"&gt;2.&lt;/span&gt; read_resource on its URI for the full body.
&lt;span class="p"&gt;3.&lt;/span&gt; Report: count of alerts today, receivedDateTime, sender, and either the
   body text or "Body is empty" if it's only whitespace.

====
&lt;span class="gu"&gt;SECTION 2 — DB Backup Report (failure check)
====
&lt;/span&gt;&lt;span class="p"&gt;1.&lt;/span&gt; Find today's report email.
&lt;span class="p"&gt;2.&lt;/span&gt; Parse rows; tally BACKUP_STATUS column.
&lt;span class="p"&gt;3.&lt;/span&gt; State explicitly: "X completed, Y failed, Z with no backup."
   &lt;span class="gs"&gt;**Bold**&lt;/span&gt; the failure count if &amp;gt; 0.

====
&lt;span class="gu"&gt;SECTION 3 — Infra notifications today
====
&lt;/span&gt;&lt;span class="p"&gt;1.&lt;/span&gt; Fetch alerts from sender, limit 50.
&lt;span class="p"&gt;2.&lt;/span&gt; Parse subject pattern: "STATUS - Alert (Server : X and DB : Y)"
&lt;span class="p"&gt;3.&lt;/span&gt; Summarize counts by status. &lt;span class="gs"&gt;**Bold**&lt;/span&gt; anything not SUCCESS.
&lt;span class="p"&gt;4.&lt;/span&gt; List non-SUCCESS items with a webLink to each.

====
&lt;span class="gu"&gt;SECTION 4 — Deployment Review (Confluence)
====
&lt;/span&gt;&lt;span class="p"&gt;1.&lt;/span&gt; getConfluencePage, contentFormat="html".
&lt;span class="p"&gt;2.&lt;/span&gt; If JIRA tool available, run each filter and include live issue counts.

====
&lt;span class="gu"&gt;OUTPUT FORMAT
====
&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt; Plain markdown. One H1 with today's date. Four H2 sections in order.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Bold**&lt;/span&gt; anything requiring attention.
&lt;span class="p"&gt;-&lt;/span&gt; End with "Sources:" — no duplicate links.
&lt;span class="p"&gt;-&lt;/span&gt; Tone: factual. No filler. No apology lines.

====
&lt;span class="gu"&gt;SANITY CHECKS
====
&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt; Section 1 zero results: &lt;span class="gs"&gt;**"No replication alerts today — monitoring may be down."**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Section 2 zero results: &lt;span class="gs"&gt;**"Backup report email not received yet."**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Section 3 zero results: &lt;span class="gs"&gt;**"No infra notifications today — verify alerting pipeline."**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Section 4 failure: include the direct Confluence URL for manual access.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Section independence is explicit.&lt;/strong&gt; Each section is instructed to fail gracefully and continue. Fault isolation encoded directly in the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanity checks are the highest-value lines.&lt;/strong&gt; Flagging "no email received today" catches the failure mode where the upstream monitoring system has broken silently — the exact failure mode that checklist-style human review tends to miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prompt is a versioned artifact.&lt;/strong&gt; As the environment changes — new email formats, new Confluence pages, connector upgrades — the prompt evolves. Treat it as a living document.&lt;/p&gt;




&lt;h2&gt;
  
  
  Honest Tradeoffs
&lt;/h2&gt;

&lt;p&gt;This wouldn't be a useful post without naming the downsides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM output is nondeterministic.&lt;/strong&gt; Identical inputs can produce slightly different summaries across runs. I spot-check the raw email count against the agent's tally once a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token truncation is real.&lt;/strong&gt; A 73-row HTML table pushed through a context window can get silently trimmed. Defensive prompting mitigates but doesn't eliminate this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The desktop-up dependency.&lt;/strong&gt; In my setup, scheduled tasks fire when the desktop app is open. A laptop asleep at 9 AM means the report runs when you open it. Acceptable for a daily digest; not for time-critical monitoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust takes time to calibrate.&lt;/strong&gt; The first week I ran this, I verified everything manually anyway. By week four, I'd stopped double-checking, and I had enough incident data to know the report was reliable. That calibration period is part of the deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pattern Generalizes
&lt;/h2&gt;

&lt;p&gt;Once you see the shape — &lt;em&gt;agent reads heterogeneous sources at a known cadence, filters to deltas, surfaces only what requires human attention&lt;/em&gt; — it appears everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On-call handoff summaries&lt;/strong&gt; (PagerDuty + Slack + Jira + APM, joined and summarized)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sprint health reports&lt;/strong&gt; (blocked tickets, aging tickets, no-owner tickets — weekly, automated)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security alert triage&lt;/strong&gt; (SOC mailers often emit 200 events to find 3 that matter)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance evidence collection&lt;/strong&gt; (SOC 2 renewals are largely a gathering problem, not an analysis problem)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The domain changes. The architecture doesn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mental Model Shift
&lt;/h2&gt;

&lt;p&gt;The efficiency gain is real — roughly 25 minutes a day × 250 working days ≈ &lt;strong&gt;100 hours annually&lt;/strong&gt;. But that's not the most interesting outcome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From push to pull.&lt;/strong&gt; Most monitoring tools send you everything and you decide what matters. The cost of sending is zero for the system and enormous for the human. An agent flips that: I'll ask each morning for the delta.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From dashboards to digests.&lt;/strong&gt; A dashboard is only valuable if someone looks at it. A daily digest that synthesizes five sources and filters to a single page is more operationally useful than five perfect dashboards, because it solves the actual constraint — &lt;strong&gt;attention&lt;/strong&gt; — rather than the assumed one — visualization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From checklists to deltas.&lt;/strong&gt; The morning health check used to be: open these five places, confirm each is green. That's cognitive load spent confirming sameness. The right version is &lt;em&gt;only tell me what changed&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Is, and Isn't
&lt;/h2&gt;

&lt;p&gt;This is not an argument for replacing your observability infrastructure with LLM prompts. Your APM is doing things this agent will never do: real-time anomaly detection, distributed trace correlation, infrastructure topology mapping. The agent doesn't compete with that.&lt;/p&gt;

&lt;p&gt;What the agent does is address the &lt;strong&gt;seam&lt;/strong&gt; — the gap between the modern observability stack and the legacy outputs it can't ingest.&lt;/p&gt;

&lt;p&gt;Making the seam visible is the first step. Closing it is the second. The agent is the bridge.&lt;/p&gt;

&lt;p&gt;If your morning starts with "let me just check a few things in my email" — you have an observability seam. The tooling to close it is available now, and the implementation cost is a well-written prompt.&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.amazonaws.com%2Fuploads%2Farticles%2F619adfdpud19nbv16ea4.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.amazonaws.com%2Fuploads%2Farticles%2F619adfdpud19nbv16ea4.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What observability seams do you have in your environment? Curious what data sources people are working around. Drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>monitoring</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Can AI Agents Replace Enterprise Workflow Orchestration? A Real-World Test — OpenClaw. n8n. Claude Dispatch. A side-by-side comparison..</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Sun, 17 May 2026 21:58:49 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/can-ai-agents-replace-enterprise-workflow-orchestration-a-real-world-test-openclaw-n8n-claude-1hho</link>
      <guid>https://dev.to/pranay_raavi/can-ai-agents-replace-enterprise-workflow-orchestration-a-real-world-test-openclaw-n8n-claude-1hho</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fg55iazexdoak4m1xq3ch.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.amazonaws.com%2Fuploads%2Farticles%2Fg55iazexdoak4m1xq3ch.png" alt=" " width="800" height="566"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;"A database administrator's honest investigation into whether the new wave of AI automation tools can handle enterprise-grade workflows — or whether the boring answer is still the right one."&lt;br&gt;
tags: n8n, automation, database, devops&lt;/p&gt;




&lt;p&gt;&lt;em&gt;A database administrator's honest investigation into whether the new wave of AI automation tools can handle enterprise-grade workflows — or whether the boring answer is still the right one.&lt;/em&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.amazonaws.com%2Fuploads%2Farticles%2Fal7p5ugc1ic5gg1nmhza.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.amazonaws.com%2Fuploads%2Farticles%2Fal7p5ugc1ic5gg1nmhza.png" alt=" " width="800" height="1201"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The workflow that started the question — and ended up answering it&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Everyone was talking about these tools. I got curious.
&lt;/h2&gt;

&lt;p&gt;I work as a database administrator. I support hundreds of databases across multiple environments — dev, staging, non-prod, production — in a HIPAA-regulated organization. Access management is a constant, grinding operational burden. Developers need access. Analysts need access. Application owners need access. And every single request needs to be approved, documented, and auditable.&lt;/p&gt;

&lt;p&gt;For a long time, the process looked like this: someone would message the DBA, the DBA would manually create a Jira ticket and a Word document, chase down approvals from a manager, a database manager, and the security team, manually create the account, and email the credentials back. Days could pass. Follow-ups stacked up. The security team had to trust that the paperwork was right.&lt;/p&gt;

&lt;p&gt;Then I started hearing about Claude Dispatch. And OpenClaw. Both were being described as AI tools that could receive a message and take action — automate tasks, call APIs, connect to services. The demos looked impressive. The communities were excited.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;And I thought: wait. Could one of these actually solve the problem I have been living with for years?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had already built something with n8n — a workflow automation tool. But I genuinely wanted to know whether I had picked the right tool, or whether something newer and smarter had passed it by. So I did what any engineer would do: I used my actual problem as the test.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem I needed to solve
&lt;/h2&gt;

&lt;p&gt;Before comparing any tools, let me describe the workflow precisely, because the details are what make the comparison meaningful.&lt;/p&gt;

&lt;p&gt;A developer — or a data analyst, or a product manager — needs access to a specific database. The request needs to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go to their direct manager for approval&lt;/li&gt;
&lt;li&gt;Then to the database manager for approval&lt;/li&gt;
&lt;li&gt;Then to the security team for final approval&lt;/li&gt;
&lt;li&gt;Create a full audit trail at every step&lt;/li&gt;
&lt;li&gt;Call a pre-existing API to provision the account with the correct access level&lt;/li&gt;
&lt;li&gt;Deliver the credentials back to the requester&lt;/li&gt;
&lt;li&gt;Spin up a Jira ticket for the network team to open the firewall port&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every approval is sequential — no step fires unless the previous one passes. If anyone says no, the chain stops and the requester is notified. If security does not approve, no account gets created. Full stop.&lt;/p&gt;

&lt;p&gt;This is not a hypothetical. It runs in a regulated environment where access to production data is governed by HIPAA. The audit trail is not nice-to-have. It is required.&lt;/p&gt;

&lt;p&gt;The access levels themselves are structured — not free text. Read only. Read/write. Dev owner. Application owner. DBA. Each maps to a specific API endpoint. Each produces a deterministic result. The central database inventory that drives all of this is a relational database populated automatically when infrastructure is created through Terraform. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nobody should be able to bypass it.&lt;/strong&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.amazonaws.com%2Fuploads%2Farticles%2Fqthftls95b7kxn8o4edc.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.amazonaws.com%2Fuploads%2Farticles%2Fqthftls95b7kxn8o4edc.png" alt=" " width="800" height="1201"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1: The complete approval chain — from Webex message to provisioned credentials and dual Jira audit trail&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  First candidate: Claude Dispatch
&lt;/h2&gt;

&lt;p&gt;Claude Dispatch is Anthropic's answer to the question: what if you could delegate tasks to your AI from your phone and come back to find them done? It lives inside Claude Cowork — a desktop agent product — and creates a persistent connection between your mobile app and the Claude Desktop app running on your computer.&lt;/p&gt;

&lt;p&gt;The pitch is genuinely compelling. Send a message from your phone, Claude acts on your desktop: reads files, calls APIs, summarizes documents, delivers results. For personal productivity this is interesting. For ad-hoc delegation it is actually useful.&lt;/p&gt;

&lt;p&gt;So I asked the obvious question: could Dispatch receive a Webex message, run an approval chain, call my database API, and write to Jira?&lt;/p&gt;

&lt;p&gt;Here is where the investigation got honest quickly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dispatch requires the Claude Desktop app to be running on your computer. The moment the laptop sleeps, it stops.&lt;/li&gt;
&lt;li&gt;There is no server. There is no always-on process. There is no execution log.&lt;/li&gt;
&lt;li&gt;The workflow is driven by an LLM reasoning about what to do — not a deterministic set of rules. The same input could produce a different output on a different day.&lt;/li&gt;
&lt;li&gt;There is no concept of a sequential approval gate. Claude does not wait for a human to respond before deciding the next step.&lt;/li&gt;
&lt;li&gt;There is no audit trail. No timestamps. No record of who approved what and when.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;A workflow that depends on a laptop staying awake is not an enterprise workflow. It is a personal convenience. There is nothing wrong with that — but it is a different category of tool entirely.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cost-wise, Dispatch is bundled with Claude Pro at $20 per month or Max at $100 per month. Accessible pricing. But the architecture disqualifies it for this use case before the price even matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  Second candidate: OpenClaw
&lt;/h2&gt;

&lt;p&gt;OpenClaw is a different kind of tool. It is open-source, self-hostable, and designed as a personal AI assistant that runs on your own infrastructure. You can connect it to Webex, Telegram, Slack, WhatsApp — it listens on those channels and uses an LLM to decide what action to take in response to a message.&lt;/p&gt;

&lt;p&gt;The self-hosted angle immediately made it more interesting for regulated environments. If you run it on a VPS rather than your laptop, it can operate 24/7. And because it is open-source under an MIT license, the software itself costs nothing. Your real costs are the VPS — roughly $5 to $15 per month — and the API tokens from whichever LLM provider you connect.&lt;/p&gt;

&lt;p&gt;So OpenClaw gets past the first disqualification that knocked out Dispatch. It can stay on. Good start.&lt;/p&gt;

&lt;p&gt;But then I pushed further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenClaw has no concept of a deterministic approval chain. It reasons about what to do. If I ask it to get manager approval before proceeding, it will try — but there is no guarantee it handles every edge case the same way every time.&lt;/li&gt;
&lt;li&gt;There is no built-in error handling or retry logic. If an API call fails, the agent may or may not handle it gracefully.&lt;/li&gt;
&lt;li&gt;There are no execution logs in any structured, auditable format. The LLM's reasoning is not a HIPAA audit trail.&lt;/li&gt;
&lt;li&gt;There is no native Jira integration. You can make API calls, but you are building that logic yourself in an environment with no visual workflow editor.&lt;/li&gt;
&lt;li&gt;Setup requires real DevOps experience — Docker, VPS configuration, model routing. Not a weekend project for someone who just needs automation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OpenClaw is genuinely impressive for what it is: a powerful, flexible personal AI assistant for technical users who want to automate their own workflows. It is not what I needed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The core tension is this: OpenClaw lets an AI decide what to do. In a regulated environment, I need a system that does exactly what it is configured to do — every single time.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The one I already had: n8n
&lt;/h2&gt;

&lt;p&gt;n8n is not the newest tool in this comparison. It is not the most talked-about. It does not have a viral GitHub repository or a growing community of people sharing AI agent demos. It is a workflow automation platform — visual, node-based, deterministic.&lt;/p&gt;

&lt;p&gt;I had already built the database access workflow in n8n before I started this investigation. What the investigation forced me to do was articulate why it works where the others do not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;n8n runs on a server. Always on. No desktop dependency.&lt;/li&gt;
&lt;li&gt;Every workflow execution is logged — step by step, with timestamps. If something fails, you know exactly where and why.&lt;/li&gt;
&lt;li&gt;The approval chain is explicit: manager node fires, waits for a webhook response, branches on yes or no. Database manager node fires. Security node fires. No LLM is deciding the order. The order is the configuration.&lt;/li&gt;
&lt;li&gt;Jira, Webex, and REST API integrations are native — no custom code required to connect them.&lt;/li&gt;
&lt;li&gt;When security approves, a Jira ticket is created automatically with every approval documented: who approved, their role, the timestamp. A second ticket fires for the network team. The requester receives credentials via Webex.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The entire chain — from chat message to provisioned account — is deterministic, auditable, and server-side.&lt;/strong&gt; It does not matter whether my laptop is on. It does not matter whether the LLM is having a creative day. The workflow does what it is built to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting them side by side
&lt;/h2&gt;

&lt;p&gt;Here is what the investigation produced — not as opinion, but as a structured comparison against the actual requirements of the problem.&lt;/p&gt;

&lt;p&gt;![Figure 2: Tool comparison — enterprise workflow criteria]&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2F36mziav4nlqsqilicocg.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.amazonaws.com%2Fuploads%2Farticles%2F36mziav4nlqsqilicocg.png" alt=" " width="800" height="717"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 2: How the three tools compare across the criteria that actually matter for enterprise workflows&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The green cells are not a coincidence. n8n was built for exactly this category of problem — structured, multi-step, multi-system, always-on automation with governance requirements. Dispatch and OpenClaw were built for a different problem: intelligent, flexible, personal task delegation.&lt;/p&gt;

&lt;p&gt;Neither of those things is wrong. They are just different categories.&lt;/p&gt;




&lt;h2&gt;
  
  
  But is n8n actually accessible? Or is it just the enterprise answer no one wants to hear?
&lt;/h2&gt;

&lt;p&gt;This is the question I kept coming back to. Because n8n has a reputation for requiring technical knowledge. Webhooks, JSON payloads, API authentication, conditional branching. It is not a no-code tool in the purest sense.&lt;/p&gt;

&lt;p&gt;And yet — compared to OpenClaw, which requires DevOps expertise to host and configure, and compared to Dispatch, which requires you to trust an LLM to handle regulated processes — n8n is actually the most accessible path to a production-grade solution.&lt;/p&gt;

&lt;p&gt;The visual builder is genuinely good. The template library covers most common patterns. The community is large. And the self-hosted Community Edition is free — you pay only for the server, which can be as little as $4 a month.&lt;/p&gt;

&lt;p&gt;What n8n asks for is clarity of thought. You need to understand your process before you can automate it. That is not a technical barrier. That is just good engineering.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In 2008, I had my first exposure to BPM tools — business process management software. Back then, automating a multi-step approval workflow required consultants, enterprise licenses, and six-month implementation projects. n8n in 2026 is that same capability, accessible to a single engineer on a modest budget, in a weekend.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The AI-native tools will get there. The direction is right. But for workflows where consistency and auditability are non-negotiable, deterministic automation still wins. Not because AI is not impressive — it is. But because a HIPAA auditor does not accept "the AI usually does it right" as an answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the real world added that no tool comparison captures
&lt;/h2&gt;

&lt;p&gt;There is one thing I did not discover until the workflow was running: some users have managers on paper who are not actually the owners or decision-makers for the systems being requested.&lt;/p&gt;

&lt;p&gt;Organizational charts say one thing. Actual accountability sits somewhere else. When the approval request went to the wrong person, the workflow stalled — not because the automation failed, but because the data it depended on was wrong.&lt;/p&gt;

&lt;p&gt;No tool comparison surfaces this. You only find it when you run the thing on real people in a real organization.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is one of the most useful things a well-designed workflow can do: make your organizational data gaps visible. The automation did not hide the problem. It exposed it. And that forced the fix.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  So — can the new AI tools replace n8n for this?
&lt;/h2&gt;

&lt;p&gt;No. Not yet. Not for this class of problem.&lt;/p&gt;

&lt;p&gt;Claude Dispatch is a remote control for your desktop. It is well-designed and genuinely useful for personal delegation. But it has no server, no audit trail, and no deterministic logic — three things that are non-negotiable in a regulated environment.&lt;/p&gt;

&lt;p&gt;OpenClaw is a powerful personal AI assistant that technical users can self-host and extend. It can call APIs and respond to messages. But it has no structured approval chain, no execution logging, and no enterprise governance features.&lt;/p&gt;

&lt;p&gt;n8n is not the flashiest answer. It did not go viral. It does not use an LLM to decide what to do next. But it runs reliably, it logs everything, it integrates natively with Jira and Webex, and it does exactly what you configure it to do — every single time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The right tool is not the newest tool. It is the tool that matches the shape of your problem. And some problems have a shape that requires determinism, not intelligence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The question I started with — can these AI tools solve what n8n solves — turned out to be the wrong question. The better question is: what kind of automation are you building? If the answer involves regulated data, sequential human approvals, and a legal requirement to prove who did what and when, the answer is still n8n. If the answer involves personal productivity, intelligent delegation, and flexible task handling, Dispatch and OpenClaw are worth a serious look.&lt;/p&gt;

&lt;p&gt;Both of those things can be true at the same time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Principal Engineer | 16+ years in databases, cloud &amp;amp; observability | Oracle · PostgreSQL · Kafka · AWS · Splunk · AppD | Platform engineering &amp;amp; AI delivery&lt;/em&gt;&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>How I Built a Zero-Subscription Local AI Stack — Inspired by a 60-Second YouTube Short</title>
      <dc:creator>Pranay Ravi</dc:creator>
      <pubDate>Sun, 17 May 2026 02:33:21 +0000</pubDate>
      <link>https://dev.to/pranay_raavi/how-i-built-a-completely-free-local-ai-stack-inspired-by-a-60-second-youtube-short-3e39</link>
      <guid>https://dev.to/pranay_raavi/how-i-built-a-completely-free-local-ai-stack-inspired-by-a-60-second-youtube-short-3e39</guid>
      <description>&lt;h1&gt;
  
  
  How I Built a Completely Free Local AI Stack — Inspired by a 60-Second YouTube Short
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;By Pranaychandra Ravi&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;It started with a YouTube Short. Someone on my feed casually demonstrated connecting a local AI model to Claude Code and I stopped mid-scroll. No API key. No subscription. No code leaving their machine. I had to know how it worked.&lt;/p&gt;

&lt;p&gt;What followed was a deep dive into local AI — Ollama, Gemma4, Docker, Open WebUI, vector databases, context windows, and a Python script that made my local model generate an ASCII diagram of the Earth and Moon. This post documents everything I learned, every question I asked, and every mistake I made along the way. If you're curious about running AI entirely on your own hardware, this one is for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  First Question: Wait, Is This Actually Free?
&lt;/h2&gt;

&lt;p&gt;My first instinct was skepticism. Claude Code is Anthropic's product. Surely using it requires a Claude subscription?&lt;/p&gt;

&lt;p&gt;The short answer is &lt;strong&gt;no — not when you pair it with Ollama and a local model.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what I learned: Claude Code is the &lt;em&gt;agent&lt;/em&gt; — the tool that reads your files, runs commands, edits code, and manages multi-step tasks in your terminal. By default it calls Anthropic's API, which costs money. But Claude Code exposes environment variables that let you redirect those API calls anywhere you want — including a local Ollama server running on your own machine.&lt;/p&gt;

&lt;p&gt;Ollama added official support for Anthropic's Messages API format, meaning Claude Code can talk to it natively. No hacks, no middleware, no subscription. The only cost is your own electricity and hardware.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Code  →  talks to  →  Ollama (local server)  →  runs  →  Your model
                              (no Anthropic servers involved)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  So What Exactly Is Ollama?
&lt;/h2&gt;

&lt;p&gt;Before I could set anything up I needed to understand what Ollama actually is, because "install Ollama" doesn't tell you much.&lt;/p&gt;

&lt;p&gt;Think of Ollama as two things in one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A model manager&lt;/strong&gt; — it downloads, stores, and organizes AI models on your machine. Like a package manager but for AI brains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A local API server&lt;/strong&gt; — once running, it exposes an endpoint at &lt;code&gt;http://localhost:11434&lt;/code&gt; that any application can call. Your code, Claude Code, Open WebUI, VS Code extensions — anything that speaks the Anthropic or OpenAI API format can connect to it.&lt;/p&gt;

&lt;p&gt;This is the key insight I kept coming back to: &lt;strong&gt;Ollama itself has no intelligence&lt;/strong&gt;. It's an empty engine. You have to download a model — a large file containing all the AI's weights and knowledge — before anything useful happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Without a model:   Ollama = empty server, useless
With a model:      Ollama = fully local AI, free forever
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Downloading Your First Model — Which One?
&lt;/h2&gt;

&lt;p&gt;This is where hardware matters. I have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;32GB RAM&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NVIDIA GPU with ~11GB VRAM&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Core i9 processor&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With an NVIDIA card, Ollama automatically uses CUDA — no setup needed. Your GPU handles inference and it's dramatically faster than CPU-only.&lt;/p&gt;

&lt;p&gt;The key concept here is &lt;strong&gt;VRAM vs RAM&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Model fits in VRAM  →  GPU handles everything  →  Very fast ✅
Model too big for VRAM  →  spills into system RAM  →  Slower ⚠️
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With 11GB VRAM I can fit most 7B–13B parameter models entirely in GPU memory, which means fast, snappy responses.&lt;/p&gt;

&lt;p&gt;After thinking through my use cases — coding help, image analysis, document review — I landed on &lt;strong&gt;Gemma4&lt;/strong&gt; (Google's multimodal model, ~12GB). Here's why it beat out alternatives like Qwen3.6 (28GB):&lt;/p&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;Gemma4&lt;/th&gt;
&lt;th&gt;Qwen3.6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;~12GB&lt;/td&gt;
&lt;td&gt;~28GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fits in 11GB VRAM&lt;/td&gt;
&lt;td&gt;Nearly (tiny RAM overflow)&lt;/td&gt;
&lt;td&gt;Partial (big RAM spill)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image understanding&lt;/td&gt;
&lt;td&gt;✅ Yes (multimodal)&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coding quality&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Better&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed on my hardware&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Slower&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My use cases included image-to-text extraction and converting images to coloring pages — Qwen3.6 can't do either because it's text-only. Gemma4 won.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. It downloads, verifies, and stores the model. You can see progress in the terminal.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture in Plain English
&lt;/h2&gt;

&lt;p&gt;Before going further, I want to share the mental model that made everything click for me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────┐
│                    YOUR COMPUTER                    │
│                                                     │
│  ┌─────────────┐    ┌──────────────┐               │
│  │ Claude Code │───▶│    Ollama    │               │
│  │  (terminal) │    │ :11434 (API) │               │
│  └─────────────┘    └──────┬───────┘               │
│                            │                        │
│  ┌─────────────┐    ┌──────▼───────┐               │
│  │  Open WebUI │───▶│   Gemma4    │               │
│  │  (browser)  │    │  (the brain) │               │
│  └─────────────┘    └─────────────┘               │
│                                                     │
│  ┌─────────────┐                                   │
│  │  Python API │───▶ http://localhost:11434        │
│  │   scripts   │                                   │
│  └─────────────┘                                   │
└─────────────────────────────────────────────────────┘
              Zero data leaves your machine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three different interfaces. One local model. Everything private.&lt;/p&gt;




&lt;h2&gt;
  
  
  Context Windows — What Are They and Why Do They Matter?
&lt;/h2&gt;

&lt;p&gt;One of the most important concepts I clarified was the &lt;strong&gt;context window&lt;/strong&gt; — the model's working memory. It's the maximum amount of text a model can "see" at once in a conversation. Exceed it and it starts forgetting the beginning.&lt;/p&gt;

&lt;p&gt;Here's the reality check comparison:&lt;/p&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;Claude Sonnet 4.5&lt;/th&gt;
&lt;th&gt;Gemma4 (local)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Context window&lt;/td&gt;
&lt;td&gt;200,000 tokens&lt;/td&gt;
&lt;td&gt;~8,000–32,000 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approximate pages&lt;/td&gt;
&lt;td&gt;~150,000 words&lt;/td&gt;
&lt;td&gt;~6,000–24,000 words&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 years of tax docs&lt;/td&gt;
&lt;td&gt;Handles comfortably&lt;/td&gt;
&lt;td&gt;Would overflow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your VRAM directly affects how large a context window your local model can hold. More VRAM = more of the model loaded = bigger context available.&lt;/p&gt;

&lt;p&gt;You can manually increase it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama run gemma4 &lt;span class="nt"&gt;--ctx-size&lt;/span&gt; 32768
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For single documents, images, or focused coding tasks — perfectly fine. For analyzing six years of tax filings all at once? That's where Claude's 200k context is a genuine advantage local models can't match yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Can Local Models Search the Internet?
&lt;/h2&gt;

&lt;p&gt;Short answer: &lt;strong&gt;No, not by default.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Local models are frozen at their training date. They have no internet connection during your conversation. This was an important distinction to understand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude (this chat)  →  Has web search tool  →  Knows current events ✅
Gemma4 (local)     →  No internet          →  Knowledge frozen at training ❌
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This raised an interesting follow-up question though. When I used Gemini to analyze my tax filing and it spotted mistakes — was it searching the internet to find them?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No.&lt;/strong&gt; And this was a real misconception I had.&lt;/p&gt;

&lt;p&gt;Gemini found tax errors because tax law, IRS rules, and common filing mistakes were baked into the model during training. It learned from millions of tax documents, accounting textbooks, and IRS publications. During your session it's not googling anything — it's applying trained knowledge to your specific document.&lt;/p&gt;

&lt;p&gt;Think of it like a tax accountant. They studied tax law for years. When reviewing your return they're not searching Google — they're applying what they already know to what you show them.&lt;/p&gt;

&lt;p&gt;Local models work the same way. The difference is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemini/Claude&lt;/strong&gt;: More recent training data, larger knowledge base, up-to-date tax law changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma4 local&lt;/strong&gt;: Good foundational knowledge, may be slightly behind on very recent rule changes, but &lt;strong&gt;your documents never leave your machine&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For sensitive financial documents, that privacy trade-off is significant.&lt;/p&gt;




&lt;h2&gt;
  
  
  Connecting Claude Code to Gemma4
&lt;/h2&gt;

&lt;p&gt;This was surprisingly simple. Claude Code reads three environment variables:&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;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_AUTH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ollama
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:11434
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or using Ollama's built-in launcher:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama launch claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Claude Code started up I saw this at the bottom of the welcome screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gemma4 · API Usage Billing · pranayraavi@gmail.com's Organization
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That confirms it's using Gemma4 through Ollama. No Anthropic billing. No subscription.&lt;/p&gt;

&lt;p&gt;What you get with this setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ File reading and editing across your project&lt;/li&gt;
&lt;li&gt;✅ Terminal command execution&lt;/li&gt;
&lt;li&gt;✅ Multi-step agentic coding tasks&lt;/li&gt;
&lt;li&gt;✅ Git operations&lt;/li&gt;
&lt;li&gt;✅ MCP connectors and plugins&lt;/li&gt;
&lt;li&gt;✅ Project context awareness&lt;/li&gt;
&lt;li&gt;⚠️ Intelligence capped at Gemma4's capability (weaker than Claude Sonnet/Opus)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Python API Test
&lt;/h2&gt;

&lt;p&gt;Before setting up a GUI I wanted to confirm the raw API worked. Here's the script I wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api/generate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemma4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="p"&gt;}&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Write a hello world in ascii diagram of moon and earth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          (           )
         /              \
  ----(---O---)    (------)  &amp;lt;-- Orbit Path
 /  /   \    /  /   \
|   |     | | |     |   |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemma4, running entirely on my machine, responding to a Python script. No API key. No internet. Completely local. This was the moment it really clicked.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting Up Open WebUI — The ChatGPT-Like Interface
&lt;/h2&gt;

&lt;p&gt;For a proper GUI I went with &lt;strong&gt;Open WebUI&lt;/strong&gt; — a beautiful, feature-rich interface that runs locally and connects to Ollama.&lt;/p&gt;

&lt;p&gt;First attempt using pip failed because I had Python 3.13 and Open WebUI requires Python 3.11 or 3.12:&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="go"&gt;ERROR: Could not find a version that satisfies the requirement open-webui
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I went the Docker route instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing Docker Desktop
&lt;/h3&gt;

&lt;p&gt;Docker Desktop is free for personal use. Download from &lt;code&gt;docker.com/products/docker-desktop&lt;/code&gt;. During install, WSL 2 backend gets configured automatically on Windows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running Open WebUI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;127.0.0.1:3000:8080&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;open-webui&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-v&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;open-webui:/app/backend/data&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--add-host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;host.docker.internal:host-gateway&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;ghcr.io/open-webui/open-webui:main&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I initially tried &lt;code&gt;-p 3000:80&lt;/code&gt; which caused a port conflict (another process was using port 3000 on my machine). Switching to &lt;code&gt;-p 127.0.0.1:3000:8080&lt;/code&gt; fixed it.&lt;/p&gt;

&lt;p&gt;Confirmed it was running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;netstat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ano&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;findstr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;3000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# TCP  0.0.0.0:3000  LISTENING  ← Docker up and running&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="n"&gt;curl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# StatusCode: 200 OK  ← Server responding&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then opened &lt;code&gt;http://localhost:3000&lt;/code&gt; in Chrome and saw the Open WebUI interface with Gemma4 auto-detected.&lt;/p&gt;

&lt;h3&gt;
  
  
  First Real Test — Image to Text Extraction
&lt;/h3&gt;

&lt;p&gt;One of the reasons I picked Gemma4 over Qwen3.6 was its multimodal capability — it can actually &lt;em&gt;see&lt;/em&gt; images. I put this to the test immediately.&lt;/p&gt;

&lt;p&gt;I had a photo of handwritten chess notes and uploaded it directly into the Open WebUI chat. The prompt was simple: &lt;em&gt;"convert this image to text"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Gemma4 thought for 11 seconds and returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FORK/DOUBLE ATTACK

When we attack two or more pieces at the same time then it is known
as fork or double attack

Note- Knights are good at making fork.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a perfect transcription of handwritten text — extracted entirely locally, no cloud OCR service, no API key, nothing leaving my machine. It even generated a relevant follow-up suggestion: &lt;em&gt;"Are there other kinds of tactical attacks besides forks, like pins or skewers?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the multimodal capability in action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Handwritten text extracted accurately&lt;/li&gt;
&lt;li&gt;✅ Context understood (chess notes)&lt;/li&gt;
&lt;li&gt;✅ Intelligent follow-up suggested&lt;/li&gt;
&lt;li&gt;✅ 100% local — image never left my PC&lt;/li&gt;
&lt;li&gt;✅ Free&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For anyone with scanned documents, handwritten notes, receipts, or any image containing text — this works out of the box with Gemma4 in Open WebUI.&lt;/p&gt;




&lt;h2&gt;
  
  
  Document Upload and RAG — How It Actually Works
&lt;/h2&gt;

&lt;p&gt;One of the most powerful features of Open WebUI is document upload with &lt;strong&gt;RAG (Retrieval Augmented Generation)&lt;/strong&gt;. This is how you can upload your AWS docs, tax returns, or any PDFs and chat with them.&lt;/p&gt;

&lt;p&gt;Here's what happens under the hood:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You upload PDF
      ↓
Open WebUI splits it into chunks
      ↓
Converts chunks to embeddings (mathematical vectors)
      ↓
Stores in ChromaDB (local vector database)
      ↓
You ask a question
      ↓
ChromaDB finds the most relevant chunks
      ↓
Sends chunks to Gemma4 as context
      ↓
Gemma4 answers based on YOUR document
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything is stored locally at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;C:\Users\lavan\AppData\Roaming\open-webui\data\
  📁 vector_db    ← document embeddings (ChromaDB)
  📁 uploads      ← original files
  📄 webui.db     ← chat history (SQLite)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your documents never leave your machine. ChromaDB is completely free and open source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One important limitation&lt;/strong&gt;: RAG finds &lt;em&gt;relevant chunks&lt;/em&gt;, not the entire document. If an answer spans many sections of a large document, it might miss some context. The workaround is to upload smaller, focused documents rather than one giant PDF.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Stack — What I Now Have Running
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ Ollama          — model manager and local API server
✅ Gemma4          — the AI model (multimodal, ~12GB)
✅ Claude Code     — agentic coding with local model
✅ Open WebUI      — browser-based chat interface with document upload
✅ Python API      — scripts calling the model directly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total monthly cost: &lt;strong&gt;$0&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Use What
&lt;/h2&gt;

&lt;p&gt;After going through all of this, here's the practical split I settled on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Coding with file editing&lt;/td&gt;
&lt;td&gt;Claude Code + Gemma4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image analysis / image to text&lt;/td&gt;
&lt;td&gt;Open WebUI + Gemma4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Document Q&amp;amp;A (private)&lt;/td&gt;
&lt;td&gt;Open WebUI + RAG + Gemma4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web research / current events&lt;/td&gt;
&lt;td&gt;Claude.ai or Perplexity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex reasoning / large context&lt;/td&gt;
&lt;td&gt;Claude.ai (paid)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax doc analysis (all years)&lt;/td&gt;
&lt;td&gt;Claude.ai or NotebookLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick Python scripts calling AI&lt;/td&gt;
&lt;td&gt;Direct Ollama API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Honest Reflections
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What surprised me&lt;/strong&gt;: How straightforward the setup actually was once I understood the mental model. Ollama is the server, the model is the brain, everything else just connects to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I underestimated&lt;/strong&gt;: The quality gap between local models and Claude Sonnet/Opus is real. For simple tasks Gemma4 is impressive. For complex multi-step reasoning, Claude's frontier models are noticeably stronger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd tell myself at the start&lt;/strong&gt;: Local AI is not a replacement for cloud AI — it's a complement. Use local for private, repetitive, or experimental tasks. Use cloud AI for research, complex reasoning, and anything that benefits from a larger context window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The privacy win is real&lt;/strong&gt;: For sensitive documents — financial records, personal data, proprietary code — local AI is genuinely better from a privacy standpoint. Your data does not leave your machine. Full stop.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ollama: &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;ollama.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Open WebUI: &lt;a href="https://openwebui.com" rel="noopener noreferrer"&gt;openwebui.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Claude Code: &lt;a href="https://claude.ai/code" rel="noopener noreferrer"&gt;claude.ai/code&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Ollama + Claude Code docs: &lt;a href="https://docs.ollama.com/integrations/claude-code" rel="noopener noreferrer"&gt;docs.ollama.com/integrations/claude-code&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker Desktop (free): &lt;a href="https://docker.com/products/docker-desktop" rel="noopener noreferrer"&gt;docker.com/products/docker-desktop&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;All of this runs on a Windows machine with 32GB RAM, an NVIDIA GPU with ~11GB VRAM, and a Core i9 processor. If you have similar hardware you can replicate this entire stack in an afternoon.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
