Skip to content
AgileLogics
6 min read

Query Optimization Techniques

Prerequisites: Reading query plans with EXPLAIN

Once you can read a query plan, most performance problems fall into a few familiar patterns.

1. Select only what you need

SELECT * fetches every column — more data to read, transfer, and hold in memory, and it prevents covering indexes (an index containing every column a query needs, so the table itself isn't read). List the columns.

2. Keep conditions index-friendly ("sargable")

-- Can't use an index on ordered_on: the column is inside a function
SELECT id FROM orders WHERE STRFTIME('%Y', ordered_on) = '2025';

-- Can use it: the column is compared directly
SELECT id FROM orders WHERE ordered_on >= '2025-01-01' AND ordered_on < '2026-01-01';

Both return the same rows; only the second can use an index on ordered_on.

3. Filter early, aggregate late

Reduce rows as early as possible — in WHERE, before joins and grouping — so later steps process less data. Prefer WHERE over HAVING for conditions that don't involve aggregates.

4. Avoid N+1 queries

Running one query per row from application code (100 orders → 100 customer lookups) is often the biggest slowdown in real applications. Fetch related data with one join or one IN (...) query.

5. Paginate with keys, not big offsets

SELECT id, ordered_on FROM orders
WHERE id > 5          -- the last id from the previous page
ORDER BY id
LIMIT 2;
+----+------------+
| id | ordered_on |
+----+------------+
|  6 | 2025-04-02 |
|  7 | 2025-04-10 |
+----+------------+

OFFSET 100000 makes the database read and discard 100,000 rows; keyset pagination jumps straight to the right place using an index.

6. Watch for accidental row multiplication

Joining two one-to-many relationships at once multiplies rows:

SELECT c.name,
  COUNT(o.id)          AS orders_wrong,
  COUNT(DISTINCT o.id) AS orders_right
FROM customers c
JOIN orders o       ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
WHERE c.id = 1
GROUP BY c.name;
+--------------+--------------+--------------+
|     name     | orders_wrong | orders_right |
+--------------+--------------+--------------+
| Ada Lovelace |            4 |            3 |
+--------------+--------------+--------------+

Each order appears once per item, so counting orders after joining items over-counts. Aggregate each relationship separately (in CTEs or subqueries), then join the results.

7. Keep statistics fresh

The planner chooses plans from statistics about your data. After large imports or deletes, run ANALYZE (PostgreSQL, SQLite) or ANALYZE TABLE (MySQL) so it has accurate numbers.

8. Measure, don't guess

Always compare EXPLAIN ANALYZE timings before and after a change, on realistic data volumes. A query that's fast on 100 rows can behave very differently on 10 million.