Supabase Query Optimization: From Slow Query to Root Cause
How to find and fix slow Supabase queries using EXPLAIN ANALYZE, pg_stat_statements, indexes, and RLS-aware query patterns, a step-by-step guide.

Introduction
A Supabase query is fast in development and slow in production. You add an index, the query gets a little better, and then it slows down again three months later when the table grows. You have no idea which query is causing the problem because there are hundreds of them and no visibility into what is actually running on the database.
This is the standard arc for query performance issues on Supabase. The fixes are straightforward once you know where to look, but most developers skip the diagnostic step and go straight to guessing. This guide shows you how to find slow queries using the tools Supabase exposes, read execution plans, and fix the root cause, not just the symptom.
Quick Answer
TL;DR: Enablepg_stat_statementsto find your slowest queries, runEXPLAIN (ANALYZE, BUFFERS)to see the execution plan, and look for Seq Scan on large tables as the first thing to fix. Add indexes withCREATE INDEX CONCURRENTLY, keep RLS policies index-friendly by filtering on indexed columns, and move heavy aggregations into Postgres functions. Most Supabase query problems come from three things: missing indexes, RLS policies that force full table scans, and N+1 queries from the client.
What Is Supabase Query Optimization?
Query optimization in Supabase means making your PostgreSQL queries run faster by changing how they access data. Since Supabase runs a real PostgreSQL database under the hood, every standard Postgres optimization technique applies,ssation but with a few Supabase-specific wrinkles around RLS policies, the PostgREST API layer, and connection pooling.
The goal is not just to make a query faster today. It is to understand why a query is slow so the fix holds as data grows.
What this guide covers:
- Finding which queries are slow using
pg_stat_statements - Reading an execution plan from
EXPLAIN ANALYZE - Fixing sequential scans with the right index type
- Writing RLS policies that do not kill query performance
- Eliminating N+1 patterns from the Supabase client
- Moving expensive aggregations to Postgres functions
The Problem: Queries That Are Fast Until They Are Not
Slow queries in Supabase almost always follow one of three patterns.
Pattern 1 — The table grew past a threshold. A full table scan on 1,000 rows takes milliseconds. On 500,000 rows it takes seconds. Without an index, PostgreSQL reads every row on every query, and the cost scales linearly with table size.
Pattern 2 — An RLS policy filters on a non-indexed column. Every authenticated request passes through RLS. If your policy does USING (auth.uid() = user_id) but user_id has no index, every query, even SELECT with a tight WHERE clause, triggers a sequential scan just to evaluate the policy.
Pattern 3 — The client sends one query per row. PostgREST and the Supabase JS client make it easy to write loops that fetch related records one at a time. One hundred orders means one hundred user lookups. This looks fine in development with ten rows and kills production with ten thousand.
The Solution: Diagnose First, Then Fix
Slow request reported
↓
pg_stat_statements → find the query
↓
EXPLAIN (ANALYZE, BUFFERS) → read the plan
↓
Identify: Seq Scan? Bad join? N+1?
↓
Fix: Index / rewrite query / Postgres function
↓
Verify: Re-run EXPLAIN, check pg_stat_statements againEvery fix in this guide starts with the diagnostic step. Skipping it means you might add an index the planner never uses, or rewrite a query that was not the bottleneck.

Prerequisites
Before following this guide, you need:
- A Supabase project (Cloud or self-hosted)
- Access to the Supabase SQL Editor or a
psqlconnection - Basic knowledge of SQL and PostgreSQL
- The
pg_stat_statementsextension enabled (covered in Step 1)
Step-by-Step Implementation
Step 1: Enable pg_stat_statements to Find Slow Queries
pg_stat_statements is a Postgres extension that records execution stats, total time, call count, mean time, rows returned, for every query the database runs. It is the fastest way to find your actual bottlenecks.
Enable it in the SQL Editor:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;On Supabase Cloud, this extension is available on all plans and is often already enabled. Verify with:
SELECT * FROM pg_extension WHERE extname = 'pg_stat_statements';Find your slowest queries by total time:
SELECT
query,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(stddev_exec_time::numeric, 2) AS stddev_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;Find queries with high mean time (slow per call, not just frequent):
SELECT
query,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(total_exec_time::numeric, 2) AS total_ms
FROM pg_stat_statements
WHERE calls > 10
ORDER BY mean_exec_time DESC
LIMIT 20;Tip: Reset stats after a deployment or index change so old data does not skew your results: SELECT pg_stat_statements_reset();
Step 2: Read an Execution Plan with EXPLAIN ANALYZE
Once you have a slow query, run it through EXPLAIN to see what the planner actually does. The ANALYZE flag executes the query and returns real timing. BUFFERS shows how much data was read from disk versus cache.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.status, o.total, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.user_id = 'some-uuid'
AND o.status = 'pending'
ORDER BY o.created_at DESC
LIMIT 50;
What to look for in the output:
| Node | What it means | Good or bad? |
|---|---|---|
| Index Scan | Using an index to find rows | Good |
| Index Only Scan | Satisfying query entirely from the index | Best |
| Seq Scan | Reading every row in the table | Bad on large tables |
| Nested Loop | Joining by looping through one side per row from the other | Fine for small inputs, bad for large |
| Hash Join | Building a hash table for the join | Usually fine |
| Bitmap Heap Scan | Fetching pages identified by a bitmap index | Good |
A sequential scan that should be an index scan looks like this:
Seq Scan on orders (cost=0.00..18432.00 rows=245 width=72)
(actual time=0.043..312.442 rows=245 loops=1)
Filter: ((user_id = 'some-uuid') AND (status = 'pending'))
Rows Removed by Filter: 450123Rows Removed by Filter: 450123 means the planner scanned all 450,000 rows and threw away 99.9% of them. That is the row you need to fix.

