Per-row versus per-statement: how policy conditions really run
Developers arriving from application-layer authorisation tend to imagine policies running once per request, like middleware. Postgres does something quite different: the policy becomes part of the query itself, and its condition is evaluated against candidate rows. Understanding that single fact explains most policy performance behaviour — and most of the folklore about it.
This page walks the mechanism as a narrative you can reproduce with EXPLAIN on your own tables. RowShield does not automate plan analysis; this is a mental model, not a product check, and we quote no timings because none transfer between databases.
RowShield does not detect this yet. This guide gives you the catalog queries to check it yourself. The nine rules that do ship are listed on the rules index.
A policy becomes a query qual
When a query targets a table with Row Level Security enabled, the planner collects every policy applicable to the calling role and command, and folds their expressions into the query as qualification conditions — the same category of thing as your WHERE clause. Permissive policies combine disjunctively: a row survives if any applicable permissive policy admits it. RESTRICTIVE policies combine conjunctively on top, so a row must additionally satisfy each one.
From that point the condition is ordinary predicate machinery. It is considered per candidate row, it can be pushed into index scans when it is sargable, and it appears in the executed plan exactly where any filter would. FORCE ROW LEVEL SECURITY extends the same treatment to the table owner, which is why migrations begin hitting policies the moment it is set.
Per row versus per statement
The per-row model has a sharp edge: anything expensive inside the policy expression repeats per candidate row. A bare auth.uid() call is the canonical case — the same value recomputed for every row considered, because a plain function reference belongs to the qual and Postgres does not rewrite function calls into one-time computations. A policy filtering an unindexed column is the sibling case: the condition is cheap, but reaching the candidates it filters is not.
Scalar subqueries behave differently, and deliberately so. Write (SELECT auth.uid()) and the planner classifies it as an uncorrelated subquery whose value cannot vary across rows, hoists it out of the per-row path entirely, and evaluates it once before execution begins — an InitPlan, referenced by the row-level conditions through a parameter. Same decisions, radically different evaluation count. The distinction is visible in the plan itself, which is the next section.
Reading a plan without inventing numbers
Run the statement under EXPLAIN with ANALYZE and BUFFERS on a staging table seeded to realistic volume, and read it in this order. First the scan nodes: an index scan feeding a small limit is healthy; a sequential scan under a policy-heavy table is where per-row costs accumulate. Second, the filter lines: policy expressions show up among the scan's conditions — VERBOSE spells out the exact expressions — and the removed-by-filter counter tells you how many candidate rows were considered and rejected, which is the multiplier your per-row costs pay. Third, look for the InitPlan line at the top of the plan: its presence confirms the wrapped call ran once, and its absence on a policy you believed was wrapped is a finding against the deployment, not the theory.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT id, title FROM public.documents WHERE owner_id = (SELECT auth.uid()) ORDER BY updated_at DESC LIMIT 20;
What the model implies for design
Everything actionable falls out of the mechanism. Put indexes under the columns policies filter, because per-row evaluation of a predicate over unindexed candidates is the expensive kind. Wrap constant-valued calls in scalar subqueries, because per-statement beats per-row wherever the pattern holds. Keep expressions cheap — comparisons over stored values beat function-wrapped recomputation — and prefer several narrow policies over OR-laden ones, since disjunctions must be tested per row until one admits. None of this is exotic; it is ordinary query-planning literacy applied to the conditions you did not write but ship anyway.
RowShield does not automate any of this analysis — there is no plan reader in the product. What it does continuously is the mechanical substrate the model rests on: UNINDEXED_RLS_PREDICATE flags predicate columns lacking indexes, RLS_UNWRAPPED_AUTH_CALL flags bare auth calls, both on every scheduled scan with generated fixes. Reads are pg_catalog-only, and RowShield is an independent product, not affiliated with or endorsed by Supabase. Run a free audit at rowshield.dev/audit — the public check needs no account.
Frequently asked
- Are RLS policies evaluated for INSERT and UPDATE too?
- Yes. INSERT and UPDATE policies carry a WITH CHECK condition evaluated per proposed row, alongside any USING condition evaluated per existing row touched. The per-row model applies to writes exactly as it does to reads.
- Is per-row evaluation inherently slow?
- No — it is the normal cost of a filter. With an indexed predicate column and a wrapped auth call, per-row work is an index descent per query, which is precisely how well-tuned ordinary queries behave.
- Can I see policy expressions in EXPLAIN output?
- Yes. Add VERBOSE to see the exact qualification expressions attached to each scan node, where policy conditions sit alongside any WHERE-clause conditions from the query itself.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit