row_security = off does not turn row-level security off. Nothing a plain session can set could turn row-level security off, for the obvious reason, and row_security is a plain-session setting: boolean, default on, context user, settable by any role in any session, and per role or per database with ALTER ROLE ... SET or ALTER DATABASE ... SET. What off changes is what happens at the moment a policy is about to be applied to your query. With on, the policy is applied and you get the rows it allows. With off, you get an error (SQLSTATE 42501, query would be affected by row-level security policy for table "docs") and no rows at all. The manual’s phrasing is exact: the parameter decides whether to “raise an error in lieu of applying a row security policy”. It never decides whether the policy applies. That is settled elsewhere, and the real off switch for row-level security is a role attribute, BYPASSRLS. row_security is the alarm that tells you whether you have it.

The decision it hangs off is made once per table reference, in the rewriter, before planning. Superusers and BYPASSRLS roles never have policies applied. The table’s owner doesn’t either, unless the table has been marked FORCE ROW LEVEL SECURITY. Everyone else does. For the roles that bypass, row_security is inert: the code returns before it looks at the setting, row_security_active() gives the same answer with it on or off, and for superusers and BYPASSRLS roles the manual says as much. For everyone else, off turns this:

1rlsdemo=> select * from docs order by id;
2 id | tenant | body
3----+----------+------
4 1 | tenant_a | a-1
5 2 | tenant_a | a-2
6(2 rows)

into this:

1rlsdemo=> set row_security = off;
2SET
3rlsdemo=> select * from docs order by id;
4ERROR: query would be affected by row-level security policy for table "docs"

Because the check sits in the rewriter, every query that touches the table trips it. EXPLAIN fails. SELECT 1 FROM docs WHERE false fails. COPY docs TO STDOUT, which never reaches the rewriter, makes the same check on its own and fails too (under on, a COPY of a policy-protected table is quietly turned into COPY (SELECT ...), which is how the policy gets applied to it at all). A query through a view is checked as the view’s owner, so a view owned by the table’s owner passes everything through and off says nothing, because nothing was filtered; a view owned by anyone else fails, and the error names the base table rather than the view, which may be a table the querying role has never heard of. The plan cache records the setting alongside the rewritten query, so a prepared statement that worked under on fails on its next EXECUTE after SET row_security = off, works again when you set it back, and the same holds for a plan cached inside a PL/pgSQL function. No cached plan is reused under off. The one thing that keeps going is a cursor you had already opened, which carries its plan with it and goes on returning filtered rows until it is closed.

If you have ever opened a plain-text dump file, you have met this parameter. The block of SET statements at the top of every one written by a 9.5 or later pg_dump includes SET row_security = off;, and pg_dump sends the same statement on its own connection during setup, just before it sets restrict_nonsystem_relation_kind and opens its transaction. This is the parameter’s reason for existing. The 2014 commit message says so: pg_dump sets it so that a backup “will have all data in the table or will error”. A backup with one tenant’s rows silently missing is the worst outcome a backup can have, because nobody finds out until the restore, and the restore is already not a good day. [PLACEHOLDER: credential aside; e.g. a logical backup found to be missing a tenant only when it was needed. Confirm, replace, or cut.]

So when the dumping role is subject to a policy, pg_dump stops:

1pg_dump: error: query failed: ERROR: query would be affected by row-level security policy for table "job"
2pg_dump: detail: Query was: COPY cron.job (jobid, schedule, command, nodename, nodeport, database, username, active, jobname) TO stdout;

That one is from pg_cron, whose cron.job table has carried ENABLE ROW LEVEL SECURITY and a username = current_user policy since its first release in 2016 (the table only started appearing in dumps with 1.2, in 2019, which is when the error started appearing too), and it is how the error tends to arrive on a managed service, where the administrative role you are handed is not a superuser and does not have BYPASSRLS. (Microsoft’s Q&A forum has it verbatim for Azure Database for PostgreSQL; Supabase has a knowledge-base article on the error.) The fixes, in order: dump as a role that bypasses, meaning a superuser or a role someone has given BYPASSRLS (only a superuser could grant that before 16; since 16 a CREATEROLE role that has BYPASSRLS itself can too); or, if the rows were never yours to back up, leave them out. For pg_cron that means the whole schema, -N cron, because cron.job_run_details has carried the same policy since 1.3 and -T cron.job on its own just moves the error one table over.

The fix that is not a fix is --enable-row-security. It makes the error go away by sending SET row_security = on instead, so pg_dump writes out the rows your role is allowed to see and omits the rest. In a plain-text dump the only evidence is a SET row_security = on; line in the header where off usually sits; a custom or directory archive records nothing at all, and pg_restore will cheerfully emit SET row_security = off; ahead of the partial data. In the cron.job case above that produced a dump with an empty cron.job and an exit status of zero. The option exists for the one job it is good at: exporting a single tenant’s slice of a shared table, on purpose, as that tenant. If you find yourself adding it to a backup command to make an error stop, you have just requested an incomplete backup in writing.

pg_restore sends SET row_security = off as well, which matters less than it sounds. A full restore loads a table’s data before its ENABLE ROW LEVEL SECURITY and CREATE POLICY statements, which pg_dump puts in the post-data section. It bites on a data-only restore into a table that already has policies, by a role subject to them. pg_restore --enable-row-security is the matching escape, and it only works on a dump taken with --inserts, because COPY FROM refuses a policy-protected table outright (COPY FROM not supported with row-level security) rather than filtering it.

The parameter carries a few scars from how row-level security was argued into existence. The design page on the wiki proposed three values, on, off and error, with off as a superuser-only true disable. The commit that landed in September 2014 shipped on, off and force, where force applied policies even to table owners and superusers, and off already meant what it means today. Noah Misch removed force a year later, on the argument that a query against a policy-protected table had two possible meanings and every function author would have had to defend against both; two weeks after that Stephen Frost added ALTER TABLE ... FORCE ROW LEVEL SECURITY, putting the decision in the hands of the table’s owner, where it belongs. Both 2015 changes were back-patched into the 9.5 branch before 9.5.0 shipped in January 2016, so no production release ever had a force (the 9.5 alphas did), and off absorbed the job the wiki had called error. What remains of the argument is the hint you get as the owner of a FORCE table with row_security = off: “To disable the policy for the table’s owner, use ALTER TABLE NO FORCE ROW LEVEL SECURITY.”

Leave it on for application sessions; there is no other sensible value there. For anything whose job is every row (an export that isn’t pg_dump, a migration that copies a table to a new home), start the session with SET row_security = off, exactly as pg_dump does, and run it as a role that bypasses, so the setting never fires. The day it does fire is the day someone added a policy to a table your job reads, and an error in the job’s log is a far better way to learn that than an empty table in a restore.