+1 (415) 649-9454

Bulk Writes Without the Deadlocks: CALL IN CONCURRENT TRANSACTIONS, Batching, and Lock Contention

Read tuning gets all the attention, but the queries that actually take production down are usually writes. A backfill that rewrites 40 million relationships, a nightly sync that MERGEs a few million nodes, a GDPR delete that touches a supernode — these are the jobs that blow the heap, hold locks for minutes, or fail at 90% with DeadlockDetectedException and leave you guessing about what committed.

This tutorial is the write-side playbook we hand to client teams: how to batch properly, what the CONCURRENT clause in Cypher 25 does (and does not) buy you, why the parallel runtime will not help your writes, and how to read lock contention when it happens anyway.

If you have not yet worked through reading a PROFILE plan, start there — everything below assumes you can tell an index seek from an AllNodesScan.

Why one big write transaction fails

A single transaction in Neo4j accumulates its uncommitted state in memory and holds every lock it takes until commit. So one statement that writes ten million records gives you three problems at once:

  1. Memory. Transaction state grows until it hits db.memory.transaction.total.max (or the heap) and the query is terminated.
  2. Lock duration. Node and relationship locks are held to commit time, so concurrent application traffic queues behind your backfill.
  3. All-or-nothing rollback. Forty minutes in, a single failure rolls back everything, and rollback of a huge transaction is itself expensive.

The fix is not "make the transaction bigger" via raised limits. It is to break the work into many committed batches.

Step 1 — batch with CALL { } IN TRANSACTIONS

The modern form of batching is a CALL subquery with IN TRANSACTIONS. The outer query streams rows; the inner subquery commits every N rows.

MATCH (o:Order) WHERE o.totalCents IS NULL
CALL (o) {
  SET o.totalCents = toInteger(round(o.total * 100))
} IN TRANSACTIONS OF 10000 ROWS

Notes that matter in practice:

  • In Cypher 25 the scoped form CALL (o) { ... } replaces the old CALL { WITH o ... } import. The WITH form still parses, but new code should use the scoped syntax.
  • CALL { } IN TRANSACTIONS only runs in an implicit (auto-commit) transaction. From a driver, use session.run(...), not execute_write / a managed transaction function — otherwise you get Cannot execute query with CALL { ... } IN TRANSACTIONS in an explicit transaction.
  • Because each batch commits separately, the job is not atomic. Design it to be idempotent (see the WHERE ... IS NULL guard above) so a rerun finishes rather than double-applies.

Add failure behaviour so a single bad batch does not sink the run:

MATCH (p:Person) WHERE p.email IS NOT NULL
CALL (p) {
  SET p.emailLower = toLower(trim(p.email))
} IN TRANSACTIONS OF 5000 ROWS
  ON ERROR CONTINUE
  REPORT STATUS AS status
RETURN status.started AS started, status.committed AS committed,
       status.errorMessage AS error
LIMIT 20

ON ERROR CONTINUE skips failing batches; ON ERROR BREAK (the default is FAIL) stops cleanly after committing prior batches. REPORT STATUS gives you a row per batch — keep it, log it, and you have a restart list instead of a mystery.

This supersedes apoc.periodic.iterate for most jobs. APOC still earns its place for retries, parallel:true with custom concurrency, and for jobs driven by a generator query you want to page yourself — but if you are writing new code, start with the native clause.

Step 2 — add CONCURRENT when batches are independent

Sequential batches leave a lot of hardware idle: one core writing, everything else waiting on I/O. Cypher 25 lets you run batches in parallel:

LOAD CSV WITH HEADERS FROM 'file:///skus.csv' AS row
CALL (row) {
  MERGE (s:Sku {id: row.sku_id})
  SET s.name = row.name, s.updatedAt = datetime()
} IN 8 CONCURRENT TRANSACTIONS OF 1000 ROWS

You can also write IN CONCURRENT TRANSACTIONS OF 1000 ROWS and let the server pick the concurrency from available threads.

The rule for using it safely: batches must not fight over the same records. Concurrency multiplies throughput when the rows are disjoint and multiplies deadlocks when they are not. Two batches that both MERGE the same (:Customer {id: 42}), or both attach relationships to the same hub node, will contend — and the loser gets a deadlock error, which with ON ERROR CONTINUE quietly becomes missing data.

Practical guidance:

  • Partition the input so each batch owns its keys. Sorting or grouping the source rows by the merge key (ORDER BY row.customer_id) keeps duplicate keys inside one batch instead of spread across eight.
  • Skip concurrency for hub-heavy writes. If every row touches a shared node — a category, a tenant, a supernode you already found during model-debt review — sequential batches are faster than a deadlock storm.
  • Start at 4. Concurrency above the number of available CPUs buys nothing but lock pressure. Measure, then raise.

The parallel runtime is not for this

A common confusion: CYPHER runtime=parallel and IN CONCURRENT TRANSACTIONS are unrelated features. The parallel runtime parallelises a read-only query inside a single transaction (aggregations, big scans, analytics). It refuses write queries. Concurrent transactions parallelise batches of writes across many transactions. Use the first to make a report fast; use the second to make a backfill fast.

Step 3 — make each batch cheap

Batching a slow write just gives you many slow writes. Two things dominate.

Index-back every MERGE and MATCH key. A MERGE on an unindexed property scans the label on every row, and it also takes a broader lock while it does so. Before any bulk job:

CREATE CONSTRAINT sku_id IF NOT EXISTS
FOR (s:Sku) REQUIRE s.id IS UNIQUE;

SHOW INDEXES YIELD name, type, state, populationPercent
WHERE state <> 'ONLINE' RETURN name, type, state, populationPercent;

A uniqueness constraint is both a correctness guarantee and the index MERGE needs. Never start a multi-million-row MERGE while an index is still populating.

Split node and relationship phases. Relationship creation is where lock contention concentrates, because it locks both endpoints. One pass to create nodes (highly parallelisable), then a second pass to create relationships, is consistently faster and calmer than doing both per row:

// pass 2: relationships only, endpoints already exist and are indexed
LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
CALL (row) {
  MATCH (c:Customer {id: row.customer_id})
  MATCH (o:Order {id: row.order_id})
  MERGE (c)-[:PLACED]->(o)
} IN 4 CONCURRENT TRANSACTIONS OF 2000 ROWS

And check the plan of the inner query before you launch: PROFILE a single small batch, confirm NodeUniqueIndexSeek rather than NodeByLabelScan, and look at db hits per row. Multiply by row count — that is your job.

Step 4 — sizing batches

There is no universal batch size; there is a method.

SymptomAdjustment
Batch commits in well under a secondIncrease rows (10k → 50k) to cut commit overhead
Transaction memory warnings, GC pausesDecrease rows; check for accidental cartesian products in the subquery
Application latency spikes during the jobDecrease rows and/or concurrency; locks are held per batch duration
Deadlock errorsDecrease concurrency first, then partition the input

Start at 10,000 rows for simple property writes, 1,000–2,000 for relationship creation, and a few hundred for anything that calls out to APOC or does per-row aggregation. Time a single batch, then extrapolate.

Step 5 — diagnose contention while it happens

When a job stalls, do not guess. Two commands tell you almost everything.

SHOW TRANSACTIONS
YIELD transactionId, currentQueryId, currentQuery, status, elapsedTime,
      activeLockCount, allocatedBytes
WHERE status <> 'Running' OR elapsedTime > duration('PT10S')
RETURN transactionId, status, elapsedTime, activeLockCount,
       left(currentQuery, 120) AS query
ORDER BY elapsedTime DESC;

status is the tell: Blocked by: [tx123] names the transaction holding what you want. A high activeLockCount on a long-running transaction is your culprit; allocatedBytes climbing toward the limit means your batch is too big.

To stop a runaway job cleanly:

TERMINATE TRANSACTIONS 'neo4j-transaction-123';

Because batches commit as they go, termination leaves the committed work in place — which is exactly why the idempotency guard from Step 1 matters. Re-run the same statement and it picks up where it left off.

For recurring jobs, log the deadlock counter alongside your usual dashboards; a rising db.transaction.rollbacks or a spike in terminated transactions is an early warning that data volume has outgrown your batch settings. If you already followed our observability setup, add a panel for it.

Step 6 — a safe backfill pattern for live systems

Putting it together, the pattern we use for schema changes on a database that cannot go offline:

  1. Add the index/constraint first, and wait for ONLINE.
  2. Write the job idempotently — a WHERE target IS NULL (or a migrationVersion marker property) guard means every rerun is a no-op on completed rows.
  3. Dry-run the count. MATCH (n:Order) WHERE n.totalCents IS NULL RETURN count(n) tells you the size of the job and, re-run later, your progress.
  4. Profile one batch with a LIMIT before touching the whole set.
  5. Run sequentially at low volume first (LIMIT 100000), watch p99 application latency, then raise batch size and add CONCURRENT.
  6. Version the statement in your migration tooling so staging and production run the same thing — see versioned migrations with Testcontainers.
  7. Verify the tail. The count from step 3 should reach zero; if it plateaus, REPORT STATUS rows tell you which batches errored.

Common mistakes

  • Running the backfill through a managed transaction function. Fails immediately; use an auto-commit session query.
  • ON ERROR CONTINUE with no REPORT STATUS. You have converted loud failures into silent data loss.
  • CONCURRENT on unsorted input with MERGE. Deadlocks scale with duplicate keys across batches.
  • Deleting with a plain DETACH DELETE on a supernode. Batch the relationship deletes first, then delete the node.
  • Assuming runtime=parallel speeds writes. It does not run them at all.

Where this shows up in client work

Most "Neo4j is slow" tickets we inherit are really write-shaped: an ETL job that grew past its batch settings, a MERGE on a property nobody indexed, or a nightly sync running concurrently with itself. The fix is usually a day of work — index the merge keys, split node and relationship phases, size the batches against measured commit times — and the difference is a job that runs in twelve minutes instead of timing out at three hours.

If you are staring at a backfill you are afraid to run, our Neo4j consultants do exactly this kind of review, and can pair with your team through the first production run. Get in touch with your row counts and the statement you are planning, and we will tell you what it is going to do before it does it.