Hook: When an API is slow, SQL is often the first place I check because one bad query can turn a 50 ms request into a 5 second wait.
Question: How do you debug and fix an API that is slow because of SQL?
Answer: I first prove the database is the bottleneck by checking request timing, slow query logs, and EXPLAIN ANALYZE. Then I look for common causes like missing indexes, scanning too many rows, N+1 queries, bad joins, or lock waits, and I fix the smallest root cause, not just the symptom.
Interview-Ready Answer: I would trace the API request, identify the slow SQL, and run EXPLAIN ANALYZE to see whether the time is spent on scans, sorts, joins, or locks. If the query is reading too many rows, I would add the right index, rewrite the filter to be sargable, or batch the work to avoid N+1 queries. Then I would re-check p95 latency and confirm the plan actually changed, because indexes help only when the query shape matches them.
An API is not slow just because the database exists; it is slow because the request is waiting somewhere in the SQL path. That wait can be from query execution, a lock wait, a slow connection checkout, or even moving a large result set over the network. p95 means the 95th percentile: 95% of requests are faster than that number, so it is a great way to spot real user pain.
EXPLAIN ANALYZE to compare the planned path with the actual work. EXPLAIN shows the plan, while ANALYZE executes it and reports real rows, loops, and timing.WHERE and ORDER BY columns, rewrite filters so they are sargable (written in a way the database can use an index), batch small queries into one, and return only the columns you need.| Symptom | Likely cause | What to check |
|---|---|---|
| Few rows, still slow | Missing index | Filter and sort columns |
| Slow only with ORDER BY | Large sort | Index order match |
| Many tiny queries | N+1 pattern | Batch or join |
| Random spikes | Locks or pool waits | Long tx, pool size |
O(n) because the database checks many rows one by one.O(log n) for the search, plus the cost of fetching matching rows.ANALYZE after big data changes matters in databases that use statistics to choose plans.Real-World Story: In a checkout service for an online store, the endpoint GET /orders?customerId=... started timing out during a sale. The API used to return in about 80 ms, but p95 jumped to 3-4 seconds after traffic grew. Tracing showed most of the time was inside one SQL query that fetched the newest orders for a customer.
The query looked simple, but it was written with DATE(created_at) in the filter and it sorted a large set before applying LIMIT 20. That meant the database could not use the date column efficiently, so it scanned far more rows than expected. At the same time, a background job was updating the same table, which caused occasional lock waits and made the latency spikes even worse.
What went wrong: users saw spinning loaders, then retries, then duplicate support tickets because they assumed the site was down. The logs showed long SQL times, increased connection pool waits, and a sharp rise in rows scanned. The fix was to add a composite index on (customer_id, created_at DESC), rewrite the date filter as a range, and reduce the number of separate SQL calls in the request. After that, the endpoint returned to sub-100 ms behavior and the spikes disappeared.
-- PostgreSQL demo: a slow query caused by SQL shape, then fixed with the right index.
-- The comments explain WHY each step matters.
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL,
total_cents INT NOT NULL
);
-- Create enough rows to make plan differences visible.
-- generate_series is built into PostgreSQL and is perfect for a small benchmark demo.
INSERT INTO orders (customer_id, created_at, status, total_cents)
SELECT (g % 10000) + 1,
now() - (g || ' seconds')::interval,
CASE WHEN g % 10 = 0 THEN 'paid' ELSE 'open' END,
(g % 50000) + 100
FROM generate_series(1, 200000) AS g;
-- Baseline: without a supporting index, the database may need to scan many rows
-- to satisfy the WHERE + ORDER BY + LIMIT combination.
EXPLAIN ANALYZE
SELECT id, created_at, total_cents
FROM orders
WHERE customer_id = 4242
ORDER BY created_at DESC
LIMIT 20;
-- Fix: a composite index that matches both the filter and the sort order.
-- This is the kind of index that turns a broad scan into a targeted lookup.
CREATE INDEX idx_orders_customer_created_at
ON orders (customer_id, created_at DESC);
ANALYZE orders;
-- Re-run the same query so you can compare the plan and timing.
EXPLAIN ANALYZE
SELECT id, created_at, total_cents
FROM orders
WHERE customer_id = 4242
ORDER BY created_at DESC
LIMIT 20;
-- Edge case: a function on the column can block normal index use.
-- DATE(created_at) is convenient to write, but it is often less index-friendly.
EXPLAIN ANALYZE
SELECT id
FROM orders
WHERE DATE(created_at) = CURRENT_DATE;
-- Better shape: a range predicate is easier for the optimizer to use.
EXPLAIN ANALYZE
SELECT id
FROM orders
WHERE created_at >= CURRENT_DATE
AND created_at < CURRENT_DATE + INTERVAL '1 day';Follow-up & Tricky Questions:
EXPLAIN ANALYZE tell you that EXPLAIN does not? EXPLAIN shows the planned strategy, but EXPLAIN ANALYZE actually runs the query and shows real execution time, row counts, and loop counts. That makes it much better for debugging slow SQL.LIMIT always make a query fast? No. If the database still has to sort or inspect many rows before it can know the top 20, LIMIT alone does not save you. A matching index is what makes the limit cheap.Common Mistakes:
EXPLAIN ANALYZE before changing anything.WHERE and ORDER BY columns.Memory Hook: Think of SQL like a librarian searching a shelf: with the right index, she goes straight to the page; without it, she reads every book until she finds the sentence.
Cheat Sheet:
EXPLAIN ANALYZE to see the real plan and timing.Practice Tasks:
WHERE clause into a range-based filter.