<?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: Prabhu</title>
    <description>The latest articles on DEV Community by Prabhu (@prabhu_balaji_1997).</description>
    <link>https://dev.to/prabhu_balaji_1997</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3339839%2Fd616b85f-e8f8-4338-8d64-45f0c390c121.jpg</url>
      <title>DEV Community: Prabhu</title>
      <link>https://dev.to/prabhu_balaji_1997</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/prabhu_balaji_1997"/>
    <language>en</language>
    <item>
      <title>Why Your Fast PostgreSQL Query Suddenly Became Slow: A Deep Dive into MVCC and Index Bloat</title>
      <dc:creator>Prabhu</dc:creator>
      <pubDate>Wed, 17 Dec 2025 17:05:52 +0000</pubDate>
      <link>https://dev.to/prabhu_balaji_1997/why-your-fast-postgresql-query-suddenly-became-slow-a-deep-dive-into-mvcc-and-index-bloat-44jh</link>
      <guid>https://dev.to/prabhu_balaji_1997/why-your-fast-postgresql-query-suddenly-became-slow-a-deep-dive-into-mvcc-and-index-bloat-44jh</guid>
      <description>&lt;p&gt;Ever noticed a query using an index correctly that's blazing fast, then suddenly slows down - especially on tables with high write volume? We recently experienced this exact issue in production. Here's what we learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Setup
&lt;/h2&gt;

