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.
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 roleappis not the owner and hasSELECT,INSERT,UPDATEandDELETEon 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
appwith 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 | |
|---|---|
| A | one policy for all commands: USING (own tenant) |
| B | FOR SELECT and FOR UPDATE … USING … WITH CHECK, no policy for inserts |
| C | FOR INSERT, FOR UPDATE and FOR DELETE on own tenant, no FOR SELECT |
| D | all four commands, each on own tenant |
| E | setup A plus a restrictive policy p_short: AS RESTRICTIVE FOR INSERT WITH CHECK (length(body) <= 5) |
| F | no 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
| Message | When 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.
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 itsUSINGif it has none. It can be a select policy:RETURNINGandON CONFLICTwith 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, withoutRETURNINGand withoutON CONFLICT?- YesNo select policy lets the new row through
RETURNING,ON CONFLICT DO UPDATEandON CONFLICT (…) DO NOTHINGcheck the new row against the select policies too; a bareON CONFLICT DO NOTHINGdid 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_iddoes not match it. - YesNo policy for inserts applies to this role, or the row is for another tenantCheck
pg_policiesfor the command and the role, then the row'stenant_id.
- NoThe tenant is not set where the write runsEmpty, or another tenant: the row's
- YesNo select policy lets the new row through
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 2 | A | B | C | D | F |
|---|---|---|---|---|---|
| 1. insert an own row | 1 | error | 1 | 1 | error |
| 2. insert a row for tenant 3 | error | error | error | error | error |
3. insert an own row RETURNING id | 1 | error | error | 1 | error |
4. insert a new own row ON CONFLICT … DO NOTHING | 1 | error | error | 1 | error |
| 5. the same for an own row that exists | 0 | error | error | 0 | error |
6. upsert (ON CONFLICT … DO UPDATE), new own row | 1 | error | error | 1 | error |
| 7. upsert, own row that exists | 1 | error | error | 1 | error |
8. MERGE, own row that exists (update) | 1 | 1 | duplicate key | 1 | error |
9. MERGE, new own row (insert) | 1 | error | 1 | 1 | error |
10. move an own row to tenant 3 with UPDATE | error | error | 0 | error | 0 |
| 11. update an own row | 1 | 1 | 0 | 1 | 0 |
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 has | Result |
|---|---|
INSERT | duplicate key value violates unique constraint "slugs_pkey" |
INSERT … ON CONFLICT (slug) DO NOTHING | INSERT 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.slug | duplicate 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 FROMas the application role failed before looking at any row:COPY FROM not supported with row-level security, with the hintUse INSERT statements instead. The documentation onCOPYsays the same. A bulk import under row-level security isINSERTstatements, or a role that bypasses the policy.- A missing
GRANTgavepermission 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 SECURITYinserted a row for tenant 3 without any error. WithFORCEthe same insert raised the message. If your application connects as the owner, the absence of this error is the problem. - An
UPDATEorDELETEthat the policy filters reportedUPDATE 0orDELETE 0— writes 10 and 11 in setups C and F. In C that zero comes from theWHEREclause, which needs a select policy: without aWHEREthe same update changed all five own rows. Why that is silent is in USING vs WITH CHECK on writes. - A
MERGEthat 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 plainUPDATEon the same row reportedUPDATE 0. - A
BEFORE INSERTtrigger that fills in the tenant changed the outcome: with a trigger that overwritestenant_idfrom the setting, an insert that named tenant 3 went in as tenant 2. The documentation explains it:WITH CHECKis enforced afterBEFOREtriggers, 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 wroteNULLand the insert failed with the policy message, not with theNOT NULLerror 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';
- Is the tenant set here? An empty or missing setting makes the comparison
NULL, andNULLis a refusal. Behind a connection pooler this is the first thing to check; whatSET LOCALdoes outside a transaction is in PgBouncer and RLS. - Is there a policy for this command and this role? Look for
cmdINSERTorALLwith your role, a role it is a member of, or{public}, inroles. 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. - Does the statement read the row? With
RETURNING, orON CONFLICTwith a conflict target, there must also be a policy withcmdSELECTorALLthat 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.