Docs
View as markdown

EXPLAIN and PROFILE

The plan relate builds for a statement, every operator in it, what PROFILE measures, how each output mode carries the plan, and how index use shows up.

cypherclimcp

EXPLAIN <statement> returns the plan The tree of operators the planner chooses to run a statement; EXPLAIN shows it without running, PROFILE runs and adds rows and time per operator. Read more in the glossary relate would run, without running it. PROFILE <statement> runs the statement, returns its rows, and returns the same plan with what each operator did. Both also return the planner's notification Advice the planner attaches to a plan, such as a missing index, with a GQLSTATUS code, a severity and a remedy; derived only under EXPLAIN or PROFILE. Read more in the glossary. Tune a query walks through reading a plan and acting on it; this page lists what a plan holds.

Synopsis

[CYPHER 5 | CYPHER 25] EXPLAIN <statement>
[CYPHER 5 | CYPHER 25] PROFILE <statement>
EXPLAINPROFILE
Runs the statementNo: writes, procedure calls and LOAD CSV are only plannedYes, writes included
Returns rowsNone, and no columnsThe statement's own columns and rows
PlanOperators, details, and row estimatesThe same, plus the measured columns
NotificationsYesYes, plus 03C93 for an estimate 10 times too high
On a read-only storeAny statement, writes includedReads only; a write fails as any write does

On the CLI, the clients and the WASM build, a statement without either prefix builds no plan and derives no notifications. The MCP read_cypher and write_cypher tools differ: they plan every statement as EXPLAIN <statement> before running it, so each call returns a plan and its plan-time notifications, and a statement that already starts with EXPLAIN or PROFILE fails with syntax error: parse error: unexpected token "EXPLAIN" (or "PROFILE").

The plan tree

A plan is a tree of operators, one per clause. The root is the clause that produces the result: ProduceResults for a RETURN, EagerAggregation for a RETURN that aggregates, or the last write clause. Each operator's input is the clause before it, nested beneath it, so a plan reads from the bottom up. Union and UnionAll hold one child per part. Each NEXT adds a Next operator under the previous stage's root, holding that stage's pipeline as its one child.

ColumnHolds
OperatorThe operator name; | + nests it under the operator that reads its rows
IdThe operator's number, in pre-order from the root at 0; notifications name it as operator_id
DetailsWhat the operator works on: a pattern, an index seek, or the item count and flags of a projection
Estimated RowsThe rows the planner expects, from label and relationship type counts; - where it has no estimate. Shown when some operator has an estimate
WarningsThe operator's plan warning, - for none. Shown when some operator has one

Operators

OperatorPlansDetails
AllNodesScanA node pattern with no labelThe pattern, (n)
NodeByLabelScanA node pattern with labelsThe pattern, (p:Person)
NodeIndexSeekAn equality on a range-indexed label and propertyRANGE INDEX p:Person(name) WHERE name = 'Ada', then the rest of the pattern
NodeIndexSeekByRangeA <, <=, > or >= comparison on a range-indexed label and propertyRANGE INDEX p:Person(name) WHERE name >= 'G'
ExpandA MATCH pattern with relationships, or several patternsThe patterns as written, from the chosen start
Optional(<operator>)An OPTIONAL MATCHThe pattern, then optional
CreateCREATEThe created patterns
MergeMERGEThe pattern, then ON CREATE SET or ON MATCH SET when present
Delete, DetachDeleteDELETE, DETACH DELETE<n> expression(s)
SetPropertySET<n> item(s)
RemovePropertyREMOVE<n> item(s)
ProjectionWITH<n> item(s) and its flags
LetLET<n> item(s)
FilterFILTER<n> item(s) and its flags
EagerAggregationA WITH or RETURN with an aggregate or GROUP BY<n> item(s), keys: <grouping keys>, aggregation and the flags
UnwindUNWINDAS <variable>
ProduceResultsRETURN, or FINISH<n> item(s) and its flags, or FINISH
ProcedureCallCALL <procedure><procedure>()
CallSubqueryCALL { … }, CALL (…) { … }optional, IN TRANSACTIONS when present
ConditionalWHEN … THEN … ELSE<n> branch(es), ELSE when present
ForeachFOREACHThe loop variable
LoadCSVLOAD CSVWITH HEADERS AS <variable> or AS <variable>
CreateIndex, DropIndex, ShowIndexesRange index statementsThe index name and schema, for CreateIndex
CatalogOther schema and catalog statementsThe statement's keyword
UseUSE—
Union, UnionAllUNION, UNION ALL<n> parts
NextNEXTstage <n>, the number of the stage it introduces

