EduFlow
SQL for Analysts
0%

Section 4 · SQL in the Real World

Indexes and Query Plans

16 min4 min readSQL exercise

Lesson at a glance

Read EXPLAIN QUERY PLAN output to tell a full scan from an index search, write filters the database can serve from an index, and judge when a new index is worth asking for.

  1. 1What an index is
  2. 2Reading a query plan
  3. 3Plans are predictions
  4. 4When to ask for a new index

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Mark has a dashboard that shows orders for a chosen day, and on the production database it’s become slow. “Same query as always. Why is it suddenly slow?” On Cartwheel’s two thousand orders, every query in this course returns instantly. Production has years of orders, and there the difference between a query that reads every row and one that jumps to the right rows is seconds versus minutes. You can’t feel that difference on a small database, but you can see it in the query plan.

What an index is

An index is a separate structure that keeps the values of one or more columns in sorted order, each with a pointer to its row. Finding ‘2025-11-28’ in a sorted list is like finding a word in a dictionary: you jump to roughly the right page instead of reading from the start. Without an index, the database has to read the whole table and test every row, which is a scan.

Left: a full scan checks all rows of the orders table one by one. Right: the idx_orders_date index holds dates in sorted order; a search jumps to 2025-11-28, reads the matching entries and follows their pointers to the rows. SCAN orders test every row SEARCH idx_orders_date 2024-01-02 … 2025-11-28 08:… 2025-11-28 23:… 2025-12-30 row 2290 row 1769 jump, read the range, follow pointers
A scan reads every row; an index search jumps to the matching range

Cartwheel’s database has a few indexes already. You can list them from the schema table:

SQL
SELECT name, tbl_name, sql
FROM sqlite_schema
WHERE type = 'index';

orders is indexed on customer_id and order_date, order_items on order_id and product_id. The sqlite_autoindex_customers_1 entry exists because email is declared UNIQUE; SQLite enforces uniqueness with an index.

Reading a query plan

Prefix any SELECT with EXPLAIN QUERY PLAN and SQLite describes how it will run it instead of running it. The interesting part is the detail column:

SQL
EXPLAIN QUERY PLAN
SELECT order_id, status
FROM orders
WHERE order_date >= '2025-11-28'
  AND order_date <  '2025-11-29';

SEARCH orders USING INDEX idx_orders_date (order_date>? AND order_date<?). SEARCH means the index narrowed the work to a range. Now Mark’s dashboard query, which someone “simplified” last month:

SQL
EXPLAIN QUERY PLAN
SELECT order_id, status
FROM orders
WHERE date(order_date) = '2025-11-28';

SCAN orders. Same result, but now every row is read and date() is computed for each one. The index stores raw order_date values; it knows nothing about date(order_date), so it can’t help. That’s the “suddenly slow”.

Two more plan phrases are worth recognising. USING COVERING INDEX means the index alone held every column the query needed, so the table wasn’t touched at all, the fastest kind of lookup. And USE TEMP B-TREE FOR GROUP BY or FOR ORDER BY means SQLite had to sort rows itself, which is fine on small results and worth a look on large ones.

Plans for joins show one line per table, in the order SQLite visits them:

SQL
EXPLAIN QUERY PLAN
SELECT o.order_id, SUM(oi.quantity) AS units
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.customer_id = 42
GROUP BY o.order_id;

Both lines say SEARCH: find customer 42’s orders through idx_orders_customer, then each order’s items through idx_items_order. An index on every foreign key you join on is the single most valuable indexing habit.

Plans are predictions

A query plan is the optimizer’s choice, not a guarantee of speed, and it depends on the data. On a tiny table the optimizer may decide a scan is cheaper than an index, and it’ll be right. Both engines can keep statistics about each table, gathered by the ANALYZE command (PostgreSQL’s autovacuum runs it for you; in SQLite someone has to run it), and use them to estimate how many rows each step returns. When those statistics are stale, plans go wrong in surprising ways, which is one more question to ask the database owner about a query that slowed down “for no reason”.

Some filters can’t use an ordinary index however you write them. name LIKE '%mat%' has to look inside every value, because a sorted list can’t help you find text in the middle of words. If a search like that is frequent, the answer is a different tool, such as full-text search, rather than another index.

When to ask for a new index

Analysts often don’t own the schema, but you’ll be the one who notices the slow query. Support filters tickets by opened_at constantly, and that column has no index, so the plan for those queries is a SCAN. The request to the database owner would be:

SQL
CREATE INDEX idx_tickets_opened_at ON support_tickets (opened_at);

Indexes aren’t free. Each one takes storage and must be updated on every insert, update and delete, so a table with ten indexes writes noticeably slower. Ask for indexes on columns that frequent queries filter, join or sort on, and that narrow the result a lot. An index on status, with five values, rarely helps; an index on a date or an id usually does. For a multi-column index, put the column you test with = first and the range column second: (customer_id, order_date) serves “customer 42’s orders in 2025” with one search.

Fast queries are only useful if people can read them. Next: the style habits and dialect differences that make your SQL reviewable and portable.

Key takeaways

  • An index is a sorted copy of one or more columns with pointers back to the rows, so the database can jump to matching values instead of reading every row.
  • In EXPLAIN QUERY PLAN, SEARCH … USING INDEX means the index narrowed the work; SCAN means every row (or every index entry) was read.
  • Wrapping an indexed column in a function or arithmetic, like date(order_date), usually stops the index from being used; compare the bare column with a range instead.
  • Indexes speed up reads but cost storage and slow down every insert and update, so add them for frequent, selective filters and join keys.

Further reading

Check your understanding

4 questions

Question 1 of 4

orders has an index on order_date. Which filter for orders on 28 November 2025 can use it as a SEARCH?

Question 2 of 4

A plan shows SCAN orders USING COVERING INDEX idx_orders_date. What does that mean?

Question 3 of 4

An analytics database gets a million new web sessions a day and is queried a few times a day by source. Someone proposes indexes on all nine columns of web_sessions. What’s the best response?

Question 4 of 4

In PostgreSQL, what does EXPLAIN ANALYZE do that EXPLAIN doesn’t?

Practice

SQL exercise

A fast and correct weekend filter

Mark’s dashboard shows orders per day for the Black Friday weekend, Friday 28 November to Monday 1 December 2025 inclusive (all statuses).

Return day (‘YYYY-MM-DD’) and orders, sorted by day. Order matters. The starter’s filter is index-friendly but drops a whole day.

Fix it so the result is complete and the filter still uses idx_orders_date: run your query with EXPLAIN QUERY PLAN in front and check that the plan says SEARCH, not SCAN.

Tables
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- Orders per day over the Black Friday weekend (Fri 28 Nov to Mon 1 Dec 2025)
SELECT
  date(order_date) AS day,
  COUNT(*) AS orders
FROM orders
WHERE order_date BETWEEN '2025-11-28' AND '2025-12-01'
GROUP BY day
ORDER BY day;Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.