Query caches
Translating a Cypher® query string into an executable query plan can be an expensive operation. To avoid repeating this work, Neo4j caches query plans when possible so that when the same query is submitted again, the planning step can be skipped and the cached plan reused directly.
By default, the set of query caches is per database. That means that a new set of caches is initialized for each new database.
How the query cache works
Neo4j caches a query at several stages:
-
Raw query string → pre-parsed query string + query options, such as
CYPHER key=value,EXPLAIN, andPROFILE(pre-parsing) -
Query string → abstract syntax tree (AST) (parsing)
-
AST → logical plan (logical planning, reported in the JSON query log as
logicalPlanCacheHit) -
Logical plan → physical execution plan (execution planning)
-
Raw query string → physical execution plan (executable query cache, reported in JSON query log as
executableQueryCacheHit)
Logging logicalPlanCacheHit and executableQueryCacheHit in the JSON query log requires setting internal.dbms.logs.query.query_cache_usage=true.
This is enabled by default on Neo4j Aura, but must be set explicitly for on-premises
deployments.
|
The fastest path is the last one: if executableQueryCacheHit is true, the exact same query string (and parameter types) has been seen before, and the previously computed plan is reused directly, with no reparsing or replanning.
If the query string is new, executableQueryCacheHit is false, and Neo4j falls back through the earlier stages (parsing, then logical planning) each of which can still be reused if that particular stage’s input has been seen before, even if the overall query text is new.
This is why two differently-worded but structurally equivalent queries can still hit the logical plan cache and avoid some replanning work, without getting the full benefit of an exact executable-query-cache hit.
Logical planning is typically the most expensive of these stages, so its outcome tends to have the largest impact on planning time. Parsing can also take meaningful time, but since it depends on the exact query text, it sometimes cannot be avoided when the query string itself is new.
+----------------------------------------------+-----------+-----------+----------+
| Query | Hash | Cache Hit | Planning |
+----------------------------------------------+-----------+-----------+----------+
| CREATE (:Person {id: 789, name: 'John'}); | 18270B26 | false | 25 ms |
| MATCH (n:Person) WHERE n.id = 789 RETURN n; | D4DE3B34 | false | 22 ms |
| MATCH (n:Person) WHERE n.id = 789 RETURN n; | D4DE3B34 | true | 0 ms |
+----------------------------------------------+-----------+-----------+----------+
The two identical MATCH queries have identical query strings, so the second hits the executable query cache and skips planning entirely (0ms).
Both happen to share the same executionPlanCacheKeyHash, confirming they resolved to the same plan, but it is the identical query string, not the hash, that determines the hit.
+----------------------------------------------+-----------+-----------+----------+
| Query | Hash | Cache Hit | Planning |
+----------------------------------------------+-----------+-----------+----------+
| MATCH (n:Person) WHERE n.id = 789 RETURN n; | BDAF71B3 | true | 1 ms |
| MATCH (n:Person {id: 789}) RETURN n; | BDAF71B3 | false | 63 ms |
+----------------------------------------------+-----------+-----------+----------+
These two queries have different query strings, so the second misses the executable query cache regardless of the first query’s result, and pays real planning time.
Both end up with the same executionPlanCacheKeyHash, showing that despite the miss, they resolved to the same execution plan (this is only confirmed after planning, not something used to skip it).
| For full information about the metrics on query cache usage, refer to the Operations Manual → Cypher metrics. |
Parameter types and the executable query cache
The executable query cache’s key includes the type of each parameter.
Two executions of the same query text with a differently-typed parameter (for example, an INTEGER in one call and a STRING in another) are treated as separate cache entries.
Internally, each parameter is represented as a ParameterTypeInfo,
capturing both its type and a size category. For example, a STRING is represented with its type and a size bucket, e.g. ParameterTypeInfo(String, ExactSize(1)) for a single-character string.
+----------------------------------------------+------------------+---------+-----------+-----------+----------+
| Query | Parameter | Type | Hash | Cache Hit | Planning|
+----------------------------------------------+------------------+---------+-----------+-----------+----------+
| MATCH (n:Person) WHERE n.id = $id RETURN n; | id: 789 | INTEGER | 855E6DCD | false | 56 ms |
| MATCH (n:Person) WHERE n.id = $id RETURN n; | id: '789' | STRING | 855E6DCD | false | 30 ms |
| MATCH (n:Person) WHERE n.id = $id RETURN n; | id: null | ANY | 855E6DCD | false | 23 ms |
| MATCH (n:Person) WHERE n.id = $id RETURN n; | id: 999 | INTEGER | 855E6DCD | true | 0 ms |
+----------------------------------------------+------------------+---------+-----------+-----------+----------+
|
Each distinct ParameterTypeInfo gets its own executable query cache entry. The fourth query hits the entry created by the first, since both use an INTEGER parameter; the STRING and null parameters in between each created their own separate entries and did not interfere with this reuse.
Therefore, if an application occasionally sends null for a parameter and a concrete value at other times, this results in at least two cache entries for what may otherwise look like a single, unchanging query.
Caching and schema changes
Creating an index or a constraint clears the executable query cache, the logical plan cache, and the execution plan cache for the database, forcing every query to go through logical planning and execution planning again on its next execution.
This means executionPlanCacheKeyHash is recomputed after a schema change, even for queries unrelated to it. The hash is tied to the logical plan produced for the query: if logical planning yields the same plan as before, the recomputed hash will be identical, so an unchanged hash does not necessarily mean the cache entry survived untouched (it may simply mean planning produced the same result again).
+------+-----------------------------------------------+-----------+-----------+----------+
| Step | Query / Action | Hash | Cache Hit | Planning |
+------+-----------------------------------------------+-----------+-----------+----------+
| 1 | MATCH (n:A) WHERE n.val = 1 RETURN n | A746F892 | false | 31 ms |
| 2 | MATCH (n:B) WHERE n.val = 1 RETURN n | CC8AAF69 | false | 20 ms |
| 3 | CREATE INDEX idx_a_val FOR (n:A) ON (n.val); | | | 10 ms |
| 4 | CALL db.awaitIndexes(); | | | 12 ms |
| 5 | MATCH (n:A) WHERE n.val = 1 RETURN n | 4505044A | false | 20 ms |
| 6 | MATCH (n:B) WHERE n.val = 1 RETURN n | CC8AAF69 | false | 17 ms |
+------+-----------------------------------------------+-----------+-----------+----------+
Both queries show executableQueryCacheHit: false after the index creation (step 5 and step 6), even though :B is completely unaffected by the new index.
This is because the index update clears the executable query cache broadly, so neither query can take the fast, top-level shortcut on its next run.
However, the two queries diverge after that point.
For :A, the new index gives the planner a better access path, so logical planning produces a different plan, and its executionPlanCacheKeyHash changes (step 1 to step 5).
For :B, logical planning is redone but produces the same plan as before, since nothing about the new index is relevant to it.
As a result, its recomputed hash is identical to before (step 2 equals step 6); not because the execution plan cache entry survived the schema change, but because planning it again produced the same result.
In the case of CREATE INDEX, the cache is cleared when the index comes online, but with CREATE CONSTRAINT the cache is cleared immediately.
|
Notifications when a cached plan is not usedIntroduced in Neo4j 2026.04
Neo4j writes a message to debug.log whenever a cached plan is not used for a query, along with a brief explanation of why.
This requires no configuration and provides a way to notice unexpected replanning without inspecting query log internals.
Cached query plan is not used because recompilation with expression code generation is triggered.
This particular trigger occurs when a query has been executed enough times that Neo4j recompiles it with more aggressive expression code generation, replacing the previously cached plan.
Configuring query caches
The maximum number of entries per cache is configured using server.memory.query_cache.per_db_cache_num_entries.
It determines the cache size only when server.memory.query_cache.sharing_enabled is set to false.
Query caches may consume a lot of memory, especially when running many active databases. To tackle this and improve predictability on memory consumption, you can configure the DBMS to use only one set of caches for all databases. For more information, see Unifying query caches.
The following is a summary of the query cache configurations. For more information, see Operations Manual → Configuration settings.
| Setting | Description | Default |
|---|---|---|
The number of cached queries for all databases.
This setting is only deciding cache size when |
|
|
The number of cached queries per database.
This setting is only deciding cache size when |
|
Unifying query caches
To enable the unified query caches, set the option server.memory.query_cache.sharing_enabled=true.
| Setting | Description | Default |
|---|---|---|
Enable sharing cache space between different databases. With this option turned on, databases will share cache space, but not cache entries. Enterprise Edition Schema-change cache flushes are always scoped to the database where the change occurred, even with sharing enabled; sharing affects available cache space, not cache entries. |
|
|
The number of cached queries for all databases. This setting is only deciding cache size when
|
|
When this feature is enabled, all databases use only one set of query caches. A database may store and retrieve entries from the shared cache, but it may not retrieve entries produced by another database.
| While databases use the same set of caches, a database may not observe entries originating from other databases. |
The database may, however, evict entries from other databases as necessary, according to the constrained cache size and cache eviction policy. In essence, databases may compete for cache space, but may not observe each other’s entries.
When this option is turned on, the cache space available to all databases is configured using the setting server.memory.query_cache.shared_cache_num_entries.
Query size limitIntroduced in 2026.01
The default query size limit for a query to be considered for query caching is 128 KiB of query text.
This limit prevents large generated query text strings from occupying too much memory in the query cache. Such query strings often contain inlined data, and are unlikely to be reusable before auto-parameterization of literals is applied.
To circumvent the default of 128 KiB, you can prefix a query with either CYPHER cache=force to always cache it, or with CYPHER cache=skip to never cache it and skip the query cache lookup.
As a best practice, avoid passing data in the query text string and use parameters instead.