PostgreSQL Query Performance Tuning
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/database/references/postgres-performance.md |
| Description | Not specified |
Source Content
PostgreSQL Query Performance Tuning
Overview
Most performance wins in a Postgres-backed app are 1–2 indexes or a query rewrite away. This guide covers diagnosis with pg_stat_statements and EXPLAIN, the right index types for different query patterns, and safe migration patterns.
Finding Slow Queries
pg_stat_statements Setup
Enable the extension in postgresql.conf:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Reset stats before a measurement windowSELECT pg_stat_statements_reset();
-- Collect data for 24 hours, then query top offendersRanking Queries by Impact
Top 20 by total execution time (worst total impact):
SELECT query, calls, total_time, mean_time, stddev_time, rowsFROM pg_stat_statementsWHERE query NOT LIKE '%pg_stat_statements%'ORDER BY total_time DESCLIMIT 20;Top 20 by mean execution time (worst per-call impact):
SELECT query, calls, total_time, mean_time, stddev_time, rowsFROM pg_stat_statementsWHERE query NOT LIKE '%pg_stat_statements%' AND calls > 10 -- Only queries called at least 10 timesORDER BY mean_time DESCLIMIT 20;Both lists matter. A query called once but taking 60 seconds is a problem; a query called 10,000 times taking 100ms each is also a problem.
EXPLAIN (ANALYZE, BUFFERS)
Never use bare EXPLAIN; always include ANALYZE and BUFFERS:
EXPLAIN (ANALYZE, BUFFERS)SELECT * FROM ordersWHERE customer_id = 123ORDER BY created_at DESCLIMIT 10;What to Look For
| Symptom | Meaning | Action |
|---|---|---|
Seq Scan on >100k rows | Full table scan, no index used | Add an index on the filter column(s) |
Bitmap Heap Scan on >50% of table | Lossy scan, high overhead | Consider covering index or column reorder |
actual rows >> estimated rows (>10×) | Planner mis-estimated; may pick wrong plan | Run ANALYZE table_name or create extended statistics |
actual rows << estimated rows | Planner overestimated; may be leaving resources unused | Re-check statistics or table changes |
External Sort | Sort spilled to disk | Increase work_mem or add index for sort order |
Nested Loop on large outer set | Cartesian product risk | Check join conditions; consider hash/merge join |
Index Types and Strategy
B-Tree (Default)
Best for equality (WHERE col = val), ranges (BETWEEN, >), and sorting (ORDER BY).
-- Single columnCREATE INDEX idx_customers_email ON customers (email);
-- Composite: equality columns first, then rangeCREATE INDEX idx_orders_customer_dateON orders (customer_id, created_at DESC);Composite column order matters:
- Equality columns first (most selective).
- Range/sort columns second in the order the query needs them.
- Never put a range column before an equality column — the planner can’t use the part after the gap.
GIN (Generalized Inverted Index)
Best for full-text search, JSONB containment, and array operations.
-- Full-text searchCREATE INDEX idx_documents_content_ftsON documents USING GIN (to_tsvector('english', content));
-- JSONB field containmentCREATE INDEX idx_users_tagsON users USING GIN (tags);BRIN (Block Range Index)
Best for append-only tables with huge row counts (100M+). Uses minimal space.
-- Time-series data ordered by created_atCREATE INDEX idx_events_created_brinON events USING BRIN (created_at);Partial Indexes
Index only the rows you query most often, reducing size and write overhead.
-- Active users onlyCREATE INDEX idx_active_users_emailON users (email)WHERE status = 'active';
-- Recent orders onlyCREATE INDEX idx_recent_ordersON orders (customer_id, created_at)WHERE created_at > CURRENT_DATE - INTERVAL '90 days';Covering Indexes (INCLUDE clause)
Avoid table lookups by including non-key columns in the index.
-- Index-only scan possible for this query:-- SELECT order_total, status FROM orders WHERE customer_id = ?CREATE INDEX idx_orders_customer_coveringON orders (customer_id)INCLUDE (order_total, status);Expression/Functional Indexes
Index the result of a function for queries on transformed data.
-- Case-insensitive email lookupsCREATE INDEX idx_users_email_lowerON users (LOWER(email));
-- Date part extractionCREATE INDEX idx_orders_monthON orders (EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at));Query Rewrites
When an index won’t help, rewrite the query.
Pagination: Cursor Over OFFSET
Bad — slow on large offsets:
SELECT * FROM ordersWHERE customer_id = ?ORDER BY created_at DESCLIMIT 20 OFFSET 1000;Good — constant time, keyset pagination:
-- First page: pass cursor_id = 0 or NULLSELECT * FROM ordersWHERE customer_id = ? AND created_at < ? -- or id < ? for the cursorORDER BY created_at DESCLIMIT 21; -- Fetch one extra to detect "more"Existence: EXISTS Over IN (subquery)
Bad — materializes the subquery:
SELECT * FROM ordersWHERE customer_id IN ( SELECT id FROM customers WHERE status = 'premium');Good — stops at first match:
SELECT * FROM orders oWHERE EXISTS ( SELECT 1 FROM customers c WHERE c.id = o.customer_id AND c.status = 'premium');Top-N Per Group: LATERAL
Bad — pulls all rows then filters:
SELECT DISTINCT ON (category) product_id, category, priceFROM productsORDER BY category, price DESC;Good — efficient per-category top-N:
SELECT p.product_id, p.category, p.priceFROM product_categories cCROSS JOIN LATERAL ( SELECT product_id, price FROM products WHERE category = c.category ORDER BY price DESC LIMIT 10) p;Index Maintenance
Finding Unused Indexes
SELECT schemaname, tablename, indexname, idx_scan, pg_size_pretty(pg_relation_size(indexname::regclass)) as sizeFROM pg_stat_user_indexesWHERE idx_scan = 0 -- Never used AND indexname NOT LIKE 'pg_toast%'ORDER BY pg_relation_size(indexname::regclass) DESC;Unused indexes slow every INSERT, UPDATE, DELETE. Drop them.
Index Size and Bloat
-- Large indexesSELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass)) as index_sizeFROM pg_indexesWHERE schemaname = 'public'ORDER BY pg_relation_size(indexname::regclass) DESCLIMIT 20;
-- Check for bloatSELECT schemaname, tablename, round(100.0 * (CASE WHEN otta > 0 THEN sml.relpages - otta ELSE 0 END) / sml.relpages) AS table_waste_percent, pg_size_pretty((CASE WHEN otta > 0 THEN sml.relpages - otta ELSE 0 END)::bigint * 8192) AS table_wasteFROM pg_stat_user_tables smlORDER BY table_waste DESC;Autovacuum Tuning
The query planner relies on accurate statistics. Autovacuum keeps them fresh.
-- Check autovacuum settings for a tableSELECT schemaname, tablename, autovacuum_vacuum_threshold, autovacuum_vacuum_scale_factorFROM pg_tablesWHERE tablename = 'orders';
-- Manually analyze for immediate stats refreshANALYZE orders;Partitioning Strategy
Consider partitioning when a table exceeds ~50M rows or has a clear time-series/natural split.
Time-Series Partitioning
CREATE TABLE events ( id BIGSERIAL, event_type TEXT, data JSONB, created_at TIMESTAMP, PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at);
-- Auto-create partitions per monthCREATE TABLE events_2024_01 PARTITION OF events FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE events_2024_02 PARTITION OF events FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');Range Partitioning by ID
CREATE TABLE customer_data ( customer_id BIGINT, data JSONB, PRIMARY KEY (customer_id)) PARTITION BY RANGE (customer_id);
CREATE TABLE customer_data_0_100k PARTITION OF customer_data FOR VALUES FROM (0) TO (100000);
CREATE TABLE customer_data_100k_200k PARTITION OF customer_data FOR VALUES FROM (100000) TO (200000);Safe Migration Pattern
Never:
CREATE INDEXwithoutCONCURRENTLYin production.- Bundle index changes with schema changes in one migration.
- Block writes while building an index.
Always:
-- Separate migration file, its own transactionCREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_dateON orders (customer_id, created_at DESC);Use CREATE INDEX CONCURRENTLY so the table remains writable during index creation. In your Taskfile or CI, run index migrations in their own deployment window if needed.
Benchmarking Before/After
Capture the query and its plan before the index, and after. Commit both to bench/<query>.sql:
-- bench/top_orders_by_customer.sql-- BEFORE:-- Seq Scan on orders (cost=0.00..350000.00 rows=100000 width=32)-- Filter: (customer_id = $1)-- Planning Time: 0.115 ms-- Execution Time: 2150.432 ms (on 100M row table)
-- AFTER index creation:-- Index Scan using idx_orders_customer_date on orders (cost=0.15..5.32 rows=100 width=32)-- Index Cond: (customer_id = $1)-- Planning Time: 0.198 ms-- Execution Time: 12.456 ms
SELECT * FROM ordersWHERE customer_id = $1ORDER BY created_at DESCLIMIT 20;References
- Markus Winand — Use The Index, Luke! — the definitive index guide.
- pganalyze — monitoring and EXPLAIN analysis.
- PostgreSQL 14 Internals by Egor Rogov — understand the planner.
- PostgreSQL documentation — EXPLAIN.