Performance and Indexing
The idea in one sentence
Section titled “The idea in one sentence”Index the columns your queries filter and join on, and in Supabase this matters doubly for any column referenced by a Row Level Security policy, because that policy condition runs on every row candidate for every single query against the table — an unindexed policy column can quietly turn a simple-looking query into a sequential scan as the table grows.
Why RLS-policy columns are a special indexing target
Section titled “Why RLS-policy columns are a special indexing target”A Row Level Security policy is not something that runs once per query — Postgres evaluates its condition against every row that could possibly be returned, on every query, for every user. A policy like auth.uid() = user_id looks like an innocuous one-line check, but if user_id has no index, Postgres has no way to jump straight to the matching rows. It has to walk the whole table, evaluating the policy condition row by row.
The dangerous part is that this stays invisible for a long time. On a table with a few hundred rows, a sequential scan and an index scan both feel instant, and the query “looks” simple because it is a single select with no obvious complexity. The cost only shows up once the table grows into the tens or hundreds of thousands of rows, by which point it is a production incident rather than a code review comment.
-- A todos table, RLS-protected so users can only see their own rowscreate table public.todos ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users (id), title text not null, done boolean not null default false);
alter table public.todos enable row level security;
create policy "Users can view their own todos"on public.todos for selectusing (auth.uid() = user_id);
-- Without this index, the policy above forces a sequential scan on every selectcreate index idx_todos_user_id on public.todos (user_id);Before the index, every select * from todos a user runs makes Postgres scan every row in the table and check auth.uid() = user_id against each one. After the index, Postgres can jump straight to the rows belonging to that user, and the query cost stops growing with the size of the whole table and starts scaling with the size of that one user’s data instead — which is the shape almost every RLS-protected query actually has.
Reading explain analyze before something is already slow
Section titled “Reading explain analyze before something is already slow”explain analyze is the direct way to see whether Postgres is actually using an index for a given query, rather than guessing from how fast it feels locally on a small table.
explain analyzeselect * from public.todos where user_id = auth.uid();The output’s top line names the plan Postgres chose. Seq Scan on todos means it read every row; Index Scan using idx_todos_user_id on todos means it used the index. ANALYZE also runs the query for real and reports actual execution time and row counts, not just Postgres’s estimate, so it is worth checking against a select rather than only insert, update, or delete where re-running it has side effects. Making a habit of running this against any new RLS-protected query — before it ships, not after it is slow in production — catches missing indexes while they are still a one-line fix.
What Supabase Studio surfaces without manual explain analyze runs
Section titled “What Supabase Studio surfaces without manual explain analyze runs”Supabase Studio ships with built-in Query Performance and Advisor tooling that surfaces a lot of this automatically, without reaching for explain analyze by hand for routine checks. Query Performance in the dashboard lists your slowest and most frequently run queries directly from Postgres’s own statistics, and the Advisors panel flags missing indexes and other common performance issues — including, specifically, unindexed columns referenced by RLS policies. It is not a replacement for understanding explain analyze, but it is the tool worth checking first for an ongoing, low-effort read on where a project’s real bottlenecks are.
flowchart LR q1["select * from todos where user_id = auth.uid()"] --> rls1["RLS policy checks user_id on every row"] rls1 --> seq["No index on user_id"] seq --> scan["Sequential scan - cost grows with table size"] q2["Same query, after create index idx_todos_user_id"] --> rls2["RLS policy checks user_id on every row"] rls2 --> idx["Index on user_id"] idx --> iscan["Index scan - cost grows with matching rows only"]