Hook: Interviewers love this question because index scan is where simple SQL turns into real performance wins: one good access path can cut a query from seconds to milliseconds.
Question: What is an index scan in SQL?
Answer: An index scan is when the database uses an index to find matching rows instead of reading the whole table. The index is a smaller, sorted structure that points to the real rows, so the database can jump straight to likely matches. It is usually faster when only a small part of the table matches the filter.
Interview-Ready Answer: I would say an index scan is a query plan where the database walks an index first, then uses the row pointers in that index to fetch the matching table rows. It is most useful for selective predicates like a unique lookup or a narrow range, because the engine avoids scanning every row. One important detail is that a normal index scan may still need to visit the table storage unless the query can be answered from the index alone.
An index is a separate data structure, usually a B-tree, that keeps keys in sorted order. A scan here means the optimizer chose to read that structure first, rather than doing a full table read. In PostgreSQL terms, the index stores key values plus a tuple ID (a pointer to the row in the main table storage, often called the heap).
ANALYZE.This is why an index scan is fast for small result sets: the engine does not read the entire table, only the part that the index says is relevant.
Use an index scan when the predicate is selective, meaning it matches a small fraction of the table. Classic examples are primary-key lookups, foreign-key lookups, date ranges, and searches on a leading column of a composite index. It also pairs well with ORDER BY and LIMIT when the index order matches the sort, because the engine can stop early.
| Plan | How it reads | Best for | Main cost |
|---|---|---|---|
| Index scan | Index, then rows | Small matches | Random row fetches |
| Seq scan | Whole table | Large matches | Reads everything |
| Index-only scan | Index only | Covered queries | Needs visibility info |
Index-only scan means the database can answer the query from the index without visiting the table storage, but only if the needed columns are all in the index and the engine knows the rows are visible. In PostgreSQL, that visibility check depends on the visibility map, which tracks pages that are safe to read without extra heap checks.
For a B-tree index, finding the first matching leaf is roughly O(log n), because the tree is balanced. Fetching matching rows adds about O(k) for k matches. But the real cost is often dominated by I/O: many small random reads can be slower than one big sequential read. On modern SSDs, a random page read may still cost tens to hundreds of microseconds, while a sequential scan can stream data at a much higher rate. That is why a query that returns 100 rows from a 10 million row table is a great index scan candidate, but a query that returns 3 million rows may be faster as a sequential scan.
Index size also matters. A B-tree index can easily be a meaningful fraction of the table size, and every INSERT, UPDATE, or DELETE must keep it updated. So indexes speed reads, but they are not free.
LOWER(status) or DATE(created_at), the normal index on that column may not be usable. The fix is usually to rewrite the predicate as a range or create an expression index.LIKE '%abc' usually cannot use a normal B-tree index because there is no searchable prefix.(a, b) is much less helpful for a filter on only b.Memory rule: an index scan is a shortcut only when the shortcut is shorter than the trip through the whole table.
Real-World Story: Imagine a checkout service with an orders table behind the payment dashboard. Support agents search by order_id all day, so the team expects those lookups to be instant. One day a developer adds a report query like WHERE DATE(created_at) = CURRENT_DATE and assumes the existing index on created_at will help. In production, the query plan switches to a sequential scan, CPU spikes, and the dashboard p95 jumps from 120 ms to 4 seconds. Logs start showing statement timeouts, the connection pool fills up, and users see spinning loaders while agents cannot find recent orders.
The fix is simple but important: rewrite the filter as a range, such as created_at >= CURRENT_DATE AND created_at < CURRENT_DATE + INTERVAL '1 day', or build an expression index if the function is truly needed. The lesson is that index scans are powerful, but only when the predicate matches the index shape.
-- PostgreSQL demo: show when an index scan is a good fit, and when a query shape breaks it.
DROP TABLE IF EXISTS orders;
DROP INDEX IF EXISTS idx_orders_status_lower;
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
amount DECIMAL(10,2) NOT NULL
);
-- Make the table large enough that the planner has a real choice.
-- A unique lookup on the primary key is a classic index-scan case.
INSERT INTO orders (order_id, customer_id, status, created_at, amount)
SELECT
gs,
(gs % 100) + 1,
CASE WHEN gs % 10 = 0 THEN 'shipped' ELSE 'new' END,
TIMESTAMP '2024-01-01 00:00:00' + (gs * INTERVAL '1 minute'),
((gs % 500) + 1) * 1.25
FROM generate_series(1, 10000) AS gs;
ANALYZE orders;
-- Good case: the primary key index can jump straight to one row.
EXPLAIN
SELECT order_id, customer_id, status, created_at, amount
FROM orders
WHERE order_id = 4242;
-- Edge case: wrapping the column in a function often blocks use of a normal B-tree index.
-- This is the kind of query that may drift toward a sequential scan.
EXPLAIN
SELECT order_id
FROM orders
WHERE LOWER(status) = 'shipped';
-- Fix for the edge case: an expression index stores the computed value.
-- Now the same predicate can use an index-backed plan again.
CREATE INDEX idx_orders_status_lower ON orders ((LOWER(status)));
ANALYZE orders;
EXPLAIN
SELECT order_id
FROM orders
WHERE LOWER(status) = 'shipped';
-- A rewrite that is usually even better than indexing the function is to avoid the function entirely.
-- For dates, use a range so the base column stays searchable.
EXPLAIN
SELECT order_id
FROM orders
WHERE created_at >= TIMESTAMP '2024-01-03 00:00:00'
AND created_at < TIMESTAMP '2024-01-04 00:00:00';Follow-up & Tricky Questions:
(a, b) helps most for filters on a, then a and b, and much less for b alone.ORDER BY always benefit from an index scan? No. It helps only when the index order matches the requested sort, or when the engine can stop early with LIMIT. Otherwise the database may still need to sort.LIKE '%abc' use a normal index? Usually not, because the leading wildcard removes the prefix the B-tree needs to navigate. A prefix search like LIKE 'abc%' is the one that typically benefits.Common Mistakes:
Memory Hook: Think of an index scan like the index at the back of a book: you jump to the right page instead of reading every page in order.
Cheat Sheet:
O(log n), but random row fetches can dominate.Practice Tasks:
EXPLAIN for an equality filter versus no filter.DATE(created_at) = ... into a range predicate and compare the plan again.