Correct concurrent applications depend on knowing exactly what Neo4j guarantees under parallel writes and replication.
Every Cypher statement runs in a transaction, either auto-commit or explicit, and Neo4j transactions are ACID. Writes take write locks on the nodes and relationships they modify (creating a relationship locks both end nodes), held until commit. The default isolation level is read committed: you never see uncommitted data, but a value read early in a transaction may change before you write it.
Consequences:
- Read-modify-write races (
MATCH ... SET n.count = n.count + 1is safe because SET takes a lock, but reading a value into the application and writing it back later is not). Take the lock early when needed, for example by setting a dummy property first. - Deadlocks happen when transactions lock the same nodes in different orders; Neo4j detects them and fails one with a transient
DeadlockDetectederror. Drivers' managed transactions (execute_query,execute_write) retry transient errors automatically, so keep transaction functions idempotent. - Dense nodes become lock hotspots when many transactions add relationships to them.
In a cluster (Enterprise / Aura), primary servers replicate writes with the Raft protocol, and secondary servers scale reads asynchronously. Drivers use bookmarks to provide causal consistency: a read after your own write waits until the serving replica has caught up. execute_query manages bookmarks for you; sessions chain them automatically.
Example
from neo4j import GraphDatabase
def transfer(tx, src, dst, amount):
# lock both accounts in a fixed order to avoid deadlocks
tx.run("""MATCH (a:Account) WHERE a.id IN $ids
WITH a ORDER BY a.id SET a._lock = true""", ids=[src, dst])
res = tx.run("""MATCH (s:Account {id: $src}), (d:Account {id: $dst})
WHERE s.balance >= $amt
SET s.balance = s.balance - $amt, d.balance = d.balance + $amt
REMOVE s._lock, d._lock
RETURN s.balance AS left""", src=src, dst=dst, amt=amount)
rec = res.single()
if rec is None:
raise ValueError("insufficient funds") # rolls the transaction back
return rec["left"]
with GraphDatabase.driver("neo4j://localhost:7687", auth=("neo4j", "pw")) as drv:
with drv.session(database="neo4j") as session:
print(session.execute_write(transfer, "A1", "B2", 30)) # retried on deadlockRemember
- ACID transactions with read-committed isolation by default
- Writes lock touched entities until commit; deadlocks raise transient errors
- Managed transactions retry transient errors, so keep them idempotent
- Clusters: Raft primaries, async secondaries, bookmarks for causal consistency
Watch out for
- Doing side effects like sending email inside a managed transaction function that may be retried.