Neo4j & Cypher¶
03:20 AM. The risk API's p99 latency alarm fires. One Cypher query — MATCH (u:User {user_id: $id})-[:USED]->(d:Device)<-[:USED]-(u2:User) RETURN u2 LIMIT 20 — is pinning a CPU core and stalling other Bolt sessions. PROFILE shows the first operator is AllNodesScan.
What's missing from this query?
A. An index on User.user_id so the planner can seek instead of scan every node. B. A LIMIT clause — there already is one, so that isn't it. C. A bound on traversal depth — the pattern is effectively unbounded. D. A read replica to spread Bolt sessions across.
Pick one before reading on. The fraud graph is modelled; what's needed now is bounded traversals on the payment path and investigations in a language that looks like the picture. Neo4j is the operational graph store in this academy — Cypher patterns, indexes to find the start, PROFILE to see expands. It is not where you run last week's GMV.
Start with the situation¶
- Risk API: 2-hop shared device/IP for this
user_id, < 50 ms,LIMIT. - Analyst: 3-hop neighbourhood of a bad IP, minutes OK, still bounded.
- CDC upserts from the payment Postgres.
Recommendations use Cypher only to read precomputed ALSO_BOUGHT or to debug a neighbourhood — not to compute similarity online.
Why the obvious approach breaks¶
Cypher is easy to write and easy to make cartesian. Indexes are easy to forget (label scan of all :Transaction). Variable-length paths are easy to unbounded-star. The database will try. The cluster will pause.
You must treat Cypher like CQL: every query has a start key, a type list, a depth cap, and a LIMIT.
Build the mental picture¶
ASCII art is the language:
MATCH is declare a pattern. WHERE filters. RETURN projects. The planner’s job is to pick a start (index) and expand. If it starts with AllNodesScan, you lost.
Under the hood¶
graph TD
C["Cypher"]
P["Planner / cost"]
I["Index seek on label+prop"]
E["Expand by type+direction"]
F["Filter / hash distinct"]
C --> P --> I --> E --> F Storage: nodes, relationships, property store, label/type tokens. Index-free adjacency after the start: expand walks relationship chains on the node.
Indexes and constraints are how MATCH (u:User {user_id: $id}) is O(log n) not O(|Users|).
Transactions: Bolt session writes are transactional; CDC should batch modestly. Do not open a transaction that upserts 10 million edges.
Memory: transaction heap + page cache. A supernode expand pulls a huge chain into the expander. Serving and GDS on the same box contend — see algorithms.
Put it to work¶
Run locally¶
docker run --name neo4j -p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/password \
-e NEO4J_PLUGINS='["graph-data-science"]' \
neo4j:5
Browser: http://localhost:7474. Bolt: 7687.
Constraints and indexes first¶
CREATE CONSTRAINT user_id IF NOT EXISTS
FOR (u:User) REQUIRE u.user_id IS UNIQUE;
CREATE CONSTRAINT device_id IF NOT EXISTS
FOR (d:Device) REQUIRE d.device_id IS UNIQUE;
CREATE CONSTRAINT ip_addr IF NOT EXISTS
FOR (i:IP) REQUIRE i.address IS UNIQUE;
CREATE CONSTRAINT txn_id IF NOT EXISTS
FOR (t:Transaction) REQUIRE t.txn_id IS UNIQUE;
CREATE CONSTRAINT merchant_id IF NOT EXISTS
FOR (m:Merchant) REQUIRE m.merchant_id IS UNIQUE;
CREATE INDEX ip_flag IF NOT EXISTS FOR (i:IP) ON (i.flag);
CREATE INDEX txn_ts IF NOT EXISTS FOR (t:Transaction) ON (t.ts);
Without the uniqueness constraints, MERGE is guesswork and rings split.
MERGE from CDC (idempotent upsert)¶
MERGE (u:User {user_id: $uid})
ON CREATE SET u.name = $name
MERGE (d:Device {device_id: $did})
ON CREATE SET d.fingerprint = $fp
MERGE (ip:IP {address: $addr})
MERGE (m:Merchant {merchant_id: $mid})
ON CREATE SET m.name = $mname
MERGE (t:Transaction {txn_id: $tid})
SET t.amount = $amount, t.ts = datetime($ts)
MERGE (u)-[:MADE]->(t)
MERGE (t)-[:ON]->(d)
MERGE (t)-[:FROM]->(ip)
MERGE (t)-[:AT]->(m)
MERGE (u)-[r:USES]->(d)
SET r.last_seen = datetime($ts)
MERGE (u)-[s:USES]->(ip)
SET s.last_seen = datetime($ts)
MERGE on a non-indexed property is a scan. Always MERGE on the constrained id.
Pattern matching — fraud serving¶
// 1 hop: devices for user
MATCH (u:User {user_id: $uid})-[:USES]->(d:Device)
RETURN d.device_id, d.fingerprint;
// 2 hop: other users sharing a device, skip supernodes
MATCH (u:User {user_id: $uid})-[:USES]->(d:Device)<-[:USES]-(other:User)
WHERE other <> u AND coalesce(d.flag, '') <> 'supernode'
RETURN DISTINCT other.user_id
LIMIT 100;
// 3 hop: merchants in that neighbourhood (bounded)
MATCH (u:User {user_id: $uid})-[:USES]->(d:Device)<-[:USES]-(other:User)
WHERE coalesce(d.flag, '') <> 'supernode'
WITH DISTINCT other LIMIT 200
MATCH (other)-[:MADE]->(t:Transaction)-[:AT]->(m:Merchant)
WHERE t.ts > datetime() - duration('P90D')
RETURN m.merchant_id, count(*) AS n
ORDER BY n DESC
LIMIT 20;
Variable-length — always capped¶
MATCH (ip:IP {address: $addr})
WHERE ip.flag IS NULL OR ip.flag <> 'supernode'
MATCH (ip)<-[:FROM]-(:Transaction)<-[:MADE]-(u:User)
MATCH path = (u)-[:USES|MADE|FROM|ON|AT*1..3]-(other:User)
RETURN DISTINCT other.user_id, min(length(path)) AS hops
LIMIT 500;
*1..3 is the product. * is an incident.
Shortest path (investigation)¶
MATCH (a:User {user_id: $a}), (b:User {user_id: $b})
MATCH path = shortestPath((a)-[:USES|MADE|FROM|ON|AT*..6]-(b))
RETURN [n IN nodes(path) | labels(n)[0] + ':' + coalesce(n.user_id, n.device_id, n.address, n.txn_id)] AS steps,
length(path) AS hops;
Still cap *..6. Unbounded shortestPath on a connected-world graph is a CPU bomb.
Aggregation (small neighbourhoods only)¶
MATCH (t:Transaction)-[:AT]->(m:Merchant {merchant_id: $mid})
WHERE t.ts > datetime() - duration('P1D')
RETURN count(t) AS n, sum(t.amount) AS amt;
Company-wide totals: ClickHouse.
Python driver¶
from neo4j import GraphDatabase
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
CYPHER_2HOP = """
MATCH (u:User {user_id: $uid})-[:USES]->(d:Device)<-[:USES]-(other:User)
WHERE other <> u AND coalesce(d.flag,'') <> 'supernode'
RETURN DISTINCT other.user_id AS user_id
LIMIT 100
"""
def shared_device_users(uid: str) -> list[str]:
with driver.session() as session:
recs = session.run(CYPHER_2HOP, uid=uid)
return [r["user_id"] for r in recs]
Parameterise. Do not f-string Cypher.
EXPLAIN and PROFILE¶
Want NodeIndexSeek / NodeUniqueIndexSeek, then Expand.
Reject AllNodesScan, NodeByLabelScan on :Transaction, cartesian ValueHashJoin of two disconnected MATCH.
Read db hits and rows on each Expand. If Expand rows jump 1 → 5,000,000, you hit a supernode.
Where teams get caught¶
Unbounded traversal
MATCH (u)-[*]-() walks the world. Always *1..N and types.
Cartesian products
MATCH (u:User), (d:Device) with no relationship. Millions of rows. Connect the pattern.
MERGE without constraint
Race → duplicates.
Eager operators
Large DISTINCT / ORDER BY on unbounded MATCH pulls the neighbourhood into memory.
Serving + GDS on one heap
PageRank of the full graph will evict the page cache the API needed.
How it fails¶
| Symptom | Cause |
|---|---|
| Query 2 ms then 40 s | Hit a supernode IP/device |
| Cluster CPU 100% | * or cartesian |
| Duplicate users | Missing constraint, bad MERGE |
| CDC lag | Giant transactions, lock on supernode |
| Bolt session leak | Driver sessions not closed; pool exhaustion |
Availability: Neo4j Causal Cluster (or Aura) — read replicas for analysts, write on primary. Do not run WCC on the primary at noon.
How to investigate¶
PROFILEthe exact parameterised query from production (same$addr).- Degree of the start node and of the first expand type.
- Query log /
query.logslow queries. - Page cache hit ratio — if GDS just ran, cache is cold.
- Lock / stopped queries:
SHOW TRANSACTIONS.
Scale: 10× / 100× / 1000×¶
10×. One instance, constraints, 90-day edges, replicas for analysts.
100×. Causal cluster; separate GDS box or gds.graph.project off-peak; prune edges; risk API may cache 2-hop sets in Redis.
1000×. Serving is ego-net in KV; Neo4j is investigation + CDC subset (recent, sampled types). Sharding graphs is still painful (fabric / sharding by tenant). Do not expect linear scale-out like Cassandra.
Trade-offs¶
Cypher + native expand is the point. You pay for clustering ops, RAM for hot graph, and a second consistency story vs Postgres.
Aura vs self-managed: same modelling, less pager, less control of GDS memory.
Alternatives¶
| Store | When |
|---|---|
| Postgres recursive CTE | Trees, rare |
| Amazon Neptune | AWS, Gremlin/SPARQL/openCypher preference |
| Memgraph | Tighter in-memory / streaming ingest |
| JanusGraph / HugeGraph | Huge graphs, you accept ops |
| Spark | Algorithm at lake scale |
Apply¶
Every production Cypher review: start label + indexed property, typed relationships, max depth, LIMIT, supernode filter, PROFILE attached as screenshot.
Check your understanding¶
Write and critique the risk query
Payment for user_id=u001. Need other user_ids sharing a device or IP in ≤2 hops, exclude NAT IPs (flag='supernode'), 90-day USES only.
- Write the Cypher.
- Which indexes must exist?
- What does PROFILE show if
u001used a CGNAT IP not flagged yet? - Why is
MATCH (u:User {user_id:'u001'})-[*]-(o:User)wrong even with LIMIT 10?
Answer
-
cypher MATCH (u:User {user_id: $uid})-[r:USES]->(x) WHERE r.last_seen > datetime() - duration('P90D') AND coalesce(x.flag,'') <> 'supernode' AND (x:Device OR x:IP) MATCH (x)<-[r2:USES]-(other:User) WHERE other <> u AND r2.last_seen > datetime() - duration('P90D') RETURN DISTINCT other.user_id LIMIT 100; -
Unique
User.user_id; indexes onUSESare usually not required if start is indexed;IP.flagoptional. Constraint on Device/IP ids for MERGE path. -
Expand on that IP’s
USESin-edges: rows explode (millions), db hits explode, query time seconds+, memory up. Fix: degree cap, flag supernodes in CDC, abort ifcount{(x)<-[:USES]-()}> threshold before the second MATCH (careful: counting can also be expensive — storedegree). -
Unbounded types and depth; LIMIT applies after a potentially huge search depending on planner; you can still traverse a huge component to find 10 users. Type and depth are the contract, not LIMIT.