SQL (PostgreSQL, MySQL, SQLite) Module 12 – Indexes, query plans and performance
Writing index-friendly (sargable) conditions
Rewrite WHERE conditions that hide an indexed column inside functions, arithmetic, casts or leading wildcards, so the index is searched and the rows match.
What you will learn
- Identify the conditions that stop an engine from searching an index
- Rewrite a function on a column as a range on the bare column
- Avoid implicit conversions, leading wildcards and collation mismatches
- Prove that a rewrite returns the same rows and searches the index
Before you start
What you’ll learn · Before you start
What you will learn
- Identify the conditions that stop an engine from searching an index
- Rewrite a function on a column as a range on the bare column
- Avoid implicit conversions, leading wildcards and collation mismatches
- Prove that a rewrite returns the same rows and searches the index
Before you start
On this page
A condition is sargable, short for search-argument-able, when the database can answer it by searching an index
instead of testing every row. An index keeps a column’s values in sorted order, so it can narrow a search only when
the condition compares the bare column with a value that is known before the search starts.
placed_at >= '2026-03-15' AND placed_at < '2026-03-16' is sargable. date(placed_at) = '2026-03-15' asks the same
question, but the column sits inside a function, and the index is not sorted by that function’s result, so the engine
has to compute it for every row. Keeping the column bare is often the cheapest speed-up SQL offers: no new index, no
bigger server, and exactly the same rows.
Why a function on the column turns a search into a scan
The PostgreSQL manual states the rule for its B-tree indexes: the planner considers one when an indexed column is
compared using <, <=, =, >= or >. SQLite’s optimizer documentation lists the same shapes, a column on one
side and an expression on the other, as the terms an index can use. A comparison on date(placed_at) has no bare
column in it, whatever the function does. The script below asks both questions of 20,000 orders with an index on
placed_at:
-- 20,000 orders placed over 90 days, stored as UTC text ('YYYY-MM-DD HH:MM:SS'),
-- and an index on the moment each order was placed.
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
placed_at TEXT NOT NULL,
amount_paise INTEGER NOT NULL
);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 20000)
INSERT INTO orders
SELECT i, 1 + (i * 37) % 2000,
strftime('%Y-%m-%d %H:%M:%S', '2026-01-01', '+' || (i * 397 % 7776000) || ' seconds'),
100 + (i * 7919) % 90000
FROM n;
CREATE INDEX orders_placed_at ON orders (placed_at);
-- One question, asked two ways: which orders were placed on 2026-03-15?
-- 1. The column is inside a function.
EXPLAIN QUERY PLAN
SELECT id, amount_paise FROM orders
WHERE date(placed_at) = '2026-03-15';
-- 2. The bare column is compared with the two ends of the day.
EXPLAIN QUERY PLAN
SELECT id, amount_paise FROM orders
WHERE placed_at >= '2026-03-15' AND placed_at < '2026-03-16';
-- Both forms find the same orders.
SELECT
(SELECT count(*) FROM orders
WHERE date(placed_at) = '2026-03-15') AS with_function,
(SELECT count(*) FROM orders
WHERE placed_at >= '2026-03-15' AND placed_at < '2026-03-16') AS with_range; Output
┌────┬────────┬─────────┬─────────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼─────────────┤ │ 2 │ 0 │ 216 │ SCAN orders │ └────┴────────┴─────────┴─────────────┘ ┌────┬────────┬─────────┬──────────────────────────────────────────────────────────────────────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼──────────────────────────────────────────────────────────────────────────┤ │ 3 │ 0 │ 165 │ SEARCH orders USING INDEX orders_placed_at (placed_at>? AND placed_at<?) │ └────┴────────┴─────────┴──────────────────────────────────────────────────────────────────────────┘ ┌───────────────┬────────────┐ │ with_function │ with_range │ ├───────────────┼────────────┤ │ 217 │ 217 │ └───────────────┴────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < day_filter.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
SCAN orders means that SQLite read every row and computed date() on each. SEARCH orders USING INDEX means that
it jumped to the first entry of the day, read entries until the next day began, and stopped: 217 entries. The plan
prints > and < for >= and <= alike. Both forms count the same 217 orders, which is the other half of every
rewrite: it must search the index and return identical rows.
The same day asked with the bare column and with date() around it
Text description of the diagram
The diagram starts from an index on placed_at, whose entries are sorted by the stored text, oldest first. Two arrows lead from it to the same question about the orders of one day, asked two ways.
1. With the column left bare, placed_at >= :day AND placed_at < :next_day, the engine searches the index: it jumps to the first entry of the day, reads on until the next day starts and stops. It reads 217 entries. 2. With the column inside a function, date(placed_at) = :day, the index cannot be searched, because it is sorted by placed_at and not by date(placed_at). The engine scans and runs date() on every row. It reads all 20,000 rows.
Both questions return the same 217 orders; only the amount of work differs.
Most slow filters hide the column in one of a few ways, and each has a rewrite:
- Functions on the column, such as
date(),lower()orsubstr(): write a range on the bare column, or index the expression itself. - Arithmetic on the column, such as
amount_paise / 100 = 500: move the arithmetic to the constant side, keeping the rounding of integer division. - Casts and implicit conversions: compare a column with a value of its own type, because converting every value of the column defeats its index.
- Leading wildcards, as in
LIKE '%@example.org': an index sorted from the first character cannot find endings, so index the part people search for. - Case and collation mismatches: a case-insensitive comparison needs an index built with the same collation, or an index on the same expression.
- OR across different columns: every column in the OR needs an index of its own, and the engine then combines the searches.
IS NULLbelongs on no such list: an ordinary B-tree index can search for NULLs in SQLite, PostgreSQL and MySQL.
A rewrite counts only when the plan improves and the rows stay identical, so check both every time.
Dates and times: half-open ranges that keep every row Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Five ways to ask for one day, and the three that lose rows
- An IST day in a UTC column, rewritten once
- Arithmetic, casts and types: keep the conversion off the column
- Integer division, reproduced: the fast rewrite that finds no rows
- Implicit conversions differ by engine
- Prefixes, suffixes and case: what LIKE can search
- OR, NULL and coalesce: conditions that only look unsearchable
- Rewrite the query or index the expression: a trade-off table
- Proving a rewrite: the same rows, a better plan
- Practise: fix a fast but wrong rewrite
- Fix a fast but wrong rewrite of a monthly filter
- Check yourself
- Interview questions
- A daily sales report filters a 40-million-row orders table with WHERE DATE(created_at) = ? and has slowed down as the table grew, although created_at has an index. Explain why, and rewrite the filter without changing the results.
- A MySQL 8.4 login lookup, WHERE phone = 9876543210, reads the whole users table although phone is an indexed VARCHAR. A colleague says the index is corrupt and wants to rebuild it. What is happening, and what do you change?
- Support agents search 20 million customers by email domain, to find everyone at one company, and by a case-insensitive prefix of the name. Both searches scan the table in PostgreSQL 18. Design the indexes and the query shapes, and say what each costs.
- Key takeaways
8 runnable examples (SQL, conversions.py, dates_and_casts.py, mysql_server.py, pg_server.py, text_patterns.py) · 1 exercise with sample tests · 3 interview answers · about 25 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
References
- PostgreSQL 18 Documentation: Index Types (B-tree operators, IS NULL and LIKE) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Operator Classes and Operator Families (text_pattern_ops) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Indexes on Expressions (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: CREATE INDEX (immutable functions) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Combining Multiple Indexes (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Operators (type resolution) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Date/Time Functions and Operators (AT TIME ZONE) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: pg_trgm (index support for LIKE) (The PostgreSQL Global Development Group)
- SQLite: The SQLite Query Optimizer Overview (WHERE clause analysis, OR and LIKE optimizations) (SQLite)
- SQLite: Datatypes In SQLite (type conversions prior to comparison) (SQLite)
- SQLite: Indexes On Expressions (SQLite)
- SQLite: Date And Time Functions (time value formats) (SQLite)
- MySQL 8.4 Reference Manual: Range Optimization (Oracle Corporation)
- MySQL 8.4 Reference Manual: Type Conversion in Expression Evaluation (Oracle Corporation)
- MySQL 8.4 Reference Manual: CREATE INDEX (functional key parts) (Oracle Corporation)
- MySQL 8.4 Reference Manual: Index Merge Optimization (Oracle Corporation)
- SQLite: Generated Columns (SQLite)
- PostgreSQL 18 Documentation: Generated Columns (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: CREATE TABLE and Generated Columns (Oracle Corporation)
- IANA: Time Zone Database (Internet Assigned Numbers Authority)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress