If you have ever needed to update millions of nodes or relationships in Neo4j, you have probably used apoc.periodic.iterate. For years it was the standard tool for the job — and as of Neo4j 2026.04 and Cypher 25, it is deprecated. The APOC documentation now points to Cypher's built-in replacement: CALL { ... } IN TRANSACTIONS.
This post covers why the procedure existed, how the replacement maps onto it option by option, and how to update the queries in your own projects.
Why apoc.periodic.iterate existed
APOC is a library of procedures and functions for things that are difficult or impossible to express in Cypher. Batched updates over a large dataset used to sit squarely on that list: a single transaction that touches millions of entities holds its entire change set in memory until it commits, which on a big graph means a painfully slow query or an out-of-memory error.
apoc.periodic.iterate solved this by taking two Cypher statements as strings — one to stream the rows, one to apply the change — plus a config map. batchSize controls how many rows are processed before each commit:
CALL apoc.periodic.iterate(
"MATCH (u:User)-[r:RATED]->(m:Movie) RETURN left(toString(m.year),2) AS rY, id(r) AS rId",
"MATCH ()-[r:RATED]->() WHERE id(r) = rId SET r.ratingY = rY",
{batchSize:1000})The approach worked, but it carried some friction. Because the two statements are string literals, the planner cannot see inside them until the procedure runs — no syntax highlighting, no autocomplete, and a typo in a label or property name only surfaces at runtime. And because they are separate queries, data can only move between them as returned values, which is why the example above passes an id and matches each relationship a second time.
The Cypher-native replacement
CALL { ... } IN TRANSACTIONS expresses the same operation as a single statement:
MATCH (u:User)-[r:RATED]->(m:Movie) // (1)
CALL (r, m) { // (2)
SET r.ratingY = left(toString(m.year), 2) // (3)
} IN TRANSACTIONS OF 1000 ROWS // (4)- 1
The outer
MATCHstreams the rows into the subquery, which applies the change. - 2
The outer variables, r and m, are passed through to the scope of the subquery.
- 3
The inner transaction is executed in separate transactions.
- 4
The
IN TRANSACTIONS OF 1000 ROWSclause replaces thebatchSizeoption to specify the amount of rows to process in each transaction.
Because the whole operation is now one statement, your editor highlights it, the planner validates it, and you can prefix it with EXPLAIN to inspect the plan before touching a single property.
CALL { ... } IN TRANSACTIONS commits batches as it goes, so it can only run in an implicit (auto-commit) transaction. In the Query tool, prepend :auto to the query.
Running batches in parallel
apoc.periodic.iterate accepted parallel: true to run batches simultaneously, with a concurrency value to control how many at once. The Cypher equivalent is IN CONCURRENT TRANSACTIONS, with an optional maximum number of concurrent transactions:
MATCH (u:User)-[r:RATED]->(m:Movie)
CALL (r, m) {
SET r.ratingY = left(toString(m.year), 2)
} IN 4 CONCURRENT TRANSACTIONS OF 1000 ROWSIf you omit the number, Neo4j chooses a default based on the available CPU cores.
Concurrency suits workloads where each batch touches a disjoint of the graph.
Batches that write to the same nodes or relationships can cause a deadlock, causing subsequent transactions to wait for the lock to be released before continuing. In other words, if two transactions attempt to write to the same node, you may not see any benefits in terms of execution time.
In the query above, no relationship will appear in more than one in any batch.
The concurrent transactions section of the Cypher manual covers deadlocks, non-determinism, and how to divide work safely.
Handling errors and reporting status
apoc.periodic.iterate returned a summary of how many batches ran and how many failed, and its config decided whether a failure stopped the run. Cypher splits these concerns into two clauses.
ON ERROR controls what happens when a batch fails:
ON ERROR FAIL— stop and fail the whole query. This is the default.ON ERROR BREAK— stop starting new batches, but the query succeeds.ON ERROR CONTINUE— skip the failed batch and keep going.ON ERROR RETRY— retry the batch with exponential backoff, optionallyFOR n SECONDS, then fall back to another behaviour withTHEN CONTINUE,THEN BREAK, orTHEN FAIL.
Whichever you choose, batches that already committed stay committed — only failed batches roll back.
REPORT STATUS AS <variable> adds a map to each row describing the inner transaction it ran in, with started, committed, transactionId, and errorMessage fields. Combined with ON ERROR CONTINUE, it lets a run complete while you collect the failures:
MATCH (u:User)-[r:RATED]->(m:Movie)
CALL (r, m) {
SET r.ratingY = left(toString(m.year), 2)
} IN TRANSACTIONS OF 1000 ROWS
ON ERROR CONTINUE
REPORT STATUS AS status
WITH status
WHERE status.committed = false
RETURN DISTINCT status.transactionId AS transaction, status.errorMessage AS errorREPORT STATUS is only allowed with ON ERROR CONTINUE or ON ERROR BREAK — with the default ON ERROR FAIL there is nothing left to report.
The error behaviour and status report sections of the Cypher manual cover both clauses in detail.
Updating your projects
Search your codebase and scripts for apoc.periodic.iterate. Each call converts the same way:
- The first statement becomes the outer part of the query.
- The second statement becomes the subquery body, with the variables it needs declared in the scope clause.
batchSizebecomesOF n ROWS,parallelandconcurrencybecomeIN n CONCURRENT TRANSACTIONS, and error-handling config becomesON ERROR.
Run EXPLAIN on the result to confirm the plan before executing it against a large graph.
To put the pattern into practice, try the LOAD CSV Data Import lab, which walks through importing data into Neo4j, including using CALL { ... } IN TRANSACTIONS to commit large imports in batches.
Comments (0)
Loading comments...