EXPLAIN Plan Visualizer
Paste EXPLAIN ANALYZE output and see the plan as a tree with per-node timing, rows and hot spots — entirely on this device.
Plans parse in a Web Worker on this device and are never stored — no request carrying your paste is ever made; verify in your network tab.
Arrow keys move, Left and Right collapse and expand, Enter selects.
Paste a plan to see its tree and hot spots.
Or load a captured sample:
Nodes ranked by exclusive time — the work each node did itself.
The hot-spot ranking appears here.
Share links carry the view options only — the pasted plan is structurally absent from them and never leaves this tab.
Press ? for shortcuts
Do it offline:$ psql -c 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM orders WHERE customer_id = 42;'
How to use it
- Run EXPLAIN on your database — the command below captures the richest form, EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON); plain EXPLAIN, the text format and MySQL's EXPLAIN FORMAT=JSON all work too.
- Paste the output. The format is detected and named on a badge you can pin, and a paste copied from psql's aligned mode — QUERY PLAN header, + continuation markers, (N rows) footer — is stripped back to the raw plan first.
- Read the tree: the bar on every node is its share of the plan's work with the percentage printed beside it, badges flag estimate misses and looped nodes, and the detail rail shows each number with the formula that produced it.
- Switch to Hot spots for the ranked list, toggle the per-node figure between time, rows and cost, and copy or download the annotated tree as text.
Paste EXPLAIN output and read the plan as a tree instead of a wall of parentheses. This page parses PostgreSQL's JSON and text formats and MySQL's EXPLAIN FORMAT=JSON, derives each node's exclusive time and estimate accuracy with the formulas printed below, and ranks the hot spots. Parsing runs in a Web Worker on your device — plans are never uploaded and never stored.
How it works
Three formats are declared, and nothing else parses: PostgreSQL EXPLAIN (FORMAT JSON) — the array envelope, plus the object envelope auto_explain writes to the log; PostgreSQL's text format, walked by indentation and the -> arrows; and MySQL EXPLAIN FORMAT=JSON with its query_block containers, including the r_ runtime fields MariaDB's ANALYZE adds. An unrecognized paste is refused with its own first line quoted — never guessed into a fake tree. MySQL's FORMAT=TREE and SQL Server plans are out of scope in this version.
PostgreSQL reports per-loop averages, so the derived numbers are computed exactly as printed here: inclusive time = actual per-loop time × loops, and exclusive time = inclusive time − Σ(child per-loop time × child loops), clamped at zero because parallel workers can otherwise double-count. The estimate badge uses misestimate factor = max(planned rows, actual rows) ÷ min(planned rows, actual rows), comparing the per-loop values the plan itself reports. MariaDB's r_total_time_ms is already a total across loops, so on that path child totals subtract directly.
Hot spots are ranked by exclusive time whenever the plan carries measured times. A plain EXPLAIN has none, so the ranking falls back to cost and a banner says so: PostgreSQL self cost = total cost − Σ children's total cost, and MySQL self cost = read_cost + eval_cost per table, because MySQL prefix costs accumulate across join siblings. Parsing runs in a Web Worker with a 10-second kill timer, inputs are capped at 10 MiB and 5,000 nodes with the cap and the actual size printed on refusal, and no request carrying your plan ever leaves the page.
Examples
explain-plan-pg16-analyze.json — the checked-in oracle plan: EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) over a customers ⋈ orders join, captured from PostgreSQL 16.141. Bitmap Heap Scan on orders — 0.684 ms (46.5%)
2. Sort — 0.548 ms (37.3%)
3. Bitmap Index Scan — 0.162 ms (11.0%)
4. Nested Loop — 0.056 ms (3.8%)
5. Index Scan on customers — 0.021 ms (1.4%)
Execution Time 1.541 ms · Planning Time 0.76 ms · 167 rowsThe hot-spot ranking from a five-node ANALYZE plan. The Bitmap Heap Scan ran three times (loops=3), so its 0.282 ms per-loop average becomes 0.846 ms inclusive, and subtracting its child's 0.162 ms leaves 0.684 ms of its own work — 46.5% of the plan.
Sort (cost=1337.54..1337.55 rows=4 width=45) (actual time=8.983..8.984 rows=4 loops=1)
Sort Key: (sum(o.total)) DESC
Sort Method: quicksort Memory: 25kB
-> HashAggregate (cost=1337.45..1337.50 rows=4 width=45) (actual time=8.959..8.961 rows=4 loops=1)
Group Key: c.region
Batches: 1 Memory Usage: 24kB
-> Hash Join (cost=29.50..1253.13 rows=11242 width=11) (actual time=0.163..7.580 rows=11239 loops=1)
Hash Cond: (o.customer_id = c.id)
-> Seq Scan on orders o (cost=0.00..1194.00 rows=11242 width=10) (actual time=0.021..6.380 rows=11239 loops=1)
Filter: (created_at > (now() - '90 days'::interval))
Rows Removed by Filter: 38761
-> Hash (cost=17.00..17.00 rows=1000 width=9) (actual time=0.136..0.137 rows=1000 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 51kB
-> Seq Scan on customers c (cost=0.00..17.00 rows=1000 width=9) (actual time=0.005..0.075 rows=1000 loops=1)
Planning Time: 0.740 ms
Execution Time: 9.044 ms1. Seq Scan on orders — 6.38 ms (71.0%)
2. HashAggregate — 1.381 ms (15.4%)
3. Hash Join — 1.063 ms (11.8%)
4. Seq Scan on customers — 0.075 ms (0.8%)
5. Hash — 0.062 ms (0.7%)
6. Sort — 0.023 ms (0.3%)
Execution Time 9.044 ms · Planning Time 0.74 ms · 4 rowsThe same walker on the text format. The Seq Scan's 6.38 ms is exclusive: the Hash Join above it reports 7.580 ms inclusive, but 6.517 ms of that belongs to its two inputs, leaving the join 1.063 ms of its own — reading inclusive times top-down always overstates the parents.
explain-plan-mysql8.json — the checked-in oracle plan: EXPLAIN FORMAT=JSON over the same join, captured from MySQL 8.4.111. Non-Unique Key Lookup on o — 566.72 (84.8%)
2. Full Table Scan on c — 101.25 (15.2%)
ranked by estimated cost (W_NO_ANALYZE_TIMINGS)MySQL's plain EXPLAIN carries no runtime, so the ranking is self cost — read_cost + eval_cost per table — and the tool says so instead of dressing estimates up as measurements. The ref lookup on orders dominates because it runs once per joined customer row.
Nested Loop (cost=0.29..1216.10 rows=1 width=8) (actual time=0.049..129.334 rows=8000 loops=1)
-> Seq Scan on batches b (cost=0.00..16.00 rows=1 width=8) (actual time=0.011..0.310 rows=800 loops=1)
-> Index Scan using items_batch_idx on items i (cost=0.29..1200.00 rows=1 width=8) (actual time=0.012..0.150 rows=10 loops=800)Index Scan on items: 0.15 ms per loop × 800 loops = 120 ms inclusive
Nested Loop: 129.334 ms − 120.31 ms (children × loops) = 9.024 ms exclusive
Nested Loop rows: planned 1 · actual 8000 — ×8000 underestimated
Index Scan rows: planned 1 · actual 10 — ×10 underestimated
warning: W_LOOPS_APPROXThe loops trap, worked in full: the inner Index Scan looks like 0.150 ms until its 800 executions multiply it to 120 ms — nearly the whole query. The ×8000 badge marks where the planner's one-row estimate collapsed, which is exactly where a statistics refresh or a different join order pays off.
Every example on this page is executed against the same engine that powers the tool, on every build.
Gotchas worth knowing
Loops multiply actual time
PostgreSQL prints per-loop averages, so actual time=0.150 on a node with loops=800 is 120 ms of real work, not a rounding error. Every derived number on this page multiplies loops back in first — the fourth example shows the arithmetic end to end.
Parallel workers can double-count
Under Gather, a child's per-loop time × loops can exceed the parent's wall-clock time because workers ran concurrently. The exclusive-time subtraction clamps at zero rather than printing a negative number, and the loops badge marks where attribution is approximate — the W_LOOPS_APPROX note says so in words.
Plain EXPLAIN heat is cost, not time
Without ANALYZE there are no measurements, so hot spots rank by self cost and the banner above the tree names the basis. Cost is the planner's guess in arbitrary units — good for spotting structure, useless for proving a regression. Run EXPLAIN ANALYZE when you need the time ranking.
MySQL runtime needs MariaDB's ANALYZE
MySQL 8's EXPLAIN FORMAT=JSON is estimates only, and its EXPLAIN ANALYZE prints the TREE format this tool does not parse. The r_ runtime fields (r_rows, r_loops, r_total_time_ms) come from MariaDB's ANALYZE FORMAT=JSON — paste one of those and the ranking switches to measured time.
CTE and InitPlan time can look doubled
A CTE is built once by its InitPlan or CTE node and then read by CTE Scan nodes, so the same milliseconds can appear in two places depending on where PostgreSQL charges them. The Parent Relationship line in the detail rail (InitPlan, SubPlan, CTE) tells you which role a subtree is playing.
psql decoration is stripped, not refused
A paste copied straight from psql's aligned output — the QUERY PLAN header, the dashes, one leading space per line, + markers on wrapped JSON and the (N rows) footer — is healed back to the raw plan before detection. The healing only fires when the header is present, so plans containing their own + survive.
Common errors
This doesn't look like a supported plan format. Supported formats: PostgreSQL EXPLAIN (FORMAT JSON), PostgreSQL text plans, and MySQL EXPLAIN FORMAT=JSON. The paste begins: "postgres=# EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM orders WHERE customer_i…"- The paste includes the psql prompt itself — the tool quotes your first line back so the mistake is visible. Copy from the line after the command (or use \o to write the plan to a file) and paste only the plan.
This input is not valid JSON — parsing failed at line 4, column 27. Truncated pastes are the usual cause; copy the whole plan and try again.- A JSON plan that stops mid-object, usually because a terminal scrollback or a chat message cut it off. The position points at the first byte the parser could not continue from — recopy the whole output, or write it to a file first.
This input is not valid JSON — it looks like PostgreSQL's text plan format. Run EXPLAIN (ANALYZE, FORMAT JSON), or switch the format to Postgres text.- Plain EXPLAIN output pasted while the format badge was pinned to Postgres JSON. The engine recognizes the text grammar in the paste and says which lever to pull: re-run with FORMAT JSON, or cycle the badge back to auto or Postgres text.
This paste is 10.0 MiB — over the 10 MiB cap for this tool. Nothing was truncated; trim the plan (one statement at a time) and try again.- The byte cap, stated with the measured size — the editor keeps your full paste and nothing is silently cut. Plans this large are usually many statements captured together; paste one statement's plan at a time.
FAQ
Is my query plan uploaded when I paste it?
No — parsing runs in a Web Worker on your machine, and the page makes no request carrying your paste, which the network panel of your browser's devtools can confirm. Plans are not stored between visits either, and share links carry only the two view options — there is no field in them a plan could ride.
Why don't the child times add up to the parent's?
Because PostgreSQL prints per-loop averages: a child with loops=800 contributes 800 × its printed time. This page multiplies loops back in before subtracting, which is the exclusive-time formula printed above, so the tree you read here already accounts for it — the fourth example walks one through.
What does the est × badge mean?
The planner's row estimate missed by at least that factor: planned 1 row, saw 10 — ×10 underestimated. Factors like that are where join strategies go wrong, and they usually point at stale statistics, correlated columns, or expressions the planner cannot see through. The detail rail prints planned and actual side by side.
Should I paste the JSON or the text format?
Both parse, but JSON carries more: BUFFERS block counts land in the detail rail, and every qualifier arrives as a structured field rather than an indented line. The text walker exists for the plan you already have in your scrollback — if lines were mangled in transit, it reports which ones it skipped instead of failing whole.
Does MySQL's EXPLAIN ANALYZE work here?
Not in this version — MySQL 8 prints EXPLAIN ANALYZE in its TREE format, which is out of scope here alongside SQL Server plans. Paste MySQL's EXPLAIN FORMAT=JSON for the cost-based view, or MariaDB's ANALYZE FORMAT=JSON, whose r_ fields light up the measured-time ranking.
How large a plan can I paste?
10 MiB of text and 5,000 plan nodes, both refused with the cap and your actual size printed — never truncated silently. Parsing runs off the main thread with a 10-second kill timer, so a pathological paste stops the worker, not your tab.
Can this tool tell me how to fix a slow query?
It shows where the time went and where the estimates broke — the two facts every tuning session starts from — but it does not rewrite queries or suggest indexes. The PostgreSQL planner documentation linked below is the honest next read once you know which node to stare at.
Keyboard shortcuts
- ⌘/Ctrl + Enter
- Re-parse the paste.
- ⌘/Ctrl + S
- Download the plan tree as a .txt file.
- ⌘/Ctrl + C
- Copy the annotated tree when no text is selected.
- ⌘/Ctrl + Shift + C
- Copy the annotated tree even mid-selection.
- v
- Switch between the tree and the hot-spot ranking.
- m
- Cycle the node figure — time, rows, cost.
- t
- Switch the page theme between light and dark.
- ? or ⌘/Ctrl + /
- Show the shortcut reference for this page.
- Esc
- Close the open dialog.