Guide · Reading
Why is this query slow?
Most queries never need a plan. Open one when a query that used to take a moment now takes seconds, when a page in your app got slow after a deploy, or when you added an index and want to know whether the planner uses it. This page reads one slow plan from the top down. If you only want to know what each control does, Plan tab is shorter.
The symptom
A revenue report by country, for web orders placed in the last 30 days:
WITH web_orders AS (
SELECT id, customer_id FROM commerce.orders
WHERE channel = 'web' AND placed_at >= now() - interval '30 days'
)
SELECT c.country,
count(*) AS items,
sum(oi.line_total) AS revenue,
rank() OVER (ORDER BY sum(oi.line_total) DESC) AS rank
FROM web_orders w
JOIN crm.customers c ON c.id = w.customer_id
JOIN commerce.order_items oi ON oi.order_id = w.id
GROUP BY c.country
ORDER BY revenue DESC;
It returns 24 rows and takes 4.6 seconds. commerce.orders has 12,000,000 rows and two indexes that contain placed_at. Nothing about the query looks wrong.
What the Plan tab shows
Run the statement, then click Plan next to Result above the rows (Pro). Seqora does not read your run. It runs its own EXPLAIN, estimates first, and for a read statement follows with EXPLAIN ANALYZE inside a transaction it rolls back. The header says EXPLAIN ANALYZE, the execution and planning time (4,581 ms and 2.4 ms here) and the work_mem in force. The rest of the tab is five parts. This section takes them one at a time, in the order your eye meets them.
The row flow
The Flow lens draws the plan right to left, from the tables into the result. Each band is the rows leaving a node, as wide as the count across every loop, so a node that ran many times is drawn at its total. Six things on this flow matter for this query.
The row flow of the report. Rows run right to left, from the tables into the result.
The row flow of the report. Rows run right to left, from the tables into the result.
- The band from the
orderswheel intoSeq Scan on ordersis 12,000,000 rows wide: the scan read the whole table. 207K ┅ 612is what left the scan, 206,880 rows, beside a dotted ghost drawn at the planner's estimate of 612. A ghost appears when an estimate is off by more than 2×, and the planner's figure joins the label past 10×. This one is off by 338×.−11.8Mis the 11,793,120 rows the filter read and threw away. The scan kept 1.7% of what it read.↻ ×207K ┅ ×612under theorder_itemsscan is how many times it ran, once per order the scan kept, where the planner had budgeted for 612 runs. The band above it is already multiplied out, 620,640 rows.1.64 son the scan's bar is its self time, the node minus its inputs. This one node is 36% of the query.- The result end, which the figure reaches when you scroll it right, is where the 24 rows leave the plan. Every node between it and the scan was planned for 612 rows.
Read it as a story. Markers 1 and 3 say the scan read everything and kept little. Marker 2 says the planner thought it would keep almost nothing, so it chose joins that probe an index once per kept row, and marker 4 says those probes ran 206,880 times instead of 612. The seconds are in the scan, marker 5, and in the probes.
The wheel beside a scan
Every scan has a wheel on its left: the table, drawn as a ring. Double-click one in the app for the TABLE WHEEL guide, which labels its parts and prints the table's numbers. This is the wheel of orders.
The wheel beside Seq Scan on orders, drawn large, with the guide's own labels.
The wheel beside Seq Scan on orders, drawn large, with the guide's own labels.
Blue · rows keptis the stretch of the table where the 206,880 kept rows live,1.7% of rows read. It is one unbroken stretch becauseplaced_atfollows the order rows were written, so the last 30 days sit together.Rim · the tableis every page ofordersin physical order, 183,000 pages and 1.5 GB. The wheel is the table, not the rows the query returned.Arrow · full scanmeans the scan swept the whole rim to reach the blue. An index onchannel, placed_atwould enter the rim at the blue stretch and read little else.
Where the time went
The strip above the canvas splits the 4,581 ms by node. Hover a segment for Self time, the node minus its inputs, Share and With inputs.
The time strip of the report, one segment per node, widest first.
The time strip of the report, one segment per node, widest first.
orders 36%is the Seq Scan's self time, 1,640 ms. That is where the seconds are, and the first segment to look at on any plan.- The total at the left is the 4.58 s every segment is a share of. A segment is self time, so the strip adds up to the total with nothing counted twice.
- The
Nested Loopsegments are the two joins, about half a second between them. The scans under them,order_itemsandcustomers, cost 0.005 ms a run, which the strip multiplies by 206,880 and 620,640 runs into 1,034 ms and 1,241 ms.
Two of the three seconds that are not the scan are those loops. The aggregate and the sort take almost nothing.
Findings
Seqora reads the plan for the patterns worth a sentence and lists them under the canvas, with a numbered badge on the node each one sits on. Both of these sit on the scan, so the flow above shows one badge.
The findings panel for the report: two findings, both on Seq Scan on orders.
The findings panel for the report: two findings, both on Seq Scan on orders.
Seq Scan on orders,keeps 1.7% · 36% of time, fires because the scan keeps less than a fifth of what it reads.Test indexrehearses the index it proposes without building it, andCopy CREATE INDEXcopies the statement that builds it for real.Estimate off 338×,more rows than planned · 207K vs 612 · analyzed 41 days ago, fires on the node where the error starts, not on every node that inherits it.Copy ANALYZE orderscopies the fix.
The inspector
Click the scan and the inspector opens on the right. It puts the scan's numbers on rulers and bars, and lists the indexes the planner could have used.
The inspector for Seq Scan on orders, with the suggested index at the bottom of its index list.
The inspector for Seq Scan on orders, with the suggested index at the bottom of its index list.
Rowsdraws read against kept, 12,000,000 against 206,880,1.7% kept. The scan did 98% of its reading for nothing.Rows per loopputs the 206,880 rows found against the 612 the planner expected on one ruler,338× more. There is noLoopsruler because the scan ran once.Pagesis99% from disk:cache 1,204againstdisk 182K. The table was not in memory, which is most of the 1,640 ms.Indexes · orderslists the three indexes on the table againstfilters on channel, placed_at.orders_customer_placed_idxandorders_status_placed_idxarenot leading: they containplaced_at, but a B-tree is entered from its first column, and neither starts withchannel.suggestedis the index the finding proposes,channel, placed_at, withTest.Testplans the statement again with a hypothetical index and prints the whole plan's cost before and after, which Will this index help? reads.
Under the index list, heap order sketches how placed_at sits in the table, with its correlation of 0.99. It matters once the index exists, because an index range on a column the table is sorted by reads neighbouring pages.
All of it together
That is every part. Here is the whole thing they came from: the Plan tab as the app shows it, with the text plan from the SQL editor beside it.
All of it together: the Plan tab as the app shows it.
The three things to look at first
Any plan, from the top:
- The node with the biggest self time. That is where the seconds are.
- The lowest node whose estimate is off by 10× or more. Every decision above it was made on a wrong number.
- A scan that reads far more than it keeps. That is a missing or unusable index.
On this plan all three point at the same node, which is common. The scan of orders is the biggest self time. It is the lowest node with a bad estimate, 612 against 206,880. And it reads 12,000,000 rows to keep 206,880, because no index on orders starts with channel.
The bad estimate explains the rest. orders was analysed 41 days ago, and most of the last 30 days of orders arrived after that. The planner priced the joins for 612 rows, and for 612 rows a Nested Loop that probes an index per row is the right call. It ran that loop 206,880 times against order_items, and the join above it 620,640 times against customers. The join above the loops expected 1,836 rows and produced 620,640. Two of the three seconds in the strip are those loops.
What to do
In this order:
ANALYZE commerce.orders;TheEstimate offfinding copies it. It reads a sample of the table and takes seconds. Seqora cannot proveANALYZEis read-only, so a locked connection rejects it: unlock from the sidebar, run it, and confirm theReview high-risk SQLpanel. Capture the plan again and the estimate is close to 206,880, so the planner switches both joins to Hash Joins. The scan is still a Seq Scan, so this alone fixes the loops and not the 1,640 ms.CREATE INDEX orders_channel_placed_idx ON commerce.orders (channel, placed_at DESC);This is the suggested index. Before building it on a 12-million-row table, test it: Will this index help? shows whatTesttells you and what it cannot.Copy CREATE INDEXcopies theCONCURRENTLYform, which is the one to run on a table people are writing to.
Caveats
EXPLAIN ANALYZEruns the query. A read that takes 4.6 seconds takes 4.6 seconds again when you openPlan.- Seqora analyzes reads on its own. For an
UPDATE,DELETEorINSERTit shows estimates and offersAnalyze in rollback, which runs the statement insideBEGIN … ROLLBACK. That needs an unlocked connection, and it asksRun this statement to analyze it?first, because sequence values, trigger side effects and the locks it takes are not undone. On production you typeRUNbeforeRun and roll back. - The inspector's
Rows per loopis per loop.order_itemsreturning 3 rows is 3 rows each of 206,880 times. The bands in the flow are already multiplied. commerce.orderswas last analysed 41 days ago in the copy behind this page. Yours was probably analysed last night by autovacuum, and the estimate will be closer. The missing index is the same either way.- The numbers above come from a 12-million-row copy of the sample. A smaller copy gives the same plan shape with smaller numbers.
- SQLite shows estimates only, with no timings. MySQL and MariaDB analyze reads only. MySQL, MariaDB and SQLite has the table.