Neo4j's Cypher query planner is remarkably good at translating declarative pattern-matching into an efficient execution plan β most of the time. But when it guesses wrong, the difference between a well-tuned query and a naive one is the difference between milliseconds and a timeout. This article builds a practical optimisation toolkit: understanding what the planner sees, choosing the right index for the job, spotting the cardinality traps that silently balloon intermediate row counts, and applying a repeatable debugging process to any slow query.
The Optimist's Folly
Most engineers approach Cypher the same way they approach SQL: write the query, get results, move on. For a few thousand nodes, that works. At a few million, the same query crawls β or worse, brings down the database.
Cypher is declarative. You describe what you want, and Neo4j's query planner decides how to get it. The planner is clever, but it cannot read your mind. If you write a query that forces it into an expensive plan, it will faithfully execute that plan whether it takes ten milliseconds or ten minutes.
This article walks through the practical toolkit for Cypher optimisation β reading execution plans, choosing the right index, spotting expensive operations before they hit production, and refactoring queries to use the graph's strengths.
Step 1: Read the Plan
Before you optimise anything, you need to know what the planner is actually doing. Two commands reveal the plan.
EXPLAIN β Cheap Preview
EXPLAIN shows the plan without running the query. Use it to see the shape of execution β which indexes are used, where filtering happens, and whether a Cartesian product appears.
EXPLAIN MATCH (p:Person)-[:WORKS_AT]->(c:Company)
WHERE c.name = "Acme Corp"
RETURN p.name, c.name
The output shows a tree of operators. Each operator has a estimated rows count. When you see an operator with millions of estimated rows, that is where your query will burn time.
PROFILE β Actual Measurement
PROFILE runs the query and returns real metrics: how many rows each operator processed, how many times it was called (db hits), and how much memory it used.
PROFILE MATCH (p:Person)-[:WORKS_AT]->(c:Company {name: "Acme Corp"})
RETURN p.name
Look at three numbers in the profile output:
Metric
What It Means
Warning Sign
db hits
Actual operations (node/seek/filter)
>10,000 for a simple query
rows
Rows flowing through each operator
Rows increasing = inefficient filtering
memory (bytes)
Heap usage during execution
>64 MB for an OLTP query
estimated rows
Planner's guess
Gap >10Γ from actual rows = stale stats
A well-optimised query has db hits roughly proportional to the size of the result. A query that touches a million nodes to return ten rows needs attention.
Step 2: Index Everything You Filter On
Neo4j supports several index types. Choosing the wrong one is the single most common performance mistake.
Single-Property Index
The default. Use it when you filter or look up by a single property.
CREATE INDEX person_name_idx FOR (n:Person) ON (n.name);
This index handles equality checks (WHERE n.name = "Alice") and STARTS WITH and CONTAINS (for string indexes).
Composite Index
Use when you filter on multiple properties together.
CREATE INDEX person_company_role_idx FOR (n:Person)
ON (n.company, n.role);
The composite index is only effective when you query using the leftmost prefix of its properties. A query filtering on role alone will not use the composite index above β you need a separate index for that.
Range Index
For range scans β dates, numeric ranges, or text comparisons β use a range index.
CREATE RANGE INDEX person_birthdate_idx FOR (n:Person)
ON (n.birthdate);
Without a range index, a query like WHERE n.birthdate > "1990-01-01" falls back to a full label scan.
Text Index
For full-text search β CONTAINS and fuzzy matching β use the text index.
CREATE TEXT INDEX person_bio_idx FOR (n:Person) ON (n.bio);
Index Type
Lookup Speed
Use Case
When to Skip
Single-property (BTREE)
O(log n)
Equality, STARTS WITH
Range queries
Composite
O(log n)
Multi-property filters
Single-property lookups
Range
O(log n)
Date/number ranges
Equality-only queries
Text
Tokenised
CONTAINS, fuzzy search
Exact match lookups
Point
Spatial
Geographic queries
Non-spatial data
The Index Verification Query
After creating indexes, verify they are online and will be used:
SHOW INDEXES
YIELD id, name, type, entityType, labelsOrTypes, properties, state
WHERE state = "ONLINE"
RETURN *
If an index shows state: "POPULATING", wait β it is not ready yet.
Step 3: Avoid the Cardinality Killers
Cartesian Products
When two MATCH clauses are independent, Cypher creates a Cartesian product β every row from the first pattern matched with every row from the second.
// DANGER: Cartesian product
MATCH (p:Person), (c:Company)
WHERE p.name = "Alice" AND c.name = "Acme Corp"
RETURN p, c
With 100,000 people and 5,000 companies, this builds 500 million intermediate rows. The planner's estimated rows will be 500 million. That is the signal to stop and rewrite.
Fix: Connect the patterns.
// Safe: relationship connects them
MATCH (p:Person {name: "Alice"})-[:WORKS_AT]->(c:Company {name: "Acme Corp"})
RETURN p, c
When entities are genuinely unrelated, use OPTIONAL MATCH or subqueries to avoid the product.
Eager Operations
An Eager operation forces Cypher to materialise the entire intermediate result set before proceeding. It is the planner's way of saying "I need to see everything before I can continue."
Common Eager triggers:
Pattern
Why Eager Happens
MERGE after MATCH
Must ensure uniqueness across all rows
CREATE with DETACH DELETE
Must separate reads from writes
SET inside FOREACH with a read
Must isolate read and write phases
REMOVE with property dependency
Must verify constraint after removal
Example β Eager from SET:
MATCH (p:Person)
WHERE p.department = "Engineering"
SET p.department = "Core Engineering"
This creates an Eager because the planner must ensure the SET does not affect subsequent matches. With millions of people, the Eager node may consume hundreds of megabytes.
Fix: Split into read and write transactions, or use CALL {} subqueries to isolate the read and write phases:
CALL {
MATCH (p:Person)
WHERE p.department = "Engineering"
RETURN p
}
SET p.department = "Core Engineering"
Subqueries give the planner more freedom to pipeline operations without materialising intermediate state.
Beyond CALL { }: Subquery Predicates with EXISTS
Neo4j 5.x introduced EXISTS subquery predicates β a separate mechanism from CALL { } that is purpose-built for filtering. Where CALL { } returns rows for further processing, EXISTS { } returns a boolean and is used directly in WHERE clauses:
// Before: pattern match with OPTIONAL MATCH and filter
MATCH (p:Person)
OPTIONAL MATCH (p)-[:WORKS_AT]->(c:Company)
WHERE c.name = "Acme Corp"
WITH p, c WHERE c IS NOT NULL
RETURN p.name
// After: EXISTS subquery predicate β cleaner, no null filtering
MATCH (p:Person)
WHERE EXISTS {
MATCH (p)-[:WORKS_AT]->(c:Company)
WHERE c.name = "Acme Corp"
}
RETURN p.name
The planner handles EXISTS { } differently from OPTIONAL MATCH because it knows the subquery is purely a boolean test β it can stop traversing as soon as it finds one match, and it never needs to materialise intermediate results. In benchmarks, this pattern consistently outperforms OPTIONAL MATCH + null filtering by 20β40% on selective predicates.
When to use EXISTS vs CALL { }:
Pattern
Use Case
Planner Behaviour
EXISTS { MATCH ... WHERE ... }
Pure existence check in WHERE
Short-circuits on first match
CALL { MATCH ... RETURN ... }
Need subquery results as rows
Materialises and returns rows
OPTIONAL MATCH ... WHERE ...
Need outer-join behaviour
Full pattern match, null for misses
A common optimisation opportunity is replacing OPTIONAL MATCH + WHERE x IS NOT NULL with an EXISTS subquery β the semantics are identical but the execution is cheaper.
Query Plan Caching and Parameterised Queries
Neo4j caches query plans for parameterised queries. If you inline literal values:
// New plan compiled every time β cache miss
MATCH (p:Person {name: "Alice"}) RETURN p
The planner compiles a fresh plan for every unique name value. With ten thousand unique values, you get ten thousand compiled plans, consuming heap and CPU.
Fix: Use parameters:
// Single plan, cached and reused
MATCH (p:Person {name: $name}) RETURN p
Plan caching matters most under high concurrency. An application that runs 1,000 queries per second with literals will spend a measurable fraction of its CPU in the query compiler. Parameterised queries compile once and amortise that cost across all executions. Monitor the plan cache with:
SHOW PROCEDURES YIELD name WHERE name CONTAINS "cache"
Step 4: Optimise Relationship Traversal
Traversing relationships is what Neo4j does best β but only if you help it.
Direction Matters
Undirected traversal (-[r]-) forces the database to check both directions. When you know the direction, specify it:
// Slower: undirected
MATCH (p:Person)-[:WORKS_AT]-(c:Company)
// Faster: directed
MATCH (p:Person)-[:WORKS_AT]->(c:Company)
Variable-Length Paths Have Limits
A query like -[*1..6]- traverses up to six hops. The branching factor multiplies at each level. For a graph with average degree 50, a 6-hop traversal visits 50^6 = 15.6 billion paths in the worst case.
Mitigations:
Use shortest-path algorithms when you do not need all paths:
MATCH p = shortestPath((a:Person {id: "alice"})-[:KNOWS*]-(b:Person {id: "bob"}))
RETURN length(p)
Add label filters to prune branches early:
MATCH (a:Person {id: "alice"})-[:KNOWS*1..4]-(b:Person)
// Cypher will only traverse through :Person nodes
Limit the variable-length range aggressively. Start with *1..3 and increase only if you have measured that the higher range is affordable.
Relationship Type Filtering
When a node has many relationship types, specify the type explicitly:
// Slower: must scan all relationship types
MATCH (p:Person)-->(n)
// Faster: only WORKS_AT relationships
MATCH (p:Person)-[:WORKS_AT]->(n)
Degree-Sorted Start Points
When a pattern starts at a node with high degree (a hypernode), the query slows down. Use LIMIT 1 with indexed lookups to find the best start point:
// Good start: indexed lookup first
MATCH (c:Company {name: "Acme Corp"})
MATCH (p:Person)-[:WORKS_AT]->(c)
RETURN p.name
The indexed lookup on Company.name returns one node. Then the traversal to employees is bounded by the company's actual headcount.
Step 5: Profile Analysis Checklist
When a query is slow, work through this checklist:
Run PROFILE. Identify which operator has the highest db hits.
Check estimated vs actual rows. A large gap means stale statistics β run CALL db.stats.retrieve('GRAPH COUNTS').
Verify index usage. If a NodeByLabelScan appears instead of NodeIndexSeek, your predicate is not indexed.
Look for CartesianProduct. Two independent MATCH clauses without connection? Refactor.
Look for Eager. Can the read and write phases be separated?
Check variable-length path ranges. Can you tighten *1..6 to *1..3?
Check the start point. Is the query starting from the most selective node?
// Template: well-optimised query pattern
MATCH (start:Label {indexed_property: $value})
MATCH (start)-[:REL_TYPE]->(related:OtherLabel)
WHERE related.filter_property = $otherValue
RETURN start.property, related.property
LIMIT 100
Step 6: When All Else Fails β Restructure the Model
Sometimes the query is not the problem: the model is. If you have optimised indexes, refactored the query, and still see poor performance, consider whether the data model is fighting the query pattern.
Two specific signals that point to a model problem:
You frequently traverse 4+ hops on the same relationship type. Materialise the transitive closure as a direct relationship using a graph projection or precomputed summary.
The decision matrix in that article covers when to promote a property to a node, how to partition hypernodes, and when time-aware modelling is necessary β all of which affect query performance as much as any optimisation technique.
Step 7: Worked Example β From 30 Seconds to 50 Milliseconds
Theory is useful; a concrete before-and-after is better. This example walks through an optimisation that mirrors a real production incident on a Neo4j knowledge graph with 2.3 million nodes and 8.7 million relationships.
The Query
Find the top ten most recent FDA clearances for companies that develop autoimmune diagnostics products, along with the company name and clearance description:
MATCH (c:Company)
MATCH (c)-[:HAS_SIGNAL]->(s:Signal)
WHERE s.type = "FDA_CLEARANCE"
MATCH (c)-[:DEVELOPS]->(a:Application)
WHERE a.name = "Autoimmune Diagnostics"
RETURN c.name, s.description, s.date
ORDER BY s.date DESC
LIMIT 10;
Step 1: PROFILE
Running PROFILE reveals the damage:
Operator
db hits
rows
Estimated
NodeByLabelScan (Company)
42,000
42,000
42,000
Expand(All) | HAS_SIGNAL
1,210,000
92,000
95,000
Filter on s.type
92,000
5,200
5,100
Expand(All) | DEVELOPS
2,100,000
63,000
65,000
Filter on a.name
63,000
420
400
Sort (DESC)
420
10
10
Top
10
10
10
Diagnosis: The query starts with a full NodeByLabelScan of all 42,000 companies, expands all their signals (1.2M db hits), then expands all their application areas (2.1M db hits), and only then filters to FDA_CLEARANCE and Autoimmune Diagnostics. The db hit ratio is catastrophic: 3.3 million db hits to return 10 rows.
Step 2: Add Indexes
The first filter applied is s.type = "FDA_CLEARANCE", but without an index on Signal.type, the planner falls back to a full expansion and filter. Similarly, Application.name is filtered but not indexed:
CREATE INDEX signal_type_idx IF NOT EXISTS FOR (s:Signal) ON (s.type);
CREATE INDEX application_name_idx IF NOT EXISTS FOR (a:Application) ON (a.name);
After creating indexes, verify they are online:
SHOW INDEXES YIELD name, state WHERE state = "ONLINE";
Step 3: Re-profile
With indexes in place, the plan changes:
Operator
db hits
rows
Estimated
NodeByLabelScan (Company)
42,000
42,000
42,000
Expand(All) | HAS_SIGNAL
1,210,000
92,000
95,000
NodeIndexSeek (Signal.type)
β
β
β
The planner still starts with the full company scan β it cannot use the index on Signal.type until it has nodes to expand from. The index helps filter signals faster but doesn't reduce the initial scan. Total db hits: 1.3M. Better, but not sub-second.
Step 4: Restructure Query Order
The trick is to start from the most selective node that can be index-looked-up. Application with name = "Autoimmune Diagnostics" is a single indexed lookup. From there, find companies that develop in that area, then find their FDA signals:
// Rewritten: start from the selective Application
MATCH (a:Application {name: "Autoimmune Diagnostics"})
MATCH (c:Company)-[:DEVELOPS]->(a)
MATCH (c)-[:HAS_SIGNAL]->(s:Signal {type: "FDA_CLEARANCE"})
RETURN c.name, s.description, s.date
ORDER BY s.date DESC
LIMIT 10;
Step 5: Final PROFILE
Operator
db hits
rows
Estimated
NodeIndexSeek (Application.name)
1
1
1
Expand(All) | DEVELOPS (reverse)
420
420
420
Expand(All) | HAS_SIGNAL
5,200
5,200
5,200
Filter on s.type
5,200
480
480
Sort (DESC)
480
10
10
Top
10
10
10
Total db hits: 5,901 β down from 3.3 million. The query returns in ~50 ms. The key insight: start from the most selective predicate, not from the entity you conceptually think of as the query's subject.
Before-and-After Comparison
Metric
Before
After
Improvement
db hits
3,302,000
5,901
99.8% reduction
Execution time
~30 s
~50 ms
600Γ faster
Memory (sort)
128 MB (spilled to disk)
64 KB (in-memory)
2,000Γ reduction
Operators scanned
7 (including 2 full scans)
6 (all indexed)
β
Generalising the Pattern
The optimisation applied here β reverse the traversal direction to start from the most selective node β is the single highest-impact refactor you can make to a Cypher query. Whenever you see a NodeByLabelScan in your PROFILE output, ask: can I start from an indexed property on a different node and traverse backwards?
Apply this heuristic in order:
Identify the most selective predicate in your query (fewest matching nodes)
Ensure it has an index
Start the query from that node
Traverse relationships to reach the other entities
Profile to verify the db hit reduction
Putting It Together
Optimising Cypher queries follows a repeatable process:
Profile first β never guess where the bottleneck is.
Index the predicates β every filter and join property needs an index.
Connect your patterns β avoid Cartesian products by using relationships or subqueries.
Isolate writes β separate read and write phases to avoid Eager operations.
Bound your traversals β variable-length paths expand exponentially; use shortestPath and tight limits.
Measure again β profile the rewritten query. Compare db hits and memory.
Fix the model last β only restructure the graph after you have exhausted query-level optimisations.
A query that takes 30 seconds on a well-modelled graph with proper indexes is almost always fixable. A query that takes 30 seconds because the model is wrong needs both modelling and query work. Start with the query, and if that does not get you to sub-second response, read the data modeling patterns article for the structural perspective.
Further Reading
Graph Data Modeling Patterns for Neo4j β The structural side of performance: hypernode mitigation, time-aware modelling, and when to promote a property to a node. Read this when query-level optimisation is not enough.
Neo4j Database Adapters Compared β Performance issues at the driver level (connection pooling, Bolt protocol version, type handling) manifest as slow queries too. Covers Python, JS, Java, Go, and .NET drivers.
Graph Algorithms with Neo4j β When your optimisation goal is analytic (find the most influential node, detect communities), the GDS library replaces hand-written Cypher with parallelised, memory-efficient algorithm calls.
The official Neo4j documentation is also indispensable: the Cypher Manual β Planning and Tuning covers execution plan operators in depth, and the dbms.listActiveConfig procedure helps verify planner configuration in your environment.