Chapter 10 · Query Planning and Profiling: Cardinality, Operators, Index Selection, and Plan Stability
EXPLAIN vs PROFILE, Logical/Physical Operators, Rows, Database Hits, Memory, and Pipeline Interpretation
Turn AtlasMart query plans into evidence rather than screenshots: understand what was estimated, what actually ran, and where work accumulated.
Learning outcomes
AtlasMart has a query that “sometimes feels slow,” but elapsed time alone cannot tell whether the problem is an expensive starting scan, fan-out after a good seek, a blocking sort, an aggregation buffer, a cache-state difference, or simply a larger result. This lesson turns the plan into the primary diagnostic artifact.
Distinguish planning from execution and explain why PROFILE is not a harmless EXPLAIN with more columns.
Read a query plan bottom-up as an operator tree and separate logical intent from physical runtime execution.
Interpret estimated rows, actual rows, database hits and memory without treating any single metric as latency.
Explain lazy pipelines, blocking/eager operators, and edition/runtime-dependent plan columns.
Capture a reproducible AtlasMart baseline before changing indexes, predicates, model or query shape.
The mandatory lab continues Neo4j Community
2026.07.1, database neo4j, explicit
CYPHER 25 for version-sensitive examples,
authentication enabled, no mandatory APOC/GDS plugin, and the
AtlasMart identifiers/model established in Chapters 01–09.
Neo4j 5.26.30 remains the LTS comparison line.
Community uses the slotted runtime by default. Enterprise uses
the pipelined runtime by default; Aura uses pipelined, and the
parallel runtime is an Enterprise capability for supported
read workloads. Therefore plan columns such as pipeline timing
and page-cache hits/misses are not universal Community output.
This generation environment does not run Neo4j or Docker.
Commands were checked against current official documentation
but were not executed here. The lessons never invent DB-hit
counts, memory figures, operator timings or latency
improvements. Instead, they define deterministic fixture
invariants and tell you exactly which values to record from
your own EXPLAIN/PROFILE runs. All
disposable Chapter 10 entities use labTag='ch10';
disposable schema objects use ch10_*.
1. EXPLAIN predicts; PROFILE executes
EXPLAIN compiles a query and returns its plan
without running it, so the plan contains estimates.
PROFILE executes the query and augments the plan
with runtime measurements such as actual rows and database hits.
This distinction is operationally important: if the query
contains writes, PROFILE performs those writes.
Start with EXPLAIN, especially when investigating
an unfamiliar statement.
| Signal | EXPLAIN | PROFILE | Interpretation |
|---|---|---|---|
| Estimated Rows | Yes | Yes | Planner estimate used for plan choice; not runtime truth |
| Rows | No | Yes | Rows actually produced by an operator |
| DB Hits | No | Yes | Abstract storage-engine work, not the same thing as rows |
| Memory (Bytes) | No | When measurable | Per-operator peak heap; footer total is query-wide peak, not a sum |
| Time / Pipeline | No | Runtime-dependent | Pipelined/parallel figures can represent fused pipelines, not individual operators |
| Page Cache Hits/Misses | No | Enterprise-only column | Do not expect this column in the Community lab |
2. Read the tree from the leaves upward
The bottom operators find starting entities; their parents filter, expand, join, aggregate, sort and finally produce results. A plan is a binary tree: a leaf has no children, unary operators transform one stream, and join/apply operators may have two inputs. The row flow is upward even though the table is printed top-down.
CYPHER 25CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.customerId IS UNIQUE;CREATE CONSTRAINT order_id IF NOT EXISTS FOR (o:Order) REQUIRE o.orderId IS UNIQUE;CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.productId IS UNIQUE;UNWIND range(1,100) AS iMERGE (c:Customer {customerId:'CH10-C-'+right('0000'+toString(i),4)})SET c.name='Plan Customer '+toString(i), c.segment=CASE WHEN i=1 THEN 'HOT' WHEN i % 10=0 THEN 'VIP' ELSE 'STANDARD' END, c.labTag='ch10';UNWIND range(1,200) AS iMERGE (p:Product {productId:'CH10-P-'+right('0000'+toString(i),4)})SET p.name='Plan Product '+toString(i), p.categoryCode=CASE WHEN i % 10 < 6 THEN 'CAM' WHEN i % 10 < 8 THEN 'PWR' WHEN i % 10 = 8 THEN 'ACC' ELSE 'OUT' END, p.price=toFloat(20 + (i % 180)), p.labTag='ch10';UNWIND range(1,2000) AS iWITH i, CASE WHEN i <= 800 THEN 1 ELSE 2 + ((i-801) % 99) END AS customerNoMATCH (c:Customer {customerId:'CH10-C-'+right('0000'+toString(customerNo),4)})MERGE (o:Order {orderId:'CH10-O-'+right('0000'+toString(i),4)})SET o.status=CASE WHEN i % 7=0 THEN 'REFUNDED' WHEN i % 3=0 THEN 'SHIPPED' ELSE 'PAID' END, o.createdAt=datetime({epochSeconds:1735689600 + i*60}), o.labTag='ch10'MERGE (c)-[:PLACED]->(o)WITH i,oMATCH (p1:Product {productId:'CH10-P-'+right('0000'+toString(1 + ((i-1) % 200)),4)})MATCH (p2:Product {productId:'CH10-P-'+right('0000'+toString(1 + ((i*7-1) % 200)),4)})MERGE (o)-[r1:CONTAINS {lineId:'CH10-L-'+right('0000'+toString(i),4)+'-A'}]->(p1)SET r1.quantity=1 + (i % 3),r1.labTag='ch10'MERGE (o)-[r2:CONTAINS {lineId:'CH10-L-'+right('0000'+toString(i),4)+'-B'}]->(p2)SET r2.quantity=1,r2.labTag='ch10';CREATE RANGE INDEX ch10_product_category_price IF NOT EXISTSFOR (p:Product) ON (p.categoryCode,p.price);CREATE RANGE INDEX ch10_order_status IF NOT EXISTSFOR (o:Order) ON (o.status);CALL db.awaitIndexes(300);
CYPHER 25MATCH (c:Customer {labTag:'ch10'}) WITH count(c) AS customersMATCH (p:Product {labTag:'ch10'}) WITH customers,count(p) AS productsMATCH (o:Order {labTag:'ch10'}) WITH customers,products,count(o) AS ordersMATCH ()-[r:CONTAINS {labTag:'ch10'}]->()RETURN customers,products,orders,count(r) AS containsRelationships;MATCH (c:Customer {labTag:'ch10'})-[:PLACED]->(o:Order {labTag:'ch10'})RETURN c.customerId,count(o) AS ordersORDER BY orders DESC,c.customerIdLIMIT 5;
3. Baseline one query before tuning it
Use the same query text and parameters for the paired plan
capture. The fixture intentionally gives
CH10-C-0001 800 orders while the remaining 99
customers share the other 1200 orders. This makes graph-shape
skew visible without needing a production-sized store.
:param customerId => "CH10-C-0001";:param category => "CAM";:param minPrice => 80.0;
CYPHER 25EXPLAINMATCH (c:Customer {customerId:$customerId})-[:PLACED]->(o:Order)-[r:CONTAINS]->(p:Product)WHERE p.categoryCode=$category AND p.price >= $minPriceRETURN count(DISTINCT o) AS orders, count(*) AS lineRows, sum(r.quantity) AS units;
CYPHER 25PROFILEMATCH (c:Customer {customerId:$customerId})-[:PLACED]->(o:Order)-[r:CONTAINS]->(p:Product)WHERE p.categoryCode=$category AND p.price >= $minPriceRETURN count(DISTINCT o) AS orders, count(*) AS lineRows, sum(r.quantity) AS units;
4. What the numbers prove—and what they do not
| Observation | What it can support | What it cannot prove alone |
|---|---|---|
| Estimated Rows far from Rows | Planner cardinality model is inaccurate for this execution | That the index is wrong or the query is globally slow |
| High DB Hits | Substantial storage-engine work occurred | Disk I/O specifically; many hits can be cache-resident |
| Large operator memory | A blocking/materializing operator has heap cost | Total process memory or page-cache pressure |
| Few final rows | API payload is small | Intermediate work was small |
| Fast one-off PROFILE | This run finished quickly | Stable p95/p99 latency under representative concurrency/cache state |
5. Deliberately wrong: tune from elapsed time alone
Run the same query twice and you may observe a faster second execution because planning, page cache, filesystem cache, JVM state and other conditions changed. The repair is to record the plan, input parameters, graph size/degree distribution, cache/warmup protocol, result cardinality and a latency distribution. Change one dimension at a time.
Check your understanding
- Does EXPLAIN execute the query?
- Can PROFILE mutate data?
- Are DB Hits identical to returned rows?
- Can Community learners expect page-cache hit/miss columns in PROFILE?
- Why read a plan bottom-up?
Review the answers
1. No. It plans the query and reports estimates.
2. Yes. PROFILE executes the statement, including writes.
3. No. One row can require many storage-engine accesses, and many rows can sometimes reuse accessed data.
4. No. Current documentation marks that plan column Enterprise-only.
5. Leaf operators establish starting data and rows flow upward through transformations to ProduceResults.
Production judgment and next step
Do not optimize for the prettiest operator name. Optimize an observable workload: correctness first, then cardinality, work, memory, tail latency and maintainability. Next, we explain why the same syntax can produce radically different work when graph degree and selectivity differ.
Summary and next step
EXPLAIN vs PROFILE, Logical/Physical Operators, Rows, Database Hits, Memory, and Pipeline Interpretation is useful only when its assumptions and observed evidence stay attached to the decision. The examples above establish a reproducible mechanism and boundary; they do not turn one lab result into a universal production rule.
Next, continue to Cardinality Estimation, Selectivity, Starting Points, Expand Operators, and Why Graph Shape Matters. Carry forward the verified assumptions, fixture state, version/edition boundaries, and measurements from this lesson instead of treating the next topic as an isolated recipe.
Authoritative references
- Current Neo4j versions — Release/LTS snapshot used for this chapter.
- Understanding query plans — EXPLAIN/PROFILE, plan columns, rows, DB hits, memory and plan reading.
- Operators — Current operator families and operator semantics.
- Operators in detail — Current seek/scan, expand, apply/join, eager, sort and aggregation operators.
- Statistics and execution plans — Statistics collection, selectivity, replanning thresholds and manual preparation.
- Query caches — Per-database query caches, cache sizing and current query-size behavior.
- Cypher runtimes — Slotted, pipelined and parallel runtime boundaries.
- Basic query tuning example — A current end-to-end plan-reading and index-tuning example.
- Runtime concepts — Community slotted versus Enterprise/Aura pipelined and parallel runtime behavior.