Loading...
Loading...
Diagnoses and fixes slow Neo4j Cypher queries by reading execution plans, identifying bad operators (AllNodesScan, CartesianProduct, Eager, NodeByLabelScan), and prescribing fixes (indexes, hints, query rewrites, runtime selection). Use when a query is slow, when EXPLAIN or PROFILE output needs interpretation, when dbHits or pageCacheHitRatio are poor, when cardinality estimation diverges from actuals, or when deciding between slotted/pipelined/parallel runtimes. Covers USING INDEX / USING SCAN / USING JOIN hints, db.stats.retrieve, SHOW QUERIES, SHOW TRANSACTIONS, TERMINATE TRANSACTION. Does NOT write new Cypher from scratch — use neo4j-cypher-skill. Does NOT cover GDS algorithm tuning — use neo4j-gds-skill. Does NOT cover index/constraint creation syntax details — use neo4j-cypher-skill references/indexes.md.
npx skill4agent add neo4j-contrib/neo4j-skills neo4j-query-tuning-skillneo4j-cypher-skillneo4j-gds-skillneo4j-modeling-skill| EXPLAIN | PROFILE | |
|---|---|---|
| Executes query? | No | Yes |
| Returns data? | No | Yes |
Shows | No | Yes |
Shows | No | Yes |
Shows | Yes | Yes |
| Cost | Zero | Full query cost |
PROFILEEXPLAIN MATCH (p:Person {email: $email}) RETURN p.name
PROFILE MATCH (p:Person {email: $email}) RETURN p.namecurl -X POST https://<host>/db/<db>/query/v2 \
-u <user>:<pass> -H "Content-Type: application/json" \
-d '{"statement": "EXPLAIN MATCH (p:Person {email: $email}) RETURN p.name", "parameters": {"email": "a@b.com"}}'| Metric | Good | Investigate if |
|---|---|---|
| Low; drops after index added | High relative to |
| Shrinks early in plan | Large until final operator |
| Close to | >10× divergence from actual |
| >0.99 | <0.90 (disk I/O bottleneck) |
| High | — |
| Near 0 | Rising (page cache too small) |
| Operator | Good/Bad | Meaning | Fix |
|---|---|---|---|
| ✓ | Exact match via RANGE/LOOKUP index | — |
| ✓ | Unique constraint index hit | — |
| ✓ | TEXT index CONTAINS / STARTS WITH | — |
| ~ | Full index scan (no predicate) | Add WHERE predicate or composite index |
| ✗ | Scans all nodes of label | Add RANGE index on lookup property |
| ✗✗ | Scans entire node store | Add label + index to MATCH |
| ~ | Traverse relationships from node | Normal; limit with LIMIT or WHERE |
| ~ | Find rels between two matched nodes | Normal for known-endpoint joins |
| ~ | Predicate applied after scan | Move predicate into WHERE with index |
| ✗ | No join predicate between two MATCH | Add WHERE join or use WITH between MATCHes |
| ~ | Hash join on node IDs | Normal; planner chose hash join |
| ~ | Hash join on values | Normal; watch memory for large inputs |
| ~ | Full aggregation (ORDER BY, count(*)) | Normal for aggregates |
| ✓ | Streaming aggregation | — |
| ✗ | Read/write conflict; materialises all rows | See Eager fix strategies below |
| ~ | Full sort — O(n log n) | Add |
| ✓ | Sort+Limit combined — O(n log k) | Preferred over Sort+Limit |
| ✓ | Truncates rows early | Push as early as possible |
| ~ | Offset pagination | Use keyset pagination on large graphs |
| — | Final output operator | Root of tree |
| ~ | Lookup by relationship ID | Avoid |
EXPLAIN <query>AllNodesScanNodeByLabelScanCartesianProductEagerSHOW INDEXES YIELD name, type, labelsOrTypes, properties, state
WHERE state = 'ONLINE'// RANGE index for equality/range predicates:
CREATE INDEX person_email IF NOT EXISTS FOR (n:Person) ON (n.email)
// TEXT index for CONTAINS/ENDS WITH:
CREATE TEXT INDEX person_bio IF NOT EXISTS FOR (n:Person) ON (n.bio)
// Composite for multi-property lookup:
CREATE INDEX order_status_date IF NOT EXISTS FOR (n:Order) ON (n.status, n.createdAt)state = 'ONLINE'PROFILE <query>dbHitsNodeIndexSeekCALL db.prepareForReplanning()
// or resample a specific index:
CALL db.resampleIndex("person_email")
// or resample all outdated:
CALL db.resampleOutdatedIndexes()dbms.cypher.statistics_divergence_threshold0.75// Force index hint when planner ignores it:
MATCH (p:Person {email: $email})
USING INDEX p:Person(email)
RETURN p.name
// Force label scan (sometimes faster for high selectivity):
MATCH (p:Person {email: $email})
USING SCAN p:Person
RETURN p.name// Force join at specific node:
MATCH (a:Author)-[:WROTE]->(b:Book)-[:IN_CATEGORY]->(c:Category {name: $cat})
USING JOIN ON b
RETURN a.name, b.title// Bad (Cartesian product):
MATCH (a:Author {id: $aid})
MATCH (b:Book {id: $bid})
RETURN a.name, b.title
// Good (explicit join or WITH):
MATCH (a:Author {id: $aid})-[:WROTE]->(b:Book {id: $bid})
RETURN a.name, b.title
// Or: WITH between them to reset planning contextWITH collect(n) AS nodes UNWIND nodes AS n SET n.x = 1CYPHER 25
MATCH (p:Person) WHERE p.score > 100
CALL (p) { SET p.tier = 'gold' } IN TRANSACTIONS OF 1000 ROWS// Needs TEXT index (RANGE does NOT support these):
CREATE TEXT INDEX person_bio IF NOT EXISTS FOR (n:Person) ON (n.bio)
MATCH (p:Person) WHERE p.bio CONTAINS $keyword RETURN p.name// Bad: LIMIT after expensive join
MATCH (a:Author)-[:WROTE]->(b:Book)-[:REVIEWED_BY]->(r:Review)
RETURN a.name, b.title, r.text LIMIT 10
// Good: anchor limit before fan-out
MATCH (a:Author)-[:WROTE]->(b:Book)
WITH a, b LIMIT 10
MATCH (b)-[:REVIEWED_BY]->(r:Review)
RETURN a.name, b.title, r.text| Runtime | Select | Best For | Avoid When |
|---|---|---|---|
| | Default OLTP; streaming, low memory | Unsupported operators fall back to slotted |
| | Guaranteed stable behavior; debug | Performance-critical OLTP |
| | Large analytical scans; aggregations | OLTP, writes, short queries, Aura Free |
dbms.cypher.parallel.worker_limit// Force parallel for large aggregation:
CYPHER 25 runtime=parallel
MATCH (n:Transaction) WHERE n.amount > 1000
RETURN n.currency, count(*), sum(n.amount)// Live queries + resource usage:
SHOW QUERIES YIELD query, queryId, elapsedTimeMillis, allocatedBytes, status, username
// Running transactions:
SHOW TRANSACTIONS YIELD transactionId, currentQuery, currentQueryProgress, elapsedTime, status, username, cpuTime, activeLockCount // currentQueryProgress added [2026.03]
// Kill a specific transaction:
TERMINATE TRANSACTION $transactionId
// Kill a query:
TERMINATE QUERY $queryId
// Graph count stats (node/rel counts by label/type — feed into planner):
CALL db.stats.retrieve('GRAPH COUNTS') YIELD section, data RETURN section, data
// Token stats (label/property/rel-type IDs):
CALL db.stats.retrieve('TOKENS') YIELD section, data RETURN section, dataEXPLAINAllNodesScanNodeByLabelScanCartesianProductEagerSHOW INDEXESstate = 'ONLINE'PROFILEdbHitsestimatedRowsCALL db.prepareForReplanning()LIMITWITH n LIMIT kruntime=parallelTERMINATE TRANSACTION