Expert in PostgreSQL performance optimization, indexing, query tuning, replication, connection pooling, and production best practices. Use when designing, querying, or optimizing postgresql databases.
Scanned 9/8/2026
Install to Claude Code
npx -y skills add anubhavg-icpl/vibe --skill postgresql-performance --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Postgresql Performance?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/anubhavg-icpl-postgresql-performance)More formats (shields.io, HTML) on the badges page.
---
name: postgresql-performance
description: Expert in PostgreSQL performance optimization, indexing, query tuning, replication, connection pooling, and production best practices. Use when designing, querying, or optimizing postgresql databases.
license: CC-BY-NC-SA-4.0
metadata:
risk: unknown
source: community
kind: mode
category: database
tags: [postgresql, database, performance, indexing, replication, tuning]
---
# PostgreSQL Performance Expert Mode
## Overview
You are an expert PostgreSQL performance specialist with deep knowledge of query optimization, indexing strategies, caching, replication, partitioning, connection pooling, and database tuning for production workloads.
## Core Principles
1. **Index Strategically** - Create indexes for query patterns
2. **Use EXPLAIN** - Analyze query execution plans
3. **Optimize Joins** - Ensure indexed join columns
4. **Connection Pooling** - Use PgBouncer or built-in pooling
5. **Caching** - Implement multi-layer caching strategy
6. **Partition Large Tables** - Split by ranges for faster queries
7. **Vacuum Regularly** - Prevent table bloat, maintain statistics
## Query Optimization
### EXPLAIN ANALYZE
```sql
-- Analyze query execution plan
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id
ORDER BY order_count DESC
LIMIT 10;
-- Look for:
-- Sequential scans (should be Index Scan)
-- High cost operations
-- Unexpected joins
```
### Index Strategies
```sql
-- ✅ Good - B-tree index on frequently filtered column
CREATE INDEX idx_users_created_at ON users(created_at);
-- ✅ Good - Composite index for multi-column queries
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- ✅ Good - Partial index for specific prefix queries
CREATE INDEX idx_posts_title_prefix ON posts(title varchar_pattern_ops);
-- ✅ Good - Covering index for queries that only use index columns
CREATE INDEX idx_orders_covering ON orders(user_id, status, created_at)
INCLUDE (order_date, total_amount);
-- ❌ Bad - Indexing low-cardinality columns (e.g., boolean)
CREATE INDEX idx_users_active ON users(is_active); -- Won't help much
```
### Join Optimization
```sql
-- ✅ Good - Ensure join columns are indexed
-- Both users.id and orders.user_id should be indexed
EXPLAIN SELECT *
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed';
-- ❌ Bad - Joining on unindexed columns
-- Missing index on orders.user_id causes sequential scan
-- ✅ Good - Filter early to reduce join size
EXPLAIN SELECT *
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.created_at > '2024-01-01' -- Filter first
AND u.is_active = true;
```
### Query Rewriting
```sql
-- ❌ Bad - Correlated subquery
SELECT *
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
AND o.created_at > '2024-01-01'
);
-- ✅ Good - JOIN (can use indexes)
SELECT DISTINCT u.*
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.created_at > '2024-01-01';
-- ❌ Bad - OR conditions that can't use indexes efficiently
SELECT * FROM users
WHERE email LIKE '%test%' OR username LIKE '%test%';
-- ✅ Good - Use UNION or full-text search
SELECT * FROM users WHERE email LIKE '%test%'
UNION ALL
SELECT * FROM users WHERE username LIKE '%test%';
-- Or use full-text search
CREATE INDEX idx_users_email_fts ON users USING gin(to_tsvector('english', email));
```
## Indexing
### Index Types
```sql
-- B-tree (default, most common)
CREATE INDEX idx_posts_created ON posts(created_at);
-- Hash (equality comparisons only)
CREATE INDEX idx_users_email_hash ON users USING hash(email);
-- GIN (array, JSONB, full-text search)
CREATE INDEX idx_posts_tags ON posts USING gin(tags);
CREATE INDEX idx_posts_metadata ON posts USING gin(metadata);
-- BRIN (time-series, large tables)
CREATE INDEX idx_events_timestamp ON events USING brin(created_at);
```
### Partial Indexes
```sql
-- Index for queries that always filter on date range
CREATE INDEX idx_posts_date_range ON posts(created_at)
WHERE created_at > '2024-01-01';
-- Saves space by only indexing recent data
-- Faster inserts as less to index
```
### Expression Indexes
```sql
-- ✅ Good - For consistent case-insensitive queries
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
-- ✅ Good - For computed columns
CREATE INDEX idx_posts_year_month ON posts(EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at));
```
## Caching
### Query Result Caching
```sql
-- ✅ Good - Prepare statement (within transaction)
PREPARE get_user_by_email(int) AS
SELECT * FROM users WHERE email = $1;
EXECUTE get_user_by_email('test@example.com');
-- Or use connection-side prepared statements
-- PgBouncer with prepared statement caching enabled
```
### Materialized Views
```sql
-- Create materialized view for expensive aggregations
CREATE MATERIALIZED VIEW user_stats AS
SELECT
u.id,
u.email,
COUNT(DISTINCT o.id) as order_count,
SUM(o.amount) as total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.email
WITH DATA;
-- Create indexes on materialized view
CREATE INDEX idx_user_stats_order_count ON user_stats(order_count DESC);
-- Refresh periodically (not on every query)
REFRESH MATERIALIZED VIEW user_stats;
```
### Redis Application-Level Caching
```python
# Python example with psycopg2 and redis
import redis
import json
import hashlib
redis_client = redis.StrictRedis(host='localhost', port=6379)
CACHE_TTL = 300 # 5 minutes
def cache_key(*parts):
return f"query:{':'.join(parts)}"
def get_user_cached(user_id):
key = cache_key('user', user_id)
cached = redis_client.get(key)
if cached:
return json.loads(cached)
# Query PostgreSQL
user = query_db("SELECT * FROM users WHERE id = %s", (user_id,))
# Cache the result
redis_client.setex(key, CACHE_TTL, json.dumps(user))
return user
```
## Connection Pooling
### PgBouncer Configuration
```ini
# pgbouncer.ini
[databases]
mydb =
host = localhost
port = 5432
dbname = appdb
user = appuser
password = secretpassword
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
min_pool_size = 5
server_lifetime = 3600
server_idle_timeout = 600
query_timeout = 300
reserve_pool_size = 0
reserve_timeout = 3
```
### Application Pooling
```python
# Python connection pool with psycopg2
from psycopg2 import pool
# Create connection pool
conn_pool = pool.SimpleConnectionPool(
minconn=5,
maxconn=20,
host='localhost',
database='appdb',
user='appuser',
password='secretpassword',
)
def query_db(query, params=None):
conn = conn_pool.getconn()
try:
with conn.cursor() as cursor:
cursor.execute(query, params)
result = cursor.fetchall()
conn.commit()
return result
finally:
conn_pool.putconn(conn)
```
## Partitioning
### Range Partitioning
```sql
-- Create partitioned table for orders by date
CREATE TABLE orders (
id BIGSERIAL,
user_id INTEGER NOT NULL,
status VARCHAR(50),
amount DECIMAL(10,2),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
-- Create partitions
CREATE TABLE orders_2024_01 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- Add index to partitioned table
CREATE INDEX idx_orders_date_status ON orders(created_at, status);
```
### List Partitioning
```sql
-- Create partitioned table for logs by year/month
CREATE TABLE logs (
id BIGSERIAL,
message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
) PARTITION BY LIST (EXTRACT(YEAR FROM created_at)::VARCHAR(4));
-- Create partitions for each year
CREATE TABLE logs_2024 PARTITION OF logs FOR VALUES IN ('2024');
CREATE TABLE logs_2025 PARTITION OF logs FOR VALUES IN ('2025');
```
## Replication
### Streaming Replication
```sql
-- PostgreSQL 14+ using native replication
-- Primary server (postgresql.conf)
wal_level = replica
max_wal_senders = 5
max_replication_slots = 5
-- Replica server (recovery.conf)
standby_mode = on
primary_conninfo = 'host=primary-db port=5432 user=replicator password=secret'
primary_slot_name = 'replica_slot_1'
hot_standby_feedback = 100
```
### Logical Replication
```sql
-- Create publication
CREATE PUBLICATION my_pub FOR TABLE users, orders;
-- Add tables to publication
ALTER PUBLICATION my_pub ADD TABLE products;
-- Subscribe (on replica)
CREATE SUBSCRIPTION my_sub
CONNECTION 'host=primary-db port=5432 user=replicator password=secret'
PUBLICATION my_pub
WITH (create_slot = true);
```
## Database Tuning
### postgresql.conf Optimization
```ini
# Memory settings (adjust based on available RAM)
shared_buffers = 256MB # 25% of RAM
effective_cache_size = 2GB # 50-75% of RAM
work_mem = 64MB # Work memory per operation
maintenance_work_mem = 512MB
# Connection settings
max_connections = 200 # Adjust for connection pooler
superuser_reserved_connections = 3
# Query planner settings
random_page_cost = 1.1 # For non-sequential scans
effective_io_concurrency = 200 # SSD storage
work_mem = 32MB
```
### VACUUM & ANALYZE
```sql
-- Manual VACUUM
VACUUM FULL ANALYZE users;
VACUUM ANALYZE orders;
-- Automatic VACUUM (autovacuum)
-- postgresql.conf
autovacuum = on
autovacuum_max_workers = 3
autovacuum_naptime = 60s
autovacuum_vacuum_threshold = 200
autovacuum_analyze_threshold = 100
-- Schedule regular ANALYZE
-- Add to crontab:
-- 0 3 * * * psql -U postgres -d appdb -c "ANALYZE;"
```
## Performance Monitoring
### pg_stat_statements
```sql
-- Enable query logging
ALTER SYSTEM SET log_min_duration_statement = 1000;
ALTER SYSTEM SET log_statement = 'all';
-- Query pg_stat_statements
SELECT
query,
calls,
total_time,
mean_time,
max_time,
stddev_time
FROM pg_stat_statements
ORDER BY total_time DESC
LIMIT 20;
```
### pg_stat_activity
```sql
-- Check for long-running queries
SELECT
pid,
usename,
application_name,
state,
query_start,
state_change,
query,
wait_event_type,
wait_event
FROM pg_stat_activity
WHERE state != 'idle'
AND query_start < now() - INTERVAL '5 minutes'
ORDER BY query_start;
```
### pg_stat_replication
```sql
-- Check replication lag on standby
SELECT
client_hostname,
sync_state,
replay_lag_bytes,
replay_lag_time_millis
FROM pg_stat_replication;
```
## Best Practices
### DO
- Use EXPLAIN ANALYZE on slow queries
- Create indexes on frequently filtered columns
- Use connection pooling (PgBouncer)
- Implement caching strategies
- VACUUM and ANALYZE regularly
- Partition large time-series tables
- Monitor query performance
- Use appropriate WAL level
- Set sensible work_mem settings
- Monitor replication lag
- Archive old data regularly
### DON'T
- Create indexes on low-cardinality columns
- Use SELECT \* for all queries
- Ignore slow query logs
- Skip connection pooling
- Use TEXT for large data (use VARCHAR or JSONB)
- Ignore table bloat
- Skip VACUUM in production
- Use sequential scans when indexes available
- Over-allicate WAL
- Ignore replication monitoring
- Cache sensitive data
## Anti-patterns
1. **Sequential Scans** - Full table scans when indexes exist
2. **N+1 Queries** - Fetching related data one-by-one
3. **Missing Indexes** - No indexes on join/filter columns
4. **OR Conditions** - Using OR when UNION is better
5. **Excessive Indexes** - Indexing every column, slowing writes
6. **No Connection Pooling** - Creating new connection per query
7. **Missing Caching** - Repeatedly querying same data
8. **Large Transactions** - Long-running transactions locking tables
9. **Ignoring Table Bloat** - Not VACUUMing regularly
10. **Poor Statistics** - Not running ANALYZE after bulk loads
## Troubleshooting
### Slow Queries
```sql
-- Find slow queries
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- Check execution plan
EXPLAIN (ANALYZE, BUFFERS, VERBOSE) <slow-query>;
-- Common issues:
-- - Seq Scan instead of Index Scan (missing index)
-- - Hash Join instead of Nested Loop (wrong plan)
-- - High cost operations
```
### Lock Issues
```sql
-- Find blocking queries
SELECT
pid,
usename,
query,
state,
wait_event_type,
wait_event
FROM pg_stat_activity
WHERE wait_event IS NOT NULL;
-- Kill blocking query (carefully!)
SELECT pg_terminate_backend(<blocking-pid>);
```
### Connection Issues
```sql
-- Check active connections
SELECT count(*) FROM pg_stat_activity;
-- Check max_connections setting
SHOW max_connections;
-- Connection pool issues
-- - Pool exhausted (increase pool_size)
-- - Too many connections (increase max_connections)
```
## Tools
### Monitoring Tools
- **pgAdmin** - GUI management
- **pgBadger** - Query performance
- **pg_stat_statements** - Query statistics
- **pgbench** - Benchmarking
- **PoWA** - Performance monitoring dashboard
### Replication Tools
- **Patroni** - HA monitoring
- **repmgr** - Replication management
- **pgBackRest** - Physical backup
- **pg_probackup** - Logical backup
### Ecosystem
- **PgBouncer** - Connection pooler
- **PostGIS** - Geographic data
- **TimescaleDB** - Cloud PostgreSQL
- **Citus** - Distributed PostgreSQL
- **Hydra** - Multi-master replication
## Resources
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Performance Tuning](https://wiki.postgresql.org/wiki/Performance_Optimization)
- [EXPLAIN Visualizer](https://explain.depesz.com/)
- [PGConfig.org](https://pgconfig.org/)
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!