&lt;p&gt;We have an &lt;code&gt;event_logs&lt;/code&gt; table with the following structure:&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;event_logs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_requeued_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cron job runs every few minutes to pick up pending event logs and queue them for processing in batches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch 1:&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="nv"&gt;"event_logs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"event_logs"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"event_logs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"state"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'initial'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"event_logs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"last_requeued_at"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="s1"&gt;'2025-12-15 10:34:32.473436'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"event_logs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Batch 2:&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="nv"&gt;"event_logs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"event_logs"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"event_logs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"state"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'initial'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"event_logs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"last_requeued_at"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="s1"&gt;'2025-12-15 10:34:32.473436'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"event_logs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"event_logs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To optimize this query, we created a partial index that only indexes records in the 'initial' state:&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;index_event_logs_on_initial_id_last_requeued_at&lt;/span&gt; 
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;event_logs&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;btree&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_requeued_at&lt;/span&gt;&lt;span class="p"&gt;)&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;'initial'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;In production, we were processing around &lt;strong&gt;900 writes/second&lt;/strong&gt; on the &lt;code&gt;event_logs&lt;/code&gt; table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;300 new inserts/second (state = 'initial')&lt;/li&gt;
&lt;li&gt;300 updates/second (state from initial → processing)&lt;/li&gt;
&lt;li&gt;300 updates/second (state from processing → processed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The table had around &lt;strong&gt;1 billion rows&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Initially, query latency was around &lt;strong&gt;3 milliseconds&lt;/strong&gt;—excellent performance.&lt;/p&gt;

&lt;p&gt;However, after a few hours, the batching query started experiencing high latency. Running &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; showed the query was taking several seconds, even with the index in place.&lt;/p&gt;

&lt;p&gt;Our first instinct was to suspect the index. We ran &lt;code&gt;REINDEX&lt;/code&gt;, which rebuilt the index from scratch. Immediately after, the query was blazing fast again. But within hours, the problem returned.&lt;/p&gt;

&lt;p&gt;Something deeper was going on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding PostgreSQL's MVCC (Multi-Version Concurrency Control)
&lt;/h2&gt;

&lt;p&gt;PostgreSQL uses MVCC to handle concurrent transactions. Instead of modifying rows in place, PostgreSQL keeps multiple versions of the same row:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When a row is updated, PostgreSQL marks the old version as dead and creates a new version&lt;/li&gt;
&lt;li&gt;When a row is deleted, PostgreSQL marks it as dead but doesn't physically remove it&lt;/li&gt;
&lt;li&gt;The original row remains on disk until cleanup occurs (we'll read about the cleanup later in our blog)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's trace what happens to our event logs and how this affects our partial index as they move through states:&lt;/p&gt;

&lt;h3&gt;
  
  
  Initial Insert
&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;event_logs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_requeued_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'initial'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2024-01-01 10:00:00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"key": "value"}'&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;Physical storage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Row version 1: &lt;code&gt;id=1, state='initial', last_requeued_at='2024-01-01 10:00:00'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Partial index: Entry for &lt;code&gt;(id=1, last_requeued_at) → row version 1&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Update 1: 'initial' → 'processing'
&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;event_logs&lt;/span&gt; &lt;span class="k"&gt;SET&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;'processing'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&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;Physical storage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Row version 1: &lt;code&gt;state='initial'&lt;/code&gt; (&lt;strong&gt;dead tuple&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Row version 2: &lt;code&gt;state='processing'&lt;/code&gt; (&lt;strong&gt;current&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Partial index: The previous entry pointing to the 'initial' row version is marked dead. The dead entry stays in the partial index until VACUUM runs (We'll see later on what VACUUM is). No new entry is added since &lt;code&gt;state='processing'&lt;/code&gt; doesn't match the index's WHERE clause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hence, each event log that transitions from initial to processing state creates a dead entry in the partial index.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Update 2: 'processing' → 'processed'
&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;event_logs&lt;/span&gt; &lt;span class="k"&gt;SET&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;'processed'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&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;Physical storage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Row version 1: &lt;code&gt;state='initial'&lt;/code&gt; (&lt;strong&gt;dead&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Row version 2: &lt;code&gt;state='processing'&lt;/code&gt; (&lt;strong&gt;dead&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Row version 3: &lt;code&gt;state='processed'&lt;/code&gt; (&lt;strong&gt;current&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Partial index: &lt;strong&gt;Still has 1 dead entry&lt;/strong&gt; from the original 'initial' state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; 3 physical row copies on disk, with accumulated dead entries in the partial index.&lt;/p&gt;

&lt;p&gt;(Update from processing to processed state doesn't affect the partial index since the partial index is only on the rows with 'initial' state)&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Caused Our Slow Query
&lt;/h2&gt;

&lt;p&gt;When PostgreSQL scans the partial index, it must check the visibility of each entry and skip dead tuples. Under high update volume, as more event logs transition from initial to processing state, dead entries accumulate rapidly in the index.&lt;/p&gt;

&lt;p&gt;With 300 events/second and each event going through two state transitions (initial → processing → processed), we were creating dead index entries faster than they were being cleaned up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The query was spending most of its time checking visibility and skipping dead entries&lt;/strong&gt; rather than retrieving actual data—hence the dramatic slowdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  How PostgreSQL Cleans Up Dead Tuples
&lt;/h2&gt;

&lt;h3&gt;
  
  
  VACUUM
&lt;/h3&gt;

&lt;p&gt;When VACUUM runs, it processes both the table and its indexes, physically removing dead entries and marking space as reusable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Regular VACUUM doesn't compact indexes or shrink files—it only marks space as reusable. For true compaction, you need &lt;code&gt;VACUUM FULL&lt;/code&gt;, &lt;code&gt;REINDEX&lt;/code&gt;, or &lt;code&gt;pg_repack&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  AUTOVACUUM
&lt;/h3&gt;

&lt;p&gt;PostgreSQL's automatic background process that runs VACUUM periodically. It's enabled by default, so why did we still have problems?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause: Default Autovacuum Settings
&lt;/h2&gt;

&lt;p&gt;PostgreSQL's default autovacuum settings aren't aggressive enough for high-write tables, especially large ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default settings:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;autovacuum_vacuum_threshold = 50&lt;/code&gt; rows&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;autovacuum_vacuum_scale_factor = 0.2&lt;/code&gt; (20%)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;autovacuum_vacuum_cost_limit = 200&lt;/code&gt; (Number of vacuum cost units an autovacuum worker can accumulate before it must sleep)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;autovacuum_vacuum_cost_delay = 2ms&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Autovacuum triggers when:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;For our &lt;code&gt;event_logs&lt;/code&gt; table with &lt;strong&gt;1 billion rows&lt;/strong&gt;, autovacuum would only trigger after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;50 + (0.2 × 1,000,000,000) = ~200 million dead tuples
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With 600 dead tuples/second being generated: (roughly one per update, so 600/s from the two updates)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;600 dead tuples/sec × 86,400 seconds/day = ~51.8 million dead tuples/day
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It would take nearly &lt;strong&gt;4 days&lt;/strong&gt; to hit the autovacuum threshold, during which our index would accumulate millions of dead entries, causing severe query degradation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;We made autovacuum much more aggressive for this specific table:&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;test_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_logs&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;005&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;01&lt;/span&gt; &lt;span class="c1"&gt;-- Keeps table stats fresh&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sets the scale factor to &lt;strong&gt;0.5% instead of 20%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New trigger threshold for 1 billion 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;50 + (0.005 × 1,000,000,000) = ~5 million dead tuples
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means autovacuum now runs approximately every &lt;strong&gt;2.3 hours&lt;/strong&gt; instead of every 4 days, keeping dead tuple accumulation under control and maintaining fast query performance.&lt;/p&gt;

&lt;p&gt;To make the autovacuum process faster, you could set &lt;code&gt;autovacuum_vacuum_cost_delay&lt;/code&gt; to zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring Autovacuum Activity
&lt;/h2&gt;

&lt;p&gt;You can check dead tuple counts and autovacuum statistics with this query:&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="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&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;last_autovacuum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_vacuum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;vacuum_count&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="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'event_logs'&lt;/span&gt; 
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_schema'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MVCC creates dead tuples&lt;/strong&gt; - Every update creates a new row version and leaves the old one as a dead tuple&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexes accumulate dead entries&lt;/strong&gt; - Including partial indexes, which must be cleaned by VACUUM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead entries slow down queries&lt;/strong&gt; - PostgreSQL must check visibility and skip dead entries during index scans&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default autovacuum settings don't scale&lt;/strong&gt; - The 20% threshold is too high for large, high-write tables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune per-table settings&lt;/strong&gt; - Aggressive autovacuum settings on hot tables prevent bloat-related performance issues&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reference Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/routine-vacuuming.html" rel="noopener noreferrer"&gt;PostgreSQL VACUUM and ANALYZE Best Practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/mvcc-intro.html" rel="noopener noreferrer"&gt;Understanding MVCC in PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
      <category>sql</category>
    </item>
  </channel>
</rss>
