Skip to content
In this guide

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.

  1. The band from the orders wheel into Seq Scan on orders is 12,000,000 rows wide: the scan read the whole table.
  2. 207K ┅ 612 is 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×.
  3. −11.8M is the 11,793,120 rows the filter read and threw away. The scan kept 1.7% of what it read.
  4. ↻ ×207K ┅ ×612 under the order_items scan 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.
  5. 1.64 s on the scan's bar is its self time, the node minus its inputs. This one node is 36% of the query.
  6. 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.

  1. Blue · rows kept is the stretch of the table where the 206,880 kept rows live, 1.7% of rows read. It is one unbroken stretch because placed_at follows the order rows were written, so the last 30 days sit together.
  2. Rim · the table is every page of orders in physical order, 183,000 pages and 1.5 GB. The wheel is the table, not the rows the query returned.
  3. Arrow · full scan means the scan swept the whole rim to reach the blue. An index on channel, placed_at would 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.

  1. 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.
  2. 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.
  3. The Nested Loop segments are the two joins, about half a second between them. The scans under them, order_items and customers, 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.

  1. Seq Scan on orders, keeps 1.7% · 36% of time, fires because the scan keeps less than a fifth of what it reads. Test index rehearses the index it proposes without building it, and Copy CREATE INDEX copies the statement that builds it for real.
  2. 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 orders copies 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.

  1. Rows draws read against kept, 12,000,000 against 206,880, 1.7% kept. The scan did 98% of its reading for nothing.
  2. Rows per loop puts the 206,880 rows found against the 612 the planner expected on one ruler, 338× more. There is no Loops ruler because the scan ran once.
  3. Pages is 99% from disk: cache 1,204 against disk 182K. The table was not in memory, which is most of the 1,640 ms.
  4. Indexes · orders lists the three indexes on the table against filters on channel, placed_at. orders_customer_placed_idx and orders_status_placed_idx are not leading: they contain placed_at, but a B-tree is entered from its first column, and neither starts with channel.
  5. suggested is the index the finding proposes, channel, placed_at, with Test. Test plans 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:

  1. The node with the biggest self time. That is where the seconds are.
  2. The lowest node whose estimate is off by 10× or more. Every decision above it was made on a wrong number.
  3. 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:

  1. ANALYZE commerce.orders; The Estimate off finding copies it. It reads a sample of the table and takes seconds. Seqora cannot prove ANALYZE is read-only, so a locked connection rejects it: unlock from the sidebar, run it, and confirm the Review high-risk SQL panel. 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.
  2. 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 what Test tells you and what it cannot. Copy CREATE INDEX copies the CONCURRENTLY form, which is the one to run on a table people are writing to.

Caveats

  • EXPLAIN ANALYZE runs the query. A read that takes 4.6 seconds takes 4.6 seconds again when you open Plan.
  • Seqora analyzes reads on its own. For an UPDATE, DELETE or INSERT it shows estimates and offers Analyze in rollback, which runs the statement inside BEGIN … ROLLBACK. That needs an unlocked connection, and it asks Run this statement to analyze it? first, because sequence values, trigger side effects and the locks it takes are not undone. On production you type RUN before Run and roll back.
  • The inspector's Rows per loop is per loop. order_items returning 3 rows is 3 rows each of 206,880 times. The bands in the flow are already multiplied.
  • commerce.orders was 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.

Last checked against Seqora 0.3.2, PostgreSQL 17 on .

Found a bug, missing a feature, or just curious? Write to me. Every email lands in my own inbox, not a ticket queue.