Why Your Query Is Slow Even Though You Have an Index

by Eric Hanson, Backend Developer at Clean Systems Consulting

The index that isn't helping

You've confirmed the index exists. \d orders shows it clearly. The query is still slow. You run EXPLAIN and see Seq Scan on orders where you expected Index Scan. This is one of the most frustrating situations in database debugging, and it has a small, identifiable set of root causes.

Cause 1: A function or expression wraps the indexed column

This is the most common cause. Any transformation applied to an indexed column in a WHERE clause defeats the index, because the index stores the raw column values, not the transformed values.

-- Index exists on: created_at
-- Query does NOT use the index:
WHERE DATE(created_at) = '2024-01-15'
WHERE EXTRACT(YEAR FROM created_at) = 2024
WHERE LOWER(email) = 'user@example.com'
WHERE CAST(user_id AS TEXT) = '42'
WHERE created_at::DATE = CURRENT_DATE

In each case, the database must compute the function for every row before comparing, making the index useless.

Fix: rearrange the condition so the column is bare:

-- Use range condition instead of function
WHERE created_at >= '2024-01-15' AND created_at < '2024-01-16'
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'

-- Normalize at write time (store emails lowercase)
WHERE email = 'user@example.com'  -- after normalizing during insert

-- Fix the type mismatch upstream
WHERE user_id = 42  -- not CAST(user_id AS TEXT)

Alternatively, create a functional index that stores the pre-computed expression:

-- PostgreSQL: functional index
CREATE INDEX idx_users_lower_email ON users(LOWER(email));

-- Now this query uses the index:
WHERE LOWER(email) = 'user@example.com'

Cause 2: A type mismatch causes implicit casting

-- user_id is BIGINT in the table
-- But the application passes a string:
WHERE user_id = '42'  -- implicit CAST(user_id AS TEXT) or CAST('42' AS BIGINT)

The behavior depends on the database and the types involved. In PostgreSQL, comparing a BIGINT column to a string literal '42' will cast the literal to BIGINT — the index is used. But comparing a VARCHAR column to an integer literal may cast the column, defeating the index.

The safe rule: always match the literal type to the column type in queries. In ORM code, ensure the parameter type matches the column type — mismatches are a common ORM bug.

Cause 3: Low selectivity — the optimizer knows better

An index on a column with few distinct values (like status with values active, inactive) is only useful when the filtered value is rare. If 95% of rows have status = 'active', the optimizer correctly determines that reading the entire table sequentially is cheaper than following index pointers to 95% of rows.

-- If 90% of orders are 'completed', this may (correctly) use a seq scan:
WHERE status = 'completed'

-- If 0.1% of orders are 'pending', this will use an index:
WHERE status = 'pending'

You can verify the optimizer's estimate vs reality with EXPLAIN ANALYZE:

Seq Scan on orders  (cost=0.00..45231.00 rows=9823451 width=...)
                    (actual rows=9412088 ...)

If the estimate is close to actual and the optimizer chose a seq scan, it's probably correct. If the estimate is wildly off (e.g., estimated 100 rows, actual 1,000,000), stale statistics are the problem — run ANALYZE orders.

Cause 4: Stale or missing statistics

The query optimizer relies on column statistics to estimate how many rows a filter will return. If those statistics are stale (table has grown significantly since the last ANALYZE run), the optimizer may underestimate or overestimate cardinality and choose a suboptimal plan.

-- PostgreSQL: manually update statistics
ANALYZE orders;

-- Check when statistics were last collected
SELECT
  schemaname,
  tablename,
  last_analyze,
  last_autoanalyze,
  n_live_tup,
  n_dead_tup
FROM pg_stat_user_tables
WHERE tablename = 'orders';

If last_autoanalyze is days or weeks old on a heavily written table, autovacuum is not keeping up. Tune autovacuum_analyze_scale_factor and autovacuum_analyze_threshold for that specific table:

ALTER TABLE orders SET (
  autovacuum_analyze_scale_factor = 0.01,  -- Analyze after 1% of rows change (default 20%)
  autovacuum_analyze_threshold = 1000
);

Cause 5: The index doesn't match the query's column order (composite indexes)

-- Composite index on (user_id, status)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

-- Uses the index (leftmost prefix matched):
WHERE user_id = 42
WHERE user_id = 42 AND status = 'pending'

-- Does NOT use the index:
WHERE status = 'pending'  -- 'status' is not the leftmost column

If your query filters only on the second column of a composite index, the index is not used. You either need a separate index on status, or the composite index needs to be restructured.

Cause 6: The index exists but the table is tiny

The optimizer will almost always choose a sequential scan for tables under a few hundred rows, regardless of index availability. It's cheaper to read 3 heap pages sequentially than to traverse a B-tree, fetch row pointers, and then do random page reads. This is correct behavior — don't force index use on small tables.

The debugging sequence

When a query is slow despite having an index:

  1. Run EXPLAIN ANALYZE — confirm whether the index is being used
  2. If seq scan: check whether the WHERE clause applies any function to the indexed column
  3. Check selectivity — is the filtered value rare enough to justify an index scan?
  4. Run ANALYZE <tablename> to refresh statistics, then re-run EXPLAIN ANALYZE
  5. Check for type mismatches between column type and query parameter
  6. If composite index: verify the leftmost prefix rule is satisfied

Most cases are explained by steps 1-4.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Clear Acceptance Criteria Matters in Software Projects

The small detail that quietly determines whether your project ships smoothly or turns into endless back-and-forth

Read more

Scalability Is Not a Feature. It Is a Consequence of Good Design.

Teams that treat scalability as something you add to a system are solving the wrong problem. Scalability is what happens when the underlying design decisions were sound.

Read more

Good Naming Is the Cheapest Form of Documentation

Names are the first thing every reader encounters and the most frequently overlooked opportunity to communicate intent. Getting them right costs almost nothing and returns value on every subsequent read.

Read more

API Keys Are Not the Same as Authentication. Here Is the Difference.

API keys identify a caller. Authentication verifies identity. Treating them as equivalent is what leads to security models that look solid but are not.

Read more