Cypher Query Optimization: Performance Tuning for Neo4j in Production
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.
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..3and 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
NodeByLabelScanappears instead ofNodeIndexSeek, your predicate is not indexed. - Look for
CartesianProduct. Two independentMATCHclauses without connection? Refactor. - Look for
Eager. Can the read and write phases be separated? - Check variable-length path ranges. Can you tighten
*1..6to*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.
- A single node accumulates >10,000 relationships. Apply the hypernode mitigations covered in Graph Data Modeling Patterns for Neo4j.
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.
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.
For a broader view of the Neo4j ecosystem, see Neo4j Database Adapters Compared if you are encountering performance issues at the driver level rather than the query level.