Interpreting the Eager operator in Cypher PROFILE
An Eager operator in a PROFILE is the planner telling you it had to stop streaming and buffer the entire intermediate result set in heap before it dared continue. Cypher normally executes lazily, pulling one row at a time from leaf to root, but when a later part of a query could read rows that an earlier part is about to change, the planner inserts Eager as a memory barrier to guarantee correctness — a read must not see its own writes half-applied. That barrier is invisible on tiny queries and catastrophic on bulk loads: a migration that streams comfortably through millions of rows will instead try to materialize all of them at once, spiking heap and stalling latency. This page explains the three situations that summon Eager, what it costs, and the concrete rewrites that make it disappear. It is a focused case within Query Plan Analysis with EXPLAIN & PROFILE, which covers the full operator vocabulary this page builds on.
Prerequisites
Why Eager appears
Eager is never a bug in your Cypher; it is the planner protecting the read-committed isolation semantics of a single statement. It shows up in three recurring situations, all of which share one shape: a downstream operation could observe rows that an upstream operation mutates.
-
Read-before-write conflict on the same label or property. The classic trigger is matching nodes and, in the same statement, creating or deleting nodes of a label the earlier match scans. If the planner let the write stream, the ongoing
MATCHmight pick up the freshly written nodes and process them too — an infinite or non-deterministic result.Eagerfully materializes the read set before any write fires. -
Aggregation feeding a write. An aggregation (
count,collect,sum) cannot emit its result until it has consumed every input row, so an aggregation that feeds a subsequentSETorCREATEforces the whole upstream to be drained first. That drain is anEager-like barrier by construction. -
MERGEafter a preceding read of the same pattern. When aMERGEcould match something an earlier clause in the same statement just created, the planner insertsEagerso theMERGE’s uniqueness reasoning sees a stable snapshot rather than a moving target.
The diagram below shows where the planner splices Eager into a load plan: it sits between the read phase at the bottom and the write phase at the top, holding the full read set in heap as a barrier.
The cost during bulk loads
On a query that returns 50 rows, Eager buffers 50 rows and nobody notices. On a migration that streams 20 million rows, Eager tries to hold all 20 million in heap simultaneously, and the consequences are severe:
- Heap pressure and
OutOfMemoryError. The buffer is unbounded — it grows to the full read cardinality. A load sized to stream comfortably will exhaust heap the moment anEagerforces materialization, exactly the oversized-transaction failure mode discussed in error handling and rollback mechanisms. - Latency cliff, not a slope. Because no write fires until every read completes, the query produces nothing for a long time, then does all its work at once. Throughput collapses and the operation looks hung.
- Lost batching.
CALL { … } IN TRANSACTIONSbatches only if rows stream into it. AnEagerupstream defeats the batching entirely by materializing before the batched write ever begins.
This is why Eager on a write query is one of the operators to hunt for first when a bulk load is slow — it converts a streaming load into a materialized one.
How to eliminate it
The fix is always to remove the read-before-write ambiguity so the planner no longer needs the barrier. Three rewrites cover almost every case.
// TRIGGER — one statement reads Person and writes Person, so the planner
// inserts Eager to stop the MATCH from seeing the newly created rows.
// PROFILE shows: NodeByLabelScan -> Eager -> Create.
PROFILE
MATCH (p:Person)
CREATE (:PersonArchive {name: p.name});
// FIX 1 — SPLIT reads from writes into two statements. Collect the read
// result first (or run it as a separate query), then write from a parameter.
// Neither statement mixes a read and a write of the same label, so no Eager.
MATCH (p:Person) RETURN p.name AS name; // statement 1: pure read
// then, statement 2: pure write from the returned rows as $names
UNWIND $names AS name
CREATE (:PersonArchive {name: name});
// FIX 2 — BATCH with CALL { … } IN TRANSACTIONS so each inner batch is its
// own transaction. This both bounds memory and removes the whole-query Eager,
// because the write is scoped per batch rather than after a full materialize.
MATCH (p:Person)
CALL (p) {
CREATE (:PersonArchive {name: p.name})
} IN TRANSACTIONS OF 10000 ROWS;
// FIX 3 — RESTRUCTURE a MERGE so it cannot match its own upstream writes.
// Anchor the MERGE on a stable key and ensure the preceding read touches a
// DIFFERENT label/pattern, so the planner sees no read-before-write hazard.
UNWIND $rows AS row
MERGE (o:Order {order_id: row.order_id}) // distinct label from any prior read
SET o.total = row.total;
Each rewrite works by breaking the dependency the barrier existed to protect. Splitting removes the shared label from a single statement. CALL { … } IN TRANSACTIONS scopes the write to a batch that commits and closes before the next read batch, so there is nothing to protect across the whole set. Restructuring the MERGE ensures the write cannot collide with an upstream read of the same pattern.
Validation & verification
Confirm the barrier is gone, not merely moved. Re-PROFILE after each rewrite and inspect the operator list.
// After the rewrite, PROFILE must show NO Eager operator in the tree.
// For the batched form, expect a TransactionForeach / batched write instead,
// with memory bounded to one batch rather than the full read cardinality.
PROFILE
MATCH (p:Person)
CALL (p) {
CREATE (:PersonArchive {name: p.name})
} IN TRANSACTIONS OF 10000 ROWS;
The verification is binary: the word Eager either appears in the profiled tree or it does not. If it is gone, memory is now bounded by batch size instead of read cardinality. If it persists, the rewrite did not actually break the read-before-write dependency — most often because the read and the write still touch the same label inside one statement. As a sanity check, confirm heap stays flat during the load rather than climbing toward the full row count, which is the observable signature of the barrier being removed.
Edge cases & gotchas
1. Splitting reintroduces the hazard through a shared label. If statement two reads the very label statement one wrote, running them back to back can still surprise you at the application level even though each plan is Eager-free. Keep the archive label distinct from the source label so the two phases never overlap.
2. CALL { … } IN TRANSACTIONS cannot run inside an open transaction. It is an auto-commit clause, so it must be issued through a plain session.run(...), never inside a managed execute_write. Nesting it inside an explicit transaction raises an error rather than batching — the same constraint that governs idempotent migration scripts.
3. An aggregation still forces materialization even without a write. If your Eager traces to a count/collect feeding a downstream clause, splitting reads from writes will not help — the aggregation itself must drain its input. Move the aggregation to its own statement and pass its result as a parameter so the barrier lands on a small, bounded result rather than the full read.
Related
- Up: Query Plan Analysis with EXPLAIN & PROFILE — the operator tree this Eager barrier sits inside.
- Cypher Query Tuning Anti-Patterns — other query shapes that inflate memory and db-hits during writes.
- Implementing Idempotent Migration Scripts for Neo4j — the batched-write pattern that both bounds memory and removes Eager.