Skip to content

Expert: design, speed and engines · Indexes, query plans and views · topic 50 of 62 · 35 min

Writing fast queries

Explanation

Indexes only help if the query lets them. Keep indexed columns bare in WHERE (sargable predicates), use date ranges instead of functions on dates, avoid leading-wildcard LIKE, select only the columns you need, and replace the N+1 pattern with one query. Page with keyset pagination instead of big OFFSETs, prefer EXISTS for "is there any", batch your writes, and keep statistics fresh.

Sargable predicates

A condition is sargable (from "search argument able") when the database can answer it by searching an index. The rule of thumb: leave the indexed column alone on one side of the comparison. As soon as you wrap the column in a function or do maths on it, the index (which stores the plain values) cannot be searched.

Not sargable (scans)Sargable (can search)
WHERE strftime('%Y-%m', order_date) = '2025-03'WHERE order_date >= '2025-03-01' AND order_date < '2025-04-01'
WHERE price * 1.18 > 1000WHERE price > 1000 / 1.18
WHERE substr(phone, 1, 3) = '981'WHERE phone LIKE '981%' (a prefix; engine rules apply)
WHERE lower(email) = ? (with an index on email)An expression index on lower(email), or store emails in lower case
output
-- With an index on orders(order_date), real SQLite plans:
WHERE strftime('%Y-%m', order_date) = '2025-03'
  SCAN orders USING COVERING INDEX idx_orders_date
WHERE order_date >= '2025-03-01' AND order_date < '2025-04-01'
  SEARCH orders USING COVERING INDEX idx_orders_date (order_date>? AND order_date<?)
Same rows, very different work: the first reads the whole index, the second jumps to March.

Notice the half-open range: >= the first day and < the first day of the next month. It works for dates and timestamps alike, and never misses the last day's evening.

LIKE with a leading wildcard

An index on name is sorted from the first letter, so LIKE 'Rice%' can (in many engines) jump to the R section. LIKE '%Rice%' could match anywhere in the text, so every value must be checked. For "contains" search on big tables, use full-text search (SQLite FTS5, PostgreSQL tsvector or trigram indexes, MySQL FULLTEXT) or a search engine.

Select only what you need

SELECT * drags every column, including long text, across the disk and network. Naming only the needed columns also makes covering indexes possible: SELECT id, order_date can be served from an index on (customer_id, order_date) alone, while SELECT * always visits the table.

The N+1 query problem

This is the most common performance bug in app code. The app runs one query to get a list, then one more query for each item in the list. With 50 customers that is 51 round trips to the database; with 5,000 it is 5,001, each paying network delay.

python
customers = db.execute("SELECT id, name FROM customers").fetchall()     # 1 query
for cid, name in customers:
    n = db.execute("SELECT COUNT(*) FROM orders WHERE customer_id = ?", (cid,)).fetchone()[0]   # + N queries
    print(name, n)
