Supabase

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.

Aarav Sharma

Aarav Sharma

September 14, 20268 min read
Share
Supabase Query Optimization: From Slow Query to Root Cause cover graphic

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: Enable pg_stat_statements to find your slowest queries, run EXPLAIN (ANALYZE, BUFFERS) to see the execution plan, and look for Seq Scan on large tables as the first thing to fix. Add indexes with CREATE 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 again

Every 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.

Flowchart from slow query report to pg_stat_statements to EXPLAIN ANALYZE to fix

Prerequisites

Before following this guide, you need:

  • A Supabase project (Cloud or self-hosted)
  • Access to the Supabase SQL Editor or a psql connection
  • Basic knowledge of SQL and PostgreSQL
  • The pg_stat_statements extension 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();
pg_stat_statements results in the Supabase SQL Editor showing top slow queries

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;
EXPLAIN ANALYZE output in the Supabase SQL Editor

What to look for in the output:

NodeWhat it meansGood or bad?
Index ScanUsing an index to find rowsGood
Index Only ScanSatisfying query entirely from the indexBest
Seq ScanReading every row in the tableBad on large tables
Nested LoopJoining by looping through one side per row from the otherFine for small inputs, bad for large
Hash JoinBuilding a hash table for the joinUsually fine
Bitmap Heap ScanFetching pages identified by a bitmap indexGood

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: 450123

Rows 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.

EXPLAIN ANALYZE output showing a sequential scan on a Supabase orders table

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() over auth.jwt() ->> 'sub' (auth.uid() is a stable function that optimizes better).
  • Check policy cost using EXPLAIN by 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_statements and EXPLAIN ANALYZE first.
  • Always use CONCURRENTLY for index creation — Avoid locking production tables.
  • Keep RLS policy columns indexed — Treat USING / WITH CHECK columns like WHERE columns.
  • 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

Supabase performance optimization checklist covering diagnostics, indexes, query patterns, and ongoing database monitoring
  • Diagnostics: pg_stat_statements checked, EXPLAIN ANALYZE run, Seq Scans identified.
  • Indexes: Foreign keys indexed, RLS columns indexed, CONCURRENTLY used.
  • Query Patterns: N+1 loops removed, aggregations moved to Postgres functions.
  • Ongoing: High seq_tup_read monitored, 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

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.

Related articles

Let's Collaborate

Tell us about your project and we'll come back with a plan, a timeline, and a quote.

Project Type

Budget

Task Message

Your Contacts