Query Parameterization & Plan Caching

Every Cypher statement Neo4j 5.x runs is compiled into an execution plan before it touches a single node, and that compilation is expensive — the cost-based planner enumerates join orders, consults index statistics, and chooses leaf operators. To avoid paying that cost on every submission, Neo4j caches compiled plans and reuses them. The cache is keyed on the query text, which means the single most consequential decision a developer makes about planning throughput is whether values arrive as parameters or as inline literals. A query written with $id compiles once and is reused forever; the same query with the value pasted into the string recompiles for every distinct value, evicting useful plans and driving planning latency into the request path. This reference explains how the plan cache is keyed, how auto-parameterization narrows but does not close the gap, how the replan mechanism reacts to changing statistics, and how to measure the hit-rate you are actually getting in production.

Prerequisite Concepts

Parameterization is a planning-layer discipline that sits on top of the query-analysis skills covered elsewhere in this reference. Before applying the patterns below, be comfortable with:

  • The overall optimization contract. Plan caching is one lever in the broader Cypher query optimization and index lifecycle reference — read a query plan, back it with the right index, then make sure the plan is reused rather than recompiled.
  • Reading a plan with EXPLAIN and PROFILE. A cache hit or miss is only visible once you can read planning time and operator shape, which is the subject of query plan analysis with EXPLAIN and PROFILE. Parameterization changes how often a plan is compiled; it does not change what the compiled plan does.
  • Index-backed anchors. A parameterized query still needs a usable index to seek rather than scan; parameterization protects the reuse of that seek plan, it does not create the seek. Index selection is covered in index selection and composite indexing.
  • Passing parameters through the driver. In application code, parameters are supplied as a map argument to session.run or execute_write, never concatenated into the string. The driver conventions live in Python driver integration patterns.

Conceptual Model: One String, One Cached Plan

The plan cache is a bounded, per-database map from a normalized query string to a compiled executable plan. When a statement arrives, Neo4j computes its cache key from the query text (after auto-parameterization normalization) and the runtime settings in force. A hit returns the compiled plan immediately; a miss runs the full planner, stores the result, and — if the cache is full — evicts the least-recently-used entry to make room.

The decisive property is that the value’s position in the string is part of the key when the value is an inline literal that cannot be normalized away. Ten thousand lookups by ten thousand distinct customer ids, each pasted into the query text, present as ten thousand distinct strings. The cache, sized at 1,000 entries by default, cannot hold them; every submission misses, compiles, and evicts a neighbour. The same ten thousand lookups written with $id present as one string and compile exactly once.

Inline literals thrash the plan cache; a parameter collapses it to one entry Left panel labelled "Inline literals" shows a query cache holding many distinct entries, each keyed by a different literal value such as id:1001, id:1002, id:1003, with an overflow arrow and an eviction note, annotated "every value misses and evicts". Right panel labelled "Parameter $id" shows the same cache holding exactly one entry keyed on the parameterized string, with three incoming calls all reusing it, annotated "every value hits". Inline literals PLAN CACHE · 1000 SLOTS id: 1001 → plan A id: 1002 → plan B id: 1003 → plan C id: 1004 → evicts plan A every value misses & evicts planning cost on the request path Parameter $id PLAN CACHE · 1000 SLOTS id: $id → one plan compiled once, reused every value hits planning cost paid once, ever low hit-rate ~100% hit-rate
Distinct literal strings each claim a slot and evict one another; a parameter occupies a single reused slot.

Design Rules: When Values Become Parameters

The rule of thumb — “parameterize every value that varies between executions” — has enough exceptions and nuances that it is worth stating as a table. The left column is what appears in the query; the right column is what it does to the cache.

Value in the query Cache behaviour Rule
WHERE u.id = $id (explicit parameter) One entry, reused for all values Always prefer this. Stable string, guaranteed hit.
WHERE u.id = 1001 (inline scalar literal) Auto-parameterized to $autoint_0; often collapses to one entry Works for simple scalars, but the raw string still re-parses; do not rely on it in hot paths.
WHERE u.status IN ['A','B','C'] (inline list) List literals of differing length produce distinct keys Pass the list as $statuses; a varying-length inline list thrashes.
LIMIT 25 (inline integer) Parameterizable, but a literal that steers the plan may block reuse Pass $limit when the bound varies across callers.
MATCH (u:User) with the label spliced from data Distinct label text → distinct string → distinct plan Never build labels or relationship types by string concatenation; use a bounded allow-list.
Property key spliced from data (u[$key]) Dynamic key access is a runtime lookup, not a parameter Keep property keys static in the query; parameterize the value, not the key.

