MP096warning

warn-matview-with-data

CREATE MATERIALIZED VIEW ... WITH DATA runs the full query inside the migration, holding locks on every source table.

operation
Views
lock taken
ACCESS EXCLUSIVE
remediation
Manual rewrite
category
Lock safety

What triggers it

A CreateTableAsStmt node whose objtype is OBJECT_MATVIEW and whose into.skipData is not true. skipData is set only by an explicit WITH NO DATA, so its absence is read as the WITH DATA default — which means the rule fires on the ordinary case where the migration wrote no data clause at all.

What does not

CREATE TABLE ... AS, which parses to the same CreateTableAsStmt node but carries an objtype other than OBJECT_MATVIEW. WITH NO DATA sets into.skipData === true and returns null. Nothing else stands it down: no catalog is consulted, no size threshold is applied, and there is no dependency on --database-url.

Where it applies

Applies to every PostgreSQL version MigrationPilot targets. It works on the SQL text alone — no database connection needed.

The lock, and what it blocks

The new matview is held at ACCESS EXCLUSIVE for the whole build, but nothing can contend for an object that did not exist a moment ago. The lock that matters is on the other side: the view query holds ACCESS SHARE on every source table until it completes, and the migration transaction stays open for that entire time, extending every lock it already holds and pinning xmin so vacuum cannot clean up dead rows anywhere in the database.

Why it matters

WITH DATA is the default, so this usually happens without anyone choosing it. The statement looks like a definition and behaves like a batch job: the migration runs the view's query to completion before returning, holding locks on every table the query reads. A materialized view is generally materialized because the query is expensive, so the build is expensive by construction — and the transaction stays open throughout, which keeps xmin pinned so vacuum cannot clean up rows anywhere in the database.

Unsafe, and safe

Flagged

CREATE MATERIALIZED VIEW daily_revenue AS
  SELECT date_trunc('day', created_at) AS day, sum(total)
  FROM orders GROUP BY 1;
-- Migration blocks until the aggregate finishes

Safe alternative

-- Returns immediately; the expensive part becomes a REFRESH you can
-- schedule and retry. Note the view is not queryable until that runs,
-- and the first REFRESH cannot use CONCURRENTLY.
CREATE MATERIALIZED VIEW daily_revenue AS
  SELECT date_trunc('day', created_at) AS day, sum(total)
  FROM orders GROUP BY 1
  WITH NO DATA;

Deploy and transaction boundaries

The suggested WITH NO DATA form leaves the view unqueryable until something refreshes it (materialized view ... has not been populated), and that first REFRESH cannot use CONCURRENTLY. The refresh therefore has to be sequenced before anything reads the view, which is a change to whatever runs next, not just to this statement.

What it assumes

The rule has no idea how expensive the query is. It reads no table stats and applies no threshold, so a matview over a ten-row lookup table produces the same finding as an aggregate over a billion-row fact table. It also cannot distinguish a deliberate WITH DATA from an omitted data clause — both parse identically — so it cannot tell a considered choice from an accident.

What the CLI prints

migrationpilot analyze migration.sql
⚠ [MP096] WARNING (line 1)
  CREATE MATERIALIZED VIEW "daily_revenue" populates immediately (WITH DATA is the default). The migration blocks until the full query completes, holding locks on every source table.

  Safe alternative:
  -- Create the view empty so the migration returns straight away:
  CREATE MATERIALIZED VIEW daily_revenue AS SELECT ... WITH NO DATA;
  
  -- Populate it outside the migration. The first refresh cannot use
  -- CONCURRENTLY, and the view is not queryable until it completes:
  REFRESH MATERIALIZED VIEW daily_revenue;
  
  -- A unique index lets every later refresh run without blocking readers:
  CREATE UNIQUE INDEX CONCURRENTLY daily_revenue_pk ON daily_revenue (<key>);
  REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;

  Why: WITH DATA is the default, so this usually happens without anyone choosing it. The migration then runs the view query to completion. An aggregate over a large fact table can take many minutes, while holding locks on every table the query reads and keeping the migration transaction open the entire time. Deploy tooling with a timeout gives up partway and leaves the schema half-applied. Creating the view WITH NO DATA returns immediately and moves the expensive part into a REFRESH you can schedule, retry, and run outside the deploy.
  Docs: https://migrationpilot.dev/rules/mp096

Generated by running the CLI's own formatter over the flagged example above, so it is the text the tool actually produces. A real run also reports the other rules that fire on the same statement; those blocks are left out here.

Turning it off

For one statement, put a comment on the line before it:

-- migrationpilot-disable MP096
CREATE MATERIALIZED VIEW daily_revenue AS

For the whole project, in .migrationpilotrc.yml — by name or by id:

.migrationpilotrc.yml
rules:
  MP096: false

# or keep it, and downgrade it
rules:
  MP096:
    severity: warning

Try it

Open this rule's flagged example in the playground. It runs in your browser — edit it and watch the finding appear and disappear.

Run MP096 in the playground

Related rules