RowShield
Guides

Wrapping auth.uid(): the InitPlan pattern, explained honestly

Almost every Supabase policy calls auth.uid(), and almost none of them wrap the call. Written bare, the function is evaluated once for every candidate row the planner considers; wrapped as (SELECT auth.uid()), the planner lifts it into an InitPlan and evaluates it once per statement. The results are identical. The work is not.

This page explains the mechanics, states honestly how large the gain tends to be and where the pattern does and does not hold, and gives you the queries to find and fix your own policies.

Rules that check this

What Postgres does with a bare call

auth.uid() is a regular SQL function: it reads the current request's claims and returns the user's identifier. Because it is stable rather than immutable, Postgres will not treat it as a constant at planning time — and crucially, Postgres never rewrites a bare function call into a one-time evaluation. A function reference folded into a policy qual belongs to that qual, and the qual is evaluated per candidate row. On a table where a query considers a hundred thousand rows, a bare auth.uid() in the policy runs up to a hundred thousand times to produce the same value each time.

Only a subquery receives the special treatment. Write (SELECT auth.uid()) and the planner recognises an uncorrelated scalar subquery — one that cannot change across rows — and hoists it into an InitPlan: a node evaluated once, before the main plan executes, whose result the per-row conditions reference as a parameter. Once per statement instead of once per row, with byte-for-byte identical access decisions.

How large is the gain, honestly

This is the most commonly recommended policy-level optimisation in the Supabase ecosystem, and the improvement practitioners routinely observe on large tables falls between ten times and a hundred times. Treat that range as the characteristic shape of the pattern, not a promise: the exact figure depends on the row count the query considers, whether the predicate column is indexed, and the shape of the surrounding query. It is not a RowShield measurement, and we publish none — on your own tables, the plan comparison in the next section is the honest arbiter.

Equally honest is the small-print direction. On a table of a few thousand rows the absolute saving is milliseconds nobody notices. Where an EXISTS subquery correlates the call to outer columns, wrapping the inner call still helps but the correlation keeps per-row work alive. And a policy whose expensive part is an unindexed column gains little from this change alone — the two fixes compound, they do not substitute.

Find your bare calls

pg_policies renders every policy expression as text, which makes a coarse sweep easy. It will also match the already-wrapped form, so read the output rather than counting rows blindly:

SELECT schemaname, tablename, policyname, qual, with_check
FROM pg_policies
WHERE qual LIKE '%auth.uid()%'
   OR with_check LIKE '%auth.uid()%';

The fix is an ALTER POLICY

Rewrite the policy with the call wrapped. No data changes, no grants change, no downtime — the access decisions come out identical, only the evaluation strategy differs:

ALTER POLICY "owners_read_own_projects"
  ON public.projects
  FOR SELECT
  TO authenticated
  USING ((SELECT auth.uid()) = owner_id);

Keeping it fixed

New policies arrive unwrapped constantly — generated migrations, copied documentation examples, and ORM helpers all tend toward the bare form. RowShield's RLS_UNWRAPPED_AUTH_CALL rule parses every developer policy's deparsed expression and flags bare calls, including calls hiding inside correlated EXISTS subqueries where the instinct to look would rarely strike. Each finding carries an ALTER POLICY generated from your actual expression, and the rule re-checks on every scheduled scan so the regression is visible as a regression.

RowShield reads pg_catalog metadata only and never touches your rows, and it is an independent product, not affiliated with or endorsed by Supabase. Paste your project URL at rowshield.dev/audit for the free public check, or connect the project to put both performance rules on a schedule.

Frequently asked

Does wrapping auth.uid() change what my policies allow?
No. The function returns the same value either way, so every row is admitted or denied exactly as before. Only the number of evaluations changes — once per statement instead of once per candidate row.
If auth.uid() is stable, why does the planner not hoist it?
Stability lets Postgres assume the value cannot change mid-statement, but the planner only converts subqueries into InitPlans — it never rewrites a bare function call into one. Writing the scalar subquery yourself is what opts the value into one-time evaluation.
Does the same trick apply to auth.jwt()?
Yes. Any per-call helper whose result is constant for the statement benefits identically from the scalar-subquery wrap, and the same reasoning holds: identical semantics, one evaluation instead of many.

Check your project in about ten seconds

Paste a URL. No signup, no writes, nothing stored.

Run the free audit
auth.uid() initplanwrap auth.uid() supabaseselect auth.uid() policysupabase rls policy performance