Back to Query Optimization & EXPLAIN
Q2Question 2 of 5

How will you optimize a slow query

Key Concepts
  • Always start with `EXPLAIN ANALYZE` to see the actual execution plan with real timings and row counts before making any changes.
  • A large gap between estimated and actual rows means stale statistics; running `ANALYZE` on the table to refresh them is sometimes the entire fix.
  • Indexing is the most common fix: add a composite index matching the filter with equality columns first and range columns last.
  • Make predicates sargable by removing functions from indexed columns so the index can be used by the query planner.
  • Ensure both sides of a join are indexed on the join key so the planner can choose an efficient hash or merge join.
  • Partitioning, caching, denormalization, and schema changes are heavier measures reserved for when indexing and rewriting are insufficient.
  • Change one thing at a time and re-run EXPLAIN ANALYZE after every change, because stacking fixes blindly hides what actually helped.

Definition

Read the plan first, fix the dominant cost with the least invasive change (index, rewrite, join, sargability), escalate to partitioning, caching, schema, or hardware only if needed, and re-check the plan every time.
  • Measure first – Always start with EXPLAIN ANALYZE to capture the actual execution plan with real timings and row counts, never guess.
  • Least invasive fix – Apply the cheapest effective change first: refresh statistics, add or adjust indexes, rewrite predicates to be sargable, choose proper join strategies.
  • Escalate when needed – Only after exhausting indexing and rewriting consider partitioning, caching, schema changes, or more server resources.
  • Re-measure always – After each change, re-run the plan to confirm the improvement rather than assuming it worked.

When a National Bank of India report is slow, you do not randomly throw more hardware at it. You first ask the database to show its work: a step-by-step replay of how it found the answer. That replay reveals the real culprit: maybe the clerk read the entire ledger instead of using the index. You fix exactly that, and only if the data is genuinely enormous do you split it into yearly volumes (partitioning) or keep popular answers on a sticky note (caching). After each fix you ask for the replay again to confirm it got faster.

Understanding the Concepts

What is the first step in optimizing a slow query?

The first step is always EXPLAIN ANALYZE, which shows the actual plan with real timings and real row counts versus the estimates.

SQL
-- At the National Bank of India, the account statement query is slow
EXPLAIN ANALYZE
SELECT t.txn_id, t.amount, t.txn_type, t.created_at
FROM transactions t
WHERE t.account_id = 'SAV2001'
  AND t.created_at >= '2024-01-01'
  AND t.created_at < '2024-02-01'
ORDER BY t.created_at DESC;

-- BAD plan output:
-- Sort  (cost=45000..45100 rows=1000 width=48) (actual time=8500..8520 rows=150)
--   Sort Key: created_at DESC
--   Sort Method: external merge  Disk: 2048kB
--   ->  Seq Scan on transactions  (cost=0..42000 rows=1000 width=48)
--                                 (actual time=0.1..8400 rows=150)
--         Filter: (account_id = 'SAV2001' AND created_at >= ... AND created_at < ...)
--         Rows Removed by Filter: 9999850
-- Planning Time: 0.2 ms
-- Execution Time: 8525 ms

The plan reveals two problems: a sequential scan reading all 10 million rows, and a sort spilling to disk.

How do stale statistics cause bad plans?

A big gap between estimated and actual rows signals stale statistics. The optimizer makes every decision based on table statistics, so if they are wrong, the plan is wrong.

SQL
-- The optimizer estimated 1000 rows but got 150
-- After a bulk load, statistics may be outdated

-- Fix: refresh statistics
ANALYZE transactions;

-- Re-run EXPLAIN ANALYZE to see if the plan changes
-- Sometimes this alone fixes the problem
JAVA
package com.bank.maintenance;

import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;

public class StatisticsRefreshService {

    private final DataSource dataSource;

    public StatisticsRefreshService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // Run after bulk loads or large data changes
    public void refreshStatistics(String tableName) throws SQLException {
        try (Connection conn = dataSource.getConnection();
             Statement stmt = conn.createStatement()) {
            stmt.execute("ANALYZE " + tableName);
            System.out.println("Statistics refreshed for " + tableName);
        }
    }
}

How does indexing fix slow queries?

Indexing is the most common fix. If a selective predicate drives a sequential scan, add a composite index matching the filter with equality columns first and range columns last.

SQL
-- The query filters by account_id (equality) and created_at (range)
-- Composite index: equality first, range last
CREATE INDEX idx_txn_acct_date
    ON transactions(account_id, created_at);