The N+1 pattern in Python. ORMs hide it well: a loop that touches order.customer.name can do the same.
sql
SELECT c.id, c.name, COUNT(o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;
The fix: one query that returns everything. ORMs offer the same, for example select_related and prefetch_related in Django.

Pagination: keyset instead of a big OFFSET

LIMIT 20 OFFSET 100000 looks cheap, but the database must produce and throw away 1,00,000 rows to give you 20, and page 5,000 is slower than page 1. Keyset pagination (also called seek pagination) remembers where the last page ended and asks for rows after it. With an index on the sort columns, every page is equally fast.

sql
-- Page 1
SELECT id, order_date, status FROM orders ORDER BY order_date, id LIMIT 3;
-- The last row was (2025-02-20, 104). Page 2:
SELECT id, order_date, status FROM orders
WHERE (order_date, id) > ('2025-02-20', 104)
ORDER BY order_date, id LIMIT 3;
-- 105 | 2025-03-02 | Shipped
-- 106 | 2025-03-15 | Delivered
-- 107 | 2025-03-28 | Placed
id is added as a tie-breaker so rows with the same date are never skipped or repeated. The row-value comparison (a, b) > (x, y) works in SQLite, PostgreSQL and MySQL.

The trade-off: keyset pages cannot jump straight to "page 37". That suits infinite scroll and "Next" buttons, which is what most apps use.

EXISTS instead of COUNT(*) > 0

To ask "does this customer have any delivered order?", COUNT(*) > 0 counts every match, while EXISTS can stop at the first one. EXISTS also states the intent clearly.

Batch your writes

Every separate statement outside a transaction is its own transaction, and committing usually waits for the disk. Inserting 10,000 rows one by one can take far longer than inserting them inside one BEGIN ... COMMIT, or with multi-row INSERT ... VALUES (...), (...). Batch in chunks (for example 1,000 rows) so one transaction does not grow too large.

Keep statistics fresh

The planner chooses plans using statistics: how many rows a table has and how values are spread. ANALYZE collects them (SQLite stores them in sqlite_stat1; the SQLite docs recommend running PRAGMA optimize regularly). PostgreSQL's autovacuum runs ANALYZE automatically, but run it by hand after a big load. MySQL uses ANALYZE TABLE.

Common mistakes

  • Wrapping an indexed date in a function. Fix: a half-open range on the bare column.
  • Fetching related rows in a loop. Fix: a JOIN, or one query with WHERE id IN (...).
  • Deep OFFSET pages on big tables. Fix: keyset pagination on an indexed sort key plus a unique tie-breaker.
  • Optimising before measuring. Fix: find the slowest frequent queries first (slow query logs, pg_stat_statements in PostgreSQL), then read their plans.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Writing fast queries · 1/7

Writing fast queries

An index only helps if the query lets the database search it. Let us write queries that do.

Prefer a video by a teacher? Find free videos on “sql query optimization sargable n+1 problem keyset pagination” on YouTube ↗

In real life: An infinite-scroll feed of transactions

A banking or UPI app shows your transaction history as an endless list. Using OFFSET would make the screen slower the further you scroll, and new transactions arriving at the top would shift pages and repeat rows. Instead, the app asks for the 20 newest transactions, remembers the time and id of the last one, and next asks for the 20 before that point, using an index on (account_id, created_at, id). Every scroll costs the same, and no row is repeated.

sql
SELECT id, created_at, amount_paise, counterparty
FROM transactions
WHERE account_id = ?
  AND (created_at, id) < (?, ?)          -- the last row of the previous page
ORDER BY created_at DESC, id DESC
LIMIT 20;
Keyset pagination, newest first.

Practice (0/4 done)

Task 1 · Code · SQL

This query is not sargable: SELECT id, order_date, status FROM orders WHERE strftime('%Y-%m', order_date) = '2025-03'. Rewrite it with a half-open date range on the bare order_date column (from 2025-03-01, before 2025-04-01) so an index could be used. Return id, order_date and status.

Task 2 · Code · SQL

Keyset pagination: orders are listed by order_date and then id, 3 per page. Page 1 ended with the row (order_date '2025-02-20', id 104). Write the query for page 2: return id, order_date and status of the next 3 orders after that row, in order_date, id order. Do not use OFFSET.

Task 3 · Code · SQL

Which customers have at least one Delivered order? Use EXISTS (not COUNT) with a correlated subquery on orders. Return name, sorted by name.

Task 4 · Code · SQL

Fix an N+1 problem. An app loads customers and then runs one COUNT query per customer. Replace all of that with ONE query: return id, name and orders (the number of orders of that customer, 0 when none) for every customer, sorted by id.

Quick quiz

Question 1

1.Which WHERE clause is sargable for an index on hire_date?

Question 2

2.A page shows 50 orders and then runs one query per order to fetch its customer's name. What is this called?

Question 3

3.Why is LIMIT 20 OFFSET 200000 slow on a big table?

Question 4

4.Why add id to ORDER BY order_date, id in keyset pagination?

Question 5

5.An import inserts 50,000 rows into SQLite, one statement at a time, and takes minutes. What is the usual fix?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

Which WHERE clause is sargable for an index on hire_date?

Answer: hire_date >= '2022-01-01' AND hire_date < '2023-01-01'

Only the range leaves the column bare, so the index can be searched.

A page shows 50 orders and then runs one query per order to fetch its customer's name. What is this called?

Answer: The N+1 query problem

One query for the list plus N follow-up queries. Use a JOIN instead.

Why is LIMIT 20 OFFSET 200000 slow on a big table?

Answer: The database must produce and skip 2,00,000 rows first

Skipped rows still cost work. Keyset pagination avoids it.

Why add id to ORDER BY order_date, id in keyset pagination?

Answer: As a unique tie-breaker, so rows with the same date are never skipped or repeated

The sort key must be unique for "after this row" to be exact.

An import inserts 50,000 rows into SQLite, one statement at a time, and takes minutes. What is the usual fix?

Answer: Wrap the inserts in one transaction (or batches of transactions)

Each standalone statement commits separately; one transaction commits once.

Which query is keyset (cursor) pagination?Related

Answer: SELECT * FROM posts WHERE id > 940 ORDER BY id LIMIT 20

Keyset pagination continues after the last seen key with WHERE id > ... ORDER BY id.

From Pagination: sending data page by page

An index exists on (customer_id, order_date). Which WHERE clause can NOT use it well?Related

Answer: order_date > '2026-01-01'

A composite index is sorted by its first column first. Without customer_id, the database cannot jump into it.

From Indexes: making queries fast

Which SQLite plan line means the index alone answered the query?Related

Answer: SEARCH orders USING COVERING INDEX idx_x (customer_id=?)

COVERING means every needed column was in the index.

From Reading query plans

What does SELECT DISTINCT city FROM customers return?Related

Answer: Each city only once

DISTINCT removes repeated rows, so each city appears once.

From ORDER BY, LIMIT and DISTINCT

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 1 more topic in “Indexes, query plans and views” before its exam: Views and materialized views.
  2. Module 10 exam: Indexes, query plans and views · 13 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Procedures, triggers, SQL engines and NoSQL. 62 topics left in the course before the final exam.

← Previous: Reading query plans