Module 9 · Advanced SQL
Query performance
Read a query plan, understand what an index does, and write queries a database can run quickly on millions of rows.
About 25 minutes
The problem
Your SQL runs in a second on Harbourline's 2,683 shipments. At your next job, the shipments table has 80 million rows, and the same query runs for twenty minutes, then times out and holds up everyone else's reports.
The data volume is out of your hands, but how you write the query isn't. The difference between a query that searches an index and one that scans every row can be a thousandfold at scale. Analysts who write fast queries get their numbers sooner and are trusted with access to bigger systems.
The concept
Scan or search
To find rows, a database either scans (reads every row in the table) or searches (uses an index to jump straight to the rows it needs). An index is like the index at the back of a book: a sorted list of values, each pointing to where the matching rows are. Searching 80 million rows through an index takes a few steps, while scanning them means reading all 80 million.
Reading the plan
EXPLAIN QUERY PLAN (SQLite), EXPLAIN (PostgreSQL, MySQL) or the "estimated execution plan" (SQL Server) shows what the database intends to do, without running the query. Look for:
| In the plan | Meaning |
|---|---|
SCAN table | reads every row. Fine for small tables, slow for big ones |
SEARCH table USING INDEX | jumps to matching rows. What you want for selective filters |
USING COVERING INDEX | the index holds every column needed, so the table isn't touched |
CORRELATED SCALAR SUBQUERY | a subquery that runs once per row of the outer query |
Habits that keep queries fast
- Keep filters sargable: leave the column bare.
WHERE strftime('%Y', booking_date) = '2026'has to calculate the year for every row, so it can't use an index onbooking_date.WHERE booking_date >= '2026-01-01' AND booking_date < '2027-01-01'can. - Select only the columns you need.
SELECT *reads and sends everything, and stops covering indexes from working. - Aggregate before you join. Total payments per shipment first, then join 2,409 rows down to 2,250, rather than joining everything and grouping at the end.
- Avoid correlated subqueries in
SELECTon big tables. Replace them with a join to a pre-aggregated CTE. - Prefer
UNION ALLtoUNIONwhen duplicates are impossible or wanted.UNIONhas to sort everything to remove them. - Beware leading wildcards.
LIKE '%Foods'can't use an index;LIKE 'Kings%'can.
Indexes aren't free: each one slows down inserts and updates and takes space. In most jobs analysts don't create indexes on production databases themselves; they show the plan to a data engineer or DBA and ask. Writing sargable queries is always in your hands.
Example
Harbourline's practice database has no indexes, so every query scans:
EXPLAIN QUERY PLAN
SELECT * FROM shipments WHERE customer_id = 42;SCAN shipments. Now create an index and ask again. Your browser has its own copy of the database, so this changes nothing for anyone else:
CREATE INDEX IF NOT EXISTS idx_shipments_customer ON shipments(customer_id);
EXPLAIN QUERY PLAN
SELECT * FROM shipments WHERE customer_id = 42;SEARCH shipments USING INDEX idx_shipments_customer (customer_id=?). The database now jumps straight to customer 42's rows.
Walkthrough
- Create an index on booking dates, and compare the plans of the two ways of filtering on a year:
CREATE INDEX IF NOT EXISTS idx_shipments_booking ON shipments(booking_date);
EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM shipments WHERE strftime('%Y', booking_date) = '2026';EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM shipments WHERE booking_date >= '2026-01-01' AND booking_date < '2027-01-01';- Read the two plans. The first still scans (the whole index, calculating the year for every entry). The second searches a range of the index:
booking_date>? AND booking_date<?. Same answer, very different work at scale. - Look at the plan for a correlated subquery:
EXPLAIN QUERY PLAN
SELECT
c.company_name,
(SELECT SUM(p.amount)
FROM payments AS p
JOIN shipments AS s ON s.shipment_id = p.shipment_id
WHERE s.customer_id = c.customer_id) AS total_paid
FROM customers AS c;- Find
CORRELATED SCALAR SUBQUERYin the plan: the subquery runs once for each of the 120 customers. With a million customers it would run a million times. The practice tasks below rewrite it.
Practice
Practice
Rewrite the correlated subquery from the walkthrough as a join to a pre-aggregated CTE. Show company_name and total_paid for customers who have made payments, highest first.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Practice
Count bookings per mode for March 2026, with a sargable date filter (no function on booking_date). Show mode and shipments, ordered by mode.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Challenge
Challenge · optional
This query lists customers with a delivered Sea shipment, but it joins every shipment and then removes duplicates: SELECT DISTINCT c.customer_id, c.company_name FROM customers c JOIN shipments s ON s.customer_id = c.customer_id JOIN routes r ON r.route_id = s.route_id WHERE r.mode = 'Sea' AND s.status = 'Delivered'. Rewrite it with EXISTS so no duplicates are created. Show customer_id and company_name, ordered by customer_id.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
More practice
Drill · optional
Look at the plan for a search inside company names. Run: EXPLAIN QUERY PLAN SELECT customer_id FROM customers WHERE company_name LIKE '%Foods%'. Then write the query itself: customer_id and company_name for every company whose name contains 'Foods', ordered by customer_id.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Drill · optional
Aggregate before joining: show each route's origin, destination and total delivered containers, by totalling shipments per route in a CTE first. Show route_id, origin, destination and containers, highest first.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Check your understanding
Answer every question to check.