Backend & APIs16 min read1,163 words

PostgreSQL Performance Optimization 2026: Complete Tuning Guide

Optimize PostgreSQL for maximum performance. Learn indexing strategies, query optimization, configuration tuning, and monitoring for high-throughput database applications.

MT

Michael Torres

PostgreSQL is the most advanced open-source database, but achieving optimal performance requires understanding its internals. This guide covers practical optimization techniques from indexing to configuration tuning.

Indexing Strategies

sql
-- Essential indexing patterns

-- 1. Composite indexes (column order matters!)
CREATE INDEX idx_orders_user_status_date 
  ON orders (user_id, status, created_at DESC);

-- Query that benefits (uses all columns left-to-right)
SELECT * FROM orders 
WHERE user_id = 'uuid' AND status = 'pending'
ORDER BY created_at DESC;

-- 2. Partial indexes (index only what you query)
CREATE INDEX idx_orders_pending 
  ON orders (user_id, created_at) 
  WHERE status = 'pending';

-- Much smaller index, faster for common query
SELECT * FROM orders 
WHERE user_id = 'uuid' AND status = 'pending';

-- 3. Expression indexes
CREATE INDEX idx_users_email_lower 
  ON users (LOWER(email));

-- Enables fast case-insensitive lookup
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';

-- 4. GIN indexes for JSONB
CREATE INDEX idx_products_metadata 
  ON products USING GIN (metadata jsonb_path_ops);

-- Fast JSONB containment queries
SELECT * FROM products 
WHERE metadata @> '{"category": "electronics"}';

-- 5. Covering indexes (include columns to avoid table lookup)
CREATE INDEX idx_orders_covering 
  ON orders (user_id, status) 
  INCLUDE (total, created_at);

-- Index-only scan possible
SELECT total, created_at FROM orders 
WHERE user_id = 'uuid' AND status = 'completed';

-- 6. BRIN indexes for time-series data
CREATE INDEX idx_events_created_brin 
  ON events USING BRIN (created_at);

-- Very small index for naturally ordered data
-- Works well when data is inserted in order

Query Optimization

sql
-- Analyze query performance
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.*, u.name as user_name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'pending'
  AND o.created_at > NOW() - INTERVAL '7 days'
ORDER BY o.created_at DESC
LIMIT 50;

-- Common optimization patterns

-- 1. Avoid SELECT * in production
-- Bad
SELECT * FROM orders WHERE user_id = 'uuid';
-- Good
SELECT id, status, total, created_at FROM orders WHERE user_id = 'uuid';

-- 2. Use EXISTS instead of IN for subqueries
-- Slower
SELECT * FROM users 
WHERE id IN (SELECT user_id FROM orders WHERE total > 1000);
-- Faster
SELECT * FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o 
  WHERE o.user_id = u.id AND o.total > 1000
);

-- 3. Pagination with keyset instead of OFFSET
-- Slow for large offsets
SELECT * FROM orders ORDER BY created_at DESC OFFSET 10000 LIMIT 20;
-- Fast regardless of position
SELECT * FROM orders 
WHERE created_at < '2026-01-01T00:00:00Z'
ORDER BY created_at DESC 
LIMIT 20;

-- 4. Use CTEs wisely (they can be optimization fences)
-- This CTE is materialized (can be slower)
WITH user_orders AS (
  SELECT * FROM orders WHERE user_id = 'uuid'
)
SELECT * FROM user_orders WHERE status = 'pending';

-- Use NOT MATERIALIZED if you want the planner to inline
WITH user_orders AS NOT MATERIALIZED (
  SELECT * FROM orders WHERE user_id = 'uuid'
)
SELECT * FROM user_orders WHERE status = 'pending';

-- 5. Batch operations
-- Instead of many single inserts
INSERT INTO events (type, data) VALUES ('view', '{}')
-- Use batch insert
INSERT INTO events (type, data) VALUES 
  ('view', '{}'),
  ('click', '{}'),
  ('purchase', '{}');

-- Or COPY for bulk loading
COPY events (type, data) FROM '/path/to/data.csv' WITH CSV;

Configuration Tuning

ini
# postgresql.conf optimizations
# Adjust based on your hardware and workload

# Memory Settings
shared_buffers = 8GB                    # 25% of RAM
effective_cache_size = 24GB             # 75% of RAM
work_mem = 256MB                        # Per-operation memory
maintenance_work_mem = 2GB              # For VACUUM, CREATE INDEX

# Write-Ahead Log
wal_level = replica                     # For replication
max_wal_size = 4GB
min_wal_size = 1GB
wal_compression = on
checkpoint_completion_target = 0.9

# Query Planning
random_page_cost = 1.1                  # Lower for SSDs (default 4.0)
effective_io_concurrency = 200          # For SSDs
default_statistics_target = 200         # Better query plans

# Parallelism
max_worker_processes = 8
max_parallel_workers = 8
max_parallel_workers_per_gather = 4
max_parallel_maintenance_workers = 4
parallel_leader_participation = on

# Connection Management
max_connections = 200                   # Use connection pooling!
idle_in_transaction_session_timeout = 10min

# Logging (for debugging)
log_min_duration_statement = 1000       # Log queries > 1s
log_lock_waits = on
log_temp_files = 0                      # Log all temp file usage
auto_explain.log_min_duration = 1000    # Auto-explain slow queries

Connection Pooling

typescript
// Application-level connection pooling with pg
import { Pool } from 'pg';

const pool = new Pool({
  host: process.env.DB_HOST,
  port: 5432,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  
  // Pool configuration
  max: 20,                    // Maximum connections
  min: 5,                     // Minimum connections
  idleTimeoutMillis: 30000,   // Close idle connections
  connectionTimeoutMillis: 5000,
  
  // Statement timeout
  statement_timeout: 30000,   // 30 seconds
});

// Proper connection handling
async function queryWithConnection<T>(
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    return await fn(client);
  } finally {
    client.release();
  }
}

// Transaction helper
async function transaction<T>(
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

// Usage
await transaction(async (client) => {
  await client.query(
    'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
    [100, fromAccountId]
  );
  await client.query(
    'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
    [100, toAccountId]
  );
});

Monitoring Queries

sql
-- Essential monitoring queries

-- 1. Find slow queries
SELECT 
  query,
  calls,
  mean_exec_time::numeric(10,2) as avg_ms,
  total_exec_time::numeric(10,2) as total_ms,
  rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;

-- 2. Table and index sizes
SELECT 
  schemaname,
  tablename,
  pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) as total_size,
  pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) as table_size,
  pg_size_pretty(pg_indexes_size(schemaname || '.' || tablename)) as indexes_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;

-- 3. Unused indexes
SELECT
  schemaname,
  tablename,
  indexname,
  pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
  idx_scan as times_used
FROM pg_stat_user_indexes
WHERE idx_scan < 50
ORDER BY pg_relation_size(indexrelid) DESC;

-- 4. Cache hit ratio (should be > 99%)
SELECT 
  sum(blks_hit) * 100.0 / sum(blks_hit + blks_read) as cache_hit_ratio
FROM pg_stat_database;

-- 5. Active connections
SELECT 
  state,
  count(*),
  array_agg(pid) as pids
FROM pg_stat_activity
GROUP BY state;

-- 6. Lock monitoring
SELECT 
  blocked_locks.pid AS blocked_pid,
  blocked_activity.usename AS blocked_user,
  blocking_locks.pid AS blocking_pid,
  blocking_activity.usename AS blocking_user,
  blocked_activity.query AS blocked_statement
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity 
  ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks 
  ON blocking_locks.locktype = blocked_locks.locktype
  AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
  AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
JOIN pg_catalog.pg_stat_activity blocking_activity 
  ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

Best Practices

PostgreSQL Optimization Checklist

Index wisely: Create indexes for your actual query patterns

Use EXPLAIN ANALYZE: Understand query execution before optimizing

Configure for your hardware: Tune shared_buffers, work_mem appropriately

Use connection pooling: PgBouncer or application-level pooling

Monitor continuously: Track slow queries and cache hit ratios

Regular maintenance: VACUUM, ANALYZE, REINDEX as needed

Partition large tables: Use native partitioning for time-series data

Conclusion

PostgreSQL performance optimization is an ongoing process. Start with proper indexing, use EXPLAIN to understand your queries, and monitor continuously. These techniques will help you achieve excellent performance even at scale.

Need help optimizing your database? Contact Jishu Labs for expert database consulting and performance tuning.

MT

About Michael Torres

Michael Torres is the Backend Lead at Jishu Labs with extensive experience in database optimization and architecture.

Related Articles

Ready to Build Your Next Project?

Let's discuss how our expert team can help bring your vision to life.

Top-Rated
Software Development
Company

Ready to Get Started?

Get consistent results. Collaborate in real-time.
Build Intelligent Apps. Work with Jishu Labs.

SCHEDULE MY CALL