A practical guide to optimizing SQL queries and database performance.
When optimizing SQL, focus on these goals:
Before optimizing a query, inspect its execution plan using `EXPLAIN`.
`EXPLAIN` shows:
EXPLAIN SELECT * FROM orders WHERE customer_id = 10 ORDER BY created_at DESC;
| Problem | Meaning |
|---|---|
| Full table scan | Database scans entire table |
| Using temporary | Temporary table created internally |
| Using filesort | Expensive sorting operation |
| Large row count | Too many rows scanned |
| No index used | Missing or incorrect index |
Queries should:
Indexes are one of the biggest performance improvements in SQL.
Create indexes for columns frequently used in:
-- WHERE condition CREATE INDEX idx_orders_customer_id ON orders(customer_id); -- ORDER BY CREATE INDEX idx_orders_created_at ON orders(created_at); -- JOIN CREATE INDEX idx_orders_user_id ON orders(user_id);
If queries often filter by multiple columns:
SELECT * FROM orders WHERE customer_id = 10 AND STATUS = 'paid';
Use a composite index:
CREATE INDEX idx_orders_customer_status ON orders(customer_id, STATUS);
Avoid using:
SELECT * FROM users;
SELECT id, name, email FROM users;
Only select the columns your application actually needs.
Fetching huge datasets is expensive.
SELECT * FROM logs ORDER BY created_at DESC;
SELECT id, message, created_at FROM logs ORDER BY created_at DESC LIMIT 100;
JOINs are expensive when tables are large.
SELECT o.id, u.name FROM orders o JOIN users u ON o.user_id = u.id;
Recommended indexes:
CREATE INDEX idx_orders_user_id ON orders(user_id);
Usually, primary keys are already indexed automatically.
Do not JOIN tables if the data is not needed.
SELECT o.id, u.name FROM orders o JOIN users u ON o.user_id = u.id;
Avoid:
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
The N+1 problem happens when an application executes one query to fetch a list, then runs additional queries for each item.
1 query to get users + 100 queries to get each user's orders = 101 queries total
$users = User::all(); foreach ($users as $user) { echo $user->orders; }
This may generate hundreds of SQL queries.
Load related data in a single query.
$users = User::with('orders')->get();
Benefits:
Sometimes a JOIN is more efficient.
SELECT users.name, orders.total FROM users JOIN orders ON users.id = orders.user_id;
Use JOIN when:
Frequently executed queries should be cached.
Application
↓
Check cache
↓
If cache exists → return cached data
If not:
Query database
Store result in cache
Return result
Normalization improves data consistency, but too many JOINs can hurt performance.
Sometimes it is beneficial to combine data from multiple tables into one table.
Instead of:
orders users products addresses
You may store some duplicated data directly in `orders`:
orders: customer_name customer_email shipping_address
This reduces expensive JOIN operations.
Only denormalize after identifying real performance bottlenecks.
Avoid loading all rows at once.
SELECT * FROM products;
SELECT id, name, price FROM products LIMIT 20 OFFSET 0;
SELECT id, name, price FROM products WHERE id > 100 ORDER BY id LIMIT 20;
Keyset pagination is usually faster for large datasets.
Enable slow query logging in production.
Before deploying a query, check:
| Situation | Recommended Solution |
|---|---|
| Slow `WHERE` query | Add index |
| Slow sorting | Index `ORDER BY` columns |
| Too many ORM queries | Use eager loading |
| Heavy repeated reads | Add cache |
| Complex JOINs on huge tables | Consider denormalization |
| Large result sets | Use pagination |
| Slow query unknown cause | Use `EXPLAIN` |
Do not optimize blindly — optimize based on actual bottlenecks.