Cypher Query Optimization Techniques: From Slow to Sub-Second
The 10-Second Query Problem
You wrote a Cypher query. It returns the right data. Then you run it on a real dataset and it takes ten seconds. A query that seemed elegant on a toy graph grinds to a halt when your knowledge graph has millions of nodes and relationships.
This is not a bug. It is a performance profile mismatch. Cypher is declarative — you describe what you want, not how to get it. The query planner decides the execution strategy, and its choices depend heavily on schema, indexes, and pattern structure. Understanding how the planner thinks is the difference between a query that scales and one that brings your database to its knees.
This article covers six levers that control Cypher query performance: execution plan analysis, indexing, pattern optimisation, query structure, subquery design, and production monitoring. We also look at APOC procedures that complement these techniques and walk through a real optimisation from 4.2 seconds to 80 milliseconds.
1. Read the Plan: PROFILE and EXPLAIN
Never guess where a query is slow. Measure it. Neo4j provides two tools for understanding query execution.
EXPLAIN shows the plan the query planner would use, without running the query. It tells you which NodeByLabelScan, Expand(All), and Filter operations the planner intends, and it estimates the number of rows flowing through each operator.
PROFILE runs the query and reports actual statistics: how many rows each operator processed, how much time it spent, and how many database hits accumulated.
PROFILE
MATCH (p:Paper)-[:ABOUT]->(t:Topic {name: "GraphRAG"})
MATCH (p)<-[:AUTHORED]-(a:Author)
RETURN a.name, p.title
The output shows a pipeline of operators. The critical metric is db hits — each database hit is one unit of work (a node lookup, a property access, a relationship traversal). If an operator shows millions of db hits for a result set of fifty rows, that operator is your bottleneck.
What to look for in a profile:
| Red Flag | What It Means | Fix |
|---|---|---|
NodeByLabelScan on a large label | The planner has no index to narrow the starting set | Add an index on the filtered property |
CartesianProduct | Two MATCH clauses are joined without a connection | Add a relationship or pattern that links them |
Filter with high db hits | Many rows are being discarded after retrieval | Push the filter earlier in the pattern |
Apply with Expand(All) | A correlated subquery expanding per row | Restructure to avoid per-row expansion |
2. Indexing Strategy
Indexes are the single highest-impact optimisation available. Without an index, the planner must scan every node of a given label — a NodeByLabelScan — and check properties one by one.
Single-Property Indexes
CREATE INDEX FOR (p:Paper) ON (p.title);
CREATE INDEX FOR (t:Topic) ON (t.name);
These make equality lookups (WHERE p.title = "GraphRAG") and range queries (WHERE p.date > 2024) efficient. The index stores a mapping from property value to node reference, giving O(log n) lookup instead of O(n) scan.
Composite Indexes
When queries filter on multiple properties of the same label, a composite index beats separate single-property indexes:
CREATE INDEX FOR (p:Paper) ON (p.category, p.date);
Now a query like:
MATCH (p:Paper)
WHERE p.category = "graph-ai" AND p.date > 2025
RETURN p.title
can satisfy both filters from a single index seek, without fetching and checking nodes individually. The order of properties in the index matters: lead with the most selective property.
Full-Text Indexes
For CONTAINS or text search queries, a B-tree index is useless — it only supports prefix matching. Full-text indexes use tokenisation and inverted indexing:
CREATE FULLTEXT INDEX paperContent FOR (n:Paper) ON EACH [n.title, n.abstract];
CALL db.index.fulltext.queryNodes("paperContent", "graphrag knowledge graph")
YIELD node, score
RETURN node.title, score
ORDER BY score DESC
LIMIT 10;
Full-text indexes support stemming, fuzzy matching, and relevance scoring — capabilities no B-tree index can provide.
Index Choice Matrix
| Query Pattern | Index Type | Example |
|---|---|---|
WHERE n.prop = value | Single-property B-tree | CREATE INDEX FOR (n:Paper) ON (n.doi) |
WHERE n.a = v1 AND n.b = v2 | Composite B-tree | CREATE INDEX FOR (n:Paper) ON (n.category, n.date) |
WHERE n.text CONTAINS "word" | Full-text | CREATE FULLTEXT INDEX ... ON EACH [n.text] |
WHERE n.prop > value | Range index (B-tree) | Single-property index suffices |
| Point / spatial | Point index | CREATE POINT INDEX FOR (n:Office) ON (n.location) |
3. Pattern Optimisation
The way you structure your MATCH patterns has a measurable effect on query performance.
Lead with the Most Selective Pattern
The planner starts from the first pattern and expands outward. If your first MATCH matches 100,000 nodes, every subsequent expansion multiplies against that set. If it matches 10 nodes, the rest of the query stays fast.
// Slow: starts with 100K Topic scans
MATCH (t:Topic)
MATCH (p:Paper)-[:ABOUT]->(t)
WHERE t.name = "GraphRAG"
RETURN p.title
// Fast: starts with 1 index lookup
MATCH (t:Topic {name: "GraphRAG"})<-[:ABOUT]-(p:Paper)
RETURN p.title
The second form embeds the filter into the pattern itself, which lets the planner use an index immediately rather than scanning all topics and filtering later.
Specify Direction
Undirected relationships force the planner to check both directions, doubling traversal work:
// Slow: checks both directions
MATCH (a:Author)-[:AUTHORED]-(p:Paper)
// Fast: only one direction
MATCH (a:Author)-[:AUTHORED]->(p:Paper)
Always specify direction unless you genuinely need bidirectional matching.
Use Relationship Type Specificity
A generic relationship ()-[]->() matches every relationship type in the graph, forcing the planner to scan all relationship stores. Always specify the relationship type:
// Slow
MATCH (a)-[r]->(p)
// Fast
MATCH (a:Author)-[:AUTHORED]->(p:Paper)
4. Query Structure Best Practices
Parameterise Everything
Never concatenate values into query strings. Parameterised queries let the planner cache execution plans and avoid recompilation:
// Bad: replanned every time
MATCH (p:Paper {title: "GraphRAG"}) RETURN p
// Good: plan cached, reusable
MATCH (p:Paper {title: $title}) RETURN p
session.run("MATCH (p:Paper {title: $title}) RETURN p", title="GraphRAG")
Limit Early
If you only need ten results, tell the database:
MATCH (p:Paper)
WHERE p.category = $category
RETURN p.title
ORDER BY p.date DESC
LIMIT 10
Without LIMIT, the planner may expand the entire result set before sorting and returning. With LIMIT, it can use index order and stop early.
Avoid Cartesian Products
Two consecutive MATCH clauses that don't share a relationship create a cartesian product — every row from the first set paired with every row from the second:
// Dangerous: creates cartesian product
MATCH (a:Author)
MATCH (p:Paper)
WHERE a.name = $name AND p.category = $category
RETURN a, p, p.title
If you genuinely need unrelated sets, use WITH to materialise the first set before the second:
MATCH (a:Author {name: $name})
WITH a
MATCH (p:Paper {category: $category})
RETURN a, p
Or better, introduce a relationship path between them.
Use EXISTS for Property Checks
Checking whether a property exists is faster with EXISTS than with IS NOT NULL:
// Fast
MATCH (p:Paper)
WHERE EXISTS(p.abstract)
RETURN p.title
// Equivalent but slower
MATCH (p:Paper)
WHERE p.abstract IS NOT NULL
RETURN p.title
EXISTS uses the property existence index when available.
5. Subquery Optimisation
Cypher subqueries — introduced with the CALL { ... } syntax — let you compose queries inside queries. Used correctly, they simplify complex logic and can even improve performance. Used carelessly, they introduce correlated execution that multiplies db hits.
Post-Union Subqueries
The most common subquery pattern is the post-union subquery, where you aggregate or filter across multiple result streams:
CALL {
MATCH (p:Paper)-[:ABOUT]->(:Topic {name: "GraphRAG"})
RETURN p.title AS title, p.date AS date
UNION
MATCH (p:Paper)-[:CITED_BY]->(:Paper {title: "Attention Is All You Need"})
RETURN p.title AS title, p.date AS date
}
RETURN title, date
ORDER BY date DESC
LIMIT 20;
The subquery collects results from two independent pattern matches, deduplicates them (via UNION), and the outer query applies ordering and limiting. The planner can use separate indexes for each branch inside the CALL block, which often beats a single OPTIONAL MATCH with OR filters.
Subqueries for Aggregation Isolation
Without subqueries, aggregations in Cypher operate over the full row set, which can produce inflated counts when rows are multiplied by intermediate MATCH clauses:
// Problem: count is inflated by the second MATCH
MATCH (a:Author)-[:AUTHORED]->(p:Paper)
MATCH (p)-[:ABOUT]->(t:Topic)
RETURN a.name, COUNT(p) AS paperCount;
If an author has written papers across many topics, the first MATCH produces one row per authored paper, then the second MATCH multiplies each row by the number of topics per paper. The COUNT(p) will count topic-assigned papers, not authored papers. A subquery isolates the aggregation:
// Correct: aggregation isolated in a subquery
MATCH (a:Author)-[:AUTHORED]->(p:Paper)
WITH a, COLLECT(p) AS papers
CALL {
WITH papers
UNWIND papers AS p
MATCH (p)-[:ABOUT]->(t:Topic)
RETURN COUNT(t) AS topicCount
}
RETURN a.name, SIZE(papers) AS paperCount, topicCount;
The CALL { WITH ... } pattern passes variables into the subquery scope. The planner materialises the outer rows first, then processes the subquery per row. This is faster than a correlated cartesian product because the outer row set is already bounded.
Subquery Execution Semantics
Understanding how the planner executes subqueries is critical for optimisation:
| Subquery Type | Planner Behaviour | Performance Implication |
|---|---|---|
CALL { ... } without outer references | Executed once, result cached | Efficient — avoid repeating the same work |
CALL { WITH ... } with outer references | Executed per row from the outer query | Can be expensive if the outer row set is large |
CALL { ... } UNION | Each branch executed independently, deduplicated | Efficient for merging disparate patterns |
Subquery with RETURN | Creates a new scope — cannot access outer variables without explicit WITH | Forces explicit data flow, which is good for clarity |
When NOT to Use Subqueries
Subqueries add overhead. For simple cases, a single MATCH with a well-chosen pattern is faster:
// Prefer this — single traversal, single plan
MATCH (a:Author)-[:AUTHORED]->(p:Paper)-[:ABOUT]->(t:Topic {name: "GraphRAG"})
RETURN a.name, p.title
// Avoid this — two separate query plans, per-row execution
MATCH (a:Author)
CALL {
WITH a
MATCH (a)-[:AUTHORED]->(p:Paper)-[:ABOUT]->(t:Topic {name: "GraphRAG"})
RETURN p.title AS title
}
RETURN a.name, title
The second form forces the planner to compile and execute a subquery plan for every author row. On a graph with 100,000 authors, that is 100,000 subquery invocations instead of one unified traversal. Profile both forms before choosing.
Rule of thumb: use subqueries for aggregation isolation and union merging. Use flat MATCH chains for linear traversals. When in doubt, profile both and compare db hits.
7. Advanced: Profiling a Real Optimisation
Let's walk through a concrete example. A knowledge graph of academic papers has 500,000 Paper nodes, 100,000 Author nodes, and 800,000 AUTHORED relationships. The following query lists papers about "GraphRAG" by title, ordered by date:
// Version 1: Baseline
MATCH (p:Paper)-[:ABOUT]->(t:Topic)
WHERE t.name = "GraphRAG"
RETURN p.title, p.date
ORDER BY p.date DESC
LIMIT 20;
Profile result: 812,000 db hits, 4.2 seconds. The planner scans all Topic nodes, filters by name, then expands to Paper nodes.
Optimisation 1 — Add an index on Topic.name:
CREATE INDEX FOR (t:Topic) ON (t.name);
Profile result: 74,000 db hits, 0.3 seconds. The index reduces the initial set from 100K Topics to ~50 matching nodes.
Optimisation 2 — Inline the property filter:
// Version 3: Index + inline filter
MATCH (p:Paper)-[:ABOUT]->(t:Topic {name: "GraphRAG"})
RETURN p.title, p.date
ORDER BY p.date DESC
LIMIT 20;
Profile result: 12,400 db hits, 0.08 seconds. By embedding the filter in the pattern, the planner uses the index at pattern-matching time rather than adding a separate Filter operator.
From 4.2 seconds to 80 milliseconds — a 52× improvement — without changing any application code.
8. Production Query Monitoring
Optimisation is not a one-off activity. Queries that perform well on a development dataset can degrade as the graph grows, index selectivity changes, or data distribution shifts. Production monitoring catches regressions before users notice them.
Identifying Slow Queries
Neo4j exposes all currently executing queries through the dbms.listQueries() procedure:
CALL dbms.listQueries()
YIELD queryId, rawQuery, elapsedTimeMillis, plan, status
WHERE elapsedTimeMillis > 5000
RETURN queryId, rawQuery, elapsedTimeMillis, status
ORDER BY elapsedTimeMillis DESC
This returns every query that has been running for more than five seconds, with its execution plan and current status. You can inspect the plan column to see whether a query is stuck in a NodeByLabelScan or a Filter with high db hits.
For a live view of resource consumption:
CALL dbms.listQueries()
YIELD queryId, rawQuery, elapsedTimeMillis, cpuTimeMillis, waitTimeMillis, allocatedBytes
RETURN queryId, LEFT(rawQuery, 120) AS query,
elapsedTimeMillis, cpuTimeMillis,
waitTimeMillis, allocatedBytes
ORDER BY allocatedBytes DESC
LIMIT 10;
Terminating Problem Queries
When a runaway query is consuming server resources, terminate it immediately:
CALL dbms.killQuery("query-1234");
Always identify the query first via dbms.listQueries() — killing the wrong query can roll back a long-running transaction and lose work.
Configuring Query Logging
Neo4j can log slow queries automatically. Set these configuration options in neo4j.conf:
# Log queries that take longer than 1 second
dbms.logs.query.threshold=1000ms
# Log query parameters for debugging
dbms.logs.query.parameter_logging_enabled=true
# Log the full query text (truncated at 120 chars by default)
dbms.logs.query.max_parameter_length=200
# Log only transactions (omit simple reads)
dbms.logs.query.transaction_level=ALL
You can also adjust these at runtime without restarting:
CALL dbms.setConfigValue("dbms.logs.query.threshold", "500");
The query log is written to logs/query.log by default. Each entry includes the query text, parameters, elapsed time, plan description, and the number of database hits — enough information to diagnose most performance issues without reproducing the query in a development environment.
Transaction Timeout Configuration
Long-running queries lock resources. Setting a transaction timeout prevents a single query from degrading throughput for all other clients:
# Kill transactions running longer than 60 seconds
dbms.transaction.timeout=60s
This is a safety net, not a substitute for optimisation. If your production neo4j.conf routinely kills queries at the timeout threshold, those queries need profiling and tuning — not a timeout extension.
Monitoring Dashboard Query
For a quick health check by application endpoint, aggregate the slow query log into a summary:
// Aggregated query wait statistics
CALL dbms.listQueries()
YIELD rawQuery, elapsedTimeMillis, waitTimeMillis, cpuTimeMillis
WITH LEFT(rawQuery, 60) AS queryPrefix,
COUNT(*) AS occurrences,
AVG(elapsedTimeMillis) AS avgElapsed,
MAX(elapsedTimeMillis) AS maxElapsed,
AVG(waitTimeMillis) AS avgWait
WHERE occurrences > 1
RETURN queryPrefix, occurrences,
round(avgElapsed) AS avgMs,
maxElapsed AS maxMs,
round(avgWait) AS avgWaitMs
ORDER BY avgElapsed DESC
LIMIT 20;
This tells you which query patterns are consuming the most cumulative database time — not just which single invocation was slow — which helps prioritise optimisation efforts.
9. APOC Procedures for Performance
The APOC standard library provides utility procedures that complement the optimisation techniques above. While APOC is primarily known for data integration and graph refactoring, several procedures are directly relevant to query performance.
Batch Operations with apoc.periodic.iterate
When updating or importing large datasets, a single Cypher transaction can overflow the transaction log or exhaust memory. apoc.periodic.iterate splits the work into configurable batches:
CALL apoc.periodic.iterate(
"MATCH (p:Paper) WHERE p.abstract IS NOT NULL RETURN p",
"SET p.abstractLength = SIZE(p.abstract)",
{batchSize: 1000, parallel: true, retries: 2}
)
YIELD batches, total, timeTaken, retries
RETURN batches, total, timeTaken, retries;
The first subquery selects the rows; the second subquery performs the work. The batchSize parameter controls how many rows are processed per transaction. Setting parallel: true distributes batches across available threads — use this for CPU-bound work, not for operations that compete for the same lock.
Multi-Statement Execution with apoc.cypher.runMany
When you need to execute multiple Cypher statements atomically (for example, creating indexes before a migration), apoc.cypher.runMany runs them in sequence within a single transaction:
CALL apoc.cypher.runMany(
"CREATE INDEX IF NOT EXISTS FOR (p:Paper) ON (p.doi);
CREATE INDEX IF NOT EXISTS FOR (a:Author) ON (a.email);
CREATE FULLTEXT INDEX paperSearch IF NOT EXISTS
FOR (n:Paper) ON EACH [n.title, n.abstract];",
{}
)
This is faster than sending each statement individually because it avoids per-statement network round trips and plan compilation overhead.
Schema Statistics for Optimisation Guidance
apoc.meta.stats() provides a quick overview of your graph's size and distribution — useful for deciding where to focus optimisation:
CALL apoc.meta.stats()
YIELD labelCount, relTypeCount, labels, relTypes
RETURN labelCount, relTypeCount, labels, relTypes;
For per-label statistics that reveal which node types dominate your graph:
CALL apoc.meta.stats()
YIELD labels
UNWIND keys(labels) AS label
RETURN label, labels[label].count AS count,
labels[label].degree AS avgDegree
ORDER BY count DESC;
If the Paper label has 5 million nodes while Topic has only 200, you know to lead with Topic in your MATCH patterns — an insight the query planner cannot give you directly.
Index Usage Verification
APOC does not currently provide index usage statistics (Neo4j's built-in SHOW INDEXES covers that), but it can help identify unused properties that might be candidates for index removal:
// Show all properties across the graph with their frequency
CALL apoc.meta.graphSample()
YIELD nodes, relationships
RETURN nodes, relationships;
When to Reach for APOC
| Scenario | APOC Procedure | Why |
|---|---|---|
| Updating millions of nodes | apoc.periodic.iterate | Batches transactions, avoids OOM |
| Running index setup scripts | apoc.cypher.runMany | Single round trip, atomic execution |
| Understanding data distribution | apoc.meta.stats | Reveals node counts, degree distributions |
| Bulk relationship creation | apoc.periodic.iterate | Parallel batch execution |
| Schema migration across databases | apoc.cypher.runFile | Executes .cypher scripts against remote databases |
Summary
Query performance in Neo4j follows a clear hierarchy of intervention:
- Profile first — identify the bottleneck operator and its db hit count
- Index strategically — single-property for equality, composite for multi-filter, full-text for search
- Tune patterns — lead with selective matches, specify direction, use typed relationships
- Restructure queries — parameterise, limit early, avoid cartesian products, use EXISTS
- Use subqueries deliberately — subqueries for aggregation isolation and union merging; flat
MATCHchains for linear traversals - Monitor production —
dbms.listQueries(), query logging thresholds, transaction timeouts - Leverage APOC —
apoc.periodic.iteratefor batch operations,apoc.meta.statsfor data distribution insight
Apply these in order. Most slow queries — easily 80% — are solved by an index and a pattern tweak. The remaining 20% require query restructuring and, occasionally, schema changes. But without profiling, you are guessing. Profile first, optimise second, measure again. The 52× improvement in the example above came from two changes identified by reading the profile output, not from intuition.
Further Reading
- Knowledge Graph Construction from Unstructured Data — Building the graph that your queries run against, with schema design and entity resolution patterns that influence query performance
- Graph Data Modeling Patterns for Neo4j — Schema decisions directly affect query performance; the hypernode pattern, time-aware graphs, and polymorphic relationships all change how you write your MATCH clauses
- Ontology in Graph Databases — Ontology decisions about node labels and relationship types determine what indexes are possible and how selective your patterns can be
- Graph Algorithms in Practice with Neo4j — Using Neo4j GDS for PageRank, community detection, and pathfinding — algorithmic queries with distinct optimisation profiles
- Neo4j. Cypher Manual — Query Tuning. https://neo4j.com/docs/cypher-manual/current/query-tuning/ — The official reference for PROFILE, EXPLAIN, and execution plan operators
- Neo4j. APOC User Guide. https://neo4j.com/labs/apoc/current/ — Reference for all APOC procedures mentioned in this article