Back to Indexing
Q8Question 8 of 18

When might a database ignore an index: functions on columns, leading wildcard, small tables, low selectivity, large result sets

Key Concepts
  • A function on the indexed column like `UPPER(email)` or `YEAR(created_at)` makes the predicate non-sargable, forcing a full table scan.
  • A leading wildcard like `LIKE '%kumar'` cannot use a B-tree because the tree is sorted left to right and there is no prefix to seek to.
  • A small table is often faster to scan entirely because one page read beats descending a B-tree plus a bookmark lookup.
  • Low selectivity means the predicate matches most rows, so random index lookups cost more than one sequential scan.
  • Implicit type coercion, such as comparing a VARCHAR column to a numeric literal, can silently cast the column and defeat the index.
  • Stale statistics after a bulk load can cause the optimizer to misjudge selectivity and skip a useful index.

Definition

The database ignores an index when a function hides the column, a leading wildcard breaks ordering, the table is tiny, the predicate is unselective, or the result set is huge.
  • Non-sargable predicate – Wrapping the indexed column in a function like UPPER() or YEAR() hides the column's sorted order from the B-tree, forcing a full scan.
  • Leading wildcard – LIKE '%kumar' cannot use a B-tree because the tree is sorted by the first character and there is no known prefix to seek to.
  • Small table – If the entire table fits in a few pages, a sequential scan is cheaper than tree navigation plus bookmark lookups.
  • Low selectivity – When a predicate matches most rows (e.g., 95% are ACTIVE), random index lookups cost more than one sequential pass.
  • Implicit type coercion – Comparing a VARCHAR column to a numeric literal can silently cast the column, defeating the index.
  • Stale statistics – Outdated row-count estimates cause the optimizer to misjudge selectivity and choose the wrong plan.

The National Bank of India clerk skips the index thumb-tabs in several situations. If you ask "find accounts where the uppercased name equals RAJESH," the clerk cannot use the alphabetical tabs because the tabs are sorted by the original name, not the transformed one. If you search for "any name containing kumar" with the wildcard at the front, the tabs are useless because they sort by first letter. If the ledger has only ten accounts, flipping through all ten is faster than consulting the index. If nearly every account is ACTIVE, an index on status matches almost everyone, so reading the whole ledger is simpler.

Understanding the Concepts

Why does a function on the indexed column defeat the index?

A B-tree is sorted by the raw column value. When you wrap the column in a function, the engine must compute that function on every row before it can compare, so the sorted index order is useless.

SQL
-- Index on accounts(email)
CREATE INDEX ix_accounts_email ON accounts(email);

-- Non-sargable: UPPER() on the indexed column
-- The B-tree is sorted by raw email, not uppercased email
EXPLAIN ANALYZE
SELECT * FROM accounts WHERE UPPER(email) = 'RAJESH@BANK.IN';
TEXT
Seq Scan on accounts
  Filter: (upper(email) = 'RAJESH@BANK.IN')
  Rows Removed by Filter: 9,999,999
  -- Full table scan: 10 million rows examined

The fix is an expression index that matches the exact function.

SQL
-- Expression index on LOWER(email)
CREATE INDEX ix_accounts_email_lower ON accounts(LOWER(email));

-- Query must use the identical function
EXPLAIN ANALYZE
SELECT * FROM accounts WHERE LOWER(email) = 'rajesh@bank.in';
TEXT
Index Scan using ix_accounts_email_lower on accounts
  Index Cond: (lower(email) = 'rajesh@bank.in')
  -- Fast: only 1 row examined

Why does a leading wildcard break the index?

A B-tree is sorted left to right. A trailing wildcard like LIKE 'Raj%' fixes the prefix, so the tree can seek to "Raj" and scan forward. A leading wildcard like LIKE '%kumar' has no known prefix, so there is nowhere to start seeking.

SQL
-- Trailing wildcard: WORKS with the index
EXPLAIN ANALYZE
SELECT * FROM accounts WHERE customer_name LIKE 'Raj%';
-- Plan: Index Scan (seeks to 'Raj', stops at 'Rak')

-- Leading wildcard: CANNOT use the index
EXPLAIN ANALYZE
SELECT * FROM accounts WHERE customer_name LIKE '%kumar';
-- Plan: Seq Scan (no prefix to seek to)

The fix for leading-wildcard searches is a trigram index.

SQL
-- PostgreSQL: enable the pg_trgm extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Create a trigram GIN index
CREATE INDEX ix_accounts_name_trgm
    ON accounts USING gin (customer_name gin_trgm_ops);

-- Now leading wildcard works
EXPLAIN ANALYZE
SELECT * FROM accounts WHERE customer_name LIKE '%kumar';
-- Plan: Bitmap Index Scan on ix_accounts_name_trgm

Why does the optimizer skip the index for small tables?

If a lookup table has only fifty rows that all fit in one or two disk pages, reading those pages sequentially is faster than descending a B-tree (three or four page reads) plus a bookmark lookup per row.

SQL
-- Small lookup table: 50 branches
CREATE TABLE branches (
    branch_code VARCHAR(10) PRIMARY KEY,
    branch_name VARCHAR(100),
    city        VARCHAR(50)
);

-- Index exists (primary key), but optimizer chooses Seq Scan
EXPLAIN ANALYZE
SELECT * FROM branches WHERE city = 'Mumbai';
TEXT
Seq Scan on branches
  Filter: (city = 'Mumbai')
  Rows: 5  (total table: 50 rows, 1 page)
  -- Reading 1 page is cheaper than an index traversal

This is correct behavior. Do not force index usage on tiny tables.

Why does low selectivity cause the optimizer to skip the index?

Selectivity is the fraction of rows a predicate matches. An index is beneficial when selectivity is low (few rows match). When selectivity is high (most rows match), the random I/O of index lookups costs more than one sequential pass.

SQL
-- 95% of accounts are ACTIVE
-- Index on status exists but is nearly useless for 'ACTIVE'
CREATE INDEX ix_accounts_status ON accounts(status);

EXPLAIN ANALYZE
SELECT * FROM accounts WHERE status = 'ACTIVE';
-- Plan: Seq Scan (9.5M random lookups > 1 sequential scan)

-- But the same index IS used for the rare value
EXPLAIN ANALYZE
SELECT * FROM accounts WHERE status = 'FROZEN';
-- Plan: Index Scan (only 500 rows match)

The fix for this pattern is a partial index on the rare value.

SQL
-- Partial index: only indexes the rare FROZEN accounts
CREATE INDEX ix_accounts_frozen
    ON accounts(account_id)
    WHERE status = 'FROZEN';

-- Tiny index, always used for FROZEN queries
EXPLAIN ANALYZE
SELECT * FROM accounts WHERE status = 'FROZEN';
-- Plan: Index Scan on ix_accounts_frozen

How does implicit type coercion defeat an index?

When you compare a VARCHAR column to a numeric literal, the database may cast the column to a number instead of casting the literal to a string. Casting the column is a function on the column, which defeats the index.

SQL
-- account_id is VARCHAR(20)
-- Wrong: comparing to a number causes implicit cast on the column
EXPLAIN ANALYZE
SELECT * FROM accounts WHERE account_id = 2001;
-- Plan: Seq Scan – implicit CAST(account_id AS INTEGER) on every row

-- Correct: compare string to string
EXPLAIN ANALYZE
SELECT * FROM accounts WHERE account_id = '2001';
-- Plan: Index Scan using accounts_pkey
JAVA
// com.bank.service.AccountService
package com.bank.service;

import java.sql.*;

public class AccountService {

    // Wrong: passing int when column is VARCHAR
    public void lookupWrong(Connection conn, int accountId)
            throws SQLException {
        String sql = "SELECT * FROM accounts WHERE account_id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setInt(1, accountId); // implicit cast on column!
            // This defeats the index
        }
    }

    // Correct: passing String to match VARCHAR column
    public void lookupCorrect(Connection conn, String accountId)
            throws SQLException {
        String sql = "SELECT * FROM accounts WHERE account_id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, accountId); // types match, index used
        }
    }
}

How do stale statistics mislead the optimizer?

The optimizer estimates selectivity from statistics collected by ANALYZE. After a large data load, these statistics are stale and can cause wrong decisions.

SQL
-- Before bulk load: 1 million transactions
-- After bulk load: 50 million transactions
-- Statistics still say 1 million

-- Optimizer underestimates rows for a date range
-- and picks an index scan when a seq scan would be cheaper
-- (or vice versa)

-- Fix: update statistics immediately after bulk loads
ANALYZE transactions;

-- Check statistics
SELECT relname, n_live_tup, last_analyze
FROM   pg_stat_user_tables
WHERE  relname = 'transactions';

What are the fixes for each cause?

| Cause | Fix |
|---|---|
| Function on column | Rewrite as sargable range, or create expression index |
| Leading wildcard | Use trigram (pg_trgm) or full-text index |
| Small table | Accept the seq scan – it is genuinely faster |
| Low selectivity | Use a partial index on the rare value |
| Large result set | Accept the seq scan, or use a covering index to reduce I/O |
| Implicit type coercion | Match the literal type to the column type |
| Stale statistics | Run ANALYZE after bulk loads |

What should a senior engineer know?

A senior engineer confirms every suspicion with EXPLAIN ANALYZE. A "Seq Scan" where you expected an "Index Scan" is the signal to investigate.

SQL
-- Diagnostic query: find sequential scans on large tables
SELECT schemaname, relname, seq_scan, idx_scan,
       seq_scan - idx_scan AS excess_seq_scans
FROM   pg_stat_user_tables
WHERE  seq_scan > idx_scan
  AND  n_live_tup > 100000
ORDER BY excess_seq_scans DESC;

What are common interview follow-up questions?

  • What is a sargable predicate and how do you rewrite a non-sargable one?
  • What is a partial index and when is it better than a full index?
  • How does CREATE INDEX CONCURRENTLY help on busy tables?
  • What is the left-prefix rule for composite indexes?

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────────┐
│    Why the Optimizer Ignores an Index            │
├─────────────────────────────────────────────────┤
│                                                 │
│  1. Function on column:                         │
│     WHERE UPPER(email) = 'X'                    │
│     B-tree sorted by raw email ──► USELESS      │
│     Fix: expression index on LOWER(email)       │
│                                                 │
│  2. Leading wildcard:                           │
│     WHERE name LIKE '%kumar'                    │
│     No prefix to seek ──► USELESS               │
│     Fix: trigram GIN index                      │
│                                                 │
│  3. Small table (50 rows):                      │
│     1 page seq scan < B-tree traversal          │
│     Fix: none needed – scan is correct          │
│                                                 │
│  4. Low selectivity:                            │
│     95% rows match ──► random I/O > seq scan    │
│     Fix: partial index on rare value            │
│                                                 │
│  5. Implicit type coercion:                     │
│     VARCHAR vs INT ──► CAST on column           │
│     Fix: match literal type to column type      │
│                                                 │
│  6. Stale statistics:                           │
│     Optimizer misjudges selectivity             │
│     Fix: ANALYZE after bulk loads               │
└─────────────────────────────────────────────────┘

Key Takeaway

A database ignores an existing index whenever using it would be slower or simply impossible, and the optimizer makes that call on cost. The most frequent reason is a non-sargable predicate: wrapping the indexed column in a function like UPPER or YEAR, or using a leading wildcard such as LIKE percent-kumar, hides the column's natural order so the B-tree cannot be seeked. The other reasons are a table small enough that a full scan beats tree navigation, a predicate with low selectivity that matches most of the rows, a result set so large that random index lookups cost more than one sequential pass, implicit type coercion that forces a cast on the column, and stale statistics that mislead the estimate.

The fixes follow directly from the cause. For a function on a column, Rajesh either rewrites it as a sargable range on the raw column, or he builds an expression index that matches the exact function. For a leading wildcard he uses a trigram or full-text index instead of a normal B-tree. For a low-selectivity column he uses a partial index on the rare value that people actually search for. And for small tables he simply accepts the scan, because it is genuinely faster.

At the National Bank of India this came up with the login lookup, which someone had written as WHERE UPPER of email equals the input. It worked in testing but crawled in production because the function defeated the email index and every login did a full scan. Rajesh added an expression index on LOWER of email and rewrote the query to match it, and logins went back to a single index seek. Separately, a support search for names ending in Kumar used a leading wildcard, so he added a trigram index to make that pattern fast.

The discipline Pankaj always stresses is to confirm the behavior with the execution plan rather than assuming. A Sequential Scan where you expected an Index Scan is the exact signal that a function, a wildcard, selectivity, or stale statistics is defeating the index, and you run EXPLAIN ANALYZE before and after every fix.

Remember one sentence: the database ignores an index when a function hides the column, a leading wildcard breaks ordering, the table is tiny, the predicate is unselective, or the result set is huge.

---

Share
Use ← → arrow keys to navigate