-- Re-run EXPLAIN ANALYZE:
-- Index Scan using idx_txn_acct_date on transactions
--   (cost=0.4..50 rows=150 width=48) (actual time=0.05..0.5 rows=150)
--   Index Cond: (account_id = 'SAV2001' AND created_at >= ... AND created_at < ...)
-- Planning Time: 0.1 ms
-- Execution Time: 0.6 ms

-- From 8525ms to 0.6ms: a 14,000x improvement
JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;

public class StatementService {

    private final DataSource dataSource;

    public StatementService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // Now fast because of idx_txn_acct_date composite index
    public List<String> getMonthlyStatement(String accountId, String startDate,
                                             String endDate) throws SQLException {
        String sql = "SELECT txn_id, amount, txn_type, created_at "
                   + "FROM transactions "
                   + "WHERE account_id = ? AND created_at >= ?::date AND created_at < ?::date "
                   + "ORDER BY created_at DESC";
        List<String> results = new ArrayList<>();
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, accountId);
            ps.setString(2, startDate);
            ps.setString(3, endDate);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    results.add(rs.getLong("txn_id") + ": "
                            + rs.getBigDecimal("amount"));
                }
            }
        }
        return results;
    }
}

What makes a predicate sargable or non-sargable?

A sargable predicate is one the optimizer can use an index to evaluate. Wrapping an indexed column in a function makes it non-sargable because the index stores the raw column value, not the function result.

SQL
-- NON-SARGABLE: function on indexed column prevents index usage
SELECT * FROM transactions
WHERE EXTRACT(YEAR FROM created_at) = 2024;
-- Plan: Seq Scan (reads ALL rows, applies function to each)

-- SARGABLE: rewrite as a range so the index works
SELECT * FROM transactions
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- Plan: Index Scan using idx_txn_acct_date

-- NON-SARGABLE: UPPER() on column
SELECT * FROM accounts WHERE UPPER(holder_name) = 'RAJESH KUMAR';
-- Fix: use a functional index
CREATE INDEX idx_upper_name ON accounts(UPPER(holder_name));
-- Or: store normalized data and compare directly
JAVA
package com.bank.repository;

public class SargabilityExamples {

    // WRONG: non-sargable predicate in application code
    // "SELECT * FROM transactions WHERE YEAR(created_at) = ?"
    // The index on created_at CANNOT be used

    // CORRECT: sargable range predicate
    // "SELECT * FROM transactions WHERE created_at >= ? AND created_at < ?"
    // The index on created_at IS used

    public static final String SARGABLE_QUERY =
            "SELECT txn_id, amount FROM transactions "
          + "WHERE account_id = ? "
          + "AND created_at >= ?::date AND created_at < ?::date";
}

How do proper joins improve performance?

Ensure both sides of a join are indexed on the join key so the planner can choose an efficient hash or merge join instead of a nested loop over unindexed rows. Filter early to shrink the rows entering the join.

SQL
-- Slow: nested loop because transaction_details has no index on txn_id
EXPLAIN ANALYZE
SELECT t.amount, d.category
FROM transactions t
JOIN transaction_details d ON t.txn_id = d.txn_id
WHERE t.account_id = 'SAV2001';

-- Fix: index the join key on the other table
CREATE INDEX idx_txndetail_txnid ON transaction_details(txn_id);

-- Now the planner uses Hash Join or Merge Join: much faster
JAVA
package com.bank.service;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import javax.sql.DataSource;

public class JoinOptimizationService {

    private final DataSource dataSource;

    public JoinOptimizationService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // Filter early: WHERE on account_id shrinks rows BEFORE the join
    public void getCategorizedTransactions(String accountId) throws SQLException {
        String sql = "SELECT t.amount, d.category "
                   + "FROM transactions t "
                   + "JOIN transaction_details d ON t.txn_id = d.txn_id "
                   + "WHERE t.account_id = ?";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, accountId);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    System.out.println(rs.getBigDecimal("amount") + " - "
                            + rs.getString("category"));
                }
            }
        }
    }
}

When should you use SELECT specific columns instead of SELECT *?

Replace SELECT * with only the columns you need so a covering index can apply and less data moves.

SQL
-- BAD: SELECT * forces heap access even when the index has what we need
SELECT * FROM transactions WHERE account_id = 'SAV2001';

-- GOOD: select only needed columns
SELECT amount, created_at FROM transactions WHERE account_id = 'SAV2001';

-- BEST: covering index answers entirely from the index
CREATE INDEX idx_txn_covering
    ON transactions(account_id) INCLUDE (amount, created_at);
-- Plan: Index Only Scan (no heap access at all)

When should you escalate to partitioning, caching, or schema changes?

Only after exhausting indexing and rewriting do you consider heavier measures.

