Performance and tuning
David Sterling  

pg_ivm 1.16: Stop Paying the REFRESH MATERIALIZED VIEW Tax

If your Postgres dashboard runs REFRESH MATERIALIZED VIEW on a schedule, you know the ritual: for a few seconds, readers queue behind a lock while the database recomputes a result it mostly already had. The fix is not a faster refresh. It is not refreshing at all. pg_ivm — the Incremental View Maintenance extension — does exactly that, and version 1.16 was released on October 5, 2026, adding PostgreSQL 19 support and fixing a handful of maintenance bugs.

The refresh tax, measured

The extension’s own documented pgbench example shows the problem cleanly. A materialized view joining pgbench_accounts (10 million rows) to pgbench_branches behaves like this:

UPDATE pgbench_accounts SET abalance = 1000 WHERE aid = 1;
-- Time: 9.052 ms

REFRESH MATERIALIZED VIEW mv_normal;
-- Time: 20575.721 ms (00:20.576)

You paid twenty seconds of work to publish one changed row. Worse, without CONCURRENTLY, the PostgreSQL documentation is blunt: a refresh that affects a lot of rows “could block other connections which are trying to read from the materialized view.” CONCURRENTLY only helps if the view has a qualifying unique index covering all rows, and even then only one refresh at a time may run against that view.

What incremental maintenance changes

Incremental View Maintenance computes only the delta. Instead of re-running the whole query, pg_ivm installs triggers on the base tables and applies the change to the view in the same transaction that modified the base table. This is immediate maintenance, so the view is never stale:

CREATE EXTENSION pg_ivm;

SELECT pgivm.create_immv('immv',
  'SELECT a.aid, b.bid, a.abalance, b.bbalance
     FROM pgbench_accounts a JOIN pgbench_branches b USING (bid)');
-- NOTICE:  created index "immv_index" on immv "immv"
--  create_immv
-- -------------
--     10000000

UPDATE pgbench_accounts SET abalance = 1234 WHERE aid = 1;
-- UPDATE 1
-- Time: 15.448 ms   (view already current, no refresh needed)

Fifteen milliseconds instead of twenty seconds. That is the whole pitch: you stop trading freshness for cost, and you stop taking an exclusive lock on the thing your application is trying to read.

One operational note: pg_ivm must be preloaded, not just created. Set shared_preload_libraries = 'pg_ivm' (or session_preload_libraries) in postgresql.conf and restart.

The index is the tuning knob that matters

Incremental maintenance still has to find the rows to update in the view, and that lookup is an index scan. The measurement I would tape to a wall:

-- with the auto-created unique index in place
UPDATE pgbench_accounts SET abalance = 9876 WHERE aid = 1;
-- Time: 15 ms

DROP INDEX immv_index;

UPDATE pgbench_accounts SET abalance = 9876 WHERE aid = 1;
-- Time: 3224.741 ms (00:03.225)

Same statement, 200 times slower, purely because the scan key lost its index. create_immv builds a unique index automatically when it can — on the GROUP BY columns, on all target columns for a DISTINCT view, or on the primary key attributes if they appear in the target list. Otherwise it creates nothing and tells you so in a notice. Never drop that index, and audit for its absence: if a restore or a well-meaning cleanup job removes it, every write on the base table degrades instantly.

Aggregate rollups are where this pays

Most reporting views are GROUP BY rollups, and those are what IVM handles best: a single row change normally touches a single group. The README’s aggregate example, on the same pgbench data:

SELECT pgivm.create_immv('immv_agg',
  'SELECT bid, count(*), sum(abalance), avg(abalance)
     FROM pgbench_accounts JOIN pgbench_branches USING (bid)
    GROUP BY bid');
-- Time: 5772.625 ms  (initial build: ~5.8 s)

SELECT pgivm.refresh_immv('immv_agg', true);
-- Time: 5766.292 ms  (a from-scratch refresh costs the same)

UPDATE pgbench_accounts SET abalance = abalance + 1000
 WHERE aid = 4112345 AND bid = 42;
-- UPDATE 1
-- Time: 16.616 ms   (one group updated, the other 99 untouched)

Three caveats that bite in production. First, min and max are not free deltas: deleting the row that holds the current minimum forces the value to be recovered from the base tables, so those views can be slow to maintain. Second, never aggregate real or double precision with sum or avg — floating-point drift makes the maintained value diverge from the base data, so use numeric. Third, every GROUP BY expression must appear in the target list; those columns are the scan keys, so they are the columns that need the index.

Bulk loads: turn immediate maintenance off first

IVM is a bargain when a small fraction of a table changes infrequently, and a liability when a large fraction changes at once — every row now pays trigger overhead. pg_ivm gives you the escape hatch explicitly:

-- before a bulk load, empty the view and disable maintenance
SELECT pgivm.refresh_immv('immv_agg', false);

COPY accounts FROM '/tmp/october_load.csv' WITH (FORMAT csv);

-- rebuild once, then re-enable immediate maintenance
SELECT pgivm.refresh_immv('immv_agg', true);

If a nightly load replaces most of a table, IVM is simply the wrong tool. Measure the fraction of rows a typical transaction changes first.

Where pg_ivm stops working

Check these against your schema before you spend a sprint on migration:

  • Base tables must be plain tables. Partitioned tables, partitions, views, materialized views, and foreign tables cannot back an IMMV — so if you partition by month for retention, an IMMV cannot sit directly on the partition set.
  • Unsupported constructs: HAVING, ORDER BY, LIMIT/OFFSET, UNION/INTERSECT/EXCEPT, DISTINCT ON, window functions, recursive CTEs, and subqueries outside FROM or EXISTS in WHERE. Aggregates are limited to count, sum, avg, min, and max — built-ins only.
  • Isolation levels: maintenance takes an ExclusiveLock on the view, and under REPEATABLE READ or SERIALIZABLE a lock conflict raises an error instead of waiting. IMMVs belong behind READ COMMITTED applications.
  • Logical replication: not supported — IMMVs on subscriber-side tables are not maintained by changes arriving from a publisher.
  • Backups and upgrades: the metadata in pgivm.pg_ivm_immv is deliberately excluded from pg_dump because it stores a PostgreSQL-internal query representation. Run pg_ivm_dump_metadata mydb > immv_restore.sql before a dump or pg_upgrade, and replay it afterwards with psql -f — otherwise you restore a table that no longer maintains itself.

Whether to adopt it this quarter

The 1.16 fixes also read like a list of what used to go wrong at scale: a segfault when an IMMV was maintained and dropped in the same transaction, maintenance failures on tables with types lacking a default equality operator, and spurious transaction failures from lock-release timing during commit. If you evaluated pg_ivm a year ago and walked away, those were likely your reasons.

For Houston teams the fit is specific: upstream oilfield sensor rollups, freight and dispatch dashboards, and energy trading position summaries share the same shape — large append-mostly tables, small frequent changes, and a handful of groups that operations watch all day. Start with one view: measure its refresh time today, then measure what fraction of the base table a typical transaction changes. “Seconds and a small fraction” means you already have your candidate.

Sources

1 Comment

  1. […] pg_ivm 1.16 — incremental view maintenance that stops you paying the REFRESH MATERIALIZED VIEW tax; we covered the upgrade path here. […]

Leave A Comment