Four rules fall out of this table:

  1. Parameterize the value, never the structure. Parameters bind values — numbers, strings, lists, maps, temporal instances. They cannot bind labels, relationship types, or property keys, because those change the plan shape and therefore the cache key. Structure comes from a fixed vocabulary; only values flow through $params.
  2. Do not lean on auto-parameterization for hot paths. Neo4j will lift simple literals into generated parameters, but the raw text still differs per submission and must be re-parsed and normalized before the cache key stabilizes. An explicit parameter skips that per-call work entirely.
  3. Pass collections as one parameter. An inline IN [...] list is the most common auto-parameterization escape hatch — lists of different lengths do not normalize to the same key. WHERE u.id IN $ids is one plan regardless of list size.
  4. Keep parameter types stable. Sending $id as an integer on one call and a string on the next changes the cache key just as a different literal would. Cast inbound values to a single canonical type before binding.

Step-by-Step Implementation

Step 1 — Write the query with named parameters

Start from the value-carrying query and replace every varying literal with a named parameter. The query text becomes a constant your application never rebuilds.

cypher
// One stable string. $id, $since, and $limit vary per call without
// changing the cache key, so this plan is compiled once and reused.
MATCH (u:User {user_id: $id})-[:PLACED]->(o:Order)
WHERE o.created_at >= $since
RETURN o.order_id, o.total
ORDER BY o.created_at DESC
LIMIT $limit

Step 2 — Bind the parameter map at the driver

In Python, parameters are the second argument to run — a map whose keys match the $name references. The neo4j 5.x driver serializes them over Bolt as typed values, so the query string that reaches the server is byte-for-byte identical on every call.

python
from neo4j import GraphDatabase

QUERY = """
MATCH (u:User {user_id: $id})-[:PLACED]->(o:Order)
WHERE o.created_at >= $since
RETURN o.order_id, o.total
ORDER BY o.created_at DESC
LIMIT $limit
"""

def recent_orders(session, user_id: str, since, limit: int = 25):
    # The parameter map is passed separately — the query string is a constant.
    # execute_read reuses the pooled connection and the cached plan.
    return session.execute_read(
        lambda tx: tx.run(
            QUERY, id=user_id, since=since, limit=limit
        ).data()
    )

Step 3 — Confirm the literal-embedded variant even seeks

A crucial subtlety: an inline-literal query is not wrong — it can still choose a NodeIndexSeek and return the right rows quickly. Parameterization is not about correctness or per-query speed; it is about plan reuse. Verify with EXPLAIN that both forms plan identically, so you can see that the only difference is caching, not the operator tree.

cypher
// Both plan to NodeIndexSeek on user_id — identical operator tree.
// The literal form pollutes the cache; the parameter form does not.
EXPLAIN MATCH (u:User {user_id: 'U-1001'}) RETURN u.email;
EXPLAIN MATCH (u:User {user_id: $id})       RETURN u.email;

Step 4 — Understand auto-parameterization and its limits

Neo4j 5.x normalizes many inline literals into generated parameters ($autoint_0, $autostring_1) before computing the cache key, which is why a loop of {id: 1001}, {id: 1002} may still share a plan. But this normalization runs after the raw string arrives, so every distinct submission is still parsed and rewritten; and it does not cover list literals of varying length, deeply nested map literals, or literals in positions the planner treats as plan-shaping. Treat auto-parameterization as a safety net for ad-hoc queries in the browser, never as a substitute for explicit parameters in application code.

Step 5 — Know when a cached plan is discarded and replanned

A reused plan can become stale. When the graph’s statistics drift — a label grows from thousands to millions of nodes after a bulk load — a plan that was optimal at compile time may now choose the wrong join order. Neo4j guards against this with the replan mechanism, governed by two settings:

bash
# A cached plan is eligible for replanning once BOTH conditions hold:
#  1. at least cypher.min_replan_interval has elapsed since it was planned
#  2. underlying statistics have diverged beyond
#     cypher.statistics_divergence_threshold (a fraction, default 0.5)
cypher.min_replan_interval=10s
cypher.statistics_divergence_threshold=0.5