Step 3: Add the Right Index
Not all indexes are the same. Match the index type to your query pattern.
Standard B-tree index for equality and range queries (covers most cases):
-- Always use CONCURRENTLY in production to avoid table locks
CREATE INDEX CONCURRENTLY idx_orders_user_id
ON orders (user_id);Composite index for multi-column WHERE clauses (put equality column first, then range/sort):
-- Matches: WHERE user_id = ? AND status = ? ORDER BY created_at DESC
CREATE INDEX CONCURRENTLY idx_orders_user_status_date
ON orders (user_id, status, created_at DESC);Partial index for a filtered subset:
CREATE INDEX CONCURRENTLY idx_orders_pending
ON orders (user_id, created_at DESC)
WHERE status = 'pending';GIN index for full-text search or JSONB columns:
-- For text search
CREATE INDEX CONCURRENTLY idx_products_search
ON products USING gin(to_tsvector('english', name || ' ' || description));
-- For JSONB containment queries (@>)
CREATE INDEX CONCURRENTLY idx_orders_metadata
ON orders USING gin(metadata);Verify the planner uses your new index: After creating the index, re-run EXPLAIN ANALYZE. You should see Index Scan or Bitmap Heap Scan replacing the Seq Scan. If the planner still chooses a sequential scan, the table might be small enough that a scan is actually faster, or the index selectivity is low.
Step 4: Fix RLS Policies That Cause Full Table Scans
RLS policies run on every query, and a policy that filters on an un-indexed column is a hidden full table scan. The policy below looks harmless:
-- This policy forces a sequential scan if user_id is not indexed
CREATE POLICY "users_see_own_orders"
ON orders FOR SELECT
USING (auth.uid() = user_id);The fix is simply to ensure the column in the USING clause has an index:
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id);Key RLS optimization principles:
- Avoid wrapping the indexed column in a function inside the policy.
- Use
auth.uid()overauth.jwt() ->> 'sub'(auth.uid()is a stable function that optimizes better). - Check policy cost using
EXPLAINby setting JWT claims locally.
-- Temporarily set the JWT claims so EXPLAIN sees the same plan RLS sees
SET LOCAL request.jwt.claims = '{"sub": "test-uuid", "role": "authenticated"}';
SET LOCAL role = authenticated;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders LIMIT 100;Step 5: Eliminate N+1 Queries with Embedded Selects
The Supabase JS client wraps PostgREST, which supports relational queries through resource embedding. Use it to replace loops that issue one query per row.
Before — N+1 pattern (one query per order):
const { data: orders } = await supabase
.from('orders')
.select('*')
.eq('status', 'pending')
// This fires one query per order — catastrophic at scale
for (const order of orders) {
const { data: user } = await supabase
.from('users')
.select('email, full_name')
.eq('id', order.user_id)
.single()
}After — single query with embedding:
const { data: orders } = await supabase
.from('orders')
.select(`
id,
status,
total,
created_at,
users (
id,
email,
full_name
)
`)
.eq('status', 'pending')
.order('created_at', { ascending: false })
.limit(50)Step 6: Move Heavy Aggregations to Postgres Functions
Aggregations that run in application code require pulling rows from the database and computing the result in Node.js or Edge Functions. Moving them into a Postgres function keeps computation close to the data.
Before — aggregation in application code:
// Fetches all rows, then aggregates in JavaScript
const { data: orders } = await supabase
.from('orders')
.select('total, status')
.eq('user_id', userId)
const stats = {
total_orders: orders.length,
total_spent: orders
.filter(o => o.status === 'completed')
.reduce((sum, o) => sum + o.total, 0),
}After — Postgres function:
CREATE OR REPLACE FUNCTION get_user_order_stats(p_user_id UUID)
RETURNS JSON
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
BEGIN
RETURN (
SELECT json_build_object(
'total_orders', COUNT(*),
'completed', COUNT(*) FILTER (WHERE status = 'completed'),
'total_spent', COALESCE(SUM(total) FILTER (WHERE status = 'completed'), 0),
'avg_order', COALESCE(AVG(total) FILTER (WHERE status = 'completed'), 0)
)
FROM orders
WHERE user_id = p_user_id
);
END;
$$;// One round-trip, aggregation done in Postgres
const { data } = await supabase.rpc('get_user_order_stats', {
p_user_id: userId,
})Step 7: Monitor Ongoing Query Performance
After fixing slow queries, set up ongoing monitoring so regressions surface before users report them.
Check for tables with no indexes but high sequential scan counts:
SELECT
schemaname,
tablename,
seq_scan,
seq_tup_read,
n_live_tup,
round(seq_tup_read::numeric / NULLIF(seq_scan, 0), 0) AS avg_rows_per_scan
FROM pg_stat_user_tables
WHERE seq_scan > 0
AND n_live_tup > 10000
ORDER BY seq_tup_read DESC
LIMIT 15;Find unused indexes:
SELECT
schemaname,
tablename,
indexname,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelid NOT IN (
SELECT conindid FROM pg_constraint WHERE contype IN ('p', 'u')
)
ORDER BY pg_relation_size(indexrelid) DESC;Common Problems and Errors
Query is slow only on the first call: Postgres buffer cache is cold. The first query reads from disk; subsequent calls hit shared buffer pool. Consider warming the cache or using pg_prewarm on critical tables.
Index exists but EXPLAIN still shows Seq Scan: Table is too small for index overhead, index column has low selectivity, or table statistics are stale. Run ANALYZE orders; to refresh stats.
RLS policy makes every query slow: Column referenced in USING (...) has no index, or policy calls a function preventing index use. Add index on policy column and avoid wrapping in functions.
pg_stat_statements shows parameterized query: Postgres normalizes query text by replacing literals with $1, $2, etc. This is intended behavior for structural grouping.
Composite index not used for range query: Column order does not match query. Put highest-cardinality equality column first.
Best Practices
- Diagnose before you index — Run
pg_stat_statementsandEXPLAIN ANALYZEfirst. - Always use CONCURRENTLY for index creation — Avoid locking production tables.
- Keep RLS policy columns indexed — Treat
USING/WITH CHECKcolumns likeWHEREcolumns. - Prefer embedding over loops in the client — Single PostgREST queries beat N+1 queries.
- Mark read-only Postgres functions as STABLE or IMMUTABLE — Enables planner optimization.
- Reset pg_stat_statements after major changes — Clear historical noise.
- Vacuum tables with high write rates — Keep dead row ratios under control.
Frequently Asked Questions
How do I find which Supabase query is slow? Enable pg_stat_statements and query it sorted by total_exec_time or mean_exec_time. Then run EXPLAIN (ANALYZE, BUFFERS) in the SQL Editor.
Does adding an index always make a query faster? No. On small tables or low-selectivity columns, sequential scans are faster. Always verify with EXPLAIN ANALYZE.
What is the difference between EXPLAIN and EXPLAIN ANALYZE? EXPLAIN estimates execution plans, while EXPLAIN ANALYZE actually executes the query and reports true runtime numbers.
Should I use OFFSET for pagination? Avoid OFFSET on large tables as it scans discarded rows. Use keyset pagination (WHERE created_at < :last_seen) instead.
How do I know if a Seq Scan is a problem? A Seq Scan is not always bad. It becomes a concern when PostgreSQL scans a large table but returns only a small number of rows. Use EXPLAIN ANALYZE to check rows scanned, rows removed by filters, and execution time.
Performance Optimization Checklist

- Diagnostics:
pg_stat_statementschecked,EXPLAIN ANALYZErun, Seq Scans identified. - Indexes: Foreign keys indexed, RLS columns indexed,
CONCURRENTLYused. - Query Patterns: N+1 loops removed, aggregations moved to Postgres functions.
- Ongoing: High
seq_tup_readmonitored, dead rows vacuumed, stats reset per release.
Conclusion
Most Supabase query performance problems trace back to three root causes: a sequential scan where an index should be, an RLS policy that forces a full table read, or N+1 queries from the client. The diagnostic path is the same every time, pg_stat_statements to find the query, EXPLAIN ANALYZE to see the plan, then a targeted fix.
The most important habit is diagnosing before fixing. Understanding the execution plan first means the fix sticks, and re-checking pg_stat_statements after the change confirms it worked.

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


