{"id":784,"date":"2026-10-16T05:01:41","date_gmt":"2026-10-16T05:01:41","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/postgres-materialized-view-auto-refresh-pg-ivm-1-16-the-index-i-would-check-first\/"},"modified":"2026-10-16T05:01:41","modified_gmt":"2026-10-16T05:01:41","slug":"postgres-materialized-view-auto-refresh-pg-ivm-1-16-the-index-i-would-check-first","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/postgres-materialized-view-auto-refresh-pg-ivm-1-16-the-index-i-would-check-first\/","title":{"rendered":"Postgres Materialized View Auto Refresh with pg_ivm 1.16"},"content":{"rendered":"<p>Okay, confession: for a long time my answer to &ldquo;the dashboard is stale&rdquo; was a cron job that ran <code>REFRESH MATERIALIZED VIEW<\/code> every five minutes and a shrug. It works. It also rebuilds the entire view every time, even when one row changed since the last run. If you&rsquo;ve ever watched that refresh crawl on a big table and wondered why Postgres can&rsquo;t just apply the one change, this post is for you.<\/p>\n<p>Short version: it can, with an extension called pg_ivm. It keeps a materialized view current inside the same transaction that changes the base table, using triggers instead of a scheduled rebuild. The project shipped <a href=\"https:\/\/www.postgresql.org\/about\/news\/pg_ivm-116-released-3391\/\" rel=\"nofollow noopener\" target=\"_blank\">version 1.16<\/a> this month with PostgreSQL 19 support. I read the whole <a href=\"https:\/\/github.com\/sraoss\/pg_ivm\" rel=\"nofollow noopener\" target=\"_blank\">README<\/a> rather than the release note, because the restrictions section is where this extension is won or lost. I haven&rsquo;t put it in front of a client database yet, so everything below is what the docs promise and what I&rsquo;d test.<\/p>\n<h2 id=\"the-problem-with-refresh-materialized-view\">The problem with REFRESH MATERIALIZED VIEW<\/h2>\n<p>A normal materialized view is a snapshot. You create it from a query, Postgres stores the result as a table, and it stays frozen until you run <a href=\"https:\/\/www.postgresql.org\/docs\/current\/sql-refreshmaterializedview.html\" rel=\"nofollow noopener\" target=\"_blank\"><code>REFRESH MATERIALIZED VIEW<\/code><\/a>. That command recomputes the full query and swaps in the result. With the <code>CONCURRENTLY<\/code> option readers aren&rsquo;t blocked, but the work is still a full recompute.<\/p>\n<p>The pg_ivm README has a timing example that shows the gap well. These are the project&rsquo;s numbers on a pgbench dataset with 10 million account rows, not mine, so treat them as the shape of the thing rather than a promise for your hardware. Updating one account row took about 9 ms. Refreshing the plain materialized view afterwards took 20.5 seconds. That is the whole reason the polling approach feels wrong: one row changed, and you paid for ten million.<\/p>\n<p>The usual workaround is what I did. Refresh on a timer, accept stale reads, and pick the timer by arguing with whoever owns the dashboard. Postgres has no built-in alternative, because incremental maintenance of arbitrary queries is a hard problem. That&rsquo;s the gap pg_ivm fills.<\/p>\n<h2 id=\"what-pg_ivm-does-differently\">What pg_ivm does differently<\/h2>\n<p>The README calls the result an Incrementally Maintainable Materialized View, or IMMV. You create one with a function instead of <code>CREATE MATERIALIZED VIEW<\/code>:<\/p>\n<pre><code class=\"language-sql\">-- before: a plain materialized view, refreshed by cron\nCREATE MATERIALIZED VIEW mv_normal AS\n  SELECT a.aid, b.bid, a.abalance, b.bbalance\n  FROM pgbench_accounts a JOIN pgbench_branches b USING (bid);\n\n-- after: an incrementally maintained one\nSELECT pgivm.create_immv('immv',\n  'SELECT a.aid, b.bid, a.abalance, b.bbalance\n   FROM pgbench_accounts a JOIN pgbench_branches b USING (bid)');\n<\/code><\/pre>\n<p>Behind that function call the extension creates triggers on the base tables. When you <code>INSERT<\/code>, <code>UPDATE<\/code> or <code>DELETE<\/code> a row, an <code>AFTER<\/code> trigger works out which rows in the view are affected and patches only those. The docs call this immediate maintenance, because the view is updated in the same transaction as the change. There is no window where a reader sees the base table and the view disagree.<\/p>\n<p>In the README&rsquo;s example, the single-row update on the base table took about 15 ms with the IMMV in place, up from 9 ms without it. So you pay roughly six extra milliseconds on every write to get a view that is always current and never needs a 20-second refresh. Whether that trade is good depends entirely on how often the base table changes, which I&rsquo;ll get to.<\/p>\n<p>The extension supports PostgreSQL 13 through 19. Installation is <code>make install<\/code> plus <code>CREATE EXTENSION pg_ivm;<\/code>, and the docs recommend adding it to <code>shared_preload_libraries<\/code> or <code>session_preload_libraries<\/code> so maintenance works correctly. That needs a restart, so plan it for a maintenance window.<\/p>\n<h2 id=\"the-index-you-must-not-forget\">The index you must not forget<\/h2>\n<p>This is the part of the README I&rsquo;d highlight with a marker. Incremental updates need to find the right row in the view quickly, and without an index on the view that lookup is a sequential scan. The README shows it directly. With the automatically created index in place, a single-row update took 15 ms. After dropping that index, the same update took about 3.2 seconds.<\/p>\n<p><code>create_immv<\/code> tries to build a unique index for you when it can derive a key from the query. A <code>GROUP BY<\/code> gives it the grouped columns, a <code>DISTINCT<\/code> gives it all the columns, and otherwise it looks for all the primary key columns of the base tables in the select list. When it can&rsquo;t find one, it prints a notice telling you to create an index yourself. Please read that notice. If you ignore it, every write to a base table will quietly get slower, and you&rsquo;ll blame the wrong thing.<\/p>\n<p>The practical rule I&rsquo;d follow: include the primary keys of your base tables in the select list, check <code>\\d<\/code> on the new view straight after creating it, and confirm an index exists before you call it done.<\/p>\n<h2 id=\"aggregates-work-with-one-slow-case\">Aggregates work, with one slow case<\/h2>\n<p>Dashboards are mostly aggregates, so this matters. The README shows a <code>GROUP BY<\/code> view with <code>count<\/code>, <code>sum<\/code> and <code>avg<\/code>, where updating one account row took about 17 ms and the aggregated numbers for that branch were correct immediately afterwards. The supported aggregates are <code>count<\/code>, <code>sum<\/code>, <code>avg<\/code>, <code>min<\/code> and <code>max<\/code>, and only built-in ones.<\/p>\n<p>Two details deserve attention. First, <code>min<\/code> and <code>max<\/code>. If you delete the row that currently holds the minimum or maximum, Postgres may have to recompute that group from the base tables, and the docs warn that this can take a long time. So a view full of <code>max()<\/code> columns on a table where rows get deleted is the wrong candidate.<\/p>\n<p>Second, floating point. The docs say using <code>sum<\/code> or <code>avg<\/code> on <code>real<\/code> or <code>double precision<\/code> columns is unsafe, because the incrementally maintained value can drift from what a fresh calculation would give. Use <code>numeric<\/code> instead. Money columns should be <code>numeric<\/code> already, but I&rsquo;d audit anything else that feeds an IMMV.<\/p>\n<p>The extension also adds hidden columns named like <code>__ivm_count__<\/code> to aggregate views, which it uses to maintain the numbers. Don&rsquo;t select them in your own queries, and don&rsquo;t be surprised when you see them in <code>\\d<\/code>.<\/p>\n<h2 id=\"restrictions-that-will-bite-you\">Restrictions that will bite you<\/h2>\n<p>The supported SQL is a subset. Inner and outer joins, <code>DISTINCT<\/code>, simple subqueries in <code>FROM<\/code>, <code>EXISTS<\/code> and simple CTEs are fine. Window functions, <code>HAVING<\/code>, <code>ORDER BY<\/code>, <code>LIMIT<\/code>, <code>UNION<\/code> and friends are not allowed. The base tables must be plain tables, which rules out views, partitioned tables and foreign tables. If your biggest table is partitioned, that alone may end the experiment, and I&rsquo;d rather find that out on day one.<\/p>\n<p>Outer joins have extra rules: only a simple equijoin, and no aggregates in the same view. Logical replication isn&rsquo;t supported either, so an IMMV on a subscriber won&rsquo;t update when the publisher&rsquo;s base table changes.<\/p>\n<p>Concurrency is the one I&rsquo;d read twice. Under <code>READ COMMITTED<\/code>, the extension takes an <code>ExclusiveLock<\/code> on the view right after a base table changes, so two transactions touching the same IMMV are serialised. Under <code>REPEATABLE READ<\/code> or <code>SERIALIZABLE<\/code>, it raises an error instead of waiting if it can&rsquo;t get the lock. That&rsquo;s a correctness choice and a sensible one, but it means a hot table feeding an IMMV can become a queue. The README also says plainly that this is a poor fit when the base table is written to frequently or in large batches.<\/p>\n<h2 id=\"bulk-loads-dumps-and-upgrades\">Bulk loads, dumps and upgrades<\/h2>\n<p>The README gives a clean escape hatch for big writes. Call <code>pgivm.refresh_immv('name', false)<\/code> to drop the triggers, load your data, then call it again with <code>true<\/code> to rebuild and re-enable maintenance. For a nightly import that&rsquo;s a normal pattern, and I&rsquo;d script it.<\/p>\n<p>One more operational detail: the extension&rsquo;s own metadata isn&rsquo;t included in <code>pg_dump<\/code> output. Before a dump or <code>pg_upgrade<\/code> you run the bundled <code>pg_ivm_dump_metadata<\/code> script, and after the restore you run the SQL it produced. If you skip that step, the view&rsquo;s data comes back as an ordinary table with no triggers, and it quietly stops updating. I&rsquo;d add that to the backup runbook before the first IMMV goes live. If you are still on the basics, my <a href=\"https:\/\/abrarqasim.com\/blog\/postgres-performance-tuning-parameters-i-actually-touch\" rel=\"noopener\">Postgres tuning notes<\/a> cover the parameters I actually touch, and this extension is another reason to do a real restore drill.<\/p>\n<h2 id=\"when-id-use-it-and-when-i-wouldnt\">When I&rsquo;d use it and when I wouldn&rsquo;t<\/h2>\n<p>I&rsquo;d reach for pg_ivm when a view joins a few tables, the base tables change at a modest rate, readers need fresh numbers, and a 20-second rebuild is not acceptable. A customer-facing totals page is the textbook case. I&rsquo;d keep a cron-refreshed materialized view when the data is allowed to be a few minutes old, the base tables take heavy writes, or the query uses features the extension doesn&rsquo;t support. Plain old stale-but-cheap is a perfectly good answer for a lot of internal reporting.<\/p>\n<h2 id=\"try-it-this-week\">Try it this week<\/h2>\n<p>Pick one materialized view you currently refresh on a timer and check it against the restriction list above: no window functions, plain base tables, only the five aggregates. If it passes, restore a copy of the database onto a scratch server, install the extension, create the IMMV with a primary key in the select list, and time your real write workload against it. Compare the extra write latency with what your refresh job costs today. If you&rsquo;d like to see the kind of client database work this sits inside, my <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">portfolio<\/a> is the place to start.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>pg_ivm keeps a Postgres materialized view current without cron refreshes. What the README says about indexes, aggregates, locks and pg_dump, and what I&#8217;d test first.<\/p>\n","protected":false},"author":2,"featured_media":783,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"pg_ivm keeps a Postgres materialized view current without cron refreshes. What the README says about indexes, aggregates, locks and pg_dump, and what I'd test first.","rank_math_focus_keyword":"postgres materialized view auto refresh","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302],"tags":[178,862,19,863,177],"class_list":["post-784","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","tag-database","tag-materialized-views","tag-performance","tag-pg-ivm","tag-postgres"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/784","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=784"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/784\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/783"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=784"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=784"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=784"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}