What Happens When You Type a URL and Press Enter
DNS lookups, TCP and TLS handshakes, HTTP requests, servers, and the browser's rendering pipeline. The whole journey of a web page, explained so it finally clicks.
Read →A query that runs in 4ms locally but takes 9 seconds in production usually has the same cause: a function like LOWER() or TRIM() wrapped around an indexed column. Learn why this makes queries non-sargable, how to spot it in your execution plan, and five engine-specific fixes — from functional indexes to PostgreSQL's citext type.
A query that ran in 4 milliseconds on your laptop takes 9 seconds in production. The table has an index on exactly the column you are filtering. You check twice. The index is there. The query still crawls.
Nine times out of ten, the culprit is sitting right there in the WHERE clause:
SELECT * FROM users WHERE LOWER(TRIM(email)) = 'preet@example.com';
That query cannot use the index on email. Not "might not" — cannot. This post explains why the database makes that decision, how to confirm it in your execution plan, and five ways to fix it depending on which engine you run.
SARGable is short for "Search ARGument able." A predicate is sargable when the database can use it to jump directly to a location in an index. It is non-sargable when the database has to compute something for every row before it can decide whether that row matches.
Here is the mechanical reason. A B-tree index on email stores the values exactly as they were written, sorted:
' Preet@Example.com'
'Amit@Corp.io'
'PREET@EXAMPLE.COM'
'preet@example.com'
'zara@mail.dev'
When you ask for email = 'preet@example.com', the engine binary-searches that sorted structure and lands on the match in a handful of page reads. Roughly O(log n).
When you ask for LOWER(TRIM(email)) = 'preet@example.com', the sort order of the index is useless. The index is sorted by email, not by LOWER(TRIM(email)). There is no way to binary-search a structure for a value that was never stored in it. So the engine does the only thing it can: read every row, apply TRIM, apply LOWER, compare. That is O(n), plus two function calls per row.
On 50,000 rows nobody notices. On 5 million rows in a hot API path, everybody notices.
One clarification worth making, because it comes up in code review arguments: the index is not always ignored. If the index covers every column the query needs, the planner may still scan the index instead of the table, because the index is physically smaller. That is a full index scan, not an index seek. You saved on I/O volume. You did not save on complexity. It is still a linear read of every entry.
Never guess at this. The plan tells you directly.
PostgreSQL
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM users WHERE LOWER(email) = 'preet@example.com';
You are looking for Seq Scan on users together with a Filter: line and a large Rows Removed by Filter count. That last number is the honest one — it tells you how many rows the database touched and threw away. Drop the LOWER() and the same plan turns into Index Scan using users_email_idx with an Index Cond: line.
MySQL
EXPLAIN ANALYZE
SELECT id FROM users WHERE LOWER(email) = 'preet@example.com';
Check the type column. ALL means full table scan. ref or const means the index is doing its job. Also check key — if it is NULL, no index was chosen at all.
SQL Server
Turn on the actual execution plan and look for "Index Scan" or "Table Scan" instead of "Index Seek." Also watch for the warning triangle on the SELECT operator, which often flags exactly this.
The tell is the same everywhere: rows examined versus rows returned. If you return 1 row and examine 5,000,000, you have a sargability problem.
This is the fix most developers skip, and it is free.
MySQL's default collation for utf8mb4 is case-insensitive (the _ci suffix in utf8mb4_0900_ai_ci). SQL Server installs commonly default to SQL_Latin1_General_CP1_CI_AS, also case-insensitive. On those engines this comparison already matches PREET@EXAMPLE.COM, Preet@Example.com, and everything in between:
SELECT id FROM users WHERE email = 'preet@example.com';
The LOWER() was doing nothing except disabling your index. A lot of this pattern is copy-paste inherited from PostgreSQL habits, or from a developer who was not sure and added it defensively.
Verify before you delete it:
-- MySQL
SHOW TABLE STATUS WHERE Name = 'users';
-- SQL Server
SELECT name, collation_name FROM sys.columns WHERE object_id = OBJECT_ID('users');
PostgreSQL is the exception. It is case-sensitive by default, so on Postgres the LOWER() is genuinely doing work and you need one of the fixes below.
The strongest fix architecturally. If email addresses should always be lowercase and trimmed, store them lowercase and trimmed. Then the read path stays clean forever.
-- One-time backfill
UPDATE users SET email = LOWER(TRIM(email)) WHERE email <> LOWER(TRIM(email));
-- Then enforce it
ALTER TABLE users ADD CONSTRAINT users_email_normalised
CHECK (email = LOWER(TRIM(email)));
Now your query is a plain equality match and every index works normally. You have moved the cost from every read to one write, which is the correct trade for almost every table, since reads usually outnumber writes heavily.
Two things to handle before you ship this:
UPDATE across 5 million rows will hold locks and bloat your WAL or transaction log.
Be careful about applying this blindly to columns where the original casing is meaningful. Display names, product SKUs, and anything a user expects to see rendered exactly as they typed it should not be destructively lowercased. For those, use Fix 3 or keep a separate normalised column.If you cannot change the stored data, change the index so it stores the computed value. This is a functional index, also called an expression index or function-based index.
PostgreSQL — supported for a long time and the cleanest implementation:
CREATE INDEX idx_users_email_lower ON users (LOWER(TRIM(email)));
Now WHERE LOWER(TRIM(email)) = $1 gets an index scan.
MySQL 8.0.13 and later — note the double parentheses, they are required:
CREATE INDEX idx_users_email_lower ON users ((LOWER(TRIM(email))));
On MySQL 5.7 there are no functional indexes. Use a generated column instead:
ALTER TABLE users
ADD COLUMN email_normalised VARCHAR(255)
AS (LOWER(TRIM(email))) STORED,
ADD INDEX idx_email_normalised (email_normalised);
SQL Server — a persisted computed column, then index it. The PERSISTED keyword matters; a non-persisted computed column is only indexable when the expression is deterministic and precise, and persisting it removes the ambiguity:
ALTER TABLE users ADD email_normalised AS LOWER(LTRIM(RTRIM(email))) PERSISTED;
CREATE INDEX idx_users_email_normalised ON users (email_normalised);
Oracle — function-based indexes, available for decades:
CREATE INDEX idx_users_email_lower ON users (LOWER(TRIM(email)));
Your query expression has to match the indexed expression. Exactly.
-- Index built on this:
CREATE INDEX idx_a ON users (LOWER(TRIM(email)));
-- Uses the index:
WHERE LOWER(TRIM(email)) = $1
-- Does NOT use the index:
WHERE TRIM(LOWER(email)) = $1 -- functions reversed
WHERE LOWER(email) = $1 -- TRIM missing
WHERE LOWER(BTRIM(email)) = $1 -- different function
The planner does not reason about the algebraic equivalence of LOWER(TRIM(x)) and TRIM(LOWER(x)), even though a human can see they produce the same result for ASCII input. It matches the expression tree. Pick one canonical form, write it down somewhere your team will read, and use it everywhere.
Also remember that functional indexes cost you on writes. Every INSERT and UPDATE to that column recomputes the expression and updates the index. On a write-heavy table, measure before you add three of them.
Half of all TRIM() calls in production code exist because a developer was worried about whitespace in user input. That whitespace is in the input, not the database. So clean it where it arrives:
# Clean the value coming in
email = request_data["email"].strip().lower()
cursor.execute("SELECT id FROM users WHERE email = %s", (email,))
Applying a function to a bind parameter is free. The database still sees a constant on the right side of the comparison and the index works perfectly. Applying a function to a column is what costs you.
This is the general rule that covers most sargability problems, not just this one:
-- Non-sargable
WHERE YEAR(created_at) = 2026
WHERE price * 1.18 > 5000
WHERE SUBSTRING(sku, 1, 3) = 'ABC'
-- Sargable equivalents
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
WHERE price > 5000 / 1.18
WHERE sku LIKE 'ABC%'
Keep the column bare on the left. Move every computation to the right.
One caveat on trailing spaces: comparison semantics differ. SQL Server and older MySQL PAD SPACE collations ignore trailing spaces in = comparisons, so 'abc' and 'abc ' compare equal. MySQL's utf8mb4_0900_ai_ci is NO PAD and treats them as different. PostgreSQL's varchar is significant about trailing spaces, while char(n) is not. Leading spaces always matter, everywhere. Test your specific engine rather than assuming.
If case-insensitive matching is a property of the column rather than a property of one query, the citext extension makes that explicit:
CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE users ALTER COLUMN email TYPE citext;
CREATE INDEX idx_users_email ON users (email);
-- Now this is case-insensitive and uses the index
SELECT id FROM users WHERE email = 'PREET@EXAMPLE.COM';
Unique constraints become case-insensitive too, which closes a real signup bug where Preet@example.com and preet@example.com both register as separate accounts.
The trade-off: citext is a non-standard type, it can complicate ORM mapping, and PostgreSQL's own documentation now nudges users toward nondeterministic ICU collations for new work. It is still widely deployed and it works. Know what you are adopting.
Once you start reading plans for sargability, you will find these nearby:
Implicit type conversion. Comparing a VARCHAR column to a numeric literal makes the engine cast the column, row by row, exactly like LOWER() does.
WHERE phone = 9876543210 -- column is VARCHAR, index dead
WHERE phone = '9876543210' -- index alive
Collation mismatch across a join. Joining a utf8mb4_general_ci column to a utf8mb4_0900_ai_ci column forces a conversion on one side and disables the index on that side. It shows up after a schema migration and looks like nothing changed.
Leading wildcard LIKE. LIKE '%example.com' cannot use a B-tree, for the same sort-order reason. For that you need a trigram index (pg_trgm on PostgreSQL) or a real full-text search engine.
OR across different columns. Often prevents a single index from being used efficiently. Splitting into two queries joined by UNION ALL sometimes beats it, though measure rather than assume.
EXPLAIN on the query. Compare rows examined to rows returned.LOWER() redundant.TRIM() and .strip() into the application layer where the input arrives.EXPLAIN again after the change. Do not trust the fix until the plan says "Index Seek" or "Index Scan using ...".The database is not ignoring your index out of spite. It is telling you, through the plan, that the thing you asked it to sort by and the thing you asked it to search for are two different values. Once that clicks, the fix is usually a one-line change: delete a redundant LOWER(), move a TRIM() into your application, or build an index on the expression you actually query.
Go pick your slowest endpoint, run EXPLAIN on the query behind it, and look for a function wrapped around a column. I would bet on finding at least one.
If this saved you a debugging session, share it with someone on your team who is still adding LOWER() to every WHERE clause out of habit. And if you have hit a variation of this that is not covered here, drop it in the comments — the collation edge cases in particular are deep enough to fill their own post.
DNS lookups, TCP and TLS handshakes, HTTP requests, servers, and the browser's rendering pipeline. The whole journey of a web page, explained so it finally clicks.
Read →There are dozens of HTTP status codes, but about fifteen cover nearly everything. What each one means, when to return it, and the mix-ups (401 vs 403, 301 vs 302) that confuse everyone.
Read →A, AAAA, CNAME, MX, TXT and NS records in plain English, why DNS changes seem slow, and the troubleshooting commands that tell you what is really going on.
Read →