The flags a projection's Details can carry are distinct, WHERE filter, ordered (an ORDER BY), skip and limit. A MATCH operator adds optional, SEARCH and WHERE filter.

What PROFILE measures

PROFILE adds these columns to the plan table. Times differ on every run.

ColumnHolds
RowsThe rows the operator produced
Time (ms)The operator's own time
Total (ms)The operator's time plus all of its descendants'
Db HitsStore accesses: relationships followed, adjacency entries read, and node records read
NodesNode records read; shown when some operator read one, and blank for zero
RelsRelationships followed to their other end; shown and blank by the same rule
DegreeAdjacency entries read, one per neighbor a walk visited; same rule
Memory (Bytes)The size of the rows the operator produced
Expression Built (Bytes)The size of the values the operator built (range, collect, comprehensions, concatenation); a running total, not a peak
Est/ActualEstimated over actual rows, as 25.0x; ! after it when the estimate is 10 times too high or more. Shown when some operator has an estimate
Executed✓ or x; shown when some operator never ran because its input produced no rows, whose metric cells then read (never executed)

A walking operator's Details also names how it started, as [start #<k>, forward | backward, <reason>], where the reason is bound-pivot, cheapest, seek-anchored or index-0.

Plan warnings

Three notifications also appear in the plan table's Warnings column and in a Warnings: line under the table, instead of in the Notifications: block:

WarningGQLSTATUSFires on
CartesianProduct03N90Patterns in one MATCH that share no variable
AllNodesScan03C90A node pattern with no label while the store has a property index
EstimationMiss03C93PROFILE only: an estimate 10 times the actual rows or more

Notifications lists every notification with its text and the statements it fires on.

The plan in each output mode

Surface or modeThe planNotifications
CLI --mode tableThe plan table, on stderr; PROFILE prints its rows on stdout firstA Notifications: block under the plan, on stderr, without the three plan warnings
CLI --mode json{"notifications": […], "plan": {…}}; PROFILE adds "data": [rows]In the object, every kind
CLI --mode jsonlPROFILE prints one line per row, then the plan object on its own lineIn the plan line
CLI --mode envelopeThe envelope's plan key, beside columns, columnTypes, rows and truncatedThe envelope's notifications
CLI --mode toonA plan: entry, then a notifications: entry, as compact JSONIn the notifications: entry
CLI --mode markdownNoneA Notifications: block after the rows, every kind
CLI --mode csv, --mode cypherNoneNone
CLI --mode speedscopePROFILE only: a speedscope evented profile, one frame per operator, times in nanosecondsNone
Clients, the WASM buildA relate.plan/v1 documentThe result's notification list
MCP read_cypher, write_cypherA relate.plan/v1 document on every call, without measured columnsThe plan-time notifications of every statement, without 03C93

The CLI's machine modes carry the plan as a structured-plan tree: each node is {operatorType, args, identifiers, children}. args holds Details, Id and EstimatedRows. On the root it also holds string-representation (the plan table as text), version and the planner* and runtime* fields, which name relate. A PROFILE adds Rows, TimeNs, DbHits, MemoryBytes, ExpressionBuiltBytes, Hops, NeighborWalks, NodeReads, SeekTimeNs and WalkTimeNs, and StartElement, StartBackward and StartReason on a walk.

The relate.plan/v1 document that the clients return is {schema, profiled, statement, root}. Each node is {operator, details, identifiers, estimated_rows, actual, children, warnings}; actual is null under EXPLAIN and holds rows, time_ms, total_time_ms, db_hits and the other measures under PROFILE. A cell's JSON view on this site shows that document.

--mode speedscope on a statement without PROFILE fails with exit code 1:

relate --mode speedscope "EXPLAIN RETURN 1 AS one"
speedscope mode requires a PROFILE statement: per-operator times are what it exports (the plan tree carries no PROFILE metrics)

Index use in a plan

Index useHow the plan shows it
A range index serves an equalityNodeIndexSeek with RANGE INDEX <var>:<Label>(<property>) WHERE <property> = <value>
A range index serves a rangeNodeIndexSeekByRange with the comparison
No index serves a filterNodeByLabelScan or Expand, and under EXPLAIN notification 03C91 with the CREATE INDEX statement that would
A vector or full-text SEARCHThe MATCH operator's Details end in SEARCH; the index's top results feed it, and the estimate is the label's count
A db.index.* procedureProcedureCall with the procedure's name, and notification 01N01, because the procedures are deprecated

