Ask
263
@supa_okonkwo ·

enabling rls took a 40ms select to 6.2s on a 2.1m row table Performance

One policy, the obvious one:

create policy docs_own on documents
  for select using (user_id = auth.uid());

There is a btree index on user_id. Before enabling RLS the query planner used it and the endpoint was 40ms. After enabling, explain analyze shows a sequential scan over all 2,146,880 rows and 6.2 seconds, with "Rows Removed by Filter: 2146868".

The index still exists. Why does the same query stop using it just because the predicate came from a policy?

8 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @wren_oyelaran · 2d ago · 3 replies

    Wrap the function call in a subselect:

    using ( user_id = (select auth.uid()) )
    

    That is the whole fix and it is worth understanding why. Written bare, the call sits in the row filter and gets evaluated per row - two million calls, each one parsing JWT claims out of a setting. Wrapped in a scalar subquery, the planner hoists it into an InitPlan, evaluates it once, and now has a constant it can compare against the index.

    While you are in there, add the role to the policy:

    create policy docs_own on documents
      for select to authenticated
      using ( user_id = (select auth.uid()) );
    

    Without to authenticated the expression is also evaluated for anonymous requests that could never match anything.

    52
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @supa_okonkwo · 2d ago

      41ms. Two parentheses. I have been staring at index definitions for two days.

      15
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @arrayformula_al · 3h ago

      This is the single highest-value thing to grep your whole policy set for. I ran it across a schema with 60-odd policies and found 40 of them with bare function calls; the ones on small tables were fine and the ones on the two big tables were the only reason we were talking about upgrading the instance.

      11
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @clickertrainbee · 3h ago

    The same trick applies to anything expensive in a policy, not just auth.uid(). If your policy joins a memberships table, put the whole lookup in a security definer function and call it inside a subselect so it runs once. A policy that joins on every row is a nested loop over your tenant table that nobody ever profiles because it never appears in application code.

    Rule of thumb I use: a policy predicate should compare a column against a constant. Anything more than that and it should be behind a function you can benchmark on its own.

    33
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @costbasis_carl · 2d ago

    After the subselect fix, check the sort. A list endpoint that orders by created_at desc limit 20 wants (user_id, created_at desc) as one index, not two separate ones. That took my equivalent query from 41ms to 3ms because it stopped sorting 8,000 rows to show 20.

    19
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @lowtech_lior · yesterday

    Related habit worth building: keep the filter in your query too. RLS is a backstop, not a query plan. select * from documents with a policy is asking the database to look at every row and then throw most away; select * from documents where user_id = $1 with the same policy asks it to look at twelve. Same result, wildly different work, and if the policy is ever wrong you still return the right rows.

    24
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @keg_line_ken · 2d ago · 2 replies

    Honestly at 2m rows I would turn RLS off and do the filtering in the API layer. You control the queries anyway and you get the plan you expect every time.

    10
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @wren_oyelaran · 3h ago

      You control the queries you remember to write. RLS exists for the one written at 6pm on a Friday by someone adding an admin export. The performance problem here has a two-character fix; "remove the safety net" is a strange trade for that.

      7
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report