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.

  1. 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 MATCH might pick up the freshly written nodes and process them too — an infinite or non-deterministic result. Eager fully materializes the read set before any write fires.

  2. 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 subsequent SET or CREATE forces the whole upstream to be drained first. That drain is an Eager-like barrier by construction.

  3. MERGE after a preceding read of the same pattern. When a MERGE could match something an earlier clause in the same statement just created, the planner inserts Eager so the MERGE’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.

Where the planner inserts an Eager barrier between reads and writes Vertical plan tree. Bottom leaf NodeByLabelScan is the read phase. Directly above it the Eager operator, drawn in amber with a heavier border, buffers all rows in heap. Above Eager, a Create operator is the write phase, topped by the EmptyResult root. A brace on the left labels everything below Eager READ PHASE and everything above it WRITE PHASE. A callout on the right of Eager reads buffers ALL rows in heap, the memory and latency cost. READ PHASE WRITE PHASE EmptyResult Create Eager NodeByLabelScan buffers ALL rows in heap at once = memory + latency The barrier drains the read fully before the first write fires
Eager is a correctness barrier: it materializes the entire read set so writes cannot corrupt an in-flight read.

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 an Eager forces 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 TRANSACTIONS batches only if rows stream into it. An Eager upstream 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.

cypher
// 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});
cypher
// 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});
cypher
// 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;
cypher
// 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.

cypher
// 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.