Now-Next

SaaS architecture

new row violates row-level security policy: the causes

What raised 'new row violates row-level security policy for table' in PostgreSQL 17, measured: inserts, RETURNING, upserts, MERGE and four message forms.

Chris van Eijk · · 9 min read

new row violates row-level security policy for table "notes" is the error row-level security gives you on almost every refused write, and it says very little: not which policy, not which expression, not which row. The same sentence comes back for a wrong tenant, a missing policy, a RETURNING clause and an upsert on a row that already exists.

We ran eleven writes against five policy setups, and a handful more against setups with a restrictive policy, on PostgreSQL 17.11, and wrote down what each one returned. The message has four spellings, and they mean different things. Several statements fail on a policy for a command you did not think you were running. And some things you would expect to raise it raise something else, or nothing at all.

What does “new row violates row-level security policy for table” mean?

PostgreSQL refused a row that a statement was about to write, because the row failed the row-level security policies of a command the statement counts as: no permissive policy for your role let it through, or a restrictive policy refused it. It is SQLSTATE 42501, the same code as permission denied for table. In our tests there were six causes: the row’s tenant did not match the tenant set on the connection, no tenant was set at all, there was no policy for inserts for that role, the statement needed a select policy as well (RETURNING, ON CONFLICT), a restrictive policy said no, or an upsert reached an existing row the role may not update — in our test a row of another tenant.

The setup

PostgreSQL 17.11 in a throwaway container, measured on 9 October 2026.

  • A table notes (tenant_id, id, body), primary key (tenant_id, id), three tenants with five rows each, row-level security enabled. The application role app is not the owner and has SELECT, INSERT, UPDATE and DELETE on the table.
  • The tenant is a session variable. Every policy uses tenant_id = nullif(current_setting('app.tenant', true), '')::int, written below as own tenant.
  • Each write ran as app with the tenant set to 2, in its own transaction that was rolled back, so every write started from the same fifteen rows.

The setups:

Policies on notes
Aone policy for all commands: USING (own tenant)
BFOR SELECT and FOR UPDATE … USING … WITH CHECK, no policy for inserts
CFOR INSERT, FOR UPDATE and FOR DELETE on own tenant, no FOR SELECT
Dall four commands, each on own tenant
Esetup A plus a restrictive policy p_short: AS RESTRICTIVE FOR INSERT WITH CHECK (length(body) <= 5)
Fno policy that applies to app: either none at all, or setup A written TO reporting

Setups A to D and F got all eleven writes of the table further down. Setup E, and two more variants of it — a restrictive update policy p_lock and a restrictive select policy — got a few writes each, to see which message comes back.

The message has four forms

MessageWhen we got it
new row violates row-level security policy for table "notes"the new row passed no permissive policy for one of the commands the statement counts as (insert, update or select) — every cause in the next table
new row violates row-level security policy "p_short" for table "notes"the row passed the permissive policy and a restrictive policy refused it
new row violates row-level security policy (USING expression) for table "slugs"an ON CONFLICT DO UPDATE reached an existing row that no update policy lets you update
new row violates row-level security policy "p_lock" (USING expression) for table "notes"the same, where a restrictive update policy p_lock refused the existing row

A name in the message was a restrictive policy in every test we ran. It does not have to be a policy for inserts: with a restrictive select policy, a plain insert went in and the same insert with RETURNING id named that policy. In setup E, inserting an own row with a long body named p_short. Inserting a row for tenant 3 with a long body gave the plain form, without a name: a row that no permissive policy lets through is reported that way, whatever the restrictive policy would have said.

Decision tree: from the message to the cause

Which form of the message did you get?

  • A policy nameA restrictive policy refused the rowThe name in the message is that policy; read its WITH CHECK, or its USING if it has none. It can be a select policy: RETURNING and ON CONFLICT with a conflict target apply those to the new row too.
  • (USING expression), no nameAn upsert reached a row you may not updateIn our tests: a unique value that already belonged to another tenant, or an own row without any update policy.
  • Neither

    Does the same row go in with a plain INSERT, without RETURNING and without ON CONFLICT?

    • YesNo select policy lets the new row throughRETURNING, ON CONFLICT DO UPDATE and ON CONFLICT (…) DO NOTHING check the new row against the select policies too; a bare ON CONFLICT DO NOTHING did not.
    • No

      Just before the write, in the same transaction, does current_setting('app.tenant', true) return the tenant you expect?

      • NoThe tenant is not set where the write runsEmpty, or another tenant: the row's tenant_id does not match it.
      • YesNo policy for inserts applies to this role, or the row is for another tenantCheck pg_policies for the command and the role, then the row's tenant_id.

