blog/database/why-index-not-being-used
Database Design & Internals

Why Your Index Isn't Being Used (Even Though You Created It)

You added an index, ran EXPLAIN, and the planner still chooses a sequential scan. Here are the six most common reasons your database ignores your index -- and the exact fixes for each one.

ยท10 min read

You Created the Index. The Planner Ignores It.

You've got a slow query. You look at the table, identify the column in the WHERE clause, and create an index. Problem solved, right?

You run EXPLAIN ANALYZE. The planner is still doing a sequential scan. Every single row. Your index sits there, unused, like a door nobody opens.

This is one of the most frustrating experiences in database performance work. You know the index should help. The planner disagrees. And the planner is almost never wrong -- it just knows something you don't.


How the Query Planner Decides

Before diving into specific failures, you need to understand how the planner thinks. It doesn't blindly use every available index. It makes a cost-based decision.

Query Planner Decision Process
Your Query
SELECT ... WHERE ...
parse
Table Statistics
Row counts, distributions
Query Planner
Cost estimator
Execution Plan
Cheapest path wins

The planner considers:

  1. How many rows will the condition match? (selectivity)
  2. How expensive is the index lookup + heap fetch vs a straight sequential scan?
  3. Are the table statistics current?

An index scan isn't free. For each matching entry in the index, the database must do a random I/O to fetch the actual row from the heap. Sequential scans do sequential I/O, which is much faster per byte. When the index would return a large fraction of the table, sequential scan wins.

๐Ÿ“Œ The crossover point

As a rough rule, if an index would match more than ~10-15% of the table's rows, the planner often decides that a sequential scan is cheaper. The exact threshold depends on table size, row width, and whether the data is physically ordered (clustered) to match the index.


The Six Index Killers

Explore each scenario below. Click EXPLAIN ANALYZE to see how the planner reacts and why the index gets ignored.

Why Isn't My Index Being Used?
QUERY
SELECT * FROM users
WHERE LOWER(email) = 'alice@example.com';
INDEX
CREATE INDEX idx_email ON users(email);
Click EXPLAIN to analyze
Row 0Row 500,000

1. Functions on Indexed Columns

This is the most common trap. You have an index on email, but your query uses LOWER(email) or TRIM(email) or EXTRACT(YEAR FROM created_at).

The index stores raw email values in a B-tree. When you wrap the column in a function, the planner can't binary-search the tree -- the transformed values aren't in the index.

-- Index on email -- but this query wraps it in LOWER()
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';
 
-- Planner sees: "I have an index on email, but the query
-- asks about LOWER(email). Those are different values.
-- I can't use this index."

The fix: expression indexes

CREATE INDEX idx_email_lower ON users(LOWER(email));

Now the index stores the pre-computed LOWER(email) values. The planner recognizes that the query expression matches the index expression exactly.

โš ๏ธ Common in ORMs

Many ORMs generate case-insensitive lookups using LOWER() or UPPER() behind the scenes. If you see sequential scans on columns you've indexed, check the actual generated SQL -- there might be a hidden function call wrapping your column.


2. Implicit Type Conversion

This one is subtle and infuriating. The column is an integer, but you pass a string in the WHERE clause. The database silently casts every row's value for comparison.

-- order_id is an integer column with an index
SELECT * FROM orders WHERE order_id = '12345';
 
-- What the database actually does:
SELECT * FROM orders WHERE CAST(order_id AS text) = '12345';
-- Now it's a function on the column -- same problem as #1
Impact of Implicit Type Conversion
Correct type (integer = integer)2ms
Implicit cast (integer = text)380ms

The fix: match your types

Always pass parameters that match the column type. In application code, ensure your ORM or query builder sends the right type. In PostgreSQL, you can use :: to be explicit:

SELECT * FROM orders WHERE order_id = 12345;
-- or explicitly: WHERE order_id = '12345'::integer

3. Wrong Composite Index Column Order

Composite (multi-column) indexes follow the leftmost prefix rule. The index on (created_at, status) can serve queries that filter on created_at, or on created_at AND status. But it cannot efficiently serve queries that filter only on status.

Think of it like a phone book sorted by last name, then first name. You can look up all "Smiths" easily. You can look up "Smith, John" easily. But finding all "Johns" regardless of last name? You'd have to scan every page.

-- Index: (created_at, status)
-- This CANNOT use the index efficiently:
SELECT * FROM orders WHERE status = 'shipped';
-- status is the SECOND column -- no leading prefix match
 
-- This CAN use the index:
SELECT * FROM orders WHERE created_at > '2026-01-01' AND status = 'shipped';

The fix: put high-selectivity filter columns first

-- Equality columns before range columns
CREATE INDEX idx_orders_status_date ON orders(status, created_at DESC);
Index column orderWHERE status = ?WHERE created_at > ?WHERE status = ? AND created_at > ?
(created_at, status)NoYesYes
(status, created_at)YesNoYes