Examples

The cells run on the shared example graph. An EXPLAIN cell's result shows no rows; its JSON view holds the plan and the notifications. The two plans below raise no notification:

Cypher
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace)
Recorded result
(no rows) · nodes created: 5, relationships created: 5, properties set: 13, labels added: 5
Cypher
EXPLAIN MATCH (p:Person)-[:WORKS_ON]->(proj:Project)
WITH proj, count(p) AS people WHERE people > 1
RETURN proj.name AS project, people ORDER BY people DESC LIMIT 5
Recorded result
(no rows)

The recorded JSON view holds a relate.plan/v1 tree: ProduceResults (2 item(s), ordered, limit) over EagerAggregation (2 item(s), keys: proj, aggregation, WHERE filter) over Expand ((proj:Project)<-[:WORKS_ON]-(p:Person)). The plan viewer reads the same document. In a browser it draws the tree; PROFILE runs the statement on this page's database and adds each operator's rows, time and database hits as bars (the table's Rows, Time (ms) and Db Hits columns), with a download of the profile in the --mode speedscope format; Plan as text shows the table --mode table prints.

The plan recorded for cell explain-aggregation, as relate --mode table prints it:

Query: MATCH (p:Person)-[:WORKS_ON]->(proj:Project) WITH proj, count(p) AS people WHERE people > 1 RETURN proj.name AS project, people ORDER BY people DESC LIMIT 5
Plan:
+----------------------+----+--------------------------------------------------+
| Operator             | Id | Details                                          |
+----------------------+----+--------------------------------------------------+
| + ProduceResults     | 0  | 2 item(s), ordered, limit                        |
+----------------------+----+--------------------------------------------------+
| | + EagerAggregation | 1  | 2 item(s), keys: proj, aggregation, WHERE filter |
+----------------------+----+--------------------------------------------------+
| | | + Expand         | 2  | (proj:Project)<-[:WORKS_ON]-(p:Person)           |
+----------------------+----+--------------------------------------------------+

Notifications: none.

Live: the plan is drawn as a tree, and PROFILE runs the statement on the page's database in your browser and adds each operator's rows, time and database hits as bars; the index button creates the index on the page's database, plans the statement with and without it side by side with the changes, and drops it again; a PROFILE downloads as a speedscope file.

Cypher
EXPLAIN MATCH (p:Person) OPTIONAL MATCH (p)-[:KNOWS]->(f) RETURN p.name AS name, f.name AS friend
Recorded result
(no rows)

The recorded JSON view holds ProduceResults over Optional(Expand) ((p)-[:KNOWS]->(f), optional, an estimate of 1 row) over NodeByLabelScan ((p:Person), an estimate of 3 rows).

The same plans on the CLI. This setup step writes the shared example graph to a new single-file store A store kept in one .rl file that holds the snapshot and the WAL together; the layout for a --database path ending in .rl. Read more in the glossary, team.rl, and adds a range index A structure the planner uses to find nodes or relationships by a property without scanning a label: range, text, point, vector or full-text. Read more in the glossary on Person.name:

relate --database ./team.rl "CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace);
CREATE INDEX person_name FOR (p:Person) ON (p.name)"

An aggregation in table mode, read-only:

relate --database ./team.rl --read-only --mode table "EXPLAIN MATCH (p:Person)-[:WORKS_ON]->(proj:Project) WITH proj, count(p) AS people WHERE people > 1 RETURN proj.name AS project, people ORDER BY people DESC LIMIT 5"
Query: MATCH (p:Person)-[:WORKS_ON]->(proj:Project) WITH proj, count(p) AS people WHERE people > 1 RETURN proj.name AS project, people ORDER BY people DESC LIMIT 5
Plan:
+----------------------+----+--------------------------------------------------+
| Operator             | Id | Details                                          |
+----------------------+----+--------------------------------------------------+
| + ProduceResults     | 0  | 2 item(s), ordered, limit                        |
+----------------------+----+--------------------------------------------------+
| | + EagerAggregation | 1  | 2 item(s), keys: proj, aggregation, WHERE filter |
+----------------------+----+--------------------------------------------------+
| | | + Expand         | 2  | (proj:Project)<-[:WORKS_ON]-(p:Person)           |
+----------------------+----+--------------------------------------------------+

A range seek on the index:

relate --database ./team.rl --read-only --mode table "EXPLAIN MATCH (p:Person) WHERE p.name >= 'G' RETURN p.name AS name"
Query: MATCH (p:Person) WHERE p.name >= 'G' RETURN p.name AS name
Plan:
+--------------------------+----+------------------------------------------------------------+----------------+
| Operator                 | Id | Details                                                    | Estimated Rows |
+--------------------------+----+------------------------------------------------------------+----------------+
| + ProduceResults         | 0  | 1 item(s)                                                  | -              |
+--------------------------+----+------------------------------------------------------------+----------------+
| | + NodeIndexSeekByRange | 1  | RANGE INDEX p:Person(name) WHERE name >= 'G', WHERE filter | 2              |
+--------------------------+----+------------------------------------------------------------+----------------+

The structured plan in JSON mode:

relate --database ./team.rl --read-only --mode json "EXPLAIN MATCH (p:Person)-[:KNOWS]->(f:Person) RETURN f.name AS friend"
{"notifications":[],"plan":{"args":{"Details":"1 item(s)","Id":0,"planner":"relate","planner-impl":"relate","planner-version":"0.1.2","runtime":"relate","runtime-impl":"relate","runtime-version":"0.1.2","string-representation":"Cypher 5\n\nPlanner relate\n\nRuntime relate\n\nRuntime version 0.1.2\n\nQuery: MATCH (p:Person)-[:KNOWS]->(f:Person) RETURN f.name AS friend\nPlan:\n+------------------+----+---------------------------------+\n| Operator         | Id | Details                         |\n+------------------+----+---------------------------------+\n| + ProduceResults | 0  | 1 item(s)                       |\n+------------------+----+---------------------------------+\n| | + Expand       | 1  | (p:Person)-[:KNOWS]->(f:Person) |\n+------------------+----+---------------------------------+","version":"5"},"children":[{"args":{"Details":"(p:Person)-[:KNOWS]->(f:Person)","Id":1,"PatternEngine":"auto"},"identifiers":[],"operatorType":"Expand"}],"identifiers":[],"operatorType":"ProduceResults"},"planCache":"miss","timings":{"elapsed_ms":0.225375,"execute_ms":0.027416999999999997,"lex_parse_ms":0.006125,"lower_ms":0.006583,"parse_ms":0.192084,"plan_ms":0.005084,"result_generation_ms":0.0,"rewrite_ms":0.00033299999999999996}}

A PROFILE in table mode prints the rows on stdout and the plan on stderr. The Time (ms) and Total (ms) columns differ on every run:

relate --database ./team.rl --read-only --mode table "PROFILE MATCH (p:Person)-[:KNOWS]->(f:Person) RETURN f.name AS friend"
+---------+
| friend  |
+---------+
| "Grace" |
+---------+
Query: MATCH (p:Person)-[:KNOWS]->(f:Person) RETURN f.name AS friend
Plan:
+------------------+----+---------------------------------------------------------------+------+-----------+------------+---------+-------+--------+----------------+--------------------------+
| Operator         | Id | Details                                                       | Rows | Time (ms) | Total (ms) | Db Hits | Nodes | Degree | Memory (Bytes) | Expression Built (Bytes) |
+------------------+----+---------------------------------------------------------------+------+-----------+------------+---------+-------+--------+----------------+--------------------------+
| + ProduceResults | 0  | 1 item(s)                                                     | 1    | 0.006     | 0.084      | 0       |       |        | 43             | 0                        |
+------------------+----+---------------------------------------------------------------+------+-----------+------------+---------+-------+--------+----------------+--------------------------+
| | + Expand       | 1  | (p:Person)-[:KNOWS]->(f:Person)  [start #0, forward, index-0] | 1    | 0.078     | 0.078      | 4       | 3     | 1      | 43             | 0                        |
+------------------+----+---------------------------------------------------------------+------+-----------+------------+---------+-------+--------+----------------+--------------------------+

Db Hits is 4: three Person node records read (Nodes) and one adjacency entry (Degree).

See also

  • Tune a query: reading a plan, acting on a notification, and confirming the fix.
  • Notifications: every notification code, its text, and its remedy.
  • Search: the vector and full-text indexes behind a SEARCH.
  • Output modes: each CLI mode's layout.
  • Text-to-Cypher: EXPLAIN as the check before a generated statement runs.