top of page

Optimize a Slow Database Query

  • Shawn West
  • Jul 30
  • 3 min read

Updated: Aug 6

Performance Engineering · Part 7

A single slow query can drag down an entire page, and the fix is rarely "add more hardware" — it's usually a query plan doing something dumb that a small change makes smart. This walks through optimizing one real slow query end to end: reading its plan, adding the right index, restructuring the joins, and knowing when to reach for materialization or approximation.

A real query: 5 seconds. The target: under 100ms. Let's walk through it.

Step 1: The Slow Query (5 min)

SELECT 
    u.id,
    u.name,
    COUNT(o.id) AS order_count,
    SUM(o.total) AS lifetime_value
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'completed'
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.name
HAVING COUNT(o.id) > 0
ORDER BY lifetime_value DESC NULLS LAST
LIMIT 100;

Takes 5 seconds on a 10M-row orders table.

Step 2: EXPLAIN ANALYZE (5 min)

EXPLAIN ANALYZE [the query];

Reveals:

Limit  (cost=X)
  Sort
    HashAggregate
      Hash Left Join
        Seq Scan on users  (filters by created_at)
        Seq Scan on orders (filters by status)
          → 5M rows scanned!

The orders sequential scan is the killer.

Step 3: First Fix — Index on Filter (5 min)

CREATE INDEX CONCURRENTLY idx_orders_status_user 
ON orders(user_id, status);

Composite: user_id first (joins on it), status second (filtered).

Re-run:

EXPLAIN ANALYZE [query];
Index Scan using idx_orders_status_user

Down to 1.5 seconds. Better but not great.

Step 4: Look at Users Scan (5 min)

Users table seq scan is also there:

Seq Scan on users  (cost=...)
  Filter: created_at > ...
CREATE INDEX idx_users_created ON users(created_at);

Now uses the index. Few hundred users out of millions.

Down to 800ms.

Step 5: Move Filter Into Join (10 min)

Hmm — query has o.status = 'completed' in JOIN condition but it's a LEFT JOIN. Pre-filtering helps:

WITH active_orders AS (
    SELECT user_id, total FROM orders WHERE status = 'completed'
)
SELECT 
    u.id, u.name,
    COUNT(o.user_id) AS order_count,
    SUM(o.total) AS lifetime_value
FROM users u
LEFT JOIN active_orders o ON o.user_id = u.id
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.name
HAVING COUNT(o.user_id) > 0
ORDER BY lifetime_value DESC NULLS LAST
LIMIT 100;

Postgres usually inlines this. But sometimes it changes the plan.

Step 6: Reconsider HAVING (10 min)

HAVING COUNT(o.id) > 0 + LEFT JOIN = effectively INNER JOIN. Make it explicit:

SELECT 
    u.id, u.name,
    COUNT(o.id) AS order_count,
    SUM(o.total) AS lifetime_value
FROM users u
INNER JOIN orders o ON o.user_id = u.id AND o.status = 'completed'
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.name
ORDER BY lifetime_value DESC
LIMIT 100;

INNER JOIN often optimizes better. Removed the unnecessary HAVING.

Step 7: Partial Index (5 min)

Since you only query status = 'completed':

CREATE INDEX idx_orders_completed
ON orders(user_id, total)
WHERE status = 'completed';

Smaller index. Just the rows that match. Includes total for the SUM.

Step 8: Materialize for Hot Path (10 min)

If this query runs constantly, materialize:

CREATE MATERIALIZED VIEW user_ltv AS
SELECT 
    user_id,
    COUNT(*) AS order_count,
    SUM(total) AS lifetime_value
FROM orders
WHERE status = 'completed'
GROUP BY user_id;

CREATE INDEX ON user_ltv(lifetime_value DESC);

REFRESH MATERIALIZED VIEW user_ltv;  -- periodically

Query then becomes:

SELECT u.id, u.name, ltv.*
FROM users u
JOIN user_ltv ltv ON ltv.user_id = u.id
WHERE u.created_at > NOW() - INTERVAL '30 days'
ORDER BY ltv.lifetime_value DESC
LIMIT 100;

Millisecond response. But data is stale to refresh time.

Step 9: Approximation (5 min)

Sometimes exact isn't needed:

-- HyperLogLog for distinct counts
SELECT estimate_distinct(user_id) FROM events;

Approximations are much faster for stats. Trade exactness for speed.

Step 10: Measure End-to-End (5 min)

Before: 5 seconds
After indexing: 800ms
After rewrite: 200ms
After materialized view: 5ms

Each step deliberate. Each verified with EXPLAIN ANALYZE.

Pick the optimization level that fits your needs.

What You Just Did

You took a 5-second query to 5ms. EXPLAIN-driven; targeted fixes; verified at each step.

Common Failure Modes

Adding random indexes. Some help; some hurt writes.

Trusting query plan from yesterday. Statistics matter; ANALYZE after big data changes.

Premature materialization. Adds complexity; often unneeded.

Optimizing rarely-run query. Spend time on what matters.

No measurements. Don't know if it helped.

Continue the Performance Engineering path

Part of the Performance Engineering learning path.

bottom of page