SQL
-- Partitioning: split huge table by date range
CREATE TABLE transactions_partitioned (
    txn_id BIGSERIAL,
    account_id VARCHAR(20) NOT NULL,
    amount NUMERIC(15,2) NOT NULL,
    created_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (created_at);

CREATE TABLE transactions_2024_01 PARTITION OF transactions_partitioned
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

-- Date-range queries prune to one partition
JAVA
package com.bank.cache;

import redis.clients.jedis.Jedis;
import java.math.BigDecimal;

public class StatementCacheService {

    private final Jedis redis;

    public StatementCacheService(Jedis redis) {
        this.redis = redis;
    }

    // Caching: hot read-mostly data served from Redis
    public void cacheStatement(String accountId, String month, String result) {
        String key = "stmt:" + accountId + ":" + month;
        redis.setex(key, 300, result);  // 5-minute TTL
    }

    public String getCachedStatement(String accountId, String month) {
        return redis.get("stmt:" + accountId + ":" + month);
    }
}

What is the optimization discipline?

Change one thing at a time and re-measure. Stacking changes blindly makes it impossible to know what actually helped.

DIAGRAM
Optimization workflow:
1. EXPLAIN ANALYZE → identify dominant cost
2. Check estimated vs actual rows → stale stats? ANALYZE
3. Sequential scan? → add index (equality first, range last)
4. Function on column? → rewrite to sargable predicate
5. Bad join? → index join key, filter early
6. SELECT *? → select only needed columns
7. Re-run EXPLAIN ANALYZE → confirm improvement
8. Still slow? → partitioning, caching, schema change
9. Last resort → hardware upgrade
10. Always: one change at a time, re-measure each time

Visual Overview

DIAGRAM
┌────────────────────────────────────────────────────────────┐
│            Query Optimization Workflow                     │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  Step 1: EXPLAIN ANALYZE                                  │
│  ┌──────────────────────────────────┐                     │
│  │ Seq Scan? Sort spill? Bad join? │                     │
│  │ Estimated vs Actual rows gap?    │                     │
│  └──────────────┬───────────────────┘                     │
│                  │                                         │
│  Step 2: Cheapest Fix First                               │
│  ┌───────────┐ ┌───────────┐ ┌────────────┐              │
│  │ ANALYZE   │ │ Add Index │ │ Rewrite    │              │
│  │ (stats)   │ │ (composite│ │ (sargable) │              │
│  └─────┬─────┘ └─────┬─────┘ └─────┬──────┘              │
│        │              │              │                     │
│  Step 3: Re-run EXPLAIN ANALYZE                           │
│  ┌──────────────────────────────────┐                     │
│  │ Improved? → done                 │                     │
│  │ Still slow? → next step          │                     │
│  └──────────────┬───────────────────┘                     │
│                  │                                         │
│  Step 4: Heavier Measures                                 │
│  ┌───────────┐ ┌───────────┐ ┌────────────┐              │
│  │ Partition │ │ Cache     │ │ Schema     │              │
│  │ (by date) │ │ (Redis)   │ │ (denorm)   │              │
│  └───────────┘ └───────────┘ └────────────┘              │
│                                                            │
│  Last Resort: Hardware (hides, does not fix)              │
└────────────────────────────────────────────────────────────┘

Key Takeaway

Optimizing a slow query is a disciplined, measure-first process, never guesswork. The very first step is always EXPLAIN ANALYZE to get the actual plan with real timings and real row counts. That immediately tells you where the time goes: a sequential scan on a big table, a nested loop over too many rows, a sort spilling to disk, or a big gap between estimated and actual rows that signals stale statistics.

From there, apply the cheapest effective change. Indexing is the most common fix: a composite index matching the filter with equality columns first and range columns last. Then rewrite for logical cost: make predicates sargable by removing functions from indexed columns, select only the columns you need instead of star, and ensure both join sides are indexed. Only when the data itself is genuinely huge do you reach for heavier tools: range partitioning so queries prune to one slice, caching for hot read-mostly results, or schema changes like pre-aggregated summary tables.

The critical discipline is to change one thing at a time and re-run EXPLAIN ANALYZE after every change, because stacking fixes blindly makes it impossible to know what actually helped. Hardware upgrades are the last resort because they hide an inefficient query rather than fixing it.

At the National Bank of India, the account-statement query ran for eight seconds. The plan showed a sequential scan over ten million rows plus a disk sort. Rajesh refreshed statistics, added a composite index on account and date, and the plan turned into an index range scan with the sort node gone, dropping the query to under one millisecond.

Remember one sentence: read the plan first, fix the dominant cost with the least invasive change, and re-check the plan every time.

---

Share
Use ← → arrow keys to navigate