SQL Query Optimizer
Diagnoses a slow SQL query and returns an optimized rewrite, index plan, and execution-plan reasoning.
Example run
Generated by Claude Opus 5.5 on September 23, 2026
Inputs used
- Database engine
- PostgreSQL
- Database engine and version
- PostgreSQL 15.4, running on Amazon RDS (db.r6g.xlarge, 4 vCPU / 32GB RAM), shared_buffers=8GB, work_mem=4MB (default, not tuned per session)
- Current query
- -- Monthly revenue-per-customer dashboard report, run every 5 minutes SELECT c.id AS customer_id, c.company_name, c.region, COUNT(DISTINCT o.id) AS order_count, SUM(oi.quantity * oi.unit_price) AS total_revenue, MAX(o.created_at) AS last_order_at FROM customers c LEFT JOIN orders o ON o.customer_id = c.id AND DATE(o.created_at) BETWEEN '2026-08-01' AND '2026-08-31' LEFT JOIN order_items oi ON oi.order_id = o.id WHERE c.status = 'active' AND c.region IN ('US-EAST', 'US-WEST', 'EU-CENTRAL') GROUP BY c.id, c.company_name, c.region HAVING SUM(oi.quantity * oi.unit_price) > 0 ORDER BY total_revenue DESC LIMIT 50;
- Schema and indexes
- -- customers: ~850,000 rows CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, company_name TEXT NOT NULL, region TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_customers_status ON customers (status); -- no index on region; no composite index on (status, region) -- orders: ~12,400,000 rows, grows ~150 rows/sec at peak CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), status TEXT NOT NULL DEFAULT 'completed' ); CREATE INDEX idx_orders_customer_id ON orders (customer_id); CREATE INDEX idx_orders_created_at ON orders (created_at); -- created_at index exists but is never chosen because WHERE wraps it in DATE() -- order_items: ~41,000,000 rows (avg ~3.3 items per order) CREATE TABLE order_items ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id), quantity INTEGER NOT NULL, unit_price NUMERIC(10,2) NOT NULL ); CREATE INDEX idx_order_items_order_id ON order_items (order_id); -- No table partitioning in place. orders.created_at values span from 2019-01-01 to present. -- autovacuum runs on default settings; last ANALYZE on orders/order_items was 2 days ago.
- Performance problem
- EXPLAIN ANALYZE shows a sequential scan over all 12.4M rows of `orders` (cost ~410,000, actual time ~9.8s) because `DATE(o.created_at)` in the join condition is non-sargable and can't use idx_orders_created_at. The join to order_items then produces a ~38M-row intermediate hash join before aggregation, and the final sort for ORDER BY total_revenue spills to disk (work_mem 4MB, sort shows "external merge Disk: 612MB"). Overall runtime is 14-18s per execution, and since this report auto-refreshes every 5 minutes for ~40 concurrent analysts viewing the dashboard, it pegs CPU at ~95% for the duration and causes visible lock waits on unrelated order-insert transactions.
- Target outcome
- Bring p95 runtime under 300ms so the dashboard stays responsive at 5-minute auto-refresh with ~40 concurrent analysts, eliminate the disk-spilling sort, and avoid any index/write overhead that would meaningfully slow the ~150 inserts/sec landing on orders and order_items.
Optimizing the monthly revenue-per-customer dashboard query (PostgreSQL 15)
Before I commit: what I still need from you
Your symptom description is detailed, but a few facts change the numbers and one design choice. I give firm recommendations below and flag where these facts matter.
| Need | Why it matters | How to get it |
|---|---|---|
| Full plan text | Confirms which node spills (I suspect it's not the final sort, see below) and where the "38M rows" come from | EXPLAIN (ANALYZE, BUFFERS, SETTINGS) on the original query |
| August volume | Sets the floor for any live query. At 150 inserts/sec peak, recent months are much larger than the ~135k/month historical average | SELECT count(*) FROM orders WHERE created_at >= '2026-08-01' AND created_at < '2026-09-01'; and the matching order_items count |
| Eligible customers | Decides whether the customers index is worth anything | SELECT count(*) FROM customers WHERE status='active' AND region IN ('US-EAST','US-WEST','EU-CENTRAL'); |
Session TimeZone of the dashboard connections |
DATE(timestamptz) depends on it, so equivalence of the rewrite depends on it |
SHOW TimeZone; from the dashboard's driver/pool |
| What the "lock waits" are | A plain SELECT can't hold heavyweight locks that block INSERT |
SELECT wait_event_type, wait_event, count(*) FROM pg_stat_activity WHERE state='active' GROUP BY 1,2; while the report runs |
| Is the month hard-coded or rolling? Is 5-minute staleness acceptable? | Decides between "cache once" and "refresh every 5 min" | You |
Reading the plan (what I infer from your symptoms)
- Seq Scan on
orders(12.4M rows, ~9.8s). This is caused byDATE(o.created_at).- A B-tree on
created_atcan only serve predicates of the formcreated_at <op> constant. - A function applied to the column hides it from the index.
- It also wrecks the row estimate. Postgres has no statistics for the expression
date(created_at), so it falls back to a default range selectivity of about 0.5%, roughly 62k rows. If August actually has several hundred thousand orders, every downstream join and memory decision is sized wrong.
- A B-tree on
- The "~38M-row" hash join. The Seq Scan on
order_items(41M rows) is most likely probing a hash of the filtered orders.- The join output itself should only be about August's line items.
- The plan will tell us which. Either way, all 41M
order_itemsrows get read.
- The 612MB "external merge" sort is probably not the
ORDER BY ... LIMIT 50sort.ORDER BY x LIMIT 50uses a top-N heapsort, which keeps only 50 rows in memory and never spills.COUNT(DISTINCT ...)can't be computed with a HashAggregate in PG 15. The planner must sort the whole joined input byc.id, c.company_name, c.regionand feed a GroupAggregate.- That input is every line item of every eligible customer. It also includes one null-extended row for each active customer with no August orders, because of the
LEFT JOIN. - That big pre-aggregation sort is what spills with
work_mem = 4MB.
- The "lock waits" on inserts.
SELECTtakesAccessShareLock, which conflicts only with DDL-level locks, not withINSERT'sRowExclusiveLock.- What your inserts are almost certainly waiting on is resources: CPU starvation at 95%,
LWLock:BufferMapping/BufferContent,IO:DataFileRead, and temp-file I/O competing with WAL writes. - Fixing the query fixes this. It isn't a locking problem, so don't go tuning lock settings.
- What your inserts are almost certainly waiting on is resources: CPU starvation at 95%,
- Concurrency multiplies all of the above. About 40 analysts run an identical 15s query every 5 minutes, and runs overlap. That is roughly 600 CPU-seconds of repeated identical work per 5-minute window on a 4-vCPU box.
Diagnosis
The single biggest cost driver is the non-sargable DATE(o.created_at) BETWEEN ... predicate. It forces a full scan of orders, gives the planner a default row estimate, and lets it pick a plan that also reads all 41M order_items rows.
Two structural issues multiply that cost:
COUNT(DISTINCT o.id)over a line-item-level fan-out forces a giant sort-based aggregation, which is the disk spill.- The
LEFT JOINfrom customers drags every eligible customer through the aggregate, even thoughHAVING SUM(...) > 0discards anyone without revenue.
On top of that, 40 clients recompute the identical result independently. That is why even a well-optimized live query is the wrong architecture for a p95 of 300ms.
Optimized query
This is logically equivalent to the original: same rows, same values, same types. As in the original, which row wins a tie at position 50 is unspecified.
SELECT
c.id AS customer_id,
c.company_name,
c.region,
t.order_count,
t.total_revenue,
t.last_order_at
FROM (
-- LEVEL 2: roll per-order totals up to one row per customer
SELECT
po.customer_id,
COUNT(*) AS order_count, -- CHANGED: input is exactly one row per order,
-- so COUNT(*) = COUNT(DISTINCT o.id) with no DISTINCT sort
SUM(po.order_revenue) AS total_revenue, -- CHANGED: sum of per-order sums; NUMERIC is exact,
-- so regrouping the addition cannot change the result
MAX(po.created_at) AS last_order_at
FROM (
-- LEVEL 1: one row per August order, with its line-item total
SELECT
o.id,
o.customer_id, -- allowed without GROUP BY: functionally dependent on PK o.id
o.created_at,
SUM(oi.quantity * oi.unit_price) AS order_revenue -- NULL for item-less orders, exactly as before
FROM orders o
LEFT JOIN order_items oi -- kept LEFT: item-less orders still count in order_count
ON oi.order_id = o.id -- and last_order_at, as in the original
WHERE o.created_at >= '2026-08-01' -- CHANGED: sargable half-open range; uses idx_orders_created_at
AND o.created_at < '2026-09-01' -- replaces DATE(o.created_at) BETWEEN '2026-08-01' AND '2026-08-31'
AND EXISTS ( -- CHANGED: customer filter applied early as a semi-join,
SELECT 1 -- so ineligible customers' items are never aggregated
FROM customers c2
WHERE c2.id = o.customer_id
AND c2.status = 'active'
AND c2.region IN ('US-EAST', 'US-WEST', 'EU-CENTRAL'))
GROUP BY o.id
) po
GROUP BY po.customer_id
HAVING SUM(po.order_revenue) > 0 -- unchanged; this is why the original LEFT JOIN from
-- customers was effectively an INNER join
ORDER BY total_revenue DESC
LIMIT 50 -- CHANGED: pick the top 50 BEFORE fetching display columns
) t
JOIN customers c ON c.id = t.customer_id -- CHANGED: only 50 primary-key lookups for company_name/region
ORDER BY t.total_revenue DESC; -- re-apply order; a join does not guarantee it is preserved
Why each rewrite is safe
1. DATE(ts) BETWEEN d1 AND d2 becomes ts >= d1 AND ts < d2 + 1.
DATE(timestamptz)converts to a local date using the sessionTimeZone.- The untyped literal
'2026-08-01'compared to atimestamptzcolumn is cast to midnight in the same sessionTimeZone. - So
date(ts) >= '2026-08-01'⇔ts >= '2026-08-01 00:00 local', anddate(ts) <= '2026-08-31'⇔ts < '2026-09-01 00:00 local'. - They are identical for any session time zone. Using
< next dayinstead of<= '2026-08-31 23:59:59'also avoids losing microsecond-precision rows in the last second. - Also, you can't fix this with an expression index on
DATE(created_at). Fortimestamptzit'sSTABLE, notIMMUTABLE, because it depends onTimeZone. Postgres rejects it with "functions in index expression must be marked IMMUTABLE". The range rewrite is the correct fix, not a workaround.
2. Dropping the LEFT JOIN from customers.
- A customer with no matching orders or items has
SUM(...) = NULL. NULL > 0is not true, soHAVINGalready removed them.- Driving the query from August orders removes those rows before aggregation instead of after.
3. Two-level aggregation instead of COUNT(DISTINCT).
- Level 1 collapses the line-item fan-out to one row per order, so a plain
COUNT(*)is exact at level 2. quantityandunit_priceareNOT NULL, andNUMERICaddition is associative, so revenue is bit-identical.- Both levels can now use HashAggregate.
4. LIMIT before the display join.
company_nameandregiondon't affect grouping, filtering, or ordering.- So we fetch them for 50 rows instead of carrying wide
TEXTthrough the aggregates.
Optional: append , t.customer_id to both ORDER BY clauses to make ties deterministic. This is a behavior improvement, not an equivalence change, so I left it out.
Index recommendations
My guiding rule, given your write constraint, is to replace rather than add. Your hot-write tables should end up with the same number of indexes they have now.
-- 1) No new index needed on orders.
-- idx_orders_created_at (created_at) becomes usable as soon as the predicate is sargable.
-- orders.id comes from BIGSERIAL and created_at defaults to now(), so the heap is
-- physically ordered by time (correlation ~1). An August range scan reads a
-- contiguous block of pages: few, sequential-ish I/Os.
-- 2) customers: composite replaces the single-column index (customers is low-write).
CREATE INDEX CONCURRENTLY idx_customers_status_region
ON customers (status, region) INCLUDE (id);
DROP INDEX CONCURRENTLY idx_customers_status; -- redundant: (status, region) serves status-only lookups too
- Why this shape: both columns are equality/IN filters.
statusgoes first so the index still serves every existingstatus = ...query, which makes the old index redundant. - Why
INCLUDE (id): it lets the semi-join build its hash from an index-only scan. - When it's worth it: only if the eligible set is a minority of the 850k rows. If ~90% of customers are active and in these regions, skip it; a seq scan of 850k narrow rows is ~50–100ms and fine.
-- 3) OPTIONAL, only if EXPLAIN shows order_items heap fetches dominating after the rewrite:
-- a covering index replaces (not supplements) the existing order_id index.
CREATE INDEX CONCURRENTLY idx_order_items_order_id_cov
ON order_items (order_id) INCLUDE (quantity, unit_price);
DROP INDEX CONCURRENTLY idx_order_items_order_id;
- What it enables: index-only scans for the per-order probes.
order_idleads, so it still serves the FK checks and cascades the old index served. - Write cost: still exactly one index insertion per row, just a wider tuple. That's roughly 1.5–2× the index size, and a negligible per-insert cost difference.
Explicitly not recommended:
- Any new index on
ordersororder_items. - An index on
order_items (order_id, quantity, unit_price)as key columns.INCLUDEdoes the same job without bloating the upper B-tree levels. - An expression index on
DATE(created_at), which is impossible as explained above.
Materially important: stop computing this 40 times (schema/config)
Let me be direct: even the optimized query must aggregate every August line item for eligible customers. If August is ~300k–1M orders and ~1–3M items, that is on the order of 0.3–1.5s per run, and 40 concurrent copies will contend for 4 vCPUs. Query tuning alone is unlikely to hold p95 under 300ms under that concurrency.
The result is identical for every analyst, so compute it once per refresh interval.
-- Proposed new object (name is a suggestion; nothing existing is renamed)
CREATE MATERIALIZED VIEW mv_customer_monthly_revenue AS
SELECT c.id AS customer_id, c.company_name, c.region,
t.order_count, t.total_revenue, t.last_order_at
FROM (
SELECT po.customer_id, COUNT(*) AS order_count,
SUM(po.order_revenue) AS total_revenue, MAX(po.created_at) AS last_order_at
FROM (
SELECT o.id, o.customer_id, o.created_at,
SUM(oi.quantity * oi.unit_price) AS order_revenue
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at >= '2026-08-01' AND o.created_at < '2026-09-01'
AND EXISTS (SELECT 1 FROM customers c2
WHERE c2.id = o.customer_id AND c2.status = 'active'
AND c2.region IN ('US-EAST','US-WEST','EU-CENTRAL'))
GROUP BY o.id
) po
GROUP BY po.customer_id
HAVING SUM(po.order_revenue) > 0 -- no LIMIT: store all qualifying customers
) t
JOIN customers c ON c.id = t.customer_id;
CREATE UNIQUE INDEX ON mv_customer_monthly_revenue (customer_id); -- required for REFRESH ... CONCURRENTLY
CREATE INDEX ON mv_customer_monthly_revenue (total_revenue DESC); -- makes the dashboard read a 50-row index scan
-- Refresh with generous memory for this one job only
CREATE OR REPLACE PROCEDURE refresh_mv_customer_monthly_revenue()
LANGUAGE plpgsql
SET work_mem = '128MB'
AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_customer_monthly_revenue; -- readers are never blocked
END;
$$;
-- pg_cron is supported on RDS PG15 (add to shared_preload_libraries in the parameter group; needs a reboot)
SELECT cron.schedule('refresh-customer-revenue', '*/5 * * * *',
'CALL refresh_mv_customer_monthly_revenue()');
-- Dashboard query becomes:
SELECT * FROM mv_customer_monthly_revenue ORDER BY total_revenue DESC LIMIT 50;
August is a closed month (today is 2026-09-23). Unless you have late or backdated orders or edits, its result never changes. Refresh it once, or nightly to catch corrections, instead of every 5 minutes.
If the dashboard is really "current month":
- Swap the literals for
date_trunc('month', now())anddate_trunc('month', now()) + interval '1 month'. - Those evaluate at refresh time, in the refreshing session's
TimeZone. Pin it, e.g.SET TimeZone = 'UTC'in the procedure, to match what analysts expect.
Other config and schema notes
work_mem: don't raise it globally. 40 sessions × several hash/sort nodes × parallel workers could reach many GB. Scope it:- per job, as in the procedure above, or
- per role:
ALTER ROLE <your_dashboard_role> SET work_mem = '64MB';(substitute your actual role).
- Visibility map (only if you adopt the covering index): index-only scans need pages marked all-visible. Keep that current on the insert-heavy table with
ALTER TABLE order_items SET (autovacuum_vacuum_insert_scale_factor = 0.01);. PG13+ triggers vacuum on inserts, but the default 0.2 × 41M rows means it rarely fires. - Statistics: ANALYZE from 2 days ago is fine for August. For current-month queries, Postgres probes the actual index endpoints for out-of-histogram values, which mitigates stale stats on
created_at. - Partitioning
ordersby month: don't do this now.- It would enable partition pruning.
- But on a partitioned table, the primary key must include
created_at. That breaksorder_items.order_id REFERENCES orders(id)unless you also carrycreated_atintoorder_items. - It's a large migration for a problem the matview already solves.
Why it's faster
| Change | Cost it removes |
|---|---|
Sargable range on created_at |
Full 12.4M-row seq scan (~9.8s) becomes an index range scan over one contiguous month. It also gives the planner real histogram-based row estimates instead of the 0.5% default, so join and memory choices are sane. |
| Driving from August orders (inner semantics) | Non-August order_items are never touched, so the planner can probe order_items by order_id instead of seq-scanning 41M rows. Also removes the null-extended rows for every idle active customer. |
Two-level aggregation, no COUNT(DISTINCT) |
Removes the whole-input sort feeding GroupAggregate, which is the 612MB external merge. Hash aggregates over roughly the number of August orders and then customers fit in modest memory. |
LIMIT before the display join |
Wide TEXT columns aren't carried through the aggregates; only 50 PK lookups. The final sort is a top-N heapsort of 50 rows, a few KB in memory. |
Materialized view + CONCURRENTLY refresh |
40 identical executions per 5 minutes become 1. Dashboard reads become a ~50-row index scan. |
Rough estimates
These assume August ≈ 300k–1M orders and 1–3M items, all warm in the 8GB shared_buffers. Confirm with your counts.
| Scenario | Per execution | Load per 5 minutes |
|---|---|---|
| Before | 14–18s, 612MB temp spill | ~40 × 15s ≈ 600 CPU-seconds on a 4-vCPU box |
| Rewrite only | ~0.3–1.5s, no spill with work_mem ≥ 32–64MB for the session |
still 40×, so p95 under concurrency likely > 300ms |
| Rewrite + matview | dashboard reads < 5ms; one refresh of ~0.5–2s | refresh cost only, which is > 99% less CPU |
Freeing that CPU is also what makes the "lock waits" (really CPU/IO/LWLock waits) on your inserts disappear.
Trade-offs & risks
- Staleness: the matview is up to 5 minutes old, plus the refresh duration. Since the dashboard already auto-refreshes every 5 minutes, this is usually acceptable, but confirm with stakeholders.
- Refresh cost:
REFRESH ... CONCURRENTLYruns the full query plus a diff against the old contents, so it's somewhat more expensive than a plain refresh.- It takes an
EXCLUSIVElock on the matview only. That blocks other refreshes, not readers, and never touchesordersororder_itemswrites. - Monitor that refresh duration stays well under 5 minutes.
- It takes an
- Time zone: the original query already returns different results for sessions with different
TimeZonesettings. If different analysts' drivers set different zones, you have an existing correctness bug. Decide on one business zone and make it explicit. CREATE INDEX CONCURRENTLY:- It can't run inside a transaction block and takes longer than a normal build.
- If it fails, it leaves an
INVALIDindex that still incurs write overhead. Checkpg_index.indisvalidand drop any leftovers. - Between the
CREATEand theDROP, both indexes exist, so writes pay double index maintenance for that window. Do it off-peak.
- Covering index: it's ~1.5–2× the size of the index it replaces. Its benefit depends on the visibility map; a stale map means heap fetches anyway.
- Edge cases to verify:
- customers whose orders all have zero or negative revenue (excluded in both versions);
- orders with no items (counted in
order_countin both); - ties at rank 50 (order unspecified in both).
Validation step
Run this on a staging clone or an RDS snapshot restore if you can.
1. Prove identical results. Use one snapshot and one session, so both queries see the same data and time zone.
BEGIN ISOLATION LEVEL REPEATABLE READ;
CREATE TEMP TABLE r_old ON COMMIT DROP AS
SELECT c.id AS customer_id, c.company_name, c.region,
COUNT(DISTINCT o.id) AS order_count,
SUM(oi.quantity * oi.unit_price) AS total_revenue,
MAX(o.created_at) AS last_order_at
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
AND DATE(o.created_at) BETWEEN '2026-08-01' AND '2026-08-31'
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE c.status = 'active' AND c.region IN ('US-EAST','US-WEST','EU-CENTRAL')
GROUP BY c.id, c.company_name, c.region
HAVING SUM(oi.quantity * oi.unit_price) > 0; -- no ORDER BY/LIMIT: compare the full set
CREATE TEMP TABLE r_new ON COMMIT DROP AS
SELECT * FROM mv_customer_monthly_revenue; -- or the optimized query with LIMIT removed
SELECT (SELECT count(*) FROM r_old) AS old_rows,
(SELECT count(*) FROM r_new) AS new_rows,
(SELECT count(*) FROM (TABLE r_old EXCEPT ALL TABLE r_new) d) AS missing_in_new, -- expect 0
(SELECT count(*) FROM (TABLE r_new EXCEPT ALL TABLE r_old) d) AS extra_in_new; -- expect 0
COMMIT;
If you compare against the matview, refresh it right before so it reflects the same data. For August that doesn't matter.
2. Confirm the plan change. Run the optimized query from above:
SET work_mem = '64MB';
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
<optimized query from above>;
Look for all of the following:
- an
Index ScanorBitmap Index Scanonidx_orders_created_at; - no
Seq Scan on order_items; HashAggregatewith no "Disk Usage" or "Batches > 1";Sort Method: top-N heapsort;- no
external mergeanywhere.
3. Confirm p95 under realistic concurrency.
echo "SELECT * FROM mv_customer_monthly_revenue ORDER BY total_revenue DESC LIMIT 50;" > dashboard.sql
pgbench -n -c 40 -j 4 -T 120 -P 10 -l -f dashboard.sql "$DATABASE_URL"
# p95 latency in ms from the per-transaction log (3rd column is latency in microseconds):
cat pgbench_log.* | awk '{print $3}' | sort -n | awk '{a[NR]=$1} END {print a[int(NR*0.95)]/1000 " ms"}'
Also run this pgbench test with the optimized live query in dashboard.sql instead of the matview read. That shows you directly whether the rewrite alone meets 300ms at 40 concurrent sessions.
If you send the EXPLAIN (ANALYZE, BUFFERS, SETTINGS) output and the August counts, I can tighten these estimates and tell you whether the optional covering index is worth it.
Related prompts
Algorithm Design Expert
Designs, analyzes, and optimizes algorithms with multiple approaches, Big-O complexity analysis, production code, and tests.
Advanced Debugging Assistant
Diagnoses bugs in your code, pinpoints root causes, and delivers fixed code with prevention tips.
API Development Architect
Designs a production-ready RESTful API with OpenAPI spec, secure auth, validation, and scalable architecture.
Code Review Expert
Performs a rigorous senior-level code review covering correctness, design, performance, and security with prioritized, actionable fixes.