Speeding up slow queries with indexes
Find slow queries, read EXPLAIN ANALYZE, and add the right B-tree, composite or partial index without locking your table. Worked example with a bookings app on SupaHost.
What you see
A query or supabase-js call that used to be fast now takes hundreds of milliseconds or more, and gets slower as the table grows. Sometimes it ends with canceling statement due to statement timeout.
Why it happens
Without a suitable index, Postgres answers a filter by reading every row (a sequential scan). That's fine for a few thousand rows and slow for a few million. An index is a sorted structure over one or more columns that lets Postgres jump straight to the matching rows, and often return them already sorted.
How to fix it
Find the slow query. If you know it from your app, translate the supabase-js call into SQL. For example:
const { data } = await supabase .from('bookings') .select('id, starts_at, guest_name') .eq('venue_id', 42) .gte('starts_at', new Date().toISOString()) .order('starts_at') .limit(50)is roughly:
select id, starts_at, guest_name from public.bookings where venue_id = 42 and starts_at >= now() order by starts_at limit 50;To find slow queries you don't know about, use
pg_stat_statementsas described in High CPU usage on your instance.Look at the plan:
explain analyze select id, starts_at, guest_name from public.bookings where venue_id = 42 and starts_at >= now() order by starts_at limit 50;Warning signs:
Seq Scan on bookingswith a largeRows Removed by Filter, or aSortstep over many rows. Compare theExecution Timebefore and after each change.Add an index that matches the query. Put equality filters first and the range or sort column last:
create index concurrently if not exists bookings_venue_starts_idx on public.bookings (venue_id, starts_at);concurrentlybuilds the index without blocking writes. It can't run inside a transaction, so run it as its own statement.Run
explain analyzeagain. You should seeIndex Scan using bookings_venue_starts_idxand no separate sort. If the planner still picks a sequential scan, refresh its statistics withanalyze public.bookings;.
Indexes worth checking
Columns used by RLS policies. A policy like
using ((select auth.uid()) = user_id)runs on every query, souser_idneeds an index:create index concurrently if not exists bookings_user_id_idx on public.bookings (user_id);Foreign key columns. Postgres doesn't index them automatically, and joins and cascading deletes need them.
Partial indexes for queries that always filter the same way, which keeps the index small:
create index concurrently if not exists bookings_pending_idx on public.bookings (starts_at) where status = 'pending';Text search and JSONB need GIN indexes rather than B-tree. See Full-text search and JSON and JSONB columns.
Don't over-index
Every index slows down writes and takes disk space that counts toward your database quota. Find indexes that are never used:
select relname as table_name, indexrelname as index_name, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) as size
from pg_stat_user_indexes
where idx_scan = 0
order by pg_relation_size(indexrelid) desc;
Drop them with drop index concurrently if they're not backing a primary key or unique constraint.
On SupaHost
- Run these in Studio's SQL editor (Dashboard → your project → Database tab → Open Studio). MCP
apply_migrationwraps each migration in a transaction, socreate index concurrentlyfails there. Run concurrent builds as a single statement in Studio, or with psql through the SSH tunnel on Business, and keep plaincreate indexin migrations for new, empty tables. - All projects share the instance's CPU, so fixing one project's slow query can speed up the others too.