Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8, PostgreSQL 18.6 and MySQL 8.4.11
  • By MySmartCoPilot
Free intro, Pro: 7 of 30 minutes free
Free: Why a function on the column turns a search into a scan. With a pass: the other 8 sections and 3 interview answers, about 23 minutes.

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:

One day asked two ways, with both plans SQL · sqlite/day_filter.sql
-- 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

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.

With the bare column the index is searched and 217 entries are read; with date() around it, all 20,000 rows are read.Index on placed_at:entries sorted by thestored text, oldest firstplaced_at >= :dayAND placed_at < :next_daydate(placed_at) = :daySEARCH: jump to the firstentry of the day, read onuntil the next day startsSCAN: the index is notsorted by date(placed_at),so date() runs on every row217 entries read20,000 rows readcolumn left barecolumn inside a function

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() or substr(): 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 NULL belongs 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.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.