Hook: Interviewers love this because “high CPU” is often not a machine problem — it is a query asking the database to do far too much work.
Question: What do you do when a server has high CPU due to SQL?
Answer: High CPU usually means one or more queries are forcing the database to scan too many rows, sort too much data, or join tables in an expensive way. I would first find the top CPU query, inspect its execution plan, and look for full scans, non-sargable filters, bad joins, or repeated executions. Then I would fix the query shape, add the right index, and verify the change with a before/after plan.
Interview-Ready Answer: “I’d start by identifying the exact query consuming CPU, not guessing. Then I’d read the execution plan to see whether it’s scanning large tables, sorting, or doing a bad join, and I’d rewrite the predicate to be index-friendly, add or adjust an index, and recheck the plan. My mental model is: make the database search like an address lookup, not a room-by-room search through the whole building.”
It means the database is busy computing, not mostly waiting. In practice, CPU burns when the engine has to inspect many rows, evaluate functions row by row, sort huge result sets, build hash tables, or execute the same expensive query many times. A small query result can still be expensive if the path to that result is bad.
DATE(created_at) or LOWER(email), the engine often has to evaluate that function for many rows first.O(n); a sort is O(n log n); a bad nested-loop join can drift toward O(n*m).The fastest path is usually: find the top SQL, inspect the plan, rewrite the predicate, and then add the right index. A useful index is one the query can actually use. If the query needs a lot of rows — for example, 30% to 40% of a table — a sequential scan can be cheaper than jumping through an index many times, so not every scan is a bug.
| Pattern | Why CPU rises | Better option |
|---|---|---|
| Function on column | Blocks index use | Use a range |
| Bad join key | Large row explosion | Index join columns |
| Big sort / DISTINCT | O(n log n) | Reduce rows early |
| Many repeats | Same work again | Cache or batch |
>= start_of_day and < next_day is safer than wrapping the column with DATE().Memory hook: “Ask for an address, not a treasure hunt.” If the query is written like an exact address, the engine can go straight there. If it is written like a riddle, the engine has to search everything.
In a checkout service, support agents open an order-history page that runs every few seconds. One day the database CPU jumps from 20% to 95%, and the whole app feels “slow,” even though the servers are healthy. The bad query uses DATE(created_at) and LOWER(status) on an 80-million-row orders table, so the engine has to scan far more rows than needed.
What the outage looks like: API latency rises from 40 ms to 3-5 seconds, dashboard requests time out, and the database log shows long-running sequential scans with millions of rows filtered out. Users see spinner loops, stale order pages, and occasional 502/504 errors because the app server threads are waiting on the database.
What fixes it: rewrite the date filter as a half-open range, store or compare the status in a consistent case, add a composite index that matches the filter order, and if needed slow down the polling frequency. The big lesson is that SQL performance issues often look like infrastructure issues until you inspect the actual query shape.
-- PostgreSQL demo: how a non-sargable filter can cause extra CPU,
-- and how rewriting it to an index-friendly range helps.
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
customer_id INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL,
total_cents INT NOT NULL
);
-- Build a realistic data set so the planner has something meaningful to optimize.
INSERT INTO orders (customer_id, created_at, status, total_cents)
SELECT
(random() * 5000)::int + 1,
now() - (((random() * 365)::int) * interval '1 day'),
CASE
WHEN random() < 0.85 THEN 'PAID'
WHEN random() < 0.95 THEN 'PENDING'
ELSE 'CANCELLED'
END,
(random() * 20000)::int + 100
FROM generate_series(1, 100000);
-- Indexes that the good query can actually use.
CREATE INDEX idx_orders_created_at ON orders (created_at);
CREATE INDEX idx_orders_status_created_at ON orders (status, created_at);
-- Refresh stats so the optimizer can estimate row counts better.
ANALYZE orders;
-- BAD: applying functions to the column makes the predicate non-sargable.
-- The engine may have to inspect many rows before it can know whether they match.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM orders
WHERE date(created_at) = current_date
AND lower(status) = 'paid';
-- GOOD: half-open date range keeps the predicate searchable.
-- This form is easier for the optimizer to turn into an index range scan.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM orders
WHERE created_at >= date_trunc('day', now())
AND created_at < date_trunc('day', now()) + interval '1 day'
AND status = 'PAID';
-- Edge case: if a predicate matches a large share of the table,
-- PostgreSQL may still choose a sequential scan, and that can be correct.
-- An index is not automatically faster when selectivity is low.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM orders
WHERE status = 'PAID';Memory Hook: “Ask for an address, not a treasure hunt.” A good SQL predicate gives the database a direct path; a bad one makes it search the whole building.
Cheat Sheet:
Practice Tasks:
WHERE DATE(created_at) = CURRENT_DATE into a half-open range.