Why Did One More Hop Make the Query So Expensive?
Read query plans, reason about candidate expansion and compare access paths without confusing a toy operation count with measured database performance.
13 sections · 4 questions · 1 coding challenge
The arrow keys move between sections too.
Declarative Graph Queries Needed Physical Execution Plans
BOTIE
For our maintenance graph, locating one asset before expanding relationships can avoid unrelated work. The improvement is a claim to verify in a real plan, because cardinality estimates, indexes and graph degree all affect execution.
Sources for this history
A Small Result Can Hide a Large Search
Botie returns three equipment IDs, but the query takes seconds. It began at every asset, followed broad relationship types and only then filtered to P7. The small final answer does not show how many candidates the database inspected.
We need to look at where the search starts and how intermediate rows grow. An index can narrow the start, while selective patterns and early justified predicates can limit expansion. We should measure the plan instead of judging work from the final row count.
Invented maintenance records. Python exercises and workshop components expose the mechanism locally. Source-linked Cypher or SPARQL examples require the stated external database setup.
Step 1 of 2
An anchored question should not begin from every component
Only the outgoing relationships of P7 are candidates for its one-hop question.
Nodes and properties (5)
- P7 · Requested start
- S1 · Relevant neighbour
- P8 · Other asset
- S2 · Other neighbour
- P9 · Other asset
- P7 to S1: DEPENDS_ON
- P8 to S2: DEPENDS_ON
- P9 to S2: DEPENDS_ON
Step 2 of 2
Count the work that the teaching model actually measures
The fixture has two DEPENDS_ON edges: P7 to S1, and P8 to P7. The adjacency lookup already exists.
| Request | Full-scan checks | Adjacency candidates |
|---|---|---|
| P7 | 2 | 1 |
| P7 repeated twice | 2 | 1 |
| P7 and P8 | 4 | 2 |
| No starts | 0 | 0 |
P7
- Full-scan checks
- 2
- Adjacency candidates
- 1
P7 repeated twice
- Full-scan checks
- 2
- Adjacency candidates
- 1
P7 and P8
- Full-scan checks
- 4
- Adjacency candidates
- 2
No starts
- Full-scan checks
- 0
- Adjacency candidates
- 0
Compare two explicit candidate-access strategies
The pair compares specified candidate-check counts over the same typed edges and distinct starts. It excludes index construction and does not predict database latency.
Compare two explicit candidate-access strategies
A tiny answer can hide a scan of unrelated relationships.
BOTIE
Result for these records
- 02
- 11
Follow the operations
1. Maintenance dependency graph
Nodes and properties (3)
- P7 · Asset
- S1 · Asset
- P8 · Asset
- P7 to S1: DEPENDS_ON
- P8 to P7: DEPENDS_ON
Inspect the node and relationship records
{ "nodes": [ { "id": "P7", "kind": "Asset" }, { "id": "S1", "kind": "Asset" }, { "id": "P8", "kind": "Asset" } ], "edges": [ { "id": "d1", "source": "P7", "target": "S1", "type": "DEPENDS_ON" }, { "id": "d2", "source": "P8", "target": "P7", "type": "DEPENDS_ON" } ] }2. Read graph relationships
[ { "id": "d1", "source": "P7", "target": "S1", "type": "DEPENDS_ON" }, { "id": "d2", "source": "P8", "target": "P7", "type": "DEPENDS_ON" } ]3. Choose a relationship type
[ { "id": "d1", "source": "P7", "target": "S1", "type": "DEPENDS_ON" }, { "id": "d2", "source": "P8", "target": "P7", "type": "DEPENDS_ON" } ]4. Requested starting IDs
- 0P7
5. Count full-scan candidate checks
2
6. Count adjacency candidates
1
7. Collect two results
- 02
- 11
8. Result
- 02
- 11
These components operate on the supplied records in your browser. They do not run a graph database or interpret Cypher.
1. Locate the start before explaining the expansion
An equality predicate on a constrained Asset.id can provide a selective starting point. A label narrows the entity category; a property condition narrows the instance. A traversal from a huge unfiltered population asks a different amount of work even if the final WHERE keeps one asset.
The optimiser may reorder compatible operations. Write clear predicates, then inspect the physical plan rather than assuming text order is execution order.
The logical query and the physical access path are connected but different descriptions.
2. Count candidate checks in a deliberately small model
A naive one-hop loop scans every edge for each distinct start. An already built adjacency lookup inspects only outgoing edges for those starts. Comparing these counts makes the access-path idea tangible.
Building and maintaining the adjacency lookup costs work and memory too. Our counter excludes that setup and is not an estimate of database latency, cache behaviour or storage I/O.
State what the measured quantity includes before interpreting the result.
Build one adjacency bucket
This example constructs the lookup rather than the later comparison report.
g={"nodes": [{"id": "P7", "kind": "Asset"}, {"id": "S1", "kind": "Asset"}, {"id": "P8", "kind": "Asset"}], "edges": [{"id": "d1", "source": "P7", "target": "S1", "type": "DEPENDS_ON"}, {"id": "d2", "source": "P8", "target": "P7", "type": "DEPENDS_ON"}]}
adj={}
for e in g['edges']:
adj.setdefault(e['source'],[]).append(e['id'])
print(adj['P7'])['d1']The bucket gives the outgoing edge candidates after the lookup structure has been built.
3. Use EXPLAIN before PROFILE
EXPLAIN shows a planned execution without executing the query. PROFILE executes it and reports actual operator statistics, so profiling a write can perform that write. Begin with read-only queries against representative test data.
Look for starting scans or seeks, expansion operators, estimated versus actual rows and substantial intermediate multiplication. Compare DB hits and timing carefully; warm caches, parameter values and server load can change results.
A final LIMIT does not necessarily remove upstream expansion, sorting or aggregation work.
Run in your own environment
Inspect an anchored dependency query
The same typed pattern is visible beside its plan request.
In a fresh Neo4j test database, run CREATE (p:Asset {id:'P7'}), (s:Asset {id:'S1'}), (q:Asset {id:'P8'}), (p)-[:DEPENDS_ON]->(s), (q)-[:DEPENDS_ON]->(p).
Install an Asset.id uniqueness constraint or suitable index, then bind asset P7.
For actual statistics, run the same read-only query with PROFILE in a controlled test environment.
EXPLAIN
MATCH (a:Asset {id:$asset})-[:DEPENDS_ON]->(d:Asset)
RETURN DISTINCT d.id AS dependencyInspect whether the plan locates P7 selectively before expansion. A three-node fixture demonstrates syntax, not a meaningful production benchmark.
Official documentation. Documentation checked .
Check yourself
Questions on Parts 1 to 3
Question 1 of 2
How many candidate checks does that teaching loop perform?
Use the information below.
There are 100 supplied edges and two distinct starting IDs. A naive loop scans every edge for each start.4. Watch degree and repeated branches at each hop
A node with many outgoing relationships can produce a large frontier even when the starting lookup is fast. Several hops can combine many routes to the same endpoint. DISTINCT removes repeated output rows but may still require processing many prior matches.
Constrain relationship types, justified properties and path length according to the actual question. Avoid enumerating all paths when you only need existence, reachability or a shortest-path result.
The operation you ask for determines how much route information must be retained.
5. WITH controls scope as well as aggregation
WITH a, count(i) AS n carries only a and n into the next stage. A variable omitted from that projection is no longer available in the ordinary following scope. WITH DISTINCT can deliberately change cardinality before later matches.
Do not drop the identity needed to connect the next branch correctly. An optimisation that accidentally changes grouping or optional-match semantics is a different query, even if it runs faster.
Preserve meaning first, then measure the equivalent alternatives.
6. Apply workload limits and monitor production behaviour
Set timeouts, result-size limits and controlled query templates appropriate to the service. A high-degree tenant or newly imported relationship type can change a previously cheap workload. Monitor latency distributions and failure rates, with enough plan context to investigate regressions.
Performance bounds do not replace tenant access checks. They protect capacity while the authorisation boundary determines which records may be touched.
The workshop compares one-hop candidate checks so its interpretation stays narrow and testable.
Check yourself
Questions on Parts 1 to 6
Question 1 of 2
What establishes how much work occurred?
Use the information below.
A query returns one row after a broad path expansion and sort.Try it with Botie
Build with Botie
Write and run Python