Now-Next

SaaS architecture

CREATE POLICY IF NOT EXISTS in PostgreSQL: what works

PostgreSQL has no CREATE POLICY IF NOT EXISTS. Three ways to write a repeatable policy migration, measured on PostgreSQL 17 and 18: gaps, locks and races.

Chris van Eijk, Now-Next · · 7 min read

PostgreSQL has no CREATE POLICY IF NOT EXISTS and no CREATE OR REPLACE POLICY. Both are syntax errors on PostgreSQL 17 and 18, and neither is in the documentation of the 19 release candidate or of the development version. A migration that has to run more than once has three ways to get there: drop and create the policy in one transaction, check the catalog in a DO block, or change the existing policy with ALTER POLICY. Which one is right depends on whether the policy might already exist with a different definition.

We measured all three on PostgreSQL 17.11 and 18.6, on a table of 100,000 invoices for 10 tenants, with FORCE ROW LEVEL SECURITY and an application role that is not the owner. The question for each pattern: what does the application see while the migration runs, which lock does it take, and what happens when two deploys run it at the same time.

Does PostgreSQL have CREATE POLICY IF NOT EXISTS?

No. On PostgreSQL 17.11 CREATE POLICY IF NOT EXISTS tenant_isolation ON invoices … stopped at ERROR: syntax error at or near "NOT", and CREATE OR REPLACE POLICY at syntax error at or near "POLICY" (SQLSTATE 42601 both times). Running a plain CREATE POLICY a second time gives:

ERROR:  42710: policy "tenant_isolation" for table "invoices" already exists

Only the drop has the clause: DROP POLICY IF EXISTS on a policy that is not there returns a notice, policy "does_not_exist" for relation "invoices" does not exist, skipping, and carries on.

When the missing clause came up in October 2025, Álvaro Herrera asked on the pgsql-general list: “How would CREATE IF NOT EXISTS handle the case of an existing policy that doesn’t match the one you want? I think it would just silently not do anything”. Replying to a patch on pgsql-hackers the same morning, he added: “I think we’ve purposefully not added many additional CREATE IF NOT EXISTS commands.” An existing policy with another definition is the real problem a migration has to solve, and it is what separates the patterns below.

Pattern 1: DROP POLICY IF EXISTS and CREATE POLICY in one transaction

BEGIN;
DROP POLICY IF EXISTS tenant_isolation ON invoices;
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::int);
COMMIT;

This leaves tenant_isolation exactly as the migration describes, whatever was there before. Other policies on the table are untouched, though: when we left an old permissive policy USING (true) next to it, tenant 3 counted all rows, because permissive policies are combined with OR. And it only holds inside one transaction. Herrera warned about the same pattern in the same message: without an explicit transaction block, “there exists a period in between where no policy exists, which could be a security hole”. We measured what that period looks like.

What the application and the owner count while a migration drops a policy and creates it again

Two statements in autocommit, 4 seconds apart

  1. MigrationDROP POLICY IF EXISTS commits straight away.
    RLS on, no policy
  2. Application, tenant 3Counts 0 invoices instead of 10,000. The owner, under FORCE, also counts 0.
    Default deny: every tenant sees an empty product
  3. MigrationCREATE POLICY commits.
    Policy back
  4. Application, tenant 3Counts 10,000.
    As before

The same two statements in one transaction, kept open for 6 seconds

  1. MigrationBEGIN, DROP POLICY, CREATE POLICY, not yet committed.
    The transaction holds ACCESS EXCLUSIVE on invoices
  2. Application, tenant 3Its count(*) waits.
    Nobody sees the table without a policy
  3. MigrationCOMMIT.
    New policy
  4. Application, tenant 3Counts 10,000, after waiting 4.5 seconds.
    As before

PostgreSQL 17.11; 18.6 gave the same counts and a wait of 4.5 seconds as well. 100,000 invoices over 10 tenants, ENABLE and FORCE ROW LEVEL SECURITY. The pauses stand in for whatever a migration tool does between two statements. The application query in the second case started 1.5 seconds into the open transaction and waited for the rest of it.

With FORCE on, the gap fails closed: nobody sees another tenant’s data, but every tenant sees nothing, and an insert fails with new row violates row-level security policy. Without FORCE, the owner counted all 100,000 rows in the gap — and also afterwards, because without FORCE the owner bypasses the policy anyway. Who bypasses a policy, and why FORCE matters, is in BYPASSRLS: who bypasses row-level security.

The price of the transaction is the lock. DROP POLICY takes ACCESS EXCLUSIVE on the table and the transaction keeps it until the commit, so every query on the table waits for the migration — and the migration first waits for every query already running. That is why it belongs with a lock_timeout, below.

Pattern 2: a DO block that checks pg_policies

DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_policies
    WHERE schemaname = 'public' AND tablename = 'invoices'
      AND policyname = 'tenant_isolation'
  ) THEN
    CREATE POLICY tenant_isolation ON invoices
      USING (tenant_id = current_setting('app.tenant_id')::int);
  END IF;
