A missing index on a table with 10,000 rows is invisible. The same missing index on a table with 10 million rows will take your app down. The problem is that most PostgreSQL performance issues develop gradually — queries that took 20ms at launch take 800ms two years later, and nobody noticed the gradual degradation until users started complaining.
This guide covers how to find slow queries, what the query planner is actually doing, and which indexes to add first based on real production impact data.
The first thing to do on any PostgreSQL database is enable pg_stat_statements. It tracks execution statistics for every query that runs — total time, calls, mean time — and it's the fastest way to find what's actually slow in production.
-- Enable extension (requires superuser)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Find your slowest queries
SELECT
round(mean_exec_time::numeric, 2) AS mean_ms,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
left(query, 80) AS query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
Run this on your production database. The queries at the top are your biggest opportunities. Mean time above 100ms is a problem. Above 500ms is urgent.
Once you have a slow query, EXPLAIN ANALYZE shows you exactly what PostgreSQL is doing to execute it. The key things to look for:
Not all missing indexes have the same impact. Prioritize by this order:
-- Find tables with missing FK indexes (PostgreSQL 14+)
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name,
c.confrelid::regclass AS references
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid
AND a.attnum = ANY(i.indkey)
);
Every index you add slows down writes. On tables with heavy INSERT/UPDATE workload, unused indexes can cost more than they save. Find them with:
-- Find indexes that are never used
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS times_used
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
AND NOT indisprimary
AND NOT indisunique
ORDER BY pg_relation_size(indexrelid) DESC;
pg_stat_user_indexes resets on server restart. If your database restarted recently, indexes that show 0 scans might still be used. Run this query and wait at least a full week of normal traffic before dropping anything.
The default index type is B-tree, which handles equality and range queries well. But for specific use cases, other index types perform significantly better:
@> and @@ operatorsDriftn connects to your Supabase project and surfaces slow queries, missing indexes, and table growth trends automatically.
View the dashboard