Avoiding plan cache thrash from inline literals

Your Neo4j service is slower than the query plans say it should be. PROFILE shows a clean NodeIndexSeek with a handful of db-hits, yet p95 latency is high and climbing with traffic, and the database CPU is busy even when result sets are tiny. The culprit is almost always the same: application code that builds each query by concatenating values into the Cypher string, so the plan cache never gets a repeat customer. Every request is a brand-new string, every string is a cache miss, and every miss runs the full planner before the query executes. This how-to shows you how to confirm that inline literals are thrashing the plan cache and how to fix it by moving values into a parameter map passed to the neo4j 5.x driver. It is the concrete driver-code failure behind the broader query parameterization and plan caching discipline.

Prerequisites

Diagnose: confirm the cache is thrashing

Before changing code, prove the diagnosis. Two observations together are conclusive. First, the query log shows many near-identical statements that differ only by an embedded value, each carrying a non-trivial planning time. Second, cypher.replan_events climbs in lockstep with request volume under otherwise steady traffic. A parameterized workload shows the opposite: a handful of distinct query shapes, each planned once, with planning time at zero on every subsequent call.

cypher
// Sample what the planner has actually compiled. If dozens of rows differ
// only by a spliced value and each has a low invocation count, those are
// inline literals that belong in a parameter.
CALL db.stats.retrieve('QUERIES')
YIELD data
RETURN data.query AS query, data.invocations AS invocations
ORDER BY invocations ASC
LIMIT 20;

Core implementation: parameter map, not string formatting

The anti-pattern and its fix sit side by side below. The bad version uses a Python f-string to splice user_id straight into the Cypher text — so "U-1001", "U-1002", and every other id produce a distinct query string and a distinct cache entry. The fixed version keeps the query text constant and passes the value through the driver’s parameter map. This is the entire fix; nothing about the query’s plan changes, only how many times it is compiled.

python
from neo4j import GraphDatabase

# ---------------------------------------------------------------------------
# ANTI-PATTERN — DO NOT DO THIS.
# The value is concatenated into the query text, so every distinct user_id
# is a NEW query string -> a NEW cache key -> a plan-cache MISS -> a full
# recompile on the request path. 10k users == 10k compiled plans that evict
# one another out of a 1,000-slot cache. It is also an injection risk.
# ---------------------------------------------------------------------------
def orders_for_user_BAD(session, user_id: str):
    query = (
        "MATCH (u:User {user_id: '" + user_id + "'})-[:PLACED]->(o:Order) "
        "RETURN o.order_id, o.total"
    )
    return session.run(query).data()          # distinct string every call

# ---------------------------------------------------------------------------
# FIX — pass a parameter map. The query string is a module-level constant, so
# it is byte-for-byte identical on every call: ONE cache key, ONE compiled
# plan, reused for every user_id. Planning cost is paid exactly once, ever.
# ---------------------------------------------------------------------------
ORDERS_FOR_USER = """
MATCH (u:User {user_id: $user_id})-[:PLACED]->(o:Order)
RETURN o.order_id, o.total
"""

def orders_for_user(session, user_id: str):
    # $user_id is bound as a typed value over Bolt; the string never changes.
    return session.execute_read(
        lambda tx: tx.run(ORDERS_FOR_USER, user_id=user_id).data()
    )

The same rule applies to writes. A migration that builds MERGE statements by formatting ids into the string thrashes the cache exactly as a read does — and worse, it runs on the hot write path of a bulk load. Bind the whole batch as a single list parameter and let one cached plan UNWIND it.

python
UPSERT_ORDERS = """
UNWIND $rows AS row
MERGE (o:Order {order_id: row.order_id})
  SET o.total = row.total, o.updated_at = datetime(row.ts)
"""

def upsert_orders(session, rows: list[dict]):
    # One constant string, one cached plan, for a batch of any size.
    session.execute_write(
        lambda tx: tx.run(UPSERT_ORDERS, rows=rows).consume()
    )
Cache occupancy before and after parameterizing the driver call Left panel labelled "Before: f-string concatenation" shows a plan cache whose slots are all filled with distinct entries keyed by different user ids, overflowing past the cache boundary, annotated "every call misses". Right panel labelled "After: parameter map" shows the same cache with a single occupied slot keyed on the parameterized query and the remaining slots free, annotated "every call hits". Before: f-string user_id:'U-1001' → plan user_id:'U-1002' → plan user_id:'U-1003' → plan user_id:'U-1004' → plan user_id:'U-1005' evicts every call misses bind After: parameter map user_id:$user_id → plan free slot free slot free slot every call hits
Moving the value into the parameter map collapses many single-use plans into one reused entry.

Validation & verification

Prove the fix the same way you proved the problem. Re-run the representative traffic and confirm two things flip. In query.log, the previously varied statements collapse to a single recurring query shape whose planning time reads zero after the first compile. And the cypher.replan_events counter, which climbed with traffic before, goes flat because no cached plan is being displaced.

cypher
// After the fix, the workload should show a SMALL number of high-invocation
// query shapes — the opposite of the long low-invocation tail seen before.
CALL db.stats.retrieve('QUERIES')
YIELD data
RETURN data.query AS query, data.invocations AS invocations
ORDER BY invocations DESC
LIMIT 10;

For a controlled check, issue the parameterized query twice with different values and confirm the second call plans in near-zero time — a plain read of the query log’s planning-time field is enough. A residual non-zero planning time on repeat calls means a value is still being spliced somewhere, or a parameter type is drifting between calls.

Edge cases & gotchas

1. A hidden literal in an IN list. You parameterized the scalar filters but left WHERE u.status IN ['active','pending'] inline, and callers pass lists of differing length. Each length is a distinct string. Bind the collection as one parameter:

cypher
// WRONG — a varying-length inline list is a new string each call
MATCH (u:User) WHERE u.status IN ['active','pending'] RETURN u
// RIGHT — one plan for any list
MATCH (u:User) WHERE u.status IN $statuses RETURN u

2. Trying to parameterize a label or relationship type. Parameters bind values, not structure, so MATCH (n:$label) is a syntax error and splicing the label back into the string reintroduces the thrash. When the label genuinely varies, select it from a bounded allow-list of constant query strings — one cached plan per known label — rather than formatting arbitrary text, in line with a disciplined node label taxonomy.

3. Parameter type drift. Binding $user_id as an integer in one code path and a string in another yields two cache entries for one query shape. Cast at the boundary so a given parameter always has one type before it reaches run.

This task is part of Query Parameterization & Plan Caching, which covers the cache-keying and replan mechanics behind the fix shown here.