END $$;

This is the closest thing to IF NOT EXISTS, and it has the same weakness Herrera described: it checks the name, not the definition. Run it twice and the second run does nothing, which is what you want. Run it against a database where tenant_isolation exists with an older USING clause and it also does nothing, without a word. Use it for a policy that is only ever created, never changed.

Check pg_policies, not pg_policy. The view has schemaname, tablename and policyname; the catalog table underneath has polname and polrelid and no table name. A policy name is unique per table, not per schema: two tables can each have a tenant_isolation, so the check needs the table too.

Two deploys at once. We ran the block on a fresh policy name, tenant_isolation_v2, in two sessions half a second apart, each in its own transaction, with a pause of 3 seconds between the check and the create. Both saw no policy. The first created it; the second’s CREATE POLICY waited for the first transaction and then failed with policy "tenant_isolation_v2" for table "invoices" already exists, which aborted its whole transaction. With PERFORM pg_advisory_xact_lock(hashtext('policy:invoices')) as the first line of the block, the second session waited for the lock, then found the policy and skipped it; both transactions committed. The pause is ours, to make the race reproducible; without it the window is smaller, not gone.

Pattern 3: ALTER POLICY when only the expression changes

ALTER POLICY changes a policy in place: its USING expression, its WITH CHECK expression, the roles it applies to (TO), and its name (RENAME TO). All four worked on 17.11 and 18.6. It does not change the command or the type: ALTER POLICY tenant_isolation ON invoices FOR SELECT and … AS RESTRICTIVE are both a syntax error. The documentation says the same: to change “the command to which it applies or whether it is permissive or restrictive, the policy must be dropped and recreated”. That is pattern 1.

ALTER POLICY also took ACCESS EXCLUSIVE on the table, the same lock as CREATE POLICY and DROP POLICY. It saves you the gap, not the lock. And it fails when the policy is not there, so on its own it is not repeatable either.

Which lock does a policy change take, and how long does it wait?

Statement, in an open transactionLock on the table
CREATE POLICYACCESS EXCLUSIVE
DROP POLICYACCESS EXCLUSIVE
ALTER POLICY … USING (…)ACCESS EXCLUSIVE

ACCESS EXCLUSIVE conflicts with everything, including a plain SELECT. With a reader running select pg_sleep(5), count(*) from invoices, a CREATE POLICY started a second later took 4.0 seconds: it waited for the reader to finish. With set lock_timeout = '1s' first, it gave up after 1.0 seconds with ERROR: canceling statement due to lock timeout (SQLSTATE 55P03). While a policy change waits, every new query on the table queues behind it, so one slow report can stall every query on the table, for every tenant, until it finishes: a third session that started a second after the CREATE POLICY waited too. That queue, and why a migration needs a lock_timeout and a retry, is measured in schema migrations on a shared multi-tenant table.

Is enabling row-level security in a migration atomic too?

Yes. In one transaction we ran ALTER TABLE … ENABLE ROW LEVEL SECURITY, ALTER TABLE … FORCE ROW LEVEL SECURITY and CREATE POLICY on a new table, then ROLLBACK. Afterwards relrowsecurity and relforcerowsecurity were both false again and the table had no policy. So the order matters only outside a transaction. ENABLE committed before the policy exists hides every row from every role that does not bypass row-level security — the owner only if FORCE is on; superusers and BYPASSRLS roles still see everything. A policy committed before ENABLE does nothing until row-level security is switched on.

Which pattern should a migration use?

  • The policy is created once and never changes: the DO block, with an advisory lock if two deploys can run at once.
  • The policy may already exist with another definition: DROP POLICY IF EXISTS and CREATE POLICY in one transaction, with a lock_timeout. It is the only pattern that always ends with that policy in the definition you wrote; check pg_policies for other policies on the table.
  • Only the expression or the roles change: ALTER POLICY, in a migration that knows the policy exists.

Whichever you choose, check how your migration tool groups statements into transactions before you rely on it. The gap in the figure is what you get when it runs each statement on its own.

What we did not measure

Migration tools themselves (Flyway, Liquibase, Prisma Migrate, Alembic, Rails); we ran the SQL they would send. Policies on partitioned tables, and ALTER POLICY on a table with long-running transactions holding only a weaker lock. Whether a prepared statement planned before a policy change keeps using the old policy.

Sources: PostgreSQL documentation on CREATE POLICY, ALTER POLICY and DROP POLICY for 17, CREATE POLICY for 18, the 19 release candidate and the development version; Álvaro Herrera, 21 October 2025, on pgsql-general on CREATE IF NOT EXISTS and on pgsql-hackers on adding more of them. All read on 16 October 2026. All measurements are our own, on PostgreSQL 17.11 and 18.6 in a container, on 16 October 2026.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation