Hook: A slow query is usually the database saying, ‘I had to check too much stuff.’ Interviewers love this topic because it shows whether you can move from guessing to measuring.
Question: What is your approach to slow query optimization in SQL?
Answer: I first reproduce the slow query with real parameters, then inspect the execution plan to see where the database spends time: scanning too many rows, sorting, joining badly, or reading from disk. After that, I usually fix the query shape so it is sargable (search-argument-able, meaning the condition can use an index efficiently), and I add or adjust indexes only where they match the most selective filters and sorts.
Interview-Ready Answer: I optimize slow SQL by measuring first, not guessing. I run the query with EXPLAIN or EXPLAIN ANALYZE, look for full table scans, row estimate mismatches, expensive sorts, and bad join order, then I rewrite the predicate to be index-friendly and add the smallest useful index. For example, I avoid wrapping an indexed column in a function and I prefer a composite index that matches my WHERE and ORDER BY pattern, because that usually cuts work from scanning thousands of rows to touching only a few.
Optimization is not ‘make every query use an index.’ The goal is to make the database do the least work for the right result. A query can be slow because it reads too many rows, sorts a huge intermediate set, joins tables in the wrong order, or repeatedly hits disk instead of memory.
EXPLAIN or EXPLAIN ANALYZE to see whether the optimizer chose a sequential scan, index scan, nested loop, hash join, or sort. A sequential scan means reading the table row by row; that is fine for tiny tables, but painful on large ones.created_at >= ... AND created_at < ... is usually better than DATE(created_at) = ... because the latter wraps the column in a function and can block index use.ANALYZE or after refreshing statistics. If the change helps one query but hurts inserts, updates, or deletes too much, roll it back.Indexes are great when you filter a small part of a large table, join on keys, or need rows in a specific order. They are less helpful when you return a big fraction of the table, because then a sequential scan can be cheaper than jumping through the index and then back to the table many times.
| Approach | Best for | Main trade-off |
|---|---|---|
| Sequential scan | Small tables or many matching rows | Reads a lot of data |
| Single-column index | One common filter | May not help sorting or joins |
| Composite index | Shared filter + sort pattern | Column order matters a lot |
| Covering index | Query can be answered from the index | More storage and write cost |
O(n), while index lookup is closer to O(log n) plus the cost of fetching matching rows. That is why indexes shine on large, selective queries.INSERT, UPDATE, and DELETE because the index must also be maintained. In real systems, too many indexes can add noticeable latency to writes.LOWER(email), DATE(created_at), and CAST(id AS TEXT) are common traps.LIKE '%abc' usually cannot use a normal B-tree index because the wildcard is at the front.NULL is not equal to anything, so = NULL does not work; use IS NULL.customer_id and sorts by created_at DESC often wants a composite index on both columns in that order.Memory Hook: Think of a library: a table scan is opening every book, while an index is the catalog card that points you to the exact shelf.
Imagine a checkout service in an e-commerce app. The page that shows a user’s recent orders suddenly jumps from 80 ms to 4 seconds after traffic grows. The logs show many repeated calls to the same query, and the database metrics show high disk reads and ‘rows examined’ far above the number of rows returned.
What went wrong? The developer wrote the date filter as DATE(created_at) = DATE '2024-06-02'. That looked readable, but it forced the database to compute a function on every row, so the index on created_at could not be used. Users saw spinners, retries, and sometimes duplicate checkout clicks because the page felt broken even though the database was still ‘working’ hard in the background.
The fix was to rewrite the filter to a range and add a composite index for the common access pattern. After that, the query stopped reading the whole table and returned fast again.
-- PostgreSQL example: show a slow pattern, then the indexed, sargable fix.
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id INTEGER,
created_at TIMESTAMP NOT NULL,
status TEXT NOT NULL,
total_cents INTEGER NOT NULL
);
-- Small sample data is enough to demonstrate the shape of the problem.
-- In a real table, the difference becomes dramatic as rows grow into millions.
INSERT INTO orders (customer_id, created_at, status, total_cents) VALUES
(1, TIMESTAMP '2024-06-01 09:00:00', 'paid', 1200),
(1, TIMESTAMP '2024-06-02 10:15:00', 'paid', 2500),
(1, TIMESTAMP '2024-06-03 14:30:00', 'refunded', 1200),
(2, TIMESTAMP '2024-06-02 08:20:00', 'paid', 800),
(2, TIMESTAMP '2024-06-02 12:45:00', 'paid', 1800),
(2, TIMESTAMP '2024-06-04 18:10:00', 'paid', 2200),
(3, TIMESTAMP '2024-06-01 07:05:00', 'pending', 500),
(3, TIMESTAMP '2024-06-03 21:00:00', 'paid', 3100),
(NULL, TIMESTAMP '2024-06-02 11:00:00', 'paid', 999);
-- This composite index matches the common pattern:
-- filter by customer_id, then return newest orders first.
CREATE INDEX idx_orders_customer_created_at
ON orders (customer_id, created_at DESC);
-- A separate index can help pure date-range lookups.
CREATE INDEX idx_orders_created_at
ON orders (created_at);
ANALYZE orders;
-- Good pattern: sargable predicate + matching composite index.
EXPLAIN ANALYZE
SELECT id, customer_id, created_at, status, total_cents
FROM orders
WHERE customer_id = 2
ORDER BY created_at DESC
LIMIT 2;
-- Bad pattern: function on the indexed column can prevent index use.
-- On larger tables, this often becomes a much slower plan.
EXPLAIN ANALYZE
SELECT id, customer_id, created_at, status, total_cents
FROM orders
WHERE DATE(created_at) = DATE '2024-06-02';
-- Better rewrite: keep the column bare so the index can be used.
EXPLAIN ANALYZE
SELECT id, customer_id, created_at, status, total_cents
FROM orders
WHERE created_at >= TIMESTAMP '2024-06-02 00:00:00'
AND created_at < TIMESTAMP '2024-06-03 00:00:00';
-- Edge case: NULL is not matched with '='; use IS NULL.
-- This is logically correct and often important when debugging missing rows.
SELECT COUNT(*) AS null_customer_rows
FROM orders
WHERE customer_id IS NULL;LIKE '%abc' often not use an index? Because the leading wildcard means the database cannot jump to a predictable starting point in the index; it has to inspect many candidates.created_at help DATE(created_at)? Not usually, because the function changes the indexed value before comparison. You need to rewrite the predicate or use an expression index if your database supports it and the pattern is common.SELECT * always bad? Not always, but it often increases I/O and memory use. Selecting only the needed columns makes index-only or covered plans more likely and reduces network cost too.Common Mistakes:
EXPLAIN or EXPLAIN ANALYZE first.WHERE and ORDER BY.Memory Hook: ‘Catalog, not chaos’: the database should look up a short path, not rummage through every row.
Cheat Sheet:
EXPLAIN ANALYZE.Practice Tasks: