backend · intermediate · 5d
Query plan visualizer
Paste an EXPLAIN (ANALYZE, FORMAT JSON) and render the plan tree with per-node timing and row-estimate error, so a bad join jumps out visually.
Deliverable
A web app that turns EXPLAIN JSON into an annotated, collapsible plan tree highlighting the worst node.
Milestones
0/3 · 0%- 01Parse EXPLAIN JSON to a tree
Parse EXPLAIN JSON into a node tree.
Definition of done- EXPLAIN (ANALYZE, FORMAT JSON) is parsed into a node tree preserving parent/child structure and node types.
- Malformed or non-JSON input fails with a clear message, not a crash.
- 02Per-node actual vs planned
Render the tree with per-node actual vs planned rows.
Definition of done- Each node shows actual vs planned rows and its self-time; the tree is collapsible.
- Loop counts are accounted for so per-loop vs total rows aren't confused.
- 03Surface the worst node
Highlight the node with the largest estimate error and the largest self-time.
Definition of done- The node with the largest estimate error (actual/planned) and the largest self-time are visually flagged.
- A correct plan with no large error shows no false alarm.
Rubric
| Junior | Mid | Senior | |
|---|---|---|---|
| Plan parsing correctness | Parses the top-level node and displays node type, estimated cost, and planned rows for simple single-node plans. | Recursively traverses Plans/children, preserves parent-child relationships across all scan and join node types, and handles plans with multiple child arrays (e.g. hash join's build vs probe sides) without conflating them. | Correctly accounts for loop counts when computing per-node actual totals: a child inside a 1000-iteration nested loop reports per-loop rows, not total rows — conflating them produces an estimate-error that is 1000x wrong. Can explain the exact JSON fields used and why Loops must be factored in. |
| Cost and timing attribution | Displays the planner's Total Cost and Actual Total Time as-is from the JSON for each node. | Computes self-time per node as Total Time minus the sum of children's Total Times, surfaces it alongside cumulative time, and shows planned vs actual rows so estimate error is immediately visible. | Flags spilled nodes: hash or sort nodes where Batches > 1 indicate a work_mem spill to disk, commonly causing a 10-100x slowdown. Surfaces a suggested work_mem lower bound from Peak Memory Usage, or estimates it from the hash batch count when that field is absent. |
| Worst-node surfacing | Highlights the node with the highest Total Cost as the bottleneck. | Computes two separate signals — largest estimate error (actual/planned rows, accounting for loops) and largest self-time — and surfaces each independently so the user sees both 'where time went' and 'where the planner was most wrong'. | Avoids false alarms on trivially cheap nodes: an estimate error of 100x on a 0.01 ms node is noise; the severity score weights error magnitude by self-time. Articulates why a correct plan with no large estimate gap should show no flags, and tests this case explicitly. |
Reference walkthrough (spoiler)
EXPLAIN JSON structure: the top-level array contains one plan object; each node has Node Type, a Plans array of children, Startup Cost, Total Cost, Plan Rows, Actual Startup Time, Actual Total Time, Actual Rows, and Loops. Loops is the multiplier: Actual Rows in the JSON is per-loop, so total actual rows = Actual Rows x Loops. Missing this produces dramatically wrong estimate-error calculations for any plan containing a nested loop.
Self-time vs cumulative time: Total Time at a node includes all children's time. The cost the node itself contributes is Total Time minus the sum of its children's Total Times. For leaf nodes (seq scan, index scan) self-time equals Total Time. Displaying only Total Time misleads — the root always appears as the most expensive node.
Seq scan as a diagnostic signal: seq scan on a large table is not always wrong — if the query returns a large fraction of rows, seq scan beats index scan because random heap fetches cost more than a sequential read. The signal worth flagging is a seq scan combined with a large estimate gap, which usually means statistics are stale or an index is missing.
work_mem spills: hash joins and sorts use in-memory hash tables bounded by work_mem (default 4 MB per operation). When data exceeds that limit, Postgres spills to disk in batches (Batches > 1). A 10-batch spill means roughly 10x the I/O. Setting work_mem too high globally is dangerous — each concurrent query can use it, so 100 connections x 256 MB = 25 GB just for sorts.
Make it senior
- Detect spilled hash/sort nodes (Batches > 1) and suggest a work_mem target.
- Diff two plans (before/after a fix) side by side.