Chapter 10 · Query Planning and Profiling: Cardinality, Operators, Index Selection, and Plan Stability
Tune a Slow Cypher Query by Changing Model, Predicate, Index, or Query Shape—Then Re-Measure
Run a controlled AtlasMart tuning experiment that separates query, predicate, index and model changes and accepts only evidence-backed improvements.
Learning outcomes
The tuning exercise now combines everything: one AtlasMart traversal, one baseline, one controlled change at a time, and an explicit decision about whether the right fix is query shape, predicate placement, index support, model shape—or no change at all.
Create a baseline that captures plan shape, actual work, result correctness and a latency distribution.
Change exactly one tuning dimension at a time and state the causal hypothesis before running it.
Compare customer-first and product-first query shapes under both dense and typical parameters.
Detect when an added index is redundant because the traversal binds the indexed entity before filtering it.
Produce an evidence-backed acceptance/rollback decision instead of declaring victory from one faster run.
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. State the workload contract before touching the query
| Contract field | Chapter 10 lab value |
|---|---|
| Question | Which high-priced products in a category were bought by a specified customer? |
| Correctness | Same product set for all semantically equivalent rewrites |
| Dense case | CH10-C-0001 (800 orders in fixture) |
| Typical case | CH10-C-0050 (roughly a small share of remaining 1200 orders) |
| Product predicate | categoryCode + price threshold |
| Schema | customer/order/product uniqueness + ch10_product_category_price range index |
| Measurements | Plan, estimates/actual rows, DB hits, memory when shown, result rows, latency distribution |
| No fabricated target | You choose an application SLO; this lesson provides method, not universal milliseconds |
2. Baseline: customer-first traversal
CYPHER 25PROFILEMATCH (c:Customer {customerId:$customerId})-[:PLACED]->(o:Order)-[line:CONTAINS]->(p:Product)WHERE p.categoryCode=$category AND p.price >= $minPriceRETURN DISTINCT p.productId,p.name,p.priceORDER BY p.price DESC,p.productId;
Hypothesis: the unique customer seek is cheap, but the hot customer generates many order and line rows before the product predicate can reject them. Record the actual rows at each expansion and filter. If that hypothesis is wrong, do not proceed as if it were true.
3. Change A: make product selectivity an alternative starting point
The composite product index from Chapter 09/this lab can only help as a starting access path if the query shape allows the planner to bind products through the indexed predicate before they are already reached by traversal. Compare a product-first rewrite, preserving result semantics.
CYPHER 25PROFILEMATCH (p:Product)WHERE p.categoryCode=$category AND p.price >= $minPriceMATCH (p)<-[line:CONTAINS]-(o:Order)<-[:PLACED]-(c:Customer {customerId:$customerId})RETURN DISTINCT p.productId,p.name,p.priceORDER BY p.price DESC,p.productId;
For a highly selective product predicate, the indexed start may reduce candidates. For a broad predicate, traversing backward from many products may be worse than customer-first. Test both dense and typical customers plus more than one price threshold.
4. Change B: add a tempting but possibly useless index
A common mistake is to create an index on every filtered
property without checking whether the plan can use it. Create a
disposable single-property price index, capture
EXPLAIN, and ask whether it changes the chosen
access path compared with the existing composite schema.
CYPHER 25CREATE RANGE INDEX ch10_product_price_only IF NOT EXISTSFOR (p:Product) ON (p.price);CALL db.awaitIndex('ch10_product_price_only',300);EXPLAINMATCH (p:Product)WHERE p.categoryCode=$category AND p.price >= $minPriceMATCH (p)<-[:CONTAINS]-(o:Order)<-[:PLACED]-(c:Customer {customerId:$customerId})RETURN DISTINCT p.productId,p.price;DROP INDEX ch10_product_price_only IF EXISTS;
If the planner keeps choosing the composite index or another better path, the added index has not earned its ongoing write/storage/build cost for this workload. Preserve the plan evidence with the schema decision.
5. Change C: model-level mitigation for repeated hot-customer analytics
If the actual requirement repeatedly asks for aggregated history across a high-degree customer, a query rewrite may not be enough. A separate summary/fact structure (for example, customer-category purchase aggregates by time window) can reduce read work but adds write complexity, freshness semantics and reconciliation. This chapter does not silently introduce that model. It requires you to prove the read bottleneck first, define the summary’s consistency contract, then benchmark both read and write consequences.
| Candidate change | Potential benefit | New cost/risk |
|---|---|---|
| Query anchor rewrite | No data-model migration | May trade one broad side for another |
| Additional index | Cheaper eligible predicate lookup | Write/storage/build overhead; may be unused |
| Predicate/window narrowing | Less fan-out/result work | Changes API semantics unless requirement permits it |
| Precomputed summary/fact node | Fast repeated aggregate reads | Write amplification, freshness and reconciliation |
| Relationship/model refactor | Can bound traversals semantically | Migration complexity and changed write/query contracts |
6. Re-measure with a causal experiment sheet
variant,case,parameterShape,planStart,estimatedRowsAtHotspot,actualRowsAtHotspot,dbHits,peakMemoryBytes,p50Ms,p95Ms,p99Ms,resultRows,correctResult,decisionbaseline,hot,dense,,,,,,,,,,,baseline,typical,sparse,,,,,,,,,,,product-first,hot,dense,,,,,,,,,,,product-first,typical,sparse,,,,,,,,,,,price-index-test,hot,dense,,,,,,,,,,,
Warm up according to a declared protocol, run enough repetitions to report a distribution rather than a single average, and keep correctness checks alongside performance. If the “faster” version returns different products or handles nulls differently, it is not a tuning win.
7. Deliberately wrong: change index, query, runtime and model together
Multiple simultaneous changes destroy causal attribution. If the result improves, you cannot know which change mattered; if it regresses, rollback becomes ambiguous. The repair is a sequence of reversible experiments with one independent variable at a time and a stable workload fixture.
Check your understanding
- Why can an index on Product.price be useless in a customer-first traversal?
- Why test both hot and typical customers?
- What must remain invariant across tuning rewrites?
- When is a model-level summary justified?
- Why change one dimension at a time?
Review the answers
1. Because Product may already be bound by traversal before the price predicate is evaluated; an index helps most when it can serve an eligible access path.
2. Because degree skew changes the number of rows produced after the same unique starting seek.
3. Business semantics and result correctness, including ordering/pagination/null behavior where part of the API contract.
4. After evidence shows recurring graph-shape work that a summary can reduce and the added write/freshness/reconciliation cost is explicitly acceptable.
5. So measured differences can be causally attributed and safely rolled back.
8. Cleanup and Chapter 11 bridge
Keep the fixture if you want to reuse it for transaction/locking experiments, or remove only Chapter 10 objects with the isolated cleanup below. Chapter 11 moves from plan work to transaction behavior: isolation, locks, deadlocks, retries and consistency.
CYPHER 25MATCH (n) WHERE n.labTag='ch10' DETACH DELETE n;DROP INDEX ch10_product_category_price IF EXISTS;DROP INDEX ch10_order_status IF EXISTS;DROP INDEX ch10_product_price_only IF EXISTS;DROP INDEX ch10_customer_segment IF EXISTS;
Summary
Performance engineering is a controlled scientific loop: representative workload → plan/runtime evidence → one hypothesis → one change → re-measure → correctness check → accept or rollback. That discipline is more durable than memorizing “good” operator names or universal index recipes.
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.
- Query tuning — Current tuning concepts and replanning controls.
- Advanced query tuning example — Evidence-driven examples of plan improvement and index-backed ordering/aggregation behavior.
- Metrics reference — Operational Cypher replan metrics and database-size signals for later production observability.