npx skills add ...
npx skills add neo4j-contrib/neo4j-skills --skill neo4j-cypher-skill
Generates, optimizes, and validates Cypher 25 queries for Neo4j 2025.x and 2026.x.
npx skills add neo4j-contrib/neo4j-skills --skill neo4j-cypher-skill
neo4j-migration-skillneo4j-cli-tools-skillneo4j-vector-index-skillGQL conformance note: LET, FINISH, FILTER, and INSERT are valid Cypher 25 clauses (introduced via GQL conformance, mostly in Neo4j 2025.06). On older versions, fall back to WITH / (omit RETURN) / WHERE / CREATE. INSERT requires &-separated multi-labels and does not support dynamic labels/types.
| ? | Known | Unknown |
|---|---|---|
<db-name>-schema.json found in project | Use it directly — skip live inspection | — |
| Schema (from context or live DB) | Use directly | Run Schema-First Protocol |
| Neo4j version | Use version features | Default to 2025.01 safe set |
| Executing (not generating)? | Use EXPLAIN + write gate | State query is unvalidated |
Schema unknown + no tool → produce non-executable sketch outside a code block:
Never fill guessed names — realistic guesses get copied blindly.
CYPHER 25 — first token; never repeat after UNION or inside subqueriesMERGE on constrained key only; rel MERGE on already-bound endpoints onlyMATCH (n) forbidden unless bound or followed by WHERE n:$($label)LIMIT 25 default on all exploratory reads; push WITH n LIMIT before high-cardinality operations (variable-length traversals, fan-out MATCH, Cartesian products)// only — -- is SQL, invalidREPEATABLE ELEMENTS / DIFFERENT RELATIONSHIPS go after MATCH, not end of patternSHOW commands: YIELD before WHERE; combinable with general Cypher clauses incl. UNION/RETURN [2026.05] — SHOW DATABASES still requires system db (use USE system). CALL on system db: YIELD then WHERE [2026.07](:Label WHERE p=x) — valid in MATCH onlyWHERE cannot follow bare UNWIND — use WITH x WHERE(a)-[:R]-(b) — undirected matches both directions, double-counts; use directed unless unknownDETACH DELETE — plain DELETE throws if node has relationships| Element | Convention |
|---|---|
| Node labels | PascalCase :Person |
| Rel types | SCREAMING_SNAKE_CASE :KNOWS |
| Properties/vars | camelCase firstName |
| Clauses | UPPERCASE MATCH |
| Booleans/null | lowercase true false null |
| Strings | single-quoted; double only if contains ' |
Schema is truth.
:Person,:KNOWS,namein examples are illustrative — substitute real names from schema.
Priority order:
<db-name>-schema.json anywhere in project → read directly, state file name + schema_retrieved_at, skip live inspection. If significantly outdated and DB reachable, offer re-fetch. Full rules: references/schema-guardrail.md.
Scripts: generate_schema.py (live DB + APOC), define_schema.py (no DB), import_neo4j_schema.py (converts neo4j-graphrag-python, graph-schema-introspector, graph-schema-json-js-utils, mcp-neo4j-data-modeling).
Schema in context → use it, skip inspection.
Schema missing → run:
Property types per label — check APOC first:
Validate before returning any query: label exists · rel type+direction correct · property on that label · index ONLINE.
SET n = {} replaces all props. SET n += {} merges (safe partial update). Use += for updates.
Every var not listed in WITH is dropped. WITH * carries all forward.
CALL { WITH x ... } deprecated → CALL (x) { ... }. COLLECT {} returns exactly one column.
Input stream must be outside subquery. Auto-commit only — never wrap in beginTransaction(). PERIODIC COMMIT deprecated.
DISJOINT BY [2026.06, Cypher 25] on IN CONCURRENT TRANSACTIONS prevents deadlocks by scheduling batches that share lock-prone resources sequentially — use when importing relationships:
DISJOINT BY (expr,...) declares lock keys (outer-query variables only); DISJOINT BY AUTO infers them via static analysis; DISJOINT BY NONE disables. Overrides dbms.cypher.transactions.default_subquery_batch_strategy. EXPLAIN/PROFILE shows keys in DISJOINT BY (...) on TransactionForeach.
Quantifier outside group: (pattern){N,M}. Groups start+end with node. REPEATABLE ELEMENTS needs bounded {m,n}. ACYCLIC implies nodes cannot repeat within a path (stronger than default DIFFERENT RELATIONSHIPS).
Match mode — add after MATCH:
DIFFERENT RELATIONSHIPS (default) — each rel traversed once per pathREPEATABLE ELEMENTS [2025.x] — nodes/rels revisitable; use for circular routes, weight-optimized paths, constrained backtracking; requires bounded {m,n}Use WHEN…THEN…ELSE for if-else-if write logic; mutually exclusive (first match wins). Not available pre-2025.06.
Create POINT index: CREATE POINT INDEX name IF NOT EXISTS FOR (n:Place) ON (n.coords)
Non-aggregating expressions in RETURN/WITH are implicit grouping keys — GROUP BY optional:
Explicit GROUP BY subclause on WITH/RETURN [2026.07, Cypher 25] states grouping keys explicitly — GQL-aligned alternative to implicit grouping; implicit grouping stays valid:
GROUP BY () = no grouping keys (one row); GROUP BY ALL = every non-aggregating return item is a key. Grouping keys absent from the projection are not returned. Rules → references/cypher-syntax.md.
count(n) counts non-null; count(*) counts rows including nulls. collect(DISTINCT expr) deduplicates.
count() is faster than size(collect()) — count() reads the internal store; collect() builds a list first.
ORDER BY/WHERE subclause expressions referencing a projection item more complex than a variable or var.prop are deprecated [2026.07] — alias the expression in the projection and order by the alias. Same for names that shadow an incoming variable. ORDER BY/WHERE may now call aggregation functions absent from the projection list when the projection clause already aggregates.
| Wrong | Right |
|---|---|
ORDER BY n.prop AS x DESC | ORDER BY n.prop DESC |
ORDER BY preAggVar after agg RETURN | Use RETURN alias |
count(r WHERE r.x=5) | sum(CASE WHEN r.x=5 THEN 1 ELSE 0 END) |
UNWIND list AS x WHERE x>5 | UNWIND list AS x WITH x WHERE x>5 |
least(a,b) / greatest(a,b) | CASE WHEN a<b THEN a ELSE b END |
-- comment | // comment |
shortestPath((a)-[*]->(b)) | SHORTEST 1 (a)(()-[]->()){1,}(b) |
id(n) | elementId(n) |
[:REL*1..5] | (()-[:REL]->()){1,5} |
CALL { WITH x ... } | CALL (x) { ... } |
COLLECT { (a)-[:R]->(b) } | COLLECT { MATCH ... RETURN b } |
SET n = {k:v} partial update | SET n += {k:v} |
DELETE n with relationships | DETACH DELETE n |
WHERE n.x = null | WHERE n.x IS NULL |
toInteger(null) throws | toIntegerOrNull(null) |
n.$key dynamic property | n[$key] |
SET n:$label | SET n:$($label) |
ZONED DATETIME >= date(...) → 0 rows | Use datetime(...) or .year accessor |
ISO string with Z suffix stored/compared as UTC | Z ≠ UTC in Neo4j — Z is parsed as an offset, not the UTC timezone; planner and range indexes treat them differently. Explicitly coerce: datetime({datetime: datetime('2025-09-10T03:43:00Z'), timezone: 'UTC'}) (neo4j#13519) |
FOREACH ... RETURN | UNWIND ... RETURN |
Full trap table → references/syntax-traps.md
Default: parameterized queries. Return named properties, not full nodes or RETURN *.
Exception: schema/diagnostic queries (CALL db.schema.visualization(), SHOW INDEXES YIELD *, EXPLAIN) where the object is the point.
Validation workflow:
EXPLAIN before any write — catches syntax errors, missing indexesLIMIT 1 firstRETURN before replacing with SET/CREATE/DELETEPROFILE to measure db hits; check for AllNodesScan, CartesianProduct, EagerQuery API v2 (no driver needed — works for schema inspection, EXPLAIN, reads, writes):
Write execution gate — only when agent executes (MCP/cypher-shell/HTTP), NOT when generating for code/scripts/user to run:
EXPLAIN → report estimated rows affectedDefault to 2025.01-safe features when version unknown.
| Feature | Min version | Fallback |
|---|---|---|
CYPHER 25, QPEs, CALL (x) {} | 2025.01 | require 2025+ |
Match modes (DIFFERENT RELATIONSHIPS, REPEATABLE ELEMENTS) | 2025.01 | require 2025+ |
Dynamic labels $($expr), coll.sort() | 2025.01 | APOC or app-side |
CONCURRENT TRANSACTIONS, REPORT STATUS | 2025.01 | drop / omit |
SEARCH clause (vector/fulltext) | 2026.01 | CALL db.index.vector.queryNodes(...) (deprecated 2026.04) |
ACYCLIC path mode (no repeated nodes in path) | 2026.03 | post-filter with size(nodes(p)) = size(apoc.coll.toSet(nodes(p))) |
string.indexOf(), string.join(), string.regexReplace() | 2026.05 | apoc.text.* or app-side |
GROUP BY subclause on WITH/RETURN, cardinality() | 2026.07 | implicit grouping keys; size() / size(keys(map)) |
WHERE on procedure calls run against the system database | 2026.07 | filter rows client-side |
GQL aliases: FOR=UNWIND, PROPERTY_EXISTS=IS NOT NULL, IS [NOT] LABELED=n:Label; function aliases (local_time, zoned_datetime, duration_between, collect_list, etc.) | 2026.02–04 | GQL compliance only — use Cypher equivalents; full list → references/cypher-syntax.md |
GRAPH TYPE schema DDL (ALTER CURRENT GRAPH TYPE SET/ADD/ALTER/DROP, SHOW CURRENT GRAPH TYPE) | 2026.02 (preview), GA 2026.06 | Use individual CREATE CONSTRAINT / CREATE INDEX |
GROUP BY subclause on WITH/RETURN (explicit grouping keys, GQL alignment) | 2026.07 | Implicit grouping — list non-aggregating expressions in the projection |
cardinality() — keys in a MAP, elements in a LIST, nodes+rels in a PATH | 2026.07 | size() for LIST/MAP keys, length() for PATH |
Aggregation functions in ORDER BY/WHERE that are not projection items (aggregating projection only) | 2026.07 | Project the aggregate as an alias, then order/filter on the alias |
WHERE after YIELD in procedure calls on the system database | 2026.07 | YIELD + RETURN, filter client-side |
EXPLAIN/PROFILE red flags: AllNodesScan CartesianProduct NodeByLabelScan Eager
Fix Eager — three approaches (choose simplest that works):
MATCH (x:CallingPoint) instead of bare MATCH (x) when writing :City nodesWITH collect(u) AS users UNWIND users AS u ...Label inference — when planner underestimates selectivity on multi-label queries: [Neo4j 5]
Index anchors: every MATCH/MERGE/WHERE on a property needs an index on the lookup property or Neo4j scans all nodes. Index only activates when the node has a label — MATCH (n {prop: $v}) never uses an index; MATCH (n:Label {prop: $v}) does. MERGE without a constraint has no atomicity guarantee (two concurrent MERGEs can create duplicates). CONTAINS/ENDS WITH → TEXT index (RANGE does not support them). Force a plan with USING INDEX n:Label(prop) when EXPLAIN shows a scan.
Chained OPTIONAL MATCH for nested data → replace with COLLECT { MATCH ... RETURN }.
Dynamic labels ($($label)) → AllNodesScan+Filter; use static labels when possible.
Full anti-patterns → references/performance.md
toIntegerOrNull()/toFloatOrNull(); guard with IS NOT NULLWITH → use count(*) not count(droppedVar)LIMIT → CALL IN TRANSACTIONS OF 1000 ROWSSHOW TRANSACTIONS YIELD currentQuery, status, currentQueryProgressZONED DATETIME >= date(...) → 0 rows; use datetime() or .yearZ suffix ≠ UTC timezone: ISO strings with Z are stored as a UTC-offset, not the UTC zone — range queries across Z and UTC stored values return 0 rows. Coerce on write: datetime({datetime: datetime($isoStr), timezone: 'UTC'}).inDays/.inMonths don't exist; use .days/.monthsCannot merge node using null property value: MERGE key resolved to null — validate params firstIndexNotFoundError: SHOW INDEXES YIELD name, state WHERE state <> 'ONLINE'Load on demand:
ALTER CURRENT GRAPH TYPE SET/ADD/ALTER/DROP, SHOW CURRENT GRAPH TYPE [AS GRAPH], element type syntax, property types, constraints, label implications, relationship type enforcement, required privileges| Need | URL |
|---|---|
| Clause semantics | https://neo4j.com/docs/cypher-manual/25/clauses/{clause}/ |
| Function signatures | https://neo4j.com/docs/cypher-manual/25/functions/{type}/ |
| QPE / paths | https://neo4j.com/docs/cypher-manual/25/patterns/ |
| Spatial/point functions | https://neo4j.com/docs/cypher-manual/25/functions/spatial/ |
| Index/constraint reference | https://neo4j.com/docs/cypher-manual/25/indexes/ |
| Full cheat sheet | https://neo4j.com/docs/cypher-cheat-sheet/25/all/ |
CYPHER 25 prefix on every top-level query$parameters used (not literals)LIMIT on exploratory reads (default 25)EXPLAIN run; red flags resolvedRETURN before executingMERGE on constrained key onlyMATCH (n)// If APOC available (preferred — use this):
CALL apoc.meta.schema() YIELD value RETURN value;
// No APOC AND database ≤ 100k nodes/rels only (expensive on large graphs):
CALL db.schema.nodeTypeProperties() YIELD nodeType, propertyName, propertyTypes, mandatory;
CALL db.schema.relTypeProperties() YIELD relType, propertyName, propertyTypes, mandatory;// MERGE on constrained key; set extras in ON CREATE/ON MATCH
CYPHER 25
MATCH (a:Person {id: $a}) MATCH (b:Person {id: $b})
MERGE (a)-[r:KNOWS]->(b)
ON CREATE SET r.since = date()
ON MATCH SET r.lastSeen = date()CYPHER 25
MATCH (a:Person)-[:KNOWS]->(b:Person)
WITH a, count(*) AS friends // b dropped here
WHERE friends > 5
RETURN a.name, friends ORDER BY friends DESCEXISTS { (a)-[:R]->(b) } // boolean check
COUNT { (a)-[:R]->(b) WHERE a.x > 0 } // count
COLLECT { MATCH (a)-[:R]->(b) RETURN b.name } // collect list (full MATCH+RETURN required)
CALL (p) { MATCH (p)-[:ACTED_IN]->(m) RETURN m } // correlated subquery (explicit import)
OPTIONAL CALL (p) { ... } // nullable subqueryCYPHER 25
LOAD CSV WITH HEADERS FROM 'file:///data.csv' AS row
CALL (row) {
MERGE (p:Person {id: row.id}) SET p += row
} IN TRANSACTIONS OF 1000 ROWS ON ERROR CONTINUE REPORT STATUS AS sCYPHER 25
LOAD CSV WITH HEADERS FROM 'file:///rels.csv' AS line
CALL (line) {
MATCH (a:Movie {id: line.movieId}), (b:Person {id: line.personId})
MERGE (b)-[:ACTED_IN]->(a)
} IN CONCURRENT TRANSACTIONS OF 1000 ROWS DISJOINT BY (line.movieId, line.personId)CYPHER 25
MATCH SHORTEST 1 (a:Person {name:'Alice'})(()-[:KNOWS]->()){1,}(b:Person {name:'Bob'})
RETURN b.name
// ACYCLIC [2026.03] — no repeated nodes within a path (prevents cycles)
CYPHER 25
MATCH p = ACYCLIC (start:Router {name: $from})-[:LINK]-+(end:Router {name: $to})
RETURN [n IN nodes(p) | n.name] AS route
ORDER BY length(p) LIMIT 5CYPHER 25
MATCH (move:Item {id: $id})
OPTIONAL MATCH (insertBefore:Item {id: $before})
OPTIONAL MATCH (insertAfter:Item {id: $after})
CALL (move, insertBefore, insertAfter) {
WHEN insertBefore IS NULL THEN {
MATCH (last:Item) WHERE NOT (last)-[:NEXT]->() AND last <> move
CREATE (last)-[:NEXT]->(move)
}
WHEN insertAfter IS NULL THEN {
CREATE (move)-[:NEXT]->(insertBefore)
}
ELSE {
CREATE (insertAfter)-[:NEXT]->(move)
CREATE (move)-[:NEXT]->(insertBefore)
}
}// Create/match/merge with dynamic rel type (must resolve to exactly one STRING)
CYPHER 25 CREATE (a:Node)-[:$($relType)]->(b:Node)
CYPHER 25 MATCH (a:Node)-[:$($relType)]->(b:Node) RETURN a.name, b.name// WGS84 geographic point
SET n.coords = point({longitude: $lon, latitude: $lat})
// Distance in metres; requires POINT index for performance
MATCH (a:Place {name: $origin}) MATCH (b:Place)
RETURN b.name, point.distance(a.coords, b.coords) AS distM
ORDER BY distM LIMIT 10
// Bounding-box pre-filter (uses POINT index) then distance
MATCH (b:Place)
WHERE point.withinBBox(b.coords,
point({longitude: $west, latitude: $south}),
point({longitude: $east, latitude: $north}))
RETURN b.name, point.distance(b.coords, $origin) AS distM// actor + director are grouping keys; count(*) is the aggregate
MATCH (a:Person)-[:ACTED_IN]->(m:Movie)<-[:DIRECTED]-(d:Person)
RETURN a.name, d.name, count(*) AS collaborations
ORDER BY collaborations DESC
// GROUP BY states keys explicitly [2026.07, Cypher 25]
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
RETURN p.name AS actor, m.genre AS genre, avg(m.rating) AS avgRating
GROUP BY actor, genreMATCH (a:Person)-[:ACTED_IN]->(m:Movie)<-[:DIRECTED]-(d:Person)
RETURN a.name, d.name, count(*) AS collaborations GROUP BY a.name, d.name
ORDER BY collaborations DESC// RIGHT: agent gets named fields it can reason over
CYPHER 25 MATCH (n:Organization {name: $name}) RETURN n.name, n.founded, n.industry LIMIT 10
// WRONG: full node object wastes tokens, leaks all properties, agent can't extract fields cleanly
CYPHER 25 MATCH (n:Organization {name: $name}) RETURN n LIMIT 10curl -X POST https://<instance>.databases.neo4j.io/db/<database>/query/v2 \
-u <user>:<password> -H "Content-Type: application/json" \
-d '{"statement": "EXPLAIN MATCH (n:Person {name: $name}) RETURN n", "parameters": {"name": "Alice"}}'
# Local: http://localhost:7474/db/<database>/query/v2
# Response: {"data": {"fields": [...], "values": [...]}} — prefix EXPLAIN to plan without executingCYPHER inferSchemaParts = most_selective_label
MATCH (admin:Administrator {name: $name}), (resource:Resource {name: $res})
MATCH p=(admin)-[:MEMBER_OF]->()-[:ALLOWED_INHERIT]->(company)
RETURN count(p)