The general rule: equality conditions first, range conditions last. This gives the planner the best chance of narrowing down results quickly.


4. Low Selectivity (The Boolean Column Trap)

You index a boolean column like is_active where 95% of rows are true. The planner does the math: using the index means 475,000 random I/O operations to fetch rows from the heap. A sequential scan reads the table in order. Sequential scan wins.

An index is most valuable when it narrows down the result to a small fraction of the table. When it returns most of the table, the overhead of the index makes things slower, not faster.

Selectivity vs Index Usefulness
1 row match (unique lookup)2ms
100 rows (0.02%)5ms
5,000 rows (1%)18ms
50,000 rows (10%)85ms
250,000 rows (50%)310ms
475,000 rows (95%) -- seq scan chosen190ms

The fix: partial indexes

Index only the rows that matter. If 95% of users are active and you're looking for the 5% that aren't:

-- Instead of indexing the whole column:
CREATE INDEX idx_inactive_users ON users(is_active) WHERE is_active = false;

The partial index is tiny (5% of the data), always selective, and always used by the planner when the query matches the WHERE condition.


5. OR Across Different Columns

OR conditions across different columns put the planner in a tough spot. It can't use one index to satisfy both sides of the OR.

SELECT * FROM products
WHERE name ILIKE '%widget%' OR category_id = 5;

The ILIKE '%widget%' with a leading wildcard can't use a B-tree index at all (the search value could be anywhere in the string). And even if category_id has an index, the OR means the planner needs rows matching either condition -- it can't combine two index scans cleanly.

The fix: UNION for index-friendly OR

-- Each branch can use its own index
SELECT * FROM products WHERE category_id = 5
UNION
SELECT * FROM products WHERE name ILIKE '%widget%';

For the ILIKE side, consider a pg_trgm GIN index:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING gin(name gin_trgm_ops);

6. Stale Table Statistics

This is the sneakiest one. Your index is correct. Your query is correct. But the planner makes a bad decision because it has outdated information about your data.

The planner relies on statistics -- row counts, value distributions, most common values, null fractions. When you bulk-load data, these stats can become wildly inaccurate.

Stale Statistics Cause Bad Plans
Bulk Load
2M new rows
outdated!
Statistics
Still says 50K rows
Planner
Thinks seq scan is fine
Seq Scan
Scanning 2M rows

The fix: ANALYZE

ANALYZE events;  -- refresh statistics for this table
-- or for the whole database:
ANALYZE;

PostgreSQL's autovacuum daemon runs ANALYZE periodically, but it can lag behind during heavy write bursts. After any bulk operation, manually ANALYZE the affected tables.

โœ… Check your statistics freshness

Run SELECT relname, last_analyze, last_autoanalyze FROM pg_stat_user_tables; to see when statistics were last updated for each table. If the timestamp is old and you've loaded data since, that's your problem.


Debugging Checklist

When your index isn't being used, work through this in order:

1

Run EXPLAIN (ANALYZE, BUFFERS)

Not just EXPLAIN -- add ANALYZE to see actual execution times and BUFFERS to see I/O. Compare estimated rows to actual rows. Large discrepancies point to stale statistics.

2

Check for function calls or casts

Look at the WHERE clause. Is the indexed column wrapped in any function? Is there an implicit type mismatch? The query must reference the raw column (or match an expression index exactly).

3

Verify composite index column order

If using a multi-column index, does the query filter on the leading columns? Remember: (A, B, C) serves queries on A, A+B, or A+B+C -- never B alone or C alone.

4

Check selectivity

How many rows does the condition match? If it's more than ~10% of the table, the planner may correctly prefer a sequential scan. Consider a partial index or restructuring the query.

5

Run ANALYZE on the table

Especially after bulk loads, schema changes, or any operation that significantly changes the data distribution. Stale stats cause good indexes to be ignored.

6

Check for OR conditions

OR across different columns often prevents index usage. Try rewriting as a UNION if each branch can use its own index.


Key Takeaways

The planner is almost always right. When it ignores your index, it's making a cost-based decision with the information it has. Your job is to make sure that information is accurate and that the query is written in a form the index can serve.

Functions kill indexes. LOWER(email), YEAR(created_at), implicit casts -- any transformation on the indexed column makes the index invisible. Use expression indexes or fix the query.

Column order in composite indexes is not optional. The leftmost prefix rule determines what queries a multi-column index can serve. Get it wrong and the index exists for nothing.

High selectivity is what makes indexes worthwhile. If your condition matches most of the table, the planner is right to skip the index. Use partial indexes to index only the selective subset.

Statistics drive plan quality. Stale statistics after bulk operations are the most invisible cause of bad query plans. ANALYZE is cheap -- run it after big changes.