The practical consequence for migrations: after a large load that multiplies a label’s cardinality, force fresh statistics and let dependent queries replan, rather than serving a plan compiled against the empty graph. You can prompt this explicitly per query with the CYPHER replan=force prefix during testing.

Constraint & Validation Layer: Measuring the Hit-Rate

Parameterization only pays off if you can prove the cache is actually being hit. Three signals, from coarsest to finest, tell you whether your workload is reusing plans or thrashing.

Query log planning time. Enable query logging and inspect the planning-time field. A cache hit reports a planning time at or near zero; a miss reports the full compilation cost. A workload dominated by non-zero planning times is thrashing.

bash
# Log every query with its parameters and planning time.
db.logs.query.enabled=INFO
db.logs.query.parameter_logging_enabled=true

The replan metric. The metrics system exposes neo4j.<db>.cypher.replan_events, a counter of how often the planner discarded and rebuilt a cached plan. A steadily climbing counter under steady traffic is the fingerprint of literal-driven cache churn — each distinct literal string forces a fresh plan that immediately evicts another.

Sampling the distinct query strings. To see what is filling the cache, collect the anonymized query texts the server has compiled and count the distinct shapes:

cypher
// Snapshot the query strings the database has planned, then read them back.
CALL db.stats.collect('QUERIES');
// ... let representative traffic run ...
CALL db.stats.stop('QUERIES');
CALL db.stats.retrieve('QUERIES')
YIELD data
RETURN data.query AS query, data.invocations AS invocations
ORDER BY invocations DESC
LIMIT 20;

If near-identical queries appear as many low-invocation rows differing only by an embedded value, those are inline literals that should be parameters. A healthy parameterized workload shows a small number of high-invocation query shapes. The server-side cache size is bounded by server.db.query_cache_size (default 1000 plans per database); raising it can mask thrash but never cures it — the fix is fewer distinct strings, not more slots.

Performance & Scale Considerations

  • Planning latency lives on the request path. A cache miss is not a background cost; it happens synchronously before the query runs, adding milliseconds to tens of milliseconds per statement for a complex plan. At thousands of requests per second, a low hit-rate turns planning into a dominant, invisible tax that no amount of index tuning removes.
  • Cache size is a ceiling, not a fix. server.db.query_cache_size bounds how many distinct plans survive. Inline literals generate unbounded distinct strings, so any finite cache thrashes eventually; parameterization bounds the working set to the number of query shapes, which is small and stable.
  • Replanning competes with query execution. Each replan re-runs the full planner. A workload that both thrashes the cache and triggers frequent statistics-driven replans pays compilation cost twice over. Stable statistics plus parameterized strings keep the planner idle and the executors busy.
  • Parameters interact with index selectivity. Because the planner compiles a parameterized query without knowing the concrete value, it plans for the typical selectivity drawn from statistics. When one parameter value is wildly atypical (a skewed hot key), the single cached plan may be suboptimal for it — a rare case where you might isolate the skewed value into its own query. This is the one scenario where a shared plan is a genuine trade-off rather than a pure win.

Known Pitfalls

  1. String-formatting query construction. Symptom: planning time never drops, replan_events climbs linearly with request volume, and the query log shows thousands of near-identical statements. Root cause: the application builds queries with f-strings or % formatting, splicing each value into the text so every call is a distinct string. Fix: pass a parameter map to run; this is the exact failure walked through in avoiding plan cache thrash from inline literals.

  2. Varying-length IN lists inline. Symptom: parameterization was adopted for scalars but the cache still churns. Root cause: an IN [1,2,3] list literal is spliced with a different number of elements each call, so auto-parameterization cannot normalize it to a stable key. Fix: bind the whole collection as WHERE x IN $ids, one plan for any list length.

  3. Type-drifting parameters. Symptom: two callers issue what looks like the same parameterized query but the hit-rate is only 50%. Root cause: one path binds $id as an integer and another as a string, producing two distinct compiled plans. Fix: cast inbound values to one canonical type before binding, aligning with disciplined graph data type selection.

  4. Serving a stale plan after a bulk load. Symptom: a query that was fast on the empty database becomes slow after a large migration, then quietly recovers minutes later. Root cause: the cached plan was compiled against pre-load statistics and only replanned once the divergence threshold and minimum interval both elapsed. Fix: after a large load, refresh statistics and let dependent queries replan before flipping traffic — coordinate this with your Python driver integration patterns.