Files
supabase/apps/docs/content/guides/database/postgres/row-level-security-performance.mdx
Miranda Limonczenko 6368f00ca0 docs(database): restructure the RLS guide by information type (#49017)
## Problem

The guide alternated between context, procedure, and reference on almost
every heading. A reader who wanted to write a policy passed through four
context or reference sections to reach one. A reader who wanted the
model had to skip three procedures.

## Solution

- Group into three sections by information type: `Understand Row Level
Security`, `Secure a table with RLS`, and `RLS reference`, with a
navigation intro.
- Merge the four policy sections. They repeated the same setup block,
burying the clause that differed. One setup block now precedes four
short policy examples.
- Move the auto-enable recipe into `event-triggers.mdx`, whose stub
section's entire body was a link back here.
- Relocate the stranded `auth.uid()` caution into the `auth.uid()`
reference.
- Lift the revoke-and-grant procedure out of the danger admonition and
merge it with the two other places that taught `enable row level
security`.
- Point the Grafana IO chart entry at the performance guide. Its
`#rls-performance-recommendations` anchor went away when tuning split
out in #49016.

765 lines to 582. 30 headings to 25.

Headings are demoted rather than renamed wherever anything links to
them. Every inbound anchor in the repo still resolves; the only one
removed, `#auto-enable-rls-for-new-tables`, was referenced solely by the
`event-triggers.mdx` stub this PR replaces.

## Note on the history

Rebuilt from `master` after #49011, #49015, and #49016 merged. The
branch previously carried those 10 commits plus rebase churn against
them.

Rebasing naively would have reverted review feedback from #49016
(`70fa812`), which removed the benchmarks table and the "This guide"
opener from the performance guide. Those are deliberately not restored
here. The only changes to that file are two missing `await`s and a join
predicate that was a tautology while unqualified.

The three PRs stacked on this one (#49268, #49269, #49270) have been
rebased onto the new base.

## Manual testing

1. Open the [Row Level Security
guide](https://docs-git-docs-rls-restructure-supabase.vercel.app/docs/guides/database/postgres/row-level-security)
on the preview. Three top-level sections appear in the table of
contents.
2. Select each link in the intro. All three jump to their section.
3. Open [Event
triggers](https://docs-git-docs-rls-restructure-supabase.vercel.app/docs/guides/database/postgres/event-triggers).
The auto-enable section holds the full recipe instead of a link.
4. Open the [performance
guide](https://docs-git-docs-rls-restructure-supabase.vercel.app/docs/guides/database/postgres/row-level-security-performance).
No benchmarks table, and the three bullets at the top link into the RLS
guide.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Reworked the Row Level Security guide with clearer guidance on grants,
policies, permissions, performance, testing, views, and secure
functions.
* Added a complete example for automatically enabling RLS on newly
created public tables.
* Improved SQL examples and clarified table references in RLS
performance guidance.
* Corrected grammar in the Grafana chart troubleshooting documentation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:51:06 -07:00

156 lines
5.3 KiB
Plaintext

---
id: 'row-level-security-performance'
title: 'Row Level Security performance'
description: 'Measure and tune Postgres Row Level Security policies.'
subtitle: 'Measure and tune Postgres Row Level Security policies.'
---
Measure the cost of Row Level Security (RLS) and tune policies that are already correct. To learn how to write correct policies, see [Row Level Security](/docs/guides/database/postgres/row-level-security).
Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. This matters most for queries that scan every row in a table, like many `select` operations, including those using limit, offset, and ordering.
Three policy rules affect performance enough that they belong with the policy itself rather than here. Apply them first:
- [Index the columns your policies filter on](/docs/guides/database/postgres/row-level-security#add-indexes)
- [Call functions with `select`](/docs/guides/database/postgres/row-level-security#call-functions-with-select)
- [Specify roles in your policies](/docs/guides/database/postgres/row-level-security#specify-roles-in-your-policies)
## Diagnose whether RLS is the bottleneck
Confirm that policies are the cost before you rewrite one. Run the query with RLS enabled, then again with it disabled, and compare. If the times are similar, the query itself is the problem.
<Admonition type="caution">
Disabling RLS exposes every row in the table to any role with a matching grant. Only do this in a non-production environment.
</Admonition>
To reproduce an API request, set the JWT claims and switch to the role the request runs as:
```sql
set session role authenticated;
set request.jwt.claims to '{"role":"authenticated", "sub":"5950b438-b07c-4012-8190-6ce79e4bd8e5"}';
explain analyze select count(*) from rlstest;
set session role postgres;
```
The output shows the policy expression as a filter, and the execution time is the number to compare:
```
Seq Scan on rlstest (cost=0.00..4334.00 rows=1 width=35) (actual time=170.999..170.999 rows=0 loops=1)
Filter: ((COALESCE(NULLIF(current_setting('request.jwt.claim.sub'::text, true), ''::text), ((NULLIF(current_setting('request.jwt.claims'::text, true), ''::text))::jsonb ->> 'sub'::text)))::uuid = user_id)
Rows Removed by Filter: 100000
Planning Time: 0.216 ms
Execution Time: 171.033 ms
```
`Rows Removed by Filter` is the signal to watch. A policy that removes most of the table on every read is a policy whose filter column needs an index.
### Measure through the Data API
PostgREST can return the query plan to a Supabase client. Enable it first:
```sql
alter role authenticator set pgrst.db_plan_enabled to true;
notify pgrst, 'reload config';
```
<Admonition type="caution">
`pgrst.db_plan_enabled` exposes query plans over your Data API. Don't enable it in production.
</Admonition>
Then add the `.explain()` modifier to a query:
```js
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('id', 1)
.explain({ analyze: true })
console.log(data)
```
```
Aggregate (cost=8.18..8.20 rows=1 width=112) (actual time=0.017..0.018 rows=1 loops=1)
-> Index Scan using projects_pkey on projects (cost=0.15..8.17 rows=1 width=40) (actual time=0.012..0.012 rows=0 loops=1)
Index Cond: (id = 1)
Filter: false
Rows Removed by Filter: 1
Planning Time: 0.092 ms
Execution Time: 0.046 ms
```
## Filter in the client query too
Policies are implicit `where` clauses, so it's common to run `select` statements without any filters. That's a bad pattern for performance. Instead of this:
{/* prettier-ignore */}
```js
const { data } = await supabase
.from('table')
.select()
```
Always add a filter:
{/* prettier-ignore */}
```js
const { data } = await supabase
.from('table')
.select()
.eq('user_id', userId)
```
Even though this duplicates the contents of the policy, Postgres can use the filter to construct a better query plan.
## Avoid joins in policy expressions
You can often rewrite a policy to avoid a join between the source and the target table. Fetch the relevant data from the target table into an array or set instead, then use an `in` or `any` operation in your filter.
This policy joins the source `test_table` to the target `team_user`:
```sql
create policy "rls_test_select" on test_table
to authenticated
using (
(select auth.uid()) in (
select user_id
from team_user
where team_user.team_id = test_table.team_id -- joins to the source table
)
);
```
Rewriting it selects the filter criteria into a set instead:
```sql
create policy "rls_test_select" on test_table
to authenticated
using (
team_id in (
select team_id
from team_user
where user_id = (select auth.uid()) -- no join
)
);
```
You can also use a [security definer function](/docs/guides/database/postgres/row-level-security#use-security-definer-functions) to bypass RLS on the join table.
<Admonition type="note">
If the list exceeds 1000 items, a different approach may be needed, or you may need to analyze the approach to ensure that the performance is acceptable.
</Admonition>
## More resources
- [Row Level Security](/docs/guides/database/postgres/row-level-security)
- [Managing indexes in Postgres](/docs/guides/database/postgres/indexes)
- [Query optimization](/docs/guides/database/query-optimization)