Transactions, locking and clusters

Neo4j · Expert · 15 min

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 + 1 is 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 DeadlockDetected error. 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 deadlock

Remember

  • 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.

Check yourself

  1. Why does execute_write sometimes run your transaction function more than once?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: neo4j.com/docs/operations-manual/current/database-internals/transaction-management/