Hook: Interviewers love this question because it separates 'I added an index' from 'I understand why the query was slow'.
Question: What are I/O bottlenecks in SQL, and how do indexes help reduce them?
Answer: An I/O bottleneck happens when a query spends most of its time waiting for data to be read from or written to storage, instead of using CPU. In SQL, that usually means too many table pages are being scanned, too many random row lookups are happening, or the engine is spilling work to disk for sorts and joins. Indexes help by letting the database jump to a small set of pages instead of reading the whole table.
Interview-Ready Answer: I/O bottlenecks are slowdowns caused by excessive disk or page reads and writes. In SQL, that often means a full table scan, lots of random lookups, or temp-file spills during sorting and joining. I reduce I/O by making predicates searchable with good indexes, especially selective and covering indexes, so the engine reads far fewer pages. A key detail is that indexes are not free: they speed reads but add storage and write overhead, so I choose them based on the real query shape.
I/O means input/output: reading and writing data pages. A page is the small block size the database moves around, commonly 8 KB in PostgreSQL and 16 KB in InnoDB/MySQL. If the needed page is not already in memory, the engine must fetch it from storage, and that wait is often much slower than CPU work.
WHERE clause matches the left side of an index and is selective, the engine can do an index seek or index range scan instead of reading every row.An index is like a book’s table of contents. Instead of reading every page to find one chapter, the engine jumps straight to the right section. That is why indexes are so powerful for selective lookups, such as finding one customer’s orders or one user’s email.
| Approach | Reads | Best for | Trade-off |
|---|---|---|---|
| Table scan | All pages | Large result sets | Simple, but lots of I/O |
| Index seek | Index pages + row lookups | Small result sets | Random reads, write cost |
| Covering index | Mostly index pages | Hot read paths | Larger index, more writes |
DATE(created_at) or using a leading wildcard like %abc.Very roughly, a table scan is O(N) in rows or pages touched, while an index lookup is closer to O(log N) to find the leaf, plus any extra row fetches. In real life, the constant factors matter: a full scan on a warm cache can be surprisingly fast, while an index plan that causes thousands of random reads can be slower than scanning. A good rule of thumb is that indexes shine when they let you read a tiny fraction of the table; once you need a meaningful chunk of the table, scans often win.
Memory model: Read fewer pages, and the database gets faster. That is the whole game.
Real-World Story: Imagine an e-commerce checkout service that shows a customer their recent orders while they finish payment. The team adds a simple index on created_at, but the real query filters by customer_id and sorts by created_at. During a holiday sale, p95 latency jumps from 40 ms to 2 seconds, the database CPU stays oddly low, and disk reads spike hard. The logs show lots of full scans and temp-file activity, which tells you the problem is I/O, not raw CPU.
The misunderstanding causes a real user-visible incident: customers click Place Order, the page spins, and some requests time out. Support sees duplicate attempts because users refresh, which creates more load and even more reads. The fix is usually a better composite index that matches the real filter order, plus a query rewrite that avoids functions on indexed columns.
-- SQLite demo: how query shape changes whether the engine can avoid extra I/O.
-- This script is self-contained and can be run as-is in sqlite3.
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
created_at TEXT NOT NULL, -- ISO-8601 timestamps sort correctly as text.
total_amount REAL NOT NULL,
shipping_city TEXT NOT NULL
);
INSERT INTO orders (order_id, customer_id, created_at, total_amount, shipping_city) VALUES
(1, 101, '2026-07-01 08:15:00', 19.99, 'Austin'),
(2, 101, '2026-07-01 09:30:00', 42.50, 'Austin'),
(3, 102, '2026-07-01 10:05:00', 15.00, 'Dallas'),
(4, 103, '2026-07-01 11:45:00', 88.10, 'Houston'),
(5, 101, '2026-07-02 07:20:00', 12.75, 'Austin'),
(6, 104, '2026-07-02 13:10:00', 63.40, 'El Paso'),
(7, 101, '2026-07-03 15:00:00', 27.25, 'Austin'),
(8, 102, '2026-07-03 16:30:00', 99.00, 'Dallas');
-- Index on created_at alone can help range filters.
CREATE INDEX idx_orders_created_at ON orders(created_at);
-- Edge case: wrapping the column in DATE() makes the predicate non-sargable.
-- The planner cannot jump straight to a useful range as easily, so this often becomes more expensive.
EXPLAIN QUERY PLAN
SELECT order_id, customer_id, total_amount
FROM orders
WHERE date(created_at) = '2026-07-01';
-- Better: a range predicate keeps the column 'searchable' and can use the index efficiently.
EXPLAIN QUERY PLAN
SELECT order_id, customer_id, total_amount
FROM orders
WHERE created_at >= '2026-07-01'
AND created_at < '2026-07-02';
-- Composite index for the real access pattern: find one customer's orders, then sort by time.
-- Including total_amount makes this query cheaper because the engine can answer from the index alone.
CREATE INDEX idx_orders_customer_created_total
ON orders(customer_id, created_at, total_amount);
-- This query is a good candidate for a covering index: no table lookup needed for these columns.
EXPLAIN QUERY PLAN
SELECT customer_id, created_at, total_amount
FROM orders
WHERE customer_id = 101
ORDER BY created_at;
-- Failure path: asking for shipping_city forces the engine to visit the table, because that column is not in the covering index.
EXPLAIN QUERY PLAN
SELECT customer_id, created_at, total_amount, shipping_city
FROM orders
WHERE customer_id = 101
ORDER BY created_at;
-- A quick sanity check query to show the actual rows returned.
SELECT customer_id, created_at, total_amount
FROM orders
WHERE customer_id = 101
ORDER BY created_at;Follow-up & Tricky Questions:
WHERE? No. Every extra index adds write cost and storage, so index only the columns that are actually selective and frequently used.ORDER BY automatically use an index? Only if the index order matches the filter and sort pattern closely enough. If not, the engine may still need to sort.Common Mistakes:
Memory Hook: Index = table of contents, I/O = page reads. If the database can jump to the right page instead of reading the whole book, it wins.
Cheat Sheet:
WHERE plus ORDER BY.Practice Tasks: