How to Write Cypher Queries That Stay Fast on Large Graphs
A Cypher query that returns in 40 ms on a test graph can take 90 seconds in production. Nothing about the syntax changes — the difference is that the planner now has 20 million relationships to walk instead of 20,000.
Direct answer: slow Neo4j queries almost always come from one of four things: a missing index, a relationship type with no direction, an early MATCH that produces a cartesian product, or DISTINCT on a huge result set. Run EXPLAIN and look for AllNodesScan — that is where to start.
How do you read a query plan?
EXPLAIN shows the plan without running it. PROFILE runs it and adds actual row counts and DB hits.
PROFILE MATCH (u:User {email: $email})-[:PURCHASED]->(p:Product)
RETURN p.name
The number that matters is dbHits per operator, not total elapsed time. A plan with 12 rows but 800,000 db hits is scanning; a plan with 50 rows and 60 db hits is indexed correctly.
Look for these operators and what they mean:
| Operator | Meaning | Action |
|---|---|---|
AllNodesScan |
no index used | create a range or text index |
NodeByLabelScan |
label-only filter | index the property in the predicate |
CartesianProduct |
disconnected MATCH clauses | restructure into one pattern |
Expand(All) |
relationship scan by type | add direction and a node index |
Filter after Expand |
predicate applied late | move the predicate into the pattern |
Why does index type matter so much?
Neo4j 5 has four index types, and picking the wrong one gives no speedup at all.
// range: equality and range on one property
CREATE INDEX user_email IF NOT EXISTS
FOR (u:User) ON (u.email);
// composite: equality on both, ordered filtering
CREATE INDEX order_lookup IF NOT EXISTS
FOR (o:Order) ON (o.status, o.created_at);
// full-text: fuzzy and substring
CREATE FULLTEXT INDEX product_search IF NOT EXISTS
FOR (p:Product) ON EACH [p.name, p.description];
// text: exact match on a long string
CREATE TEXT INDEX doc_hash IF NOT EXISTS
FOR (d:Document) ON (d.hash);
A common miss: CONTAINS in a WHERE clause. A range index does nothing for WHERE p.name CONTAINS 'widget'. That needs the full-text index and a db.index.fulltext.queryNodes call.
Should you always specify relationship direction?
Yes. An undirected pattern (a)-[:KNOWS]-(b) forces the planner to expand in both directions and roughly doubles traversal cost. Real relationships usually have a direction; encode it.
Better still, constrain both endpoints:
// slow
MATCH (a {id: $id})-[:KNOWS]-(b)-[:LIKES]-(c)
// fast
MATCH (a:Person {id: $id})-[:KNOWS]->(b:Person)-[:LIKES]->(c:Post)
Labels on every node in the pattern let the planner start from the smallest index match. Without them it may begin from the wrong end.
What is the cartesian product trap?
Two disconnected patterns in one query multiply. With 10,000 users and 5,000 products you get 50 million intermediate rows, and the query appears to hang.
// bad: disconnected
MATCH (u:User {id: $uid})
MATCH (p:Product {id: $pid})
RETURN u.name, p.name
With indexed lookups this is fine — each side returns one row. The disaster case is two broad matches:
MATCH (u:User), (p:Product) WHERE u.active AND p.featured
That is O(n·m). If you need two independent sets, aggregate each separately with CALL {} subqueries, or collect and cross in application code.
How do you paginate without killing the database?
SKIP 500000 LIMIT 20 still walks 500,020 rows. Keyset pagination is the fix — order by an indexed property and filter past the last seen value.
MATCH (o:Order)
WHERE o.created_at < $lastCreatedAt
RETURN o
ORDER BY o.created_at DESC
LIMIT 20
This stays flat as the offset grows. Neo4j 5.24 also supports CALL { ... } IN TRANSACTIONS for large writes, which keeps heap usage bounded.
Where the advice breaks down
Some queries are irreducibly expensive. Shortest-path across a dense graph, or a variable-length pattern like [:KNOWS*1..6] on a social network with 50M edges, will cost what it costs. Cap the upper bound (*1..3) and accept an approximate answer.
Also note that excessive indexes slow writes. Each index adds work on every CREATE and SET, so 40 indexes on a hot label turns a 5 ms ingestion into 30 ms. Audit for unused indexes with SHOW INDEXES and check the lastRead field.
A practical checklist
- Run
PROFILEand scan for any operator over 10,000 db hits. - Every
MATCHshould start from an indexed property. - Add labels to every node in a pattern.
- Add direction to every relationship.
- Replace
CONTAINSwith a full-text index call. - Replace
SKIPoffsets with keyset pagination. - Cap variable-length patterns at three hops.
Key Takeaways
AllNodesScanin a plan means an index is missing; that is the first thing to fix.- Composite indexes serve equality plus ordered range on the second property; full-text handles
CONTAINS. - Relationship direction and node labels in patterns can halve traversal cost.
- Disconnected
MATCHclauses multiply row counts; restructure or use subqueries. - Unused indexes slow writes — check
SHOW INDEXESand drop what nothing reads.