The tree covers the causes we measured, with a tenant in a session variable called app.tenant; use your own setting's name. A duplicate-key error on the plain INSERT counts as No. A policy that checks something else can fail for other reasons.

Which writes raise it?

Write, as tenant 2ABCDF
1. insert an own row1error11error
2. insert a row for tenant 3errorerrorerrorerrorerror
3. insert an own row RETURNING id1errorerror1error
4. insert a new own row ON CONFLICT … DO NOTHING1errorerror1error
5. the same for an own row that exists0errorerror0error
6. upsert (ON CONFLICT … DO UPDATE), new own row1errorerror1error
7. upsert, own row that exists1errorerror1error
8. MERGE, own row that exists (update)11duplicate key1error
9. MERGE, new own row (insert)1error11error
10. move an own row to tenant 3 with UPDATEerrorerror0error0
11. update an own row11010

Numbers are rows affected; error is the plain form of the message; duplicate key is duplicate key value violates unique constraint "notes_pkey". The numbers in italics are an UPDATE 0 without any error. Both variants of F gave the same column. Write 1 without a tenant set failed in setup A with the same message.

Setups A and D behave the same: they raise the error only for the two writes that should fail, 2 and 10. Every other error in the table is a missing policy.

Why does an upsert fail when the row already exists?

Because the insert policy is checked before PostgreSQL knows whether there is a conflict. In setup B the role may read and update its rows but has no policy for inserts. Write 7 would only ever update, and it still failed; so did write 5, which would have done nothing. The documentation says so: an INSERT with ON CONFLICT DO NOTHING/UPDATE “will check the INSERT policies’ WITH CHECK expressions for all rows proposed for insertion, regardless of whether or not they end up being inserted”.

MERGE is different. In setup B write 8 updated the row, because a MERGE whose row matches only runs its update action. The documentation: “No separate policy exists for MERGE. Instead, the policies defined for SELECT, INSERT, UPDATE, and DELETE are applied while executing MERGE, depending on the actions that are performed.” So if the role may update but not insert, a MERGE works where an upsert does not, as long as no source row reaches the insert action: with one new row among the matches, the whole MERGE failed with the same message. A MERGE with only WHEN MATCHED updated the matching row and skipped the new one.

Why does RETURNING or ON CONFLICT fail when a plain INSERT works?

Because RETURNING, and ON CONFLICT with a conflict target, make the statement read the row it writes, and reading goes through the select policies. Setup C has a policy for inserts and none for selects: write 1 inserted the row, and writes 3 to 7 all failed with the same message.

For RETURNING the documentation requires that “any newly inserted or updated rows from the relation must satisfy the relation’s SELECT policies in order to be available to the RETURNING clause”. For ON CONFLICT with a conflict target, “the rows proposed for insertion are checked using the relation’s SELECT policies”. The conflict target matters: in setup C a bare ON CONFLICT DO NOTHING, without (tenant_id, id), inserted the row. An ORM that adds RETURNING to its inserts to get the generated key back hits this: an insert that works in psql then fails from the application with this message.

MERGE without a select policy went wrong in another way. In setup C, write 8 could not see the existing row, so it treated it as new, tried to insert it, and stopped on the primary key: duplicate key value violates unique constraint "notes_pkey". That is a different error for the same missing policy.

What happens on a unique value that belongs to another tenant?

It depends on the statement, and one of the three answers is silence. We added a table slugs (slug primary key, tenant_id, body) with the same policy, where tenant 1 already has the slug alpha, and wrote that slug as tenant 2:

