Hook: Interviewers love this because a query that is “fine” on 1,000 rows can quietly become a memory monster on 10 million rows.
Question: How do you troubleshoot and fix a SQL query that is causing a memory issue?
Answer: Most query memory problems happen because the database has to hold a big sort, hash join, hash aggregate, or huge intermediate result in memory. The fix is usually not “add more RAM”; it is to reduce rows earlier, return fewer columns, and help the optimizer with better indexes or a simpler plan. I would first inspect the execution plan, find the operator that is using memory, and then rewrite the query so the database does less work.
Interview-Ready Answer: “I would first confirm which part of the plan is using memory, usually a sort, hash join, or hash aggregate. Then I would reduce the amount of data flowing through the plan by filtering earlier, selecting only needed columns, and adding or adjusting indexes so the engine can avoid large in-memory work areas. In PostgreSQL, for example, I also watch for settings like work_mem, because it applies per sort or hash node, so one query can use several times that amount.”
A query is not one block of memory. It is a plan made of operators, and each operator may ask for its own working space. The most common memory-hungry operators are Sort, Hash Join, and Hash Aggregate. A Sort needs space to order rows; a hash operator needs space to build a lookup table for fast matching or grouping.
Important mental model: most “memory issues” are really “too much data too late.”
| Operator | Memory use | Typical risk | Better move |
|---|---|---|---|
| Sort | High | Large ORDER BY | Index order, top-N |
| Hash Join | Medium-high | Big build side | Smaller build input |
| Hash Aggregate | High | Many groups | Pre-filter rows |
| Nested Loop | Low | Can be slow | Use when inner side is tiny |
EXPLAIN (ANALYZE, BUFFERS) is the usual starting point.DISTINCT, and filter as early as possible.Use a rewrite when the query shape is the problem, use an index when the access pattern is the problem, and use configuration tuning only after the query itself is sane. In PostgreSQL, the default work_mem is often 4 MB, and it is per sort/hash node, not per query. That means one query with several memory-heavy operators, or several parallel workers, can consume far more than you first expect.
Sort: about O(n log n) time; memory grows with the number and width of rows.Hash Join / Hash Aggregate: usually O(n) average time, but memory depends on the size of the build side or number of groups.Nested Loop: low memory, but worst-case time can be O(n*m).Edge case: a query can look small but still use a lot of memory if it creates a wide intermediate result, for example after joining many columns or using a window function over a large partition.
Version gotcha: in PostgreSQL, CTEs were treated as optimization fences in older versions, but newer versions can inline them unless marked MATERIALIZED. That difference can change memory use a lot.
Real-World Example: Imagine a checkout service in an e-commerce app that needs to list recent paid orders with customer details. A developer adds DISTINCT plus ORDER BY created_at DESC after joining orders, payments, and customers. It works in staging, but in production the query suddenly spikes memory, spills to disk, and p95 latency jumps from 200 ms to 6 seconds.
The logs show temp file growth, the database node starts swapping, and some app pods get killed because the database and app are fighting for the same machine’s memory. Users see slow page loads and failed order-history requests. The bug was not one line of SQL; it was a plan shape that forced the engine to hold too much data before it could return anything.
-- Reproducible SQL example: show a query shape that can waste memory,
-- then a safer rewrite that reduces the amount of data processed.
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
region VARCHAR(20) NOT NULL
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
created_at DATE NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL
);
INSERT INTO customers (customer_id, region) VALUES
(1, 'North'),
(2, 'South'),
(3, 'East'),
(4, 'West');
-- Generate a larger set without vendor-specific helper functions.
-- On a real production table, this would be millions of rows, not 2,000.
WITH RECURSIVE nums(n) AS (
SELECT 1
UNION ALL
SELECT n + 1
FROM nums
WHERE n < 2000
)
INSERT INTO orders (order_id, customer_id, created_at, amount, status)
SELECT
n,
(n % 4) + 1,
DATE '2024-01-01' + (n % 180),
CAST((n % 500) + 1 AS DECIMAL(10,2)),
CASE WHEN n % 11 = 0 THEN 'CANCELLED' ELSE 'PAID' END
FROM nums;
-- Helpful indexes: they reduce scanning and can avoid large sorts.
CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at);
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
-- Bad pattern:
-- 1) returns every order column with o.*
-- 2) filters loosely
-- 3) forces the engine to handle a bigger intermediate result
-- On a huge table, this shape can create a large sort or hash step.
EXPLAIN
SELECT c.region, o.*
FROM customers c
JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.status <> 'CANCELLED'
ORDER BY o.created_at DESC;
-- Better pattern:
-- 1) return only columns the application actually needs
-- 2) filter on a single status value, which is usually easier to optimize
-- 3) ask for a small page, so the engine can stop earlier
EXPLAIN
SELECT c.region, o.order_id, o.created_at, o.amount
FROM customers c
JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.status = 'PAID'
AND o.created_at >= DATE '2024-03-01'
ORDER BY o.created_at DESC
FETCH FIRST 10 ROWS ONLY;
-- Edge case: LIMIT alone is not magic if the database still has to sort
-- a very large unsorted set first. Narrowing the WHERE clause is what
-- really cuts memory pressure.
EXPLAIN
SELECT o.order_id, o.customer_id, o.created_at
FROM orders o
ORDER BY o.created_at DESC
FETCH FIRST 10 ROWS ONLY;Follow-up & Tricky Questions:
work_mem? In PostgreSQL it is the memory budget for each sort or hash operation, not for the whole query. That means several operators, or several parallel workers, can multiply the total memory use.DISTINCT or a broad join.LIMIT always save memory? Not always. If the database must sort the full result before applying the limit, the memory savings may be small. A matching index or a better filter is what really helps.Common Mistakes:
SELECT *. Fix: return only the columns the caller needs so the engine moves less data.LIMIT and thinking the problem is solved. Fix: make the filter selective or add an index so the engine does not sort a giant set first.Memory Hook: Think of the query like packing a suitcase: SELECT * adds too many clothes, ORDER BY forces you to arrange everything at once, and the zipper pops. The fix is to pack less and fold earlier.
Cheat Sheet:
work_mem is per operator, not per query.Practice Tasks:
ORDER BY and then remove unused columns; compare the plan shape.