Think of the CPU like a chef: if every order makes the chef chop every ingredient from scratch, the kitchen gets hot fast. Interviewers love this topic because the fix is often not 'buy a bigger server' but 'make the database do less work'.
Question: What are CPU bottlenecks in SQL, and how do you spot and fix them?
Answer: A CPU bottleneck means the database is spending most of its time computing instead of waiting on disk or locks. Common causes are scanning too many rows, sorting large result sets, joining badly, or using functions and casts that stop indexes from helping. The fix is usually to reduce the rows the engine must touch, make predicates sargable, and choose the right index for the filter and sort pattern.
Interview-Ready Answer: I look at CPU bottlenecks as 'too much work per query'. My first step is to check the execution plan and row counts to see whether the engine is scanning, sorting, hashing, or evaluating expressions on lots of rows. Then I try to cut the work early with a more selective or covering index, rewrite non-sargable predicates like functions on indexed columns, and remove unnecessary sorts or joins. The key idea is that a good index does not just make reads faster; it lowers the amount of CPU the engine spends per request.
CPU is the part of the database that evaluates predicates, compares rows, builds hash tables, sorts results, and returns data. When a query is CPU-bound, the server is not mostly waiting for storage; it is busy doing computations on the rows it already has.
O(n log n); a scan is O(n); an index seek is closer to O(log n + k), where k is the number of matched rows.An index helps CPU when it lets the engine skip most rows. A narrow, selective index on the exact filter columns often reduces both row reads and per-row comparisons. A covering index, meaning an index that contains every column the query needs, can also avoid extra table lookups and save CPU.
But indexes are not magic. Too many indexes increase write cost because every insert, update, and delete must maintain them. Also, if the query matches a large part of the table, a full scan can be cheaper than bouncing through the index row by row.
| Type | Main symptom | What to check |
|---|---|---|
| CPU-bound | High CPU, low disk wait | Sorts, hashes, scans, functions |
| I/O-bound | Slow reads, disk wait | Cache misses, random access |
| Lock-bound | Sessions blocked | Lock waits, deadlocks |
WHERE clause so fewer rows reach later steps.created_at >= '2024-01-01' over DATE(created_at) = '2024-01-01' unless you have an expression index.Memory idea: reduce rows first, then reduce work per row. That is the whole game.
Real-World Story: An e-commerce checkout service had a 'recent paid orders' report that joined orders and order_items. A developer wrapped created_at in DATE() to make the filter look clean, but that stopped the normal timestamp index from being useful. During peak traffic the CPU hit 95%, p95 latency rose from 120 ms to nearly 5 seconds, and the logs showed no major disk waits, which fooled the team into thinking storage was the problem.
The real fix was to rewrite the filter into a sargable range and add a matching index for the access pattern. After that, the query touched far fewer rows, the hot core cooled down, and the autoscaling bill dropped because the service no longer needed extra pods just to do useless per-row work.
What goes wrong when people miss it: the app looks 'healthy' at the infrastructure layer, but users see slow pages, queue growth, and retries. The database logs often show long-running queries with huge row counts, while the app sees timeouts and occasional 500 errors.
-- Demonstration: how a non-sargable predicate can turn an index-friendly query
-- into extra CPU work, and how an expression index can fix it.
-- PostgreSQL syntax.
DROP TABLE IF EXISTS orders;
CREATE TEMP TABLE orders (
order_id bigserial PRIMARY KEY,
customer_id integer NOT NULL,
status text NOT NULL,
created_at timestamp NOT NULL,
amount numeric(10,2) NOT NULL
);
-- Create enough rows to make the plan interesting.
-- The point is not the exact data, but that the table is large enough
-- for the optimizer to care about row counts and index usage.
INSERT INTO orders (customer_id, status, created_at, amount)
SELECT
(gs % 1000) + 1,
CASE
WHEN gs % 10 = 0 THEN 'CANCELLED'
WHEN gs % 3 = 0 THEN 'PENDING'
ELSE 'PAID'
END,
TIMESTAMP '2024-01-01 00:00:00' + (gs || ' minutes')::interval,
((gs % 200) + 1)::numeric(10,2)
FROM generate_series(1, 20000) AS gs;
-- This index supports filters like:
-- WHERE status = 'PAID' AND created_at >= ... ORDER BY created_at DESC
CREATE INDEX idx_orders_status_created_at ON orders (status, created_at DESC);
ANALYZE orders;
-- Good: the predicate is sargable, so the index can help prune rows early.
EXPLAIN ANALYZE
SELECT order_id, customer_id, amount
FROM orders
WHERE status = 'PAID'
AND created_at >= TIMESTAMP '2024-02-01 00:00:00'
ORDER BY created_at DESC
LIMIT 10;
-- Bad: wrapping the column in DATE() makes the engine compute DATE(created_at)
-- for many rows before it can decide if each row matches.
-- That often increases CPU and can block the plain index above.
EXPLAIN ANALYZE
SELECT order_id, customer_id, amount
FROM orders
WHERE DATE(created_at) = DATE '2024-02-01';
-- Fix: an expression index matches the exact expression in the query.
-- In real systems, use this only when the expression is a common access pattern.
CREATE INDEX idx_orders_created_date ON orders ((DATE(created_at)));
EXPLAIN ANALYZE
SELECT order_id, customer_id, amount
FROM orders
WHERE DATE(created_at) = DATE '2024-02-01';
-- Edge case: a query can still be 'fast' but return no rows.
-- That is not a bottleneck; it is just a filter that finds nothing.
SELECT COUNT(*) AS no_match_rows
FROM orders
WHERE status = 'REFUNDED'
AND created_at < TIMESTAMP '2023-01-01 00:00:00';Follow-up & Tricky Questions:
col >= value is usually sargable; DATE(col) = value often is not unless you add an expression index.Common Mistakes:
Memory Hook: Feed the CPU fewer rows, not faster rows. If the engine touches less data, the CPU naturally cools down.
Cheat Sheet:
Practice Tasks:
DATE(), LOWER(), or cast on the filtered column into a range predicate.WHERE clause and the ORDER BY clause, then compare the plan.