Slow SQL queries are one of the most common performance bottlenecks in web applications. This guide covers practical optimization techniques that work across PostgreSQL, MySQL, and SQLite — from indexing strategies to reading EXPLAIN output to eliminating the N+1 problem.
Before optimizing, you need to understand how the database is executing your query. EXPLAIN shows the execution plan. EXPLAIN ANALYZE actually runs the query and shows real timing data.
-- See the execution plan (does not run the query)
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- Run the query and show actual timing
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 123;
-- PostgreSQL: more detail
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT * FROM orders WHERE user_id = 123;
Key things to look for in the output:
The single biggest query optimization is adding the right index. A B-tree index lets the database find matching rows in O(log n) time instead of scanning all rows.
-- Add an index on the column you filter by most
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Composite index for queries filtering on multiple columns
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- Index for ORDER BY to avoid sort operations
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
-- Partial index: only index a subset of rows (very efficient)
CREATE INDEX idx_active_users ON users(email) WHERE active = true;
Column order matters in composite indexes. Put the most selective column first, and ensure the index matches the query's WHERE clause column order. An index on (user_id, status) can serve a query filtering on user_id alone, but NOT a query filtering on status alone.
Applying a function to an indexed column in a WHERE clause defeats the index.
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';SELECT * FROM users WHERE email = 'user@example.com'; (store emails lowercase to begin with)SELECT * FROM orders WHERE YEAR(created_at) = 2024;SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';If you need case-insensitive search, use a functional index: CREATE INDEX ON users(LOWER(email)); and then query with WHERE LOWER(email) = ....
SELECT * FROM users WHERE id = 123;SELECT id, name, email FROM users WHERE id = 123;Using SELECT * forces the database to read every column, even ones your application never uses. This increases I/O and memory usage. It also prevents covering index optimizations where the index alone can answer the query without reading the table.
N+1 is when you execute 1 query to get a list, then N more queries to fetch related data for each item. This is the most common ORM-induced performance problem.
-- N+1 problem: 1 query for users, then 1 query per user for their orders
SELECT * FROM users LIMIT 100;
-- Then for each user:
SELECT * FROM orders WHERE user_id = ?; -- runs 100 times!
-- Solution: JOIN to fetch everything in one query
SELECT
u.id, u.name, u.email,
COUNT(o.id) AS order_count,
SUM(o.total) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name, u.email
LIMIT 100;
Never return unlimited rows from a table query in a user-facing API. Always paginate.
-- Offset pagination (simple, but slow for large offsets)
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 1000;
-- Cursor pagination (much faster for large datasets)
SELECT * FROM posts
WHERE created_at < '2024-05-01T10:30:00'
ORDER BY created_at DESC
LIMIT 20;
Offset pagination becomes slow at large offsets because the database must scan and discard all preceding rows. Cursor pagination (keyset pagination) avoids this by filtering from a specific point.
Window functions are often much faster than correlated subqueries for analytical calculations.
SELECT id, total, (SELECT SUM(total) FROM orders o2 WHERE o2.user_id = o1.user_id) FROM orders o1;
-- Fast: use window function
SELECT
id,
total,
SUM(total) OVER (PARTITION BY user_id) AS user_total
FROM orders;
Opening a new database connection for every request is expensive (50-100ms). Use a connection pool to reuse existing connections.
-- Node.js with pg pool
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // maximum pool size
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Reuse connections from the pool
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
EXPLAIN ANALYZE on slow queries before optimizingSELECT *