Skip to content

Grokem · https://www.grokem.com/databases/administration-and-tuning/query-optimization/read-a-query-plan

Query OptimizationHow-to guide

Read a query plan and find the slow part

Ask the database how it will run a query, read the plan from the inside out, and use it to decide whether an index, a rewrite or nothing at all is the fix.

Grokem Editorial

Editorial desk

Published
  • Intermediate
  • 15 min to complete
Tested withSQLite3.42

Before you start

  • Comfort with SELECT, JOIN and GROUP BY
  • A query that feels slow, or a toy one to practise on

A slow query rarely needs cleverness. It needs a look at what the database is actually doing. The query plan is the database's own description of how it will fetch the rows, and reading one is a skill worth an afternoon.

1. Get the plan

Put the keyword in front of the query:

Database Command Runs the query?
SQLite EXPLAIN QUERY PLAN SELECT ... No
PostgreSQL EXPLAIN SELECT ... No
PostgreSQL EXPLAIN (ANALYZE, BUFFERS) SELECT ... Yes, and reports real timings
MySQL EXPLAIN SELECT ... No

Be careful with EXPLAIN ANALYZE on UPDATE, DELETE or INSERT: it performs them. Use BEGIN; EXPLAIN ANALYZE ...; ROLLBACK;.

2. A worked example

Two tables, 5,000 customers and 100,000 orders, and a query for the top spenders in Germany among large orders.

python
import random
import sqlite3

conn = sqlite3.connect(":memory:")
conn.executescript(
    """
    CREATE TABLE customers (id INTEGER PRIMARY KEY, country TEXT, name TEXT);
    CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total REAL, created_at TEXT);
    """
)
random.seed(2)
conn.executemany("INSERT INTO customers VALUES (?, ?, ?)",
                 [(i, random.choice(["DE", "FR", "ES", "IT", "NL"]), f"customer {i}") for i in range(1, 5001)])
conn.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)",
                 [(i, random.randint(1, 5000), round(random.uniform(5, 500), 2), "2025-03-01") for i in range(1, 100001)])
conn.commit()

sql = """
SELECT c.name, SUM(o.total) AS spent
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE c.country = 'DE' AND o.total > 400
GROUP BY c.id
ORDER BY spent DESC
LIMIT 5
"""


def show(label):
    print(label)
    for _id, parent, _notused, detail in conn.execute("EXPLAIN QUERY PLAN " + sql):
        print(f"  {_id:>2} (parent {parent:>2})  {detail}")


show("before")
conn.execute("CREATE INDEX idx_orders_customer_total ON orders (customer_id, total)")
conn.execute("ANALYZE")
show("after the index")

This prints:

text
before
   9 (parent  0)  SCAN o
  14 (parent  0)  SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
  19 (parent  0)  USE TEMP B-TREE FOR GROUP BY
  64 (parent  0)  USE TEMP B-TREE FOR ORDER BY
after the index
   9 (parent  0)  SCAN c
  13 (parent  0)  SEARCH o USING COVERING INDEX idx_orders_customer_total (customer_id=? AND total>?)
  53 (parent  0)  USE TEMP B-TREE FOR ORDER BY

3. Read it line by line

Plans are read as steps, and the shape matters more than the numbers.

Before. SCAN o means every one of the 100,000 orders is read. For each order that passes the total > 400 test, SEARCH c USING INTEGER PRIMARY KEY is a cheap lookup of its customer. Then two temporary B-trees are built, one to group and one to sort the result. The expensive line is the first.

After. The index on (customer_id, total) changes the strategy. The database now scans the small customers table (5,000 rows), keeps the German ones, and for each customer searches the index for orders with total > ?. The sort for the grouping has gone, because customers are now visited one at a time and each customer's orders arrive together. Only the final sort remains, over one row per customer.

Notice that the planner reversed the join order. You wrote FROM orders JOIN customers, but the plan began with customers. The order you write a join in is not the order it runs.

4. A short vocabulary

You see It means Usually
SCAN t / Seq Scan Every row of the table is read Fine for small tables or when most rows are needed; a warning on a big table with a selective filter
SEARCH ... USING INDEX / Index Scan The index finds the matching rows What you want for selective filters
COVERING INDEX / Index Only Scan The index holds all needed columns; the table is not read The ideal case
USE TEMP B-TREE FOR ORDER BY / Sort The rows are sorted after they are found Free if an index can supply the order; costly on large results
Nested Loop, Hash Join, Merge Join (PostgreSQL) How two inputs are joined Hash joins suit large unsorted inputs; nested loops suit a few rows with an index

5. In PostgreSQL, compare estimates with reality

EXPLAIN (ANALYZE, BUFFERS) adds what actually happened to what the planner expected. For each node look at estimated rows against actual rows. A big gap (the planner expected 10 and got 100,000) is the classic cause of a bad plan: its statistics are stale or it cannot estimate the filter. Running ANALYZE on the table refreshes the statistics, and is the first thing to try.

6. Decide what to do

  1. A scan on a large table with a selective filter: consider an index on the filtered column, then confirm in the plan that it is used.
  2. An index exists but is not used: the usual causes are a function around the column, a leading wildcard or a mismatched type. The indexes guide lists them.
  3. A sort on a big result: an index that matches the ORDER BY can remove it.
  4. Plan is fine but the query is still slow: the work is in returning too many rows, or the machine is short of memory. Fetch fewer columns and rows.
  5. Nothing looks wrong: stop. Not every slow-feeling query has a fix, and an unnecessary index only slows writes.

Change one thing at a time, read the plan again, and keep the version that is simpler.

Expected result

You can say, from the plan alone, which table is read in full, which is searched through an index, and where a sort happens.

Frequently asked questions

Does EXPLAIN run the query?

Plain EXPLAIN (or EXPLAIN QUERY PLAN in SQLite) only plans it. PostgreSQL's EXPLAIN ANALYZE really runs it, so wrap statements that change data in a transaction you roll back.

Are plans the same in every database?

The ideas are: scans, index searches, joins, sorts, aggregation. The wording and the detail differ, so learn to read your database's vocabulary.

References

From Grokem: https://www.grokem.com/databases/administration-and-tuning/query-optimization/read-a-query-plan

How did this strike you?

Be the first to react – it is anonymous.

Community

Questions about this?

Ask in Questions and help and get help from other readers.

Ask a questionBrowse Questions and help

Comments

No comments yet. Share what worked for you, or ask a question.

Choose what we may use. You can change this at any time with “Privacy choices” at the foot of every page. Details are in the Cookie policy and the Privacy policy.

Strictly necessary

Remembers your theme, the lessons you marked as done and this choice. It stays on your device, tracks nothing and cannot be switched off.

Always on
What exactly is collected, and why

Grokem’s own counts

Collects
The pages you open and how long you stay, how far you scroll, where you came from, your device type, browser, screen size, language and time zone. Your IP address is turned into a code that changes every day; it is never stored as it is.
Why
To see how long lessons are read and where readers leave. Not shared with anyone.
Stored
A random visitor ID, a visit ID and the time of your last page view in your browser’s storage (gr_vid, gr_sid, gr_last). No name or email is attached.