Statement, as tenant 2, for a slug tenant 1 hasResult
INSERTduplicate key value violates unique constraint "slugs_pkey"
INSERT … ON CONFLICT (slug) DO NOTHINGINSERT 0 0, no error
INSERT … ON CONFLICT (slug) DO UPDATE …new row violates row-level security policy (USING expression) for table "slugs"
MERGE … ON s.slug = v.slugduplicate key value violates unique constraint "slugs_pkey"

The upsert is the (USING expression) form: the existing row is not one this tenant may update, and unlike a standalone UPDATE, “an error will be thrown (the UPDATE path will never be silently avoided)”. DO NOTHING reports success with zero rows, so the application believes its row is there. All four tell tenant 2 that the slug is taken, and three of them that it is not its own — DO NOTHING returns the same zero for a slug the tenant already has; why a unique constraint on a tenant table should start with tenant_id is in unique per tenant in PostgreSQL.

What does not raise it?

Six things we expected to see this message for, and did not:

  • COPY FROM as the application role failed before looking at any row: COPY FROM not supported with row-level security, with the hint Use INSERT statements instead. The documentation on COPY says the same. A bulk import under row-level security is INSERT statements, or a role that bypasses the policy.
  • A missing GRANT gave permission denied for table notes. It has the same SQLSTATE, 42501, so code that only looks at the code cannot tell a missing grant from a refused row.
  • The table owner without FORCE ROW LEVEL SECURITY inserted a row for tenant 3 without any error. With FORCE the same insert raised the message. If your application connects as the owner, the absence of this error is the problem.
  • An UPDATE or DELETE that the policy filters reported UPDATE 0 or DELETE 0 — writes 10 and 11 in setups C and F. In C that zero comes from the WHERE clause, which needs a select policy: without a WHERE the same update changed all five own rows. Why that is silent is in USING vs WITH CHECK on writes.
  • A MERGE that matches a row it may see but not update raised a sibling message: target row violates row-level security policy (USING expression) for table "notes". A plain UPDATE on the same row reported UPDATE 0.
  • A BEFORE INSERT trigger that fills in the tenant changed the outcome: with a trigger that overwrites tenant_id from the setting, an insert that named tenant 3 went in as tenant 2. The documentation explains it: WITH CHECK is enforced after BEFORE triggers, so “a BEFORE ROW trigger may modify the data to be inserted, affecting the result of the security policy check”. With no tenant set, the trigger wrote NULL and the insert failed with the policy message, not with the NOT NULL error of the column.

How do you find the cause in your own database?

Three checks, run in the same transaction just before the write that fails — after the error the transaction is aborted and answers nothing — and as the role the application connects as:

select current_user, current_setting('app.tenant', true);

select policyname, cmd, permissive, roles, qual, with_check
from pg_policies
where tablename = 'notes';
  1. Is the tenant set here? An empty or missing setting makes the comparison NULL, and NULL is a refusal. Behind a connection pooler this is the first thing to check; what SET LOCAL does outside a transaction is in PgBouncer and RLS.
  2. Is there a policy for this command and this role? Look for cmd INSERT or ALL with your role, a role it is a member of, or {public}, in roles. No such row is setup F: every insert fails, whatever the row contains. For an upsert you need the insert policy even when the row exists.
  3. Does the statement read the row? With RETURNING, or ON CONFLICT with a conflict target, there must also be a policy with cmd SELECT or ALL that the new row passes.

If all three hold, compare the tenant_id in the row with the setting. The rest of the list — the owner, the plan, making silence an error — is in debugging row-level security in PostgreSQL.

What we did not measure

Versions other than PostgreSQL 17.11, MERGE with a DELETE action, security-barrier views, partitioned tables and foreign tables. Hosted platforms that put their own roles in front of the table add causes of their own; we tested plain PostgreSQL. Policies that look up a membership table bring an error of their own, measured in infinite recursion detected in policy: causes and fixes. The test used fifteen rows; which writes succeed follows from the policies, not from the size of the table.

Sources: the PostgreSQL 17 documentation on CREATE POLICY (policies per command, ON CONFLICT, RETURNING, MERGE, BEFORE triggers) and on COPY, read on 9 October 2026. All measurements are our own, on PostgreSQL 17.11, on 9 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