
Neo4j Cypher Skill
- 841 installs
- 101 repo stars
- Updated August 3, 2026
- neo4j-contrib/neo4j-skills
neo4j-cypher-skill is a Claude Code skill that generates, optimizes, and validates Cypher 25 queries for Neo4j 2025.x and 2026.x for developers who need correct graph patterns without slow or schema-blind queries.
About
neo4j-cypher-skill version 1.0.1 targets Neo4j 2025.x and 2026.x with Cypher 25 as the required first token on every query. It enforces a schema-first protocol using project schema JSON or live SHOW INDEXES/CONSTRAINTS inspection, default LIMIT 25 on exploratory reads, MERGE only on constrained keys, and a write execution gate requiring EXPLAIN before mutations. Coverage spans MATCH, MERGE, QPE path expressions, CALL IN TRANSACTIONS bulk loads, vector SEARCH clause 2026.01+, fulltext indexes, and six on-demand references including a 40+ entry syntax-traps table. Developers reach for neo4j-cypher-skill when writing new graph queries, debugging slow patterns, or validating batch writes against a real schema instead of guessing labels and relationship types.
- Cypher 25: MATCH/OPTIONAL MATCH, MERGE constrained-key rules, CALL IN TRANSACTIONS, UNWIND, FOREACH
- Subqueries: EXISTS, COUNT, COLLECT, CALL (x) { }, OPTIONAL CALL
- Path expressions: QPEs, match modes, path selectors (SHORTEST 1, ALL SHORTEST)
- Performance: Eager operator detection, parallel runtime, index hints, anti-patterns by severity
- Search: vector SEARCH (2026.01+) with procedure fallback; fulltext via db.index.fulltext
Neo4j Cypher Skill by the numbers
- 841 all-time installs (skills.sh)
- +60 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #99 of 911 Databases skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/neo4j-contrib/neo4j-skills --skill neo4j-cypher-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 841 |
|---|---|
| repo stars | ★ 101 |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | neo4j-contrib/neo4j-skills ↗ |
How do you write and optimize Cypher 25 for Neo4j 2025.x?
Generate, tune, and validate Cypher 25 for Neo4j 2025.x/2026.x without shipping slow or incorrect graph queries.
Who is it for?
Backend engineers on Neo4j 2025.x or 2026.x who need production-safe Cypher with schema guards, performance checks, and modern path-expression syntax.
Skip if: Teams needing Neo4j driver migration, server administration, or hybrid vector+fulltext ranking should use neo4j-migration-skill, neo4j-cli-tools-skill, or neo4j-vector-index-skill instead.
When should I use this skill?
The user writes, optimizes, or debugs Cypher queries, graph pattern matching, vector search, or batch Neo4j writes.
What you get
Parameterized Cypher 25 queries, EXPLAIN/PROFILE plans, schema validation notes, and version-gated SEARCH or QPE patterns.
- Parameterized Cypher queries
- EXPLAIN/PROFILE output
- Schema validation checklist
By the numbers
- Skill version 1.0.1 for Neo4j >= 2025.01
- Includes 40+ documented Cypher syntax traps
- Ships 6 on-demand reference guides
Files
When to Use
- Writing, optimizing, or debugging Cypher queries
- Graph pattern matching, QPEs, variable-length paths
- Vector/fulltext search, subqueries, batch writes, LOAD CSV
When NOT to Use
- Driver migration/API changes →
neo4j-migration-skill - DB admin (users, config, backups) →
neo4j-cli-tools-skill - Hybrid search that combines vector with fulltext or other ranked sources →
neo4j-vector-index-skill
GQL 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.
---
Pre-flight
| ? | 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:
(<SOURCE_LABEL> {<KEY>: $value})-[:<REL_TYPE>]->(<TARGET_LABEL>)Never fill guessed names — realistic guesses get copied blindly.
---
Defaults — apply every query
1. CYPHER 25 — first token; never repeat after UNION or inside subqueries 2. Schema first — inspect before writing; if schema in prompt, use it directly 3. MERGE on constrained key only; rel MERGE on already-bound endpoints only 4. Label-free MATCH (n) forbidden unless bound or followed by WHERE n:$($label) 5. LIMIT 25 default on all exploratory reads; push WITH n LIMIT before high-cardinality operations (variable-length traversals, fan-out MATCH, Cartesian products) 6. Comments: // only — -- is SQL, invalid 7. REPEATABLE ELEMENTS / DIFFERENT RELATIONSHIPS go after MATCH, not end of pattern 8. SHOW commands: YIELD before WHERE; combinable with general Cypher clauses incl. UNION/RETURN [2026.05] — SHOW DATABASES still requires system db (use USE system) 9. Inline node predicates (:Label WHERE p=x) — valid in MATCH only 10. WHERE cannot follow bare UNWIND — use WITH x WHERE 11. (a)-[:R]-(b) — undirected matches both directions, double-counts; use directed unless unknown 12. DETACH DELETE — plain DELETE throws if node has relationships
---
Style
| 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.
---
Schema-First Protocol
Priority order:
1. <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.
- Existence — labels/rel-types/properties must be in schema; try synonym resolution before asking
- Property type — reason about intent first (e.g. string vs INTEGER may be null check); ask only if unclear
- Relationship direction — wrong direction → correct silently and note
- Synonym mapping — unambiguous → resolve silently; ambiguous → pick most likely, note; ask if unresolvable
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).
2. Schema in context → use it, skip inspection.
3. Schema missing → run:
CALL db.schema.visualization() YIELD nodes, relationships RETURN nodes, relationships;
SHOW INDEXES YIELD name, type, labelsOrTypes, properties, state WHERE state = 'ONLINE';
SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties;
SHOW PROCEDURES YIELD name RETURN split(name,'.')[0] AS namespace, count(*) AS procedures;Property types per label — check APOC first:
// 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;Validate before returning any query: label exists · rel type+direction correct · property on that label · index ONLINE.
---
Key Patterns
MERGE
// 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()SET n = {} replaces all props. SET n += {} merges (safe partial update). Use += for updates.
WITH scope
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 DESCEvery var not listed in WITH is dropped. WITH * carries all forward.
Subqueries — cheat sheet
EXISTS { (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 subqueryCALL { WITH x ... } deprecated → CALL (x) { ... }. COLLECT {} returns exactly one column.
CALL IN TRANSACTIONS (bulk writes)
CYPHER 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 sInput stream must be outside subquery. Auto-commit only — never wrap in beginTransaction(). PERIODIC COMMIT deprecated.
QPE basics
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 5Quantifier 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}
Conditional CALL subqueries [2025.06]
CYPHER 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)
}
}Use WHEN…THEN…ELSE for if-else-if write logic; mutually exclusive (first match wins). Not available pre-2025.06.
Dynamic relationship types [2025.x]
// 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.nameSpatial / Point
// 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 distMCreate POINT index: CREATE POINT INDEX name IF NOT EXISTS FOR (n:Place) ON (n.coords)
Aggregation grouping keys
Non-aggregating expressions in RETURN/WITH are implicit grouping keys — no GROUP BY needed:
// 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 DESCcount(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.
---
Common Syntax Traps (top causes of broken queries)
| 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
---
Output Mode and Write Gate
Default: parameterized queries. *Return named properties, not full nodes or `RETURN `.**
// 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 10Exception: schema/diagnostic queries (CALL db.schema.visualization(), SHOW INDEXES YIELD *, EXPLAIN) where the object is the point.
Validation workflow: 1. EXPLAIN before any write — catches syntax errors, missing indexes 2. New read: test with LIMIT 1 first 3. Write: verify read half as RETURN before replacing with SET/CREATE/DELETE 4. PROFILE to measure db hits; check for AllNodesScan, CartesianProduct, Eager
Query API v2 (no driver needed — works for schema inspection, EXPLAIN, reads, writes):
curl -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 executingWrite execution gate — only when agent executes (MCP/cypher-shell/HTTP), NOT when generating for code/scripts/user to run: 1. Run EXPLAIN → report estimated rows affected 2. Wait for user confirmation before executing
---
Version Gates
Default 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 |
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, EXTEND GRAPH TYPE WITH, DROP GRAPH TYPE ELEMENTS, SHOW CURRENT GRAPH TYPE) | 2026.02 — PREVIEW | Use individual CREATE CONSTRAINT / CREATE INDEX |
---
Performance
EXPLAIN/PROFILE red flags: AllNodesScan CartesianProduct NodeByLabelScan Eager
Fix Eager — three approaches (choose simplest that works): 1. Add specific labels to MATCH nodes to eliminate read/write ambiguity: MATCH (x:CallingPoint) instead of bare MATCH (x) when writing :City nodes 2. Collect first, then write: WITH collect(u) AS users UNWIND users AS u ... 3. CALL IN TRANSACTIONS: isolates each batch in its own transaction
Label inference — when planner underestimates selectivity on multi-label queries: [Neo4j 5]
CYPHER inferSchemaParts = most_selective_label
MATCH (admin:Administrator {name: $name}), (resource:Resource {name: $res})
MATCH p=(admin)-[:MEMBER_OF]->()-[:ALLOWED_INHERIT]->(company)
RETURN count(p)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
---
Failure Recovery
- 0 results: check param types, remove WHERE predicates one-by-one, EXPLAIN for index use
- TypeErrors: use
toIntegerOrNull()/toFloatOrNull(); guard withIS NOT NULL - Variable out of scope: not listed in
WITH→ usecount(*)notcount(droppedVar) - Timeouts: fix AllNodesScan → add early
LIMIT→CALL IN TRANSACTIONS OF 1000 ROWS - Long-running query progress [2026.03]:
SHOW TRANSACTIONS YIELD currentQuery, status, currentQueryProgress - DateTime mismatch:
ZONED DATETIME >= date(...)→ 0 rows; usedatetime()or.year Zsuffix ≠ UTC timezone: ISO strings withZare stored as a UTC-offset, not the UTC zone — range queries acrossZandUTCstored values return 0 rows. Coerce on write:datetime({datetime: datetime($isoStr), timezone: 'UTC'})- Duration:
.inDays/.inMonthsdon't exist; use.days/.months Cannot merge node using null property value: MERGE key resolved to null — validate params firstIndexNotFoundError:SHOW INDEXES YIELD name, state WHERE state <> 'ONLINE'
---
References
Load on demand:
- references/indexes.md — index types (RANGE/TEXT/FULLTEXT/POINT/COMPOSITE/LOOKUP), constraints, MERGE lock semantics, fulltext Lucene syntax, import pre-flight
- references/cypher-syntax.md — full syntax reference: WITH, DELETE, ORDER BY, CASE, null, lists, strings, dates, spatial/point, LOAD CSV, subqueries, QPEs, dynamic labels, SEARCH; conditional CALL (WHEN/THEN/ELSE); label pattern expressions; allReduce; NEXT clause; compact CASE WHEN; normalize(); index/constraint types table; functions annotated with version introduced
- references/syntax-traps.md — 40+ syntax trap table
- references/performance.md — anti-patterns, text vs fulltext indexes, Eager (3 fix strategies), label inference, batching best practices, parallel runtime
- references/advanced-patterns.md — REPEATABLE ELEMENTS patterns, allReduce stateful traversal, multi-stop QPE, route planning simulation, DAG critical path, temporal fraud detection component graph, cycle detection, OPTIONAL CALL
- references/apoc.md — APOC Core: refactoring, virtual graph, merge helpers, path expanders, triggers, collections, conditional execution
- references/graph-type.md — PREVIEW (2026.02+) GRAPH TYPE DDL:
ALTER CURRENT GRAPH TYPE SET,EXTEND GRAPH TYPE WITH,DROP GRAPH TYPE ELEMENTS, property types, constraints, label implications, relationship type enforcement
WebFetch
| 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/ |
---
Checklist
- [ ] Schema inspected or confirmed in context
- [ ]
CYPHER 25prefix on every top-level query - [ ]
$parametersused (not literals) - [ ]
LIMITon exploratory reads (default 25) - [ ]
EXPLAINrun; red flags resolved - [ ] Write half verified as
RETURNbefore executing - [ ] Write execution gate applied if agent is executing (not generating)
- [ ]
MERGEon constrained key only - [ ] No label-free
MATCH (n) - [ ] Schema ops not inside explicit transaction
neo4j-cypher-skill
Generates, optimizes, and validates Cypher 25 queries for Neo4j 2025.x and 2026.x.
Topics covered
Query writing — reads, writes, subqueries, batch operations, LOAD CSV, schema inspection, EXPLAIN/PROFILE validation
Patterns — MATCH, OPTIONAL MATCH, WITH, UNION, MERGE (constrained-key rules), FOREACH, UNWIND, CALL IN TRANSACTIONS
Subqueries — EXISTS {}, COUNT {}, COLLECT {}, CALL (x) { }, OPTIONAL CALL
Path expressions — Quantified Path Expressions (QPEs), match modes (DIFFERENT RELATIONSHIPS, REPEATABLE ELEMENTS), path selectors (SHORTEST 1, ALL SHORTEST)
Search — vector search (SEARCH clause 2026.01+, procedure fallback for 2025.x), fulltext (db.index.fulltext)
Schema — db.schema.visualization, SHOW INDEXES/CONSTRAINTS/PROCEDURES, apoc.meta.schema() (preferred when APOC available)
Performance — Eager operator detection and fixes, parallel runtime, index hints, anti-patterns by severity
Language features — dynamic labels/properties (2025.01), type predicates (IS :: INTEGER NOT NULL), OrNull casting, coll.sort(), btrim(), date/time arithmetic, null handling, 40+ syntax traps
Version coverage
Defaults to 2025.01-safe features. Items new in 2025.x are annotated [2025.01] in the reference files; 2026.x items [2026.01].
Not covered
- Driver migration →
neo4j-migration-skill - DB administration →
neo4j-cli-tools-skill
Reference files
Loaded on demand — not bundled into the main skill context:
| File | Contents |
|---|---|
| `references/cypher-syntax.md` | Full syntax reference: clauses, patterns, functions. Items introduced in 2025.x annotated [2025.01]; 2026.x items [2026.01]; older deprecated forms annotated [replaces X] |
| `references/syntax-traps.md` | 40+ table of invalid → correct Cypher — SQL habits and pre-2025 syntax |
| `references/performance.md` | Anti-patterns with severity levels, text vs fulltext index comparison, Eager operator triggers and fixes |
Not covered
- Driver migration or version upgrade →
neo4j-migration-skill - Database administration (users, config, backups) →
neo4j-cli-tools-skill - GQL clauses:
LET,FINISH,FILTER, andINSERTare valid in Cypher 25 (introduced via GQL conformance, mostly in Neo4j 2025.06); not available on older versions
Related skills
| Skill | Purpose |
|---|---|
neo4j-getting-started-skill | Zero-to-app: provision, model, load, explore, build |
neo4j-migration-skill | Upgrade Cypher syntax and drivers across major versions |
neo4j-cli-tools-skill | DB administration via neo4j-admin, cypher-shell, Aura CLI |
Install
npx skills add https://github.com/neo4j-contrib/neo4j-skills --skill neo4j-cypher-skillAdvanced Graph Patterns
Load when solving path-finding, fraud detection, DAG traversal, temporal graphs, or stateful QPE problems.
Version markers: [Neo4j 5] = Neo4j 5.x, [2025.x] = Neo4j 2025.x / Cypher 25, [2025.06] = 2025.06+.
---
REPEATABLE ELEMENTS — When to Use [2025.x]
Default: DIFFERENT RELATIONSHIPS — each relationship traversed at most once per path. Use REPEATABLE ELEMENTS when:
- Nodes have limited connectivity (single in/out relationship) and weight-optimized paths are needed
- Problem requires backtracking through already-visited nodes (circular routes, constrained path search)
- Path must revisit waypoints (multi-stop routes, recurring visits)
// Find circular routes from CPH using same connections multiple times
CYPHER 25
MATCH (cph:Airport {iata: 'CPH'})
MATCH REPEATABLE ELEMENTS p=(cph)(()-[c:CONNECTION]-() WHERE c.km < 548){2,6}(cph)
WITH p, reduce(d=0, c IN relationships(p) | d + c.km) AS distance
WHERE distance >= 805
ORDER BY distance LIMIT 1
RETURN p, distanceREPEATABLE ELEMENTS requires bounded quantifier {m,n} — do not use {1,} (unbounded).
---
Multi-Stop Shortest Path [2025.x]
Multiple waypoints in one query via chained QPE groups:
CYPHER 25
MATCH REPEATABLE ELEMENTS p =
ALL SHORTEST (:Airport {iata:'CPH'})--{,10}
(:Airport {iata:'IFJ'})--{,10}
(:Airport {iata:'DFW'})
WITH p, reduce(d=0, c IN relationships(p) | d + c.km) AS distance
ORDER BY distance LIMIT 1
RETURN p, distance---
Stateful Route Planning with allReduce [2025.x]
allReduce for simulation-style traversal where path validity depends on accumulated state (energy, time, cost):
CYPHER 25 runtime=parallel
MATCH (src:Geo {name: $source}), (dst:Geo {name: $target})
MATCH REPEATABLE ELEMENTS p=(src)(()-[r:ROAD|CHARGE]-(x:Geo)){1,12}(dst)
WHERE allReduce(
curr = {soc: $initial_soc_pct, mins: 0.0},
r IN relationships(p) |
CASE
WHEN r:ROAD THEN {soc: curr.soc - r.drain_pct, mins: curr.mins + r.drive_mins}
WHEN r:CHARGE THEN {soc: curr.soc + r.charge_pct, mins: curr.mins + r.charge_mins}
END,
$min_soc <= curr.soc <= $max_soc AND curr.mins <= $max_mins
)
// Spatial pre-filter: skip detours > 1.3x direct distance
AND ALL(x IN nodes(p) WHERE
point.distance(x.geo, dst.geo) < 1.3 * point.distance(src.geo, dst.geo))
RETURN p, reduce(d=0, r IN relationships(p) | d + r.drive_mins) AS total_mins
ORDER BY total_mins ASC LIMIT 1allReduce(accumulator = initial, item IN list | updateExpr, predicate) — returns true only if predicate holds at every step. Prunes invalid paths inline during expansion.
---
DAG Traversal and Critical Path [Neo4j 5]
Model: ActivityStart/ActivityEnd nodes connected by weighted :ACTIVITY edges; zero-weight :DEPENDS_ON edges for sequencing.
// Longest path (critical path) — small graphs only
// For large graphs use gds.dag.longestPath instead
CYPHER 25
MATCH p=(a:ActivityStart)-[:ACTIVITY|DEPENDS_ON]*->(b:ActivityEnd)
WITH b.name AS task,
reduce(t=0, r IN [x IN relationships(p) WHERE type(x)='ACTIVITY' | x] |
t + r.expectedTime) AS totalTime
RETURN task, max(totalTime) AS criticalPathTime
ORDER BY criticalPathTime DESC
// GDS alternative for large DAGs (much faster):
// CALL gds.dag.longestPath.stream('dag_graph') YIELD nodeId, distanceLimitation: Cypher QPE for longest path fails on large graphs; use gds.dag.longestPath for production.
---
Fraud Detection — Temporal Component Graph [2025.x]
Pattern: User-Event-Thing model where fraud rings = connected components sharing resources (IPs, devices, emails).
Problem: Standard WCC includes future events → "future leakage" in ML features. Solution: Chronological :SAME_CC_AS forest — each event links only to components existing at its timestamp.
// Step 1: Build temporal connected components (process events in timestamp order)
CYPHER 25
MATCH (e:Event&!ConnectedComponent)
WITH e ORDER BY e.timestamp
CALL (e) {
MATCH (e)(()-[:WITH]->(entity)<-[:WITH]-(:ConnectedComponent)){0,1}()<-[:COMMITS]-(u)
WITH DISTINCT e, u
MATCH (u)-[:SAME_CC_AS]->*(cc WHERE NOT EXISTS {(cc)-[:SAME_CC_AS]->()})
MERGE (cc)-[:SAME_CC_AS]->(e)
SET e:ConnectedComponent
} IN TRANSACTIONS OF 100 ROWS
// Step 2: Point-in-time component snapshot (features as of $asOfDate)
CYPHER 25
MATCH (cc:Event)
WHERE cc.timestamp <= $asOfDate
AND NOT EXISTS {(cc)-[:SAME_CC_AS]->(x:Event WHERE x.timestamp <= $asOfDate)}
RETURN cc
// Step 3: Retrieve component membership for an event
CYPHER 25
MATCH p=(u:User)(()-[:SAME_CC_AS]->(ev))*(e:Event {event_id: $event_id})
UNWIND ev + [e] AS event
RETURN p, [(event)-[r:WITH]->(x) | [r, x]] AS with_thingsScaling with GDS WCC — process independent components in parallel:
CYPHER 25
CALL gds.wcc.stream('wcc_graph') YIELD nodeId, componentId
WITH gds.util.asNode(nodeId) AS event, componentId
WITH componentId, collect(event) AS events
ORDER BY rand()
CALL (events) {
UNWIND events AS e
WHERE NOT e:ConnectedComponent
ORDER BY e.timestamp ASC
CALL (e) {
MATCH (e)(()-[:WITH]->(entity)<-[:WITH]-(:ConnectedComponent)){0,1}()<-[:COMMITS]-(p)
WITH DISTINCT e, p
MATCH (p)-[:SAME_CC_AS]->*(cc WHERE NOT EXISTS {(cc)-[:SAME_CC_AS]->()})
MERGE (cc)-[:SAME_CC_AS]->(e)
SET e:ConnectedComponent
}
} IN CONCURRENT TRANSACTIONS OF 100 ROWSAvoid O(n²) clique projection — use linear path through shared entity instead:
CYPHER 25
MATCH (thing:Thing|User)
CALL (thing) {
MATCH (e:Event)-[:WITH|COMMITS]-(thing)
WITH DISTINCT e
WITH collect(e) AS events
WITH CASE size(events) WHEN 1 THEN [events[0], null] ELSE events END AS events
UNWIND range(0, size(events)-2) AS ix
RETURN events[ix] AS source, events[ix+1] AS target
}
RETURN gds.graph.project('wcc_graph', source, target, {})---
Cycle Detection with QPE [Neo4j 5]
Detect non-repeating cycles without artificial length limits:
// All cycles through a node (bounded for safety)
CYPHER 25
MATCH (start:Account {id: $id})
MATCH DIFFERENT RELATIONSHIPS p=(start)(()-[:TRANSFERS_TO]->()){2,10}(start)
RETURN p, length(p) AS cycleLength
ORDER BY cycleLength LIMIT 20
// Count paths through complex small-graph traversal
CYPHER 25 runtime=parallel
MATCH REPEATABLE ELEMENTS path=(:Start)((xs:!End)--(:!Start)){0,100}(e:End)
WHERE allReduce(
visited = [],
x IN xs | CASE WHEN x:Big THEN visited ELSE visited + [x] END,
size(visited) <= size(apoc.coll.toSet(visited)) + 1
)
RETURN count(path) AS validPaths---
Path Selector Reference [2025.x]
| Selector | Returns | Use case |
|---|---|---|
SHORTEST 1 | One shortest path | Existence + distance |
ALL SHORTEST | All equal-minimum-length paths | Parallel routing |
ANY | Any path (no length guarantee) | Fast existence check |
SHORTEST k | k shortest paths | Top-k routing |
SHORTEST k GROUPS | All paths grouped by length up to k distinct lengths | Tier-based routing |
// k-shortest paths with cost
CYPHER 25
MATCH SHORTEST 3 (a:City {name: $from})(()-[r:ROAD]->()){1,}(b:City {name: $to})
WITH *, reduce(c=0, r IN relationships(*) | c + r.cost) AS totalCost
ORDER BY totalCost
RETURN totalCost, [n IN nodes(*) | n.name] AS route---
Type Predicate for Schema Discovery [Neo4j 5]
Identifies properties by runtime type — useful in GraphRAG pipelines to auto-detect text fields:
// Find all STRING properties on nodes in a label
CYPHER 25
MATCH (n:Article)
WITH keys(n) AS props, n LIMIT 1
UNWIND props AS p
WHERE n[p] IS :: STRING NOT NULL
RETURN p AS textProperty---
OPTIONAL CALL [Neo4j 5]
Left-outer join for procedures — row kept even if procedure returns no results:
CYPHER 25
MATCH (m:Movie)
OPTIONAL CALL apoc.algo.dijkstra(m, $target, 'ROAD', 'distance') YIELD path, weight
RETURN m.title, weight // weight is null when no path foundAPOC Core Reference
Load when using APOC procedures for graph refactoring, virtual graphs, merge helpers, path expansion, triggers, or utility operations.
Verify APOC available: RETURN apoc.version()
APOC Core ships bundled with Neo4j. APOC Extended is a separate Labs plugin — procedures below are Core only.
---
Graph Metadata
// Schema snapshot: labels, rel types, properties, counts
CALL apoc.meta.schema() YIELD value RETURN value
// Fast label/rel-type/property counts (sampled)
CALL apoc.meta.stats() YIELD labels, relTypesCount, properties RETURN *
// Per-label property details (name, type, nullable, indexed)
CALL apoc.meta.nodeTypeProperties()
YIELD nodeType, propertyName, propertyTypes, mandatory RETURN *
// Per-rel-type property details
CALL apoc.meta.relTypeProperties()
YIELD relType, propertyName, propertyTypes, mandatory RETURN *apoc.meta.schema() samples the graph; apoc.meta.stats() is near-instant from counters.
---
Graph Refactoring
Rename labels / types / properties
// Rename label on all nodes (optional: pass list to limit scope)
CALL apoc.refactor.rename.label('OldLabel', 'NewLabel', [])
// Rename relationship type
CALL apoc.refactor.rename.type('OLD_TYPE', 'NEW_TYPE', [])
// Rename node property across all (or matched) nodes
CALL apoc.refactor.rename.nodeProperty('oldProp', 'newProp', [])
// Rename relationship property
CALL apoc.refactor.rename.relationshipProperty('oldProp', 'newProp', [])Config optional: {batchSize: 10000, parallel: true}. Third argument [] means all; pass a list of nodes/rels to scope.
Merge nodes
// Merge person duplicates — first node is target
MATCH (a:Person {email: $email})
WITH collect(a) AS dupes
CALL apoc.refactor.mergeNodes(dupes, {
properties: 'combine', // 'overwrite' | 'discard' | 'combine'
mergeRels: true
}) YIELD node RETURN nodeClone nodes
// Clone without relationships
MATCH (n:Template {id: $id})
CALL apoc.refactor.cloneNodes([n], false, []) YIELD output RETURN output
// Clone with relationships
CALL apoc.refactor.cloneNodes([n], true, ['internalId']) YIELD input, output, errorExtract node from relationship
Splits a relationship into a node-rel-node triple:
// apoc.refactor.extractNode(rels, [labels], outRelType, inRelType)
MATCH ()-[r:TRANSACTION]->() WHERE r.amount > 10000
WITH collect(r) AS bigTxns
CALL apoc.refactor.extractNode(bigTxns, ['HighValueTx'], 'HAS_TX', 'FROM_ACCT')
YIELD input, output RETURN output---
Merge Helpers (dynamic MERGE)
Use when label or rel-type is a parameter — Cypher MERGE requires literal labels at compile time.
// apoc.merge.node(labels, identProps [, onCreateProps, onMatchProps])
CALL apoc.merge.node(['Person'], {email: $email},
{createdAt: datetime()},
{lastSeen: datetime()}
) YIELD node RETURN node
// apoc.merge.relationship(startNode, relType, identProps, onCreateProps, endNode [, onMatchProps])
MATCH (a:Company {id: $from}), (b:Company {id: $to})
CALL apoc.merge.relationship(a, $relType, {}, {since: date()}, b, {})
YIELD rel RETURN rel---
Virtual Graph (in-memory, no write)
Virtual nodes/relationships exist only in query result — for projecting computed subgraphs to visualization tools or passing to other APOC procedures.
// Virtual node — does NOT persist to DB
WITH apoc.create.vNode(['Person'], {name: 'Alice', score: 0.9}) AS vn
RETURN vn
// Virtual relationship between two real nodes
MATCH (a:Person {id: $a}), (b:Person {id: $b})
WITH a, b, apoc.create.vRelationship(a, 'SIMILAR_TO', {score: 0.85}, b) AS vr
RETURN a, vr, b
// Virtual subgraph from Cypher statement
CALL apoc.graph.fromCypher(
'MATCH (a:Person)-[r:KNOWS]->(b:Person) WHERE a.age < 30 RETURN a, r, b',
{}, 'youngNetwork', {}
) YIELD graph RETURN graph---
Path Expanders
Variable-depth traversal with label/rel-type filters. Use when depth is unknown at write time or needs runtime configuration.
expandConfig — flexible traversal
// apoc.path.expandConfig(startNode, config) :: (path)
MATCH (start:Person {id: $id})
CALL apoc.path.expandConfig(start, {
minLevel: 1,
maxLevel: 3,
relationshipFilter: 'KNOWS>|WORKS_AT', // direction: > out, < in, omit = both
labelFilter: '+Person|+Company|-Blocked', // + whitelist, - blacklist
uniqueness: 'NODE_GLOBAL', // NODE_GLOBAL|RELATIONSHIP_GLOBAL|NODE_PATH
bfs: true,
limit: 100
}) YIELD path RETURN pathsubgraphAll — all nodes + rels in subgraph
// Returns LIST<NODE> + LIST<RELATIONSHIP>
MATCH (root:Company {id: $id})
CALL apoc.path.subgraphAll(root, {
maxLevel: 2,
relationshipFilter: 'SUBSIDIARY_OF|OWNS'
}) YIELD nodes, relationships RETURN nodes, relationshipsspanningTree — spanning tree paths
MATCH (root:Person {id: $id})
CALL apoc.path.spanningTree(root, {
maxLevel: 3,
relationshipFilter: 'FOLLOWS>'
}) YIELD path RETURN pathlabelFilter syntax: +WhitelistLabel, -BlacklistLabel, >TerminatorLabel, /EndNodeLabel.
---
Triggers
Fire Cypher on write events. Require apoc.trigger.enabled=true in apoc.conf.
For Neo4j 2025.x / Cypher 25: use apoc.trigger.install (system db) + apoc.trigger.list. apoc.trigger.add / apoc.trigger.remove / apoc.trigger.pause were removed in Cypher 25.
// Install — run from system database
USE system
CALL apoc.trigger.install(
'neo4j', // target database
'stamp-created', // trigger name
'UNWIND $createdNodes AS n SET n.createdAt = datetime()',
{phase: 'before'} // before | after | rollback | afterAsync
) YIELD name, installed RETURN name, installed
// List triggers for current database
CALL apoc.trigger.list()
YIELD name, query, selector, installed, paused RETURN *
// Pause / drop (system db)
USE system
CALL apoc.trigger.pause('neo4j', 'stamp-created')
CALL apoc.trigger.drop('neo4j', 'stamp-created')Available bindings in trigger statement: $createdNodes, $deletedNodes, $assignedLabels, $removedLabels, $assignedNodeProperties, $removedNodeProperties, $createdRelationships, $deletedRelationships.
---
Conditional Execution
// apoc.do.when — read/write branching
CALL apoc.do.when(
size($ids) > 0,
'MATCH (n:Person) WHERE n.id IN $ids SET n.active = true RETURN count(n)',
'RETURN 0 AS count',
{ids: $ids}
) YIELD value RETURN value
// apoc.do.case — multi-branch
CALL apoc.do.case(
[$score > 0.9, 'RETURN "high" AS tier',
$score > 0.5, 'RETURN "mid" AS tier'],
'RETURN "low" AS tier',
{score: $score}
) YIELD value RETURN value.tierapoc.do.when / apoc.do.case execute write Cypher; apoc.when / apoc.case are read-only variants.
Both deprecated in Cypher 25 — use native CASE + conditional CALL { ... } or OPTIONAL CALL.
---
Collections
// Flatten nested list
RETURN apoc.coll.flatten([[1,2],[3,[4,5]]], true) // [1,2,3,4,5]
// Distinct union of two lists
RETURN apoc.coll.union([1,2,3], [2,3,4]) // [1,2,3,4]
// Deduplicate list
RETURN apoc.coll.toSet([1,2,2,3]) // [1,2,3]flatten and toSet deprecated in Cypher 25 — use apoc.coll.flatten only for deeply nested lists where the native [x IN list | ...] flattening is insufficient.
---
Maps
// Merge two maps (right overwrites left on key collision)
RETURN apoc.map.merge({a:1, b:2}, {b:3, c:4}) // {a:1, b:3, c:4}
// Build map from list of [key, value] pairs
RETURN apoc.map.fromPairs([['k1',1],['k2',2]]) // {k1:1, k2:2}
// Extract sub-map by keys
RETURN apoc.map.submap({a:1,b:2,c:3}, ['a','c']) // {a:1, c:3}---
JSON Conversion
// Serialize any Cypher value to JSON string
MATCH (n:Event {id: $id})
RETURN apoc.convert.toJson(n{.*})
// Parse JSON string → Cypher list
WITH '[{"name":"Alice"},{"name":"Bob"}]' AS raw
RETURN apoc.convert.fromJsonList(raw, '$[*].name', []) // ['Alice','Bob']
// Parse JSON string → Cypher map
WITH '{"score":0.9,"tier":"A"}' AS raw
RETURN apoc.convert.fromJsonMap(raw, null, [])---
Date / Time Utilities
apoc.date.* deprecated in Cypher 25 — use native datetime(), date(), duration. Use APOC date only when parsing non-ISO legacy format strings or converting epoch integers.
// Parse legacy date string → epoch ms
RETURN apoc.date.parse('2024-03-15 09:00:00', 'ms', 'yyyy-MM-dd HH:mm:ss')
// Format epoch ms → string
RETURN apoc.date.format(1710489600000, 'ms', 'yyyy-MM-dd', 'UTC')
// Convert between units (ms → s)
RETURN apoc.date.convert(1710489600000, 'ms', 's')---
String Utilities
// Split by regex
RETURN apoc.text.split('a,b,,c', ',', 0) // ['a','b','','c']
// Join list of strings
RETURN apoc.text.join(['foo','bar','baz'], '-') // 'foo-bar-baz'
// URL-safe slug
RETURN apoc.text.slug('Hello World! 2025', '-') // 'hello-world-2025'
// Regex capture groups
RETURN apoc.text.regexGroups('2025-04-01', '(\\d{4})-(\\d{2})-(\\d{2})')
// [['2025-04-01','2025','04','01']]---
Node Lookup by ID
// Fetch nodes by internal id list
CALL apoc.nodes.get([123, 456, 789]) YIELD node RETURN nodePrefer elementId(n) over integer IDs — stable across restores.
---
Export
Requires apoc.export.file.enabled=true in apoc.conf. Pass {stream:true} to return data inline instead of file output.
// Export query results to CSV (inline)
CALL apoc.export.csv.query(
'MATCH (p:Person) RETURN p.name AS name, p.age AS age',
null,
{stream: true}
) YIELD data RETURN data
// Export to JSON file
CALL apoc.export.json.query(
'MATCH (n:Event)-[r:ATTENDED_BY]->(p:Person) RETURN n, r, p',
'/var/lib/neo4j/import/events.json',
{}
) YIELD file, nodes, rels, properties RETURN *
// Export as Cypher CREATE/MERGE statements
CALL apoc.export.cypher.query(
'MATCH (n:Config) RETURN n',
'/var/lib/neo4j/import/config.cypher',
{format: 'cypher-shell'} // cypher-shell | plain | neo4j-shell
) YIELD file RETURN file---
Deprecation Summary (Cypher 25)
| Deprecated | Replacement |
|---|---|
apoc.trigger.add / .remove / .pause | apoc.trigger.install / .drop / .pause (system db) |
apoc.do.when / apoc.do.case | Native CASE + conditional CALL {} |
apoc.coll.flatten (simple) | `[x IN nested |
apoc.coll.toSet | apoc.coll.toSet still works; or DISTINCT in collect |
apoc.date.parse / .format / .convert | datetime(), date(), duration() native functions |
apoc.periodic.iterate | CALL { ... } IN TRANSACTIONS OF N ROWS |
---
WebFetch
| Need | URL |
|---|---|
| Full procedure list | https://neo4j.com/docs/apoc/current/overview/ |
| Path expander config | https://neo4j.com/docs/apoc/current/graph-querying/path-expander/ |
| Trigger reference | https://neo4j.com/docs/apoc/current/background-operations/triggers/ |
| Refactoring ops | https://neo4j.com/docs/apoc/current/graph-refactoring/ |
| Export config | https://neo4j.com/docs/apoc/current/export-import/ |
Cypher Syntax Reference
Full syntax reference for clauses, patterns, and functions. Version markers: [2025.01] = new/changed in Cypher 25 / Neo4j 2025.x — older models default to the pre-2025 form. [2026.01] = requires Neo4j 2026.x. Unmarked = stable pre-2025, well-known priors.
---
Index and Constraint Types
Index decision table
| Index type | Best for | CONTAINS/ENDS WITH | Spatial | Fulltext |
|---|---|---|---|---|
RANGE | =, >, <, STARTS WITH, IS NOT NULL | Slow (use TEXT instead) | ❌ | ❌ |
TEXT | CONTAINS, ENDS WITH, = on strings, list IN with strings | ✅ | ❌ | ❌ |
POINT | point.distance(), point.withinBBox() | ❌ | ✅ | ❌ |
FULLTEXT | Lucene tokenized search; multiple labels/props | ❌ | ❌ | ✅ |
COMPOSITE | Queries always testing 2+ properties together | — | ❌ | ❌ |
Create syntax:
CREATE RANGE INDEX name IF NOT EXISTS FOR (n:Label) ON (n.prop)
CREATE TEXT INDEX name IF NOT EXISTS FOR (n:Label) ON (n.prop)
CREATE POINT INDEX name IF NOT EXISTS FOR (n:Label) ON (n.prop)
CREATE COMPOSITE INDEX name IF NOT EXISTS FOR (n:Label) ON (n.p1, n.p2)
CREATE FULLTEXT INDEX name IF NOT EXISTS FOR (n:Label|OtherLabel) ON EACH [n.p1, n.p2]
// Relationship index:
CREATE RANGE INDEX name IF NOT EXISTS FOR ()-[r:TYPE]-() ON (r.prop)Constraint types
// Uniqueness (+ implicitly creates RANGE index)
CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE n.prop IS UNIQUE
// Existence (node property must not be null)
CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE n.prop IS NOT NULL
// Relationship existence
CREATE CONSTRAINT name IF NOT EXISTS FOR ()-[r:TYPE]-() REQUIRE r.prop IS NOT NULL
// Node key = unique + existence (Enterprise only)
CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE n.prop IS NODE KEY
// Multi-property node key:
CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE (n.p1, n.p2) IS NODE KEY
// Relationship key (Enterprise only)
CREATE CONSTRAINT name IF NOT EXISTS FOR ()-[r:TYPE]-() REQUIRE r.prop IS RELATIONSHIP KEYRules:
- Add uniqueness constraint on MERGE key before loading data
IF NOT EXISTSprevents error on re-runSHOW CONSTRAINTS YIELD name, typeto inspect
---
MERGE Safety
// DO: MERGE on constrained key only; set other properties 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()
// DON'T: MERGE on multiple non-constrained properties -- can create duplicates
// DON'T: MERGE a full path with unbound endpoints -- creates ghost nodes
// DON'T: MERGE key properties that are not in a constraint -- slow and creates duplicates---
Property Updates
SET n = {} replaces all properties (destructive). SET n += {} merges (additive — unmentioned properties are preserved).
// SET = replaces -- wipes all other properties not in the map
CYPHER 25
MATCH (n:Person {id: $id})
SET n = {name: $name, age: $age} // every other property is removed
// SET += merges -- safe partial update
CYPHER 25
MATCH (n:Person {id: $id})
SET n += {name: $name} // other properties preserved
// Bulk import with parameter map -- set all map keys onto node
CYPHER 25
UNWIND $rows AS row
MERGE (n:Person {id: row.id})
SET n += row---
DELETE and REMOVE
// DETACH DELETE -- removes node AND all its relationships
CYPHER 25
MATCH (n:TempNode {id: $id})
DETACH DELETE n
// DELETE relationship only
CYPHER 25
MATCH (a:Person {id: $a})-[r:KNOWS]->(b:Person {id: $b})
DELETE r
// Plain DELETE on a node with relationships -> runtime error; always DETACH DELETE nodes
// REMOVE a property (sets it absent -- not null, absent)
CYPHER 25
MATCH (n:Person {id: $id})
REMOVE n.nickname
// REMOVE a label
CYPHER 25
MATCH (n:Person {id: $id})
REMOVE n:VIPMember
// Remove ALL properties -- SET to empty map (REMOVE cannot do this)
CYPHER 25
MATCH (n:Person {id: $id})
SET n = {}---
WITH Scope and Aggregation
WITH defines a new scope — every variable not listed is dropped. Use WITH * to carry all forward.
// Variable 'b' dropped after WITH
CYPHER 25
MATCH (a:Person)-[:KNOWS]->(b:Person)
WITH a, count(*) AS friends // 'b' is out of scope after this line
WHERE friends > 5
RETURN a.name, friends
ORDER BY friends DESCWITH resets aggregation scope — filter on aggregates before further traversal:
CYPHER 25
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
WITH p, count(m) AS movieCount
WHERE movieCount > 3
MATCH (p)-[:KNOWS]->(f:Person) // second MATCH uses filtered 'p'
RETURN p.name, f.name*`count() vs count(expr)**: count(*) counts all rows including nulls; count(n) counts only non-null values. Use count(DISTINCT n.prop)` to deduplicate.
Aggregation grouping keys: every non-aggregating expression in RETURN/WITH is implicitly a grouping key.
---
ORDER BY
- No
AS aliasin ORDER BY items —ORDER BY n.prop DESCnotORDER BY n.prop AS p DESC - No
NULLS LAST/NULLS FIRST— SQL syntax; nulls sort last ascending / first descending by default - After aggregation, sort by the RETURN alias, not the pre-aggregation variable
// DO:
CYPHER 25
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
RETURN p.name, count(m) AS movies
ORDER BY movies DESC, p.name ASC
LIMIT 10---
Conditional Expressions
// Generic CASE (if-elseif-else)
CYPHER 25
MATCH (n:Movie)
RETURN n.title,
CASE
WHEN n.rating >= 8 THEN 'great'
WHEN n.rating >= 6 THEN 'good'
ELSE 'skip'
END AS verdict
// Simple CASE (switch on one expression)
RETURN n.status,
CASE n.status
WHEN 'A' THEN 'Active'
WHEN 'I' THEN 'Inactive'
ELSE 'Unknown'
END AS labelNo least() / greatest() — use CASE WHEN a < b THEN a ELSE b END.
Conditional counting — count(x WHERE ...) is SQL, not Cypher:
// DO:
sum(CASE WHEN r.rating = 5 THEN 1 ELSE 0 END) AS fiveStarCount
COUNT { MATCH (r:Review) WHERE r.rating = 5 } AS fiveStarCount---
Null Handling
WHERE n.email IS NOT NULL // correct
WHERE n.email = null // always null, never matches
// coalesce() -- returns first non-null argument
RETURN coalesce(n.nickname, n.name) AS displayNamecollect() and aggregation functions ignore null values. null = null is null (not true). WHERE treats null as false.
---
Type Coercion
Prefer OrNull variants — return null on unconvertible input instead of throwing [2025.01; pre-2025 base forms throw]:
toIntegerOrNull(n.score)
toFloatOrNull(n.score)
toBooleanOrNull(n.flag)
toStringOrNull(n.value)Type predicates for mixed-type properties: [2025.01]
MATCH (n:Event)
WHERE n.value IS :: INTEGER NOT NULL // true only for non-null INTEGER values
RETURN n.name, n.valueDateTime vs date() mismatch: datetime_prop >= date('2025-01-01') returns 0 rows — use .year accessor or datetime() literals for ZONED DATETIME properties.
GQL compliance aliases [2026.02–04] — valid syntax, but use the Cypher form in new code:
| GQL alias | Cypher equivalent |
|---|---|
FOR x IN list | UNWIND list AS x |
PROPERTY_EXISTS(n, 'prop') | n.prop IS NOT NULL |
n IS [NOT] LABELED Label | n:Label / NOT n:Label |
FILTER | WHERE |
LET x = expr | WITH expr AS x |
GQL function aliases [2026.02]: ceiling, ln, local_time, local_datetime, zoned_time, zoned_datetime, duration_between, path_length, collect_list, percentile_cont, percentile_disc, stdev_samp, stdev_pop | ceil, log, localtime, localdatetime, time, datetime, duration.between, length, collect, percentileCont, percentileDisc, stDev, stDevP |
---
List Expressions
[x IN list WHERE x > 0] // filter only
[x IN list | x * 2] // transform only
[x IN list WHERE x > 0 | x * 2] // filter + transform
ANY(x IN list WHERE x > 0)
ALL(x IN list WHERE x > 0)
NONE(x IN list WHERE x > 0)
SINGLE(x IN list WHERE x > 0)
size(list)
head(list) / tail(list) / last(list)
list[0..3] // slice
list + [newElement]
coll.sort(list) // [2025.01] native — replaces apoc.coll.sort()2 IN [1, null, 3] returns null — guard with IS NOT NULL before membership tests.
Pattern comprehension:
MATCH (n:Person {id: $id})
RETURN [(n)-[:KNOWS]->(f:Person) | f.name] AS friends,
[(n)-[:ACTED_IN]->(m:Movie) WHERE m.year > 2020 | m.title] AS recentFilmsUse pattern comprehensions for simple one-hop inline collections; for multi-step traversals use COLLECT { MATCH ... RETURN ... }.
---
String Functions
toLower(s) / toUpper(s) // case conversion (lower/upper are GQL aliases) [2025.01: lower()/upper() added as aliases]
trim(s) / ltrim(s) / rtrim(s) // strip whitespace; btrim(s, 'xy') strips custom chars [2025.01: btrim]
split(s, delimiter) // returns LIST<STRING>
substring(s, start, length) // 0-indexed; length optional
left(s, n) / right(s, n) // first/last n characters
replace(s, search, replacement) // replace all occurrences
size(s) // character count (same as char_length)
reverse(s) // reverse string
toString(x) / toStringOrNull(x) // convert any type to STRING
string.indexOf(input, value) // index of first match, -1 if absent [2026.05, Cypher 25]
string.join(list, delimiter) // join LIST<STRING> with delimiter [2026.05, Cypher 25]
string.regexReplace(original, regex, repl) // regex replace all matches [2026.05, Cypher 25]All string functions return null when any argument is null.
---
Introspection Functions
labels(n) // LIST<STRING> of all labels
type(r) // STRING relationship type name
keys(n) // LIST<STRING> of property keys
properties(n) // MAP of all properties
elementId(n) // STRING internal ID [replaces deprecated id(n) — pre-2025 models generate id()]---
FOREACH vs UNWIND
| Use | When |
|---|---|
| `FOREACH (x IN list \ | write-clause)` |
UNWIND list AS x | Need to read, filter, or return list items |
FOREACH cannot be followed by RETURN or WITH. When in doubt, use UNWIND.
// FOREACH -- side-effect only
CYPHER 25
MATCH p = (a:Person {name:'Alice'})-[:KNOWS*1..3]->(b:Person)
FOREACH (n IN nodes(p) | SET n.visited = true)
// UNWIND -- when you need to process and return
CYPHER 25
UNWIND $items AS item
WITH item WHERE item.active = true
MERGE (n:Item {id: item.id})
ON CREATE SET n.name = item.name
RETURN count(n) AS created---
OPTIONAL MATCH
Returns null for the optional pattern rather than eliminating the row.
CYPHER 25
MATCH (p:Person {id: $id})
OPTIONAL MATCH (p)-[:MANAGES]->(d:Department)
RETURN p.name, d.name AS department // d.name is null when no match
// Boolean check -- use EXISTS instead of OPTIONAL MATCH
RETURN p.name, EXISTS { (p)-[:MANAGES]->() } AS isManagerDo NOT chain multiple OPTIONAL MATCH for nested optional data — each fan-out multiplies row count. Use COLLECT {} instead.
---
UNION and UNION ALL
UNION deduplicates (slow). UNION ALL keeps all rows (fast). Both branches must return identical column names and count.
CYPHER 25 // prefix only on first branch
MATCH (n:Employee) RETURN n.name AS name, n.email AS email
UNION ALL
MATCH (n:Contractor) RETURN n.name AS name, n.email AS emailSHOW commands cannot be combined with UNION. Never repeat CYPHER 25 on subsequent branches.
---
Spatial / Point
// Create a point (WGS84 geographic)
point({longitude: -122.4194, latitude: 37.7749}) // 2D
point({longitude: -122.4194, latitude: 37.7749, height: 100}) // 3D
// Create a point (Cartesian)
point({x: 1.5, y: 2.3}) // 2D cartesian
point({x: 1.5, y: 2.3, z: 4.0}) // 3D cartesian
// Store on node
MATCH (p:Location {id: $id})
SET p.coords = point({longitude: $lon, latitude: $lat})
// Distance in metres
MATCH (a:Location) WHERE a.name = 'HQ'
MATCH (b:Location)
RETURN b.name, point.distance(a.coords, b.coords) AS distM
ORDER BY distM LIMIT 10
// Bounding-box filter before distance (uses POINT index)
MATCH (b:Location)
WHERE point.withinBBox(b.coords,
point({longitude: -123.0, latitude: 37.0}),
point({longitude: -122.0, latitude: 38.0}))
RETURN b.name, point.distance(b.coords, $origin) AS distMPOINT index (required for fast spatial queries):
CREATE POINT INDEX location_coords IF NOT EXISTS
FOR (n:Location) ON (n.coords)Point components: .x / .y / .z (Cartesian) and .longitude / .latitude / .height (WGS84).
---
Date and Time
date() // DATE
datetime() // ZONED DATETIME
localdatetime() // LOCAL DATETIME
localtime() // LOCAL TIME
date('2025-01-15')
datetime('2025-01-15T10:30:00+02:00')
duration({days: 7, hours: 2})
n.birthDate.year / .month / .day
n.createdAt.hour / .minute / .second / .timezone
date() + duration({months: 3})
duration.between(date1, date2)
date.truncate('month', date()) // first day of current monthType rule: ZONED DATETIME properties must be compared with datetime() literals, not date() — mixing types returns 0 rows.
Duration components: .years, .months, .days, .hours, .minutes, .seconds — .inDays / .inMonths / .inSeconds do NOT exist.
---
LOAD CSV
// With headers
CYPHER 25
LOAD CSV WITH HEADERS FROM 'file:///persons.csv' AS row
MERGE (p:Person {id: toInteger(row.id)})
SET p.name = row.name, p.score = toFloat(row.score)
// Large files -- always wrap in CALL IN TRANSACTIONS
CYPHER 25
LOAD CSV WITH HEADERS FROM 'file:///large.csv' AS row
CALL (row) {
MERGE (p:Person {id: row.id})
SET p += row
} IN TRANSACTIONS OF 1000 ROWS ON ERROR CONTINUEAll CSV fields are STRING — coerce explicitly. PERIODIC COMMIT deprecated; use CALL IN TRANSACTIONS.
---
Subqueries [2025.01]
Expression subqueries (auto-import outer variables — no WITH needed):
EXISTS { (a)-[:R]->(b) }
EXISTS { MATCH (a)-[:R]->(b) WHERE a.x > 0 }
NOT EXISTS { (a)-[:R]->(b) }
COUNT { (a)-[:R]->(b) WHERE a.x > 0 }
COLLECT { MATCH (a)-[:R]->(b) RETURN b.name } // COLLECT: full MATCH+RETURN required
// COLLECT { (a)-[:R]->(b) } // SYNTAX ERROR -- bare pattern invalidCOLLECT {} returns exactly one column.
`CALL` subqueries — outer variables NOT auto-imported; declare explicitly in CALL (x) { ... }:
CYPHER 25
MATCH (p:Person)
CALL (p) {
MATCH (p)-[:ACTED_IN]->(m:Movie)
RETURN count(m) AS movieCount
}
RETURN p.name, movieCount
// CALL (*) imports all outer variables; CALL () imports nothing
// CALL { WITH x ... } deprecated [pre-2025 form] -- use CALL (x) { ... } [2025.01]| Goal | Use |
|---|---|
| Boolean existence check | EXISTS { (a)-[:R]->(b) } |
| Count matching subgraph | COUNT { (a)-[:R]->(b) } |
| Collect related items into a list | COLLECT { MATCH (a)-[:R]->(b) RETURN b.name } |
| Nullable join | OPTIONAL MATCH (simple) or OPTIONAL CALL (complex) |
| Subquery with own aggregation or writes | CALL (x) { ... } |
---
Quantified Path Expressions (QPEs) [2025.01 — replaces shortestPath()/allShortestPaths() and [:R*m..n] syntax]
// Reachability: 1-3 hops with relationship predicate
CYPHER 25
MATCH (start:Person {name: 'Alice'})
(()-[rel:KNOWS WHERE rel.since > date('2024-01-01')]->(:Person)){1,3}
(end)
WITH DISTINCT end
RETURN end.name
// Inner variables become lists -- access with list comprehension
CYPHER 25
MATCH (src:Person {name: 'Alice'})
((n:Person)-[:KNOWS]->()){1,3}(dst:Person)
RETURN [x IN n | x.name] AS via, dst.name AS reachedSyntax rules:
- Prefer
{1,}over+,{0,}over* - Quantifier goes outside the group:
(pattern){N,M} - Groups must start AND end with a node
Match modes [2025.01] (immediately after MATCH):
| Mode | Semantics |
|---|---|
DIFFERENT RELATIONSHIPS | Default — each relationship traversed at most once per path |
REPEATABLE ELEMENTS | Nodes AND relationships may be revisited; requires bounded {m,n} |
ACYCLIC [2026.03] | No repeated nodes within a path; GQL path mode — prevents cycles |
ACYCLIC is placed before the path pattern: MATCH p = ACYCLIC (a)-[:R]-+(b). Nodes cannot repeat within a path; may still repeat across paths (equijoins work).
Path selectors (immediately after MATCH, before the pattern):
| Selector | Semantics |
|---|---|
SHORTEST 1 | One shortest path |
ALL SHORTEST | All shortest paths of equal minimum length |
ANY | Any single path (no length guarantee) |
SHORTEST k GROUPS | All paths grouped by length up to k distinct lengths |
Path modes combine with shortest selectors [2026.05]: MATCH ANY SHORTEST ACYCLIC (a)-[:R]-+(b) — ACYCLIC valid with ANY SHORTEST, SHORTEST k, ALL SHORTEST, SHORTEST k GROUPS.
CYPHER 25 MATCH SHORTEST 1 (a:Person {name:'Alice'})(()-[:KNOWS]->()){1,}(b:Person {name:'Bob'})
RETURN b.name---
Dynamic Labels and Properties [2025.01]
// Filter by dynamic label
CYPHER 25
MATCH (n)
WHERE n:$($label)
RETURN n
// Set label dynamically
CYPHER 25
MATCH (n:Pending)
SET n:$(n.category)
// Dynamic property key -- bracket notation required
CYPHER 25
MATCH (n:Config)
RETURN n[$key]
MATCH (n:Config {id: $id})
SET n[$key] = $value
// DON'T: SET n.$key = $value // SyntaxError
// Copy properties between elements
SET n = properties(r)
// DON'T: SET n = r // TypeError -- assigns reference, not properties---
SEARCH Clause (Vector/Fulltext Search) [2026.01]
// Node vector index
CYPHER 25
MATCH (c:Chunk)
SEARCH c IN (VECTOR INDEX news FOR $embedding LIMIT 10)
SCORE AS score
WHERE score > 0.8
RETURN c.text, score
ORDER BY score DESC
// Procedure fallback (pre-2026.01):
CYPHER 25 CALL db.index.vector.queryNodes('news', 10, $embedding) YIELD node AS c, score RETURN c.text, score
// Fulltext -- always use procedure regardless of version:
CYPHER 25 CALL db.index.fulltext.queryNodes('entity', $query) YIELD node, score RETURN node.name, score LIMIT 20SEARCH syntax: binding variable only (not (c)); LIMIT inside parens; SCORE AS after closing paren.
---
CALL IN TRANSACTIONS (write batching only) [2025.01: CONCURRENT, REPORT STATUS added; PERIODIC COMMIT removed]
Input stream must be outside the subquery — filtering inside collapses everything into one transaction.
// Basic batch update
CYPHER 25
MATCH (c:Customer)
CALL (c) {
SET c.flag = 'done'
} IN TRANSACTIONS OF 1000 ROWS
RETURN count(c)
// With error handling and status reporting
CYPHER 25
LOAD CSV WITH HEADERS FROM 'file:///data.csv' AS row
CALL (row) {
MERGE (p:Person {id: row.id})
ON CREATE SET p.name = row.name
} IN TRANSACTIONS OF 500 ROWS
ON ERROR CONTINUE
REPORT STATUS AS s
WITH s WHERE s.errorMessage IS NOT NULL
RETURN s.transactionId, s.errorMessage
// Parallel batches
CYPHER 25
UNWIND $rows AS row
CALL (row) {
MERGE (:Movie {id: row.id})
} IN 4 CONCURRENT TRANSACTIONS OF 10 ROWS
ON ERROR CONTINUEIN TRANSACTIONS comes after the { } block. Read-only use prohibited. Requires auto-commit — do not wrap in beginTransaction().
ON ERROR options: FAIL (default) | CONTINUE (skip failed batch) | BREAK (stop after first error) | RETRY FOR N SECS [2025.03+]
---
Conditional CALL Subqueries (WHEN…THEN…ELSE) [2025.06 / Neo4j 2025.06+]
If-else-if semantics in a single subquery block. Replaces multiple independent CALL blocks or complex CASE with side effects.
// Move a linked-list item: insert before/after depending on context
CYPHER 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)
}
}Rules:
- Branches receive only params declared in
CALL(params) - Mutually exclusive — first matching WHEN wins
- Each branch can contain full write clauses;
ELSEis optional - Cannot mix
WHEN...THENand regular subquery body in sameCALL
---
Label Pattern Expressions [Neo4j 5+]
Boolean logic on labels using | (OR), & (AND), ! (NOT):
// Nodes with label A OR B
MATCH (n:Person|Organization) RETURN n
// Nodes with label A AND B
MATCH (n:Employee&Manager) RETURN n
// Nodes with label A but NOT B
MATCH (n:Person&!VIP) RETURN n
// Complex expression
MATCH (n:Marvel|(DCComics&!Batman)) RETURN nDynamic label quantifiers in MATCH (require $() wrapper) [2025.01]:
// Node must have ALL labels in the list
MATCH (n:$all($labelList)) RETURN n
// Node must have ANY label in the list
MATCH (n:$any($labelList)) RETURN n---
Compact CASE WHEN [Neo4j 5+]
Multiple values and comparison operators in a single WHEN branch.
// Multiple values in WHEN (simple CASE)
MATCH (n:Event)
RETURN CASE n.dayOfWeek
WHEN 1, 7 THEN 'weekend'
WHEN 2, 3, 4, 5, 6 THEN 'weekday'
ELSE 'unknown'
END AS dayType
// Comparison operators in WHEN (generic CASE)
RETURN CASE n.age
WHEN > 65 THEN 'senior'
WHEN > 18 THEN 'adult'
WHEN < 0 THEN 'invalid'
ELSE 'minor'
END AS ageGroup---
String Normalization [Neo4j 5+]
normalize(s) converts to NFC Unicode — solves accented character comparison where identical glyphs have different code points:
// Match regardless of Unicode encoding differences (e.g., 'ö' as U+00F6 vs o + combining diacritic)
MATCH (c:City)
WHERE normalize(c.name) = normalize($cityName)
RETURN c
// Index on normalized form for consistent lookups
CREATE RANGE INDEX city_name IF NOT EXISTS FOR (c:City) ON (c.normalizedName)
MATCH (c:City) SET c.normalizedName = normalize(c.name)---
allReduce Function (Traversal State) [CYPHER 25]
Accumulates state during QPE traversal — mid-traversal filtering and stateful path constraints. Prunes invalid paths inline instead of post-filtering.
// Syntax: allReduce(accumulator = initial, var IN list | updateExpr, predicate)
// Returns true only if predicate holds for every intermediate accumulator value
// Example: track visited small nodes, require no revisits
CYPHER 25
MATCH REPEATABLE ELEMENTS path = (:Start)((xs:!End)--(:!Start)){0,100}(e:End)
WHERE allReduce(
visited = [],
x IN xs | CASE WHEN x:Big THEN visited ELSE visited + [x] END,
size(visited) <= size(apoc.coll.toSet(visited)) + 1
)
RETURN count(path)
// Example: stateful battery charge simulation during route traversal
CYPHER 25 runtime=parallel
MATCH REPEATABLE ELEMENTS p=(a:Geo {name: $src})(()-[r:ROAD|CHARGE]-(x:Geo)){1,12}(b:Geo {name: $dst})
WHERE allReduce(
curr = {soc: $initial_soc, mins: 0.0},
r IN relationships(p) |
CASE
WHEN r:ROAD THEN {soc: curr.soc - r.drain, mins: curr.mins + r.drive_mins}
WHEN r:CHARGE THEN {soc: curr.soc + r.charge, mins: curr.mins + r.charge_mins}
END,
$min_soc <= curr.soc <= $max_soc AND curr.mins <= $max_mins
)
RETURN p, reduce(d=0, r IN relationships(p) | d + r.drive_mins) AS total_mins
ORDER BY total_mins LIMIT 1allReduce is evaluated inline during path expansion — prunes branches early rather than filtering after full traversal.
---
NEXT Clause [CYPHER 25]
Chains query blocks without re-traversal; each block adds computed columns:
CYPHER 25
MATCH (a:Airport {iata: $src})-[r:FLIGHT]->(b:Airport {iata: $dst})
RETURN a, b, r
NEXT
RETURN a, b, r, r.duration + r.layover AS totalTime
ORDER BY totalTime ASC LIMIT 5GRAPH TYPE — Schema Enforcement DDL
PREVIEW feature — Neo4j 2026.02+
Enterprise Edition, Infinigraph Edition, all Aura tiers.
Syntax may change before GA. Not supported for production use.
Feedback: graphtype@neo4j.com
GRAPH TYPE consolidates what previously required dozens of individual CREATE CONSTRAINT / CREATE INDEX statements into a single declarative schema definition. It operates on an open model: validation applies only to defined elements; extra labels and properties on nodes/relationships are still allowed.
Underlying constraints generated by GRAPH TYPE are visible via SHOW CONSTRAINTS.
---
Lifecycle Commands
| Command | Purpose |
|---|---|
SHOW CURRENT GRAPH TYPE | Display the full enforced schema |
ALTER CURRENT GRAPH TYPE SET { … } | Replace/initialise the graph type (full redeclaration required) |
EXTEND GRAPH TYPE WITH { … } | Add new elements without touching existing ones |
DROP GRAPH TYPE ELEMENTS { … } | Remove schema enforcement; data is preserved |
---
Syntax
Define or replace the full schema
CYPHER 25
ALTER CURRENT GRAPH TYPE SET {
(person:Person {
id :: INTEGER NOT NULL,
name :: STRING NOT NULL,
born :: INTEGER
}) REQUIRE person.id IS KEY,
(movie:Movie {
movieId :: STRING NOT NULL,
title :: STRING NOT NULL,
released :: INTEGER
}) REQUIRE movie.movieId IS KEY,
// Label implication: every Crew node must also be a Person
(crew:Crew => :Person),
// Relationship type with enforced source and target node types
(:Person)-[:ACTED_IN { roles :: LIST<STRING> }]->(:Movie),
(:Person)-[:DIRECTED]->(:Movie),
(:Person)-[:KNOWS { since :: DATE }]->(:Person)
}Extend incrementally
CYPHER 25
EXTEND GRAPH TYPE WITH {
(producer:Producer {
name :: STRING NOT NULL
}) REQUIRE producer.name IS UNIQUE,
(:Producer)-[:PRODUCED]->(:Movie)
}Drop specific elements
CYPHER 25
DROP GRAPH TYPE ELEMENTS {
(:Producer)-[:PRODUCED]->(:Movie)
}Inspect current schema
CYPHER 25
SHOW CURRENT GRAPH TYPE---
Property Types
| Type keyword | Notes |
|---|---|
STRING | UTF-8 string |
INTEGER | 64-bit signed integer |
FLOAT | 64-bit float |
BOOLEAN | true / false |
DATE | Calendar date |
ZONED DATETIME | Datetime with timezone |
LOCAL DATETIME | Datetime without timezone |
DURATION | ISO 8601 duration |
POINT | Spatial point |
LIST<T> | Homogeneous list (e.g. LIST<STRING>) |
Append NOT NULL to prohibit null values: name :: STRING NOT NULL
---
Constraints Within GRAPH TYPE
| Syntax | Equivalent standalone constraint |
|---|---|
REQUIRE node.prop IS KEY | CREATE CONSTRAINT … REQUIRE n.prop IS NODE KEY |
REQUIRE node.prop IS UNIQUE | CREATE CONSTRAINT … REQUIRE n.prop IS UNIQUE |
prop :: TYPE NOT NULL (inside node def) | CREATE CONSTRAINT … REQUIRE n.prop IS NOT NULL |
---
Label Implications
(crew:Crew => :Person) // every :Crew node must also have :Person label
(admin:Admin => :Person:Staff) // multiple implied labelsThe implication is enforced on write — Neo4j rejects or auto-adds the implied label depending on configuration.
---
Relationship Type Enforcement
Specifying source and target node types constrains which nodes may participate:
(:Person)-[:KNOWS { since :: DATE }]->(:Person)Attempting to create a :KNOWS relationship from a :Movie to a :Person will be rejected.
---
When to Use GRAPH TYPE vs Individual Constraints
| Scenario | Recommendation |
|---|---|
| Greenfield project on 2026.02+, Enterprise/Aura | Use GRAPH TYPE — single source of truth for the schema |
| Existing database with many constraints already | Migrate incrementally with EXTEND GRAPH TYPE WITH |
| Neo4j < 2026.02 or Community Edition | Use CREATE CONSTRAINT IF NOT EXISTS per property |
| Production workload requiring stability | Wait for GA — PREVIEW syntax may change |
---
Fallback (pre-2026.02)
CREATE CONSTRAINT IF NOT EXISTS FOR (n:Person) REQUIRE n.id IS NODE KEY;
CREATE CONSTRAINT IF NOT EXISTS FOR (n:Movie) REQUIRE n.movieId IS UNIQUE;
CREATE CONSTRAINT IF NOT EXISTS FOR (n:Person) REQUIRE n.name IS NOT NULL;---
References
- Docs:
https://neo4j.com/docs/cypher-manual/current/schema/graph-types/ - Blog:
https://neo4j.com/blog/developer/graph-type-schema-enforcement-made-easy-preview/
Neo4j Indexes and Constraints
Why indexes are critical
Every MATCH, MERGE, or WHERE predicate on a node/relationship property requires an index on the initial lookup property (the anchor that starts traversal). Without one, Neo4j does a full AllNodesScan or AllRelationshipsScan.
Index requires a label. Without a label, Neo4j cannot identify which index to use and falls back to full scan even if an index exists.
// IGNORED: no label → no index used, full scan
MATCH (n {email: $email}) RETURN n.name, n.email
// USED: label present → RANGE/UNIQUE index on Person.email
MATCH (n:Person {email: $email}) RETURN n.name, n.email
// IGNORED in MERGE too: label required
MERGE (n {email: $email}) // full scan, no lock
MERGE (n:Person {email: $email}) // index lookup + constraint lockMERGE compounds this: MERGE (n:Person {email: $email}) = MATCH + CREATE IF NOT EXISTS. MATCH phase scans without an index. With a constraint, MERGE also acquires a lock on the constraint entry, preventing concurrent duplicate creation.
Single index per MATCH clause by default. Planner picks one anchor index for multi-predicate queries. Use USING INDEX hints to force multiple indexes in the same MATCH.
---
Index type decision table
| Query predicate | Index type | Notes |
|---|---|---|
prop = $val, prop > $val, prop < $val, prop >= $val, prop <= $val | RANGE | Numbers, dates, booleans, strings |
prop STARTS WITH $val | RANGE | Also supported by TEXT but RANGE is faster for prefix |
prop CONTAINS $val, prop ENDS WITH $val | TEXT | Uses trigram (text-2.0); RANGE does NOT support these efficiently |
prop IN [$a, $b] (string list) | TEXT | Faster than RANGE for string list membership |
prop IS NOT NULL | RANGE | Existence check with range index |
point.distance(n.loc, $pt) < $r, point.withinBBox(...) | POINT | Spatial queries |
| Full-text search, multiple labels/props, fuzzy, Lucene syntax | FULLTEXT | Returns score; not a filter index |
(n:Label) or ()-[r:TYPE]-() without property | LOOKUP | Always exists; covers label/type scans |
vector.similarity.*, SEARCH ... VECTOR INDEX | VECTOR | See neo4j-vector-index-skill |
| Multiple props on same label in AND | COMPOSITE | All composite props must appear in WHERE |
---
Index providers (internal implementations)
| Index type | Default provider | Notes |
|---|---|---|
| RANGE / UNIQUE / NODE KEY / COMPOSITE | range-1.0 | B-tree variant; all scalar types |
| TEXT | text-2.0 | Trigram-based — see section below |
| FULLTEXT | fulltext-1.0 | Apache Lucene (lucene+native-3.0) |
LOOKUP indexes (auto-created, two per database) have no user-configurable provider.
---
TEXT index — trigram internals
Default text-2.0 indexes STRING values as overlapping trigrams (3-Unicode-codepoint windows). Example: "developer" → ["dev","eve","vel","elo","lop","ope","per"].
CONTAINS "vel"/ENDS WITH "per"→ direct trigram lookup, O(1) index probe.STARTS WITHworks via trigram but RANGE is faster for prefix-only.- When both RANGE and TEXT exist on the same STRING property, planner auto-selects TEXT for
CONTAINS/ENDS WITH, RANGE forSTARTS WITH/=/range predicates. - TEXT takes less storage than RANGE for high-cardinality string data.
- TEXT may show higher db-hits but lower elapsed time vs RANGE for substring queries — measure elapsed ms, not db-hits.
text-1.0(pre-5.1) does NOT use trigrams — deprecated.
---
Create syntax
// RANGE (numbers, dates, booleans, strings: =, >, <, STARTS WITH, IS NOT NULL)
CREATE RANGE INDEX person_email IF NOT EXISTS FOR (n:Person) ON (n.email)
// RANGE on relationship
CREATE RANGE INDEX event_date IF NOT EXISTS FOR ()-[r:OCCURRED_ON]-() ON (r.date)
// RANGE composite (all listed props must appear in WHERE for planner to use it)
CREATE INDEX person_name_age IF NOT EXISTS FOR (n:Person) ON (n.name, n.age)
// RANGE composite on relationship
CREATE INDEX purchased_date_amount IF NOT EXISTS FOR ()-[r:PURCHASED]-() ON (r.date, r.amount)
// TEXT (string CONTAINS, ENDS WITH, IN list — trigram internally)
CREATE TEXT INDEX person_name_text IF NOT EXISTS FOR (n:Person) ON (n.name)
// TEXT on relationship
CREATE TEXT INDEX rates_interest IF NOT EXISTS FOR ()-[r:KNOWS]-() ON (r.interest)
// POINT (spatial)
CREATE POINT INDEX place_location IF NOT EXISTS FOR (n:Place) ON (n.location)
// POINT with spatial bounding box config (WGS-84 geographic CRS)
CREATE POINT INDEX place_wgs IF NOT EXISTS FOR (n:Place) ON (n.location)
OPTIONS {
indexConfig: {
`spatial.wgs-84.min`: [-180.0, -90.0],
`spatial.wgs-84.max`: [180.0, 90.0]
}
}
// Other spatial CRS config keys: spatial.cartesian.min/max, spatial.cartesian-3d.min/max, spatial.wgs-84-3d.min/max
// FULLTEXT (Lucene; multi-label, multi-prop, scored)
CREATE FULLTEXT INDEX search_articles IF NOT EXISTS
FOR (n:Article|BlogPost) ON EACH [n.title, n.body]
// FULLTEXT with analyzer + eventually-consistent background updates
CREATE FULLTEXT INDEX search_articles IF NOT EXISTS
FOR (n:Article|BlogPost) ON EACH [n.title, n.body]
OPTIONS {
indexConfig: {
`fulltext.analyzer`: 'english',
`fulltext.eventually_consistent`: true
}
}
// LOOKUP (auto-created per database — shown for reference only; do NOT drop or recreate)
CREATE LOOKUP INDEX node_label_lookup FOR (n) ON EACH labels(n)
CREATE LOOKUP INDEX rel_type_lookup FOR ()-[r]-() ON EACH type(r)FULLTEXT analyzer options
| Analyzer | Use case |
|---|---|
standard-no-stop-words | Default — general purpose, removes stop words |
english | English stemming (run/runs/running → same token) |
simple | Lowercase only, no stemming |
| Custom (Java SPI) | Implement AnalyzerProvider interface |
fulltext.eventually_consistent: true — index updated in background. Improves write throughput at cost of slight search lag.
---
Constraints
Enforce data integrity AND create an implicit RANGE index (UNIQUE, NODE KEY). Prefer constraint over bare index when uniqueness is required.
Edition notes: UNIQUE and NOT NULL available in all editions. NODE KEY, RELATIONSHIP KEY, RELATIONSHIP UNIQUE, property type (IS ::) require Enterprise Edition.
// UNIQUE node — creates implicit RANGE index; MERGE acquires lock
CREATE CONSTRAINT person_email_unique IF NOT EXISTS
FOR (n:Person) REQUIRE n.email IS UNIQUE
// UNIQUE composite node
CREATE CONSTRAINT book_title_year IF NOT EXISTS
FOR (n:Book) REQUIRE (n.title, n.publicationYear) IS UNIQUE
// UNIQUE relationship (Enterprise)
CREATE CONSTRAINT sequel_order IF NOT EXISTS
FOR ()-[r:SEQUEL_OF]-() REQUIRE r.order IS UNIQUE
// NODE KEY — composite uniqueness + existence; creates composite RANGE index (Enterprise)
CREATE CONSTRAINT person_key IF NOT EXISTS
FOR (n:Person) REQUIRE (n.firstName, n.lastName) IS NODE KEY
// RELATIONSHIP KEY (Enterprise)
CREATE CONSTRAINT owns_key IF NOT EXISTS
FOR ()-[r:OWNS]-() REQUIRE r.ownershipId IS RELATIONSHIP KEY
// NOT NULL node (existence only — no index created)
CREATE CONSTRAINT person_name_exists IF NOT EXISTS
FOR (n:Person) REQUIRE n.name IS NOT NULL
// NOT NULL relationship
CREATE CONSTRAINT wrote_year_exists IF NOT EXISTS
FOR ()-[r:WROTE]-() REQUIRE r.year IS NOT NULL
// PROPERTY TYPE node (Enterprise) — IS ::, IS TYPED, and :: are synonyms; IS :: is preferred
CREATE CONSTRAINT movie_title_type IF NOT EXISTS
FOR (n:Movie) REQUIRE n.title IS :: STRING
// PROPERTY TYPE relationship (Enterprise)
CREATE CONSTRAINT rating_type IF NOT EXISTS
FOR ()-[r:RATED]-() REQUIRE r.rating IS :: INTEGERSupported types for IS ::: BOOLEAN, STRING, INTEGER, FLOAT, DATE, LOCAL TIME, ZONED TIME, LOCAL DATETIME, ZONED DATETIME, DURATION, POINT, LIST<type>.
---
MERGE and constraints
MERGE = MATCH + conditional CREATE. Without an index/constraint on the merge property, MATCH scans all nodes of that label.
// Without constraint: full scan + no atomicity guarantee
MERGE (p:Person {email: $email})
// With UNIQUE constraint:
// 1. O(log n) lookup via implicit RANGE index
// 2. Lock on constraint entry → prevents concurrent duplicate creation
// 3. Atomic: two concurrent MERGEs cannot both create the same node
CREATE CONSTRAINT person_email_unique IF NOT EXISTS
FOR (n:Person) REQUIRE n.email IS UNIQUE
MERGE (p:Person {email: $email})
ON CREATE SET p.createdAt = datetime()
ON MATCH SET p.lastSeenAt = datetime()Merge on multiple properties without NODE KEY: planner may not use index. Merge only on the indexed property, set others after: MERGE (n:Label {keyProp: $val}) SET n.otherProp = $other
---
Fulltext search
Lucene — tokenized, scored, not a filter index. Result nodes must be joined back to the graph. Supports LIST<STRING> properties — each element analyzed independently.
// Create (multi-label, multi-prop)
CREATE FULLTEXT INDEX article_search IF NOT EXISTS
FOR (n:Article|BlogPost) ON EACH [n.title, n.body]
// Query nodes — returns node + score (descending)
CALL db.index.fulltext.queryNodes('article_search', 'graph database')
YIELD node, score
WHERE score > 0.5
RETURN node.title, score
ORDER BY score DESC LIMIT 10
// Query relationships
CALL db.index.fulltext.queryRelationships('rel_search', 'query string')
YIELD relationship, score
RETURN relationship, score
// Lucene query syntax:
// 'graph database' token AND (default)
// '"graph database"' exact phrase
// 'graph OR database' OR
// 'graph -relational' NOT
// 'graph~' fuzzy
// 'graph*' wildcard prefix
// 'title:graph' field-scoped search
// 'team:"Operations"' field + exact phraseFulltext index does NOT participate in WHERE predicate planning. Use CALL db.index.fulltext.queryNodes / queryRelationships explicitly.
---
Index hints (USING INDEX)
Force a specific index when the planner chooses a suboptimal plan. Use EXPLAIN first to confirm the issue.
// Generic hint — planner uses any available index on the property
MATCH (p:Person)
USING INDEX p:Person(email)
WHERE p.email = $email
RETURN p.name, p.email
// Force RANGE index specifically
MATCH (s:Scientist {born: 1850})
USING RANGE INDEX s:Scientist(born)
RETURN s.name, s.born
// Force TEXT index specifically
MATCH (c:Country)
USING TEXT INDEX c:Country(name)
WHERE c.name = 'Country7'
RETURN c.name, c.population
// Two hints in one query — forces both path ends to use their index (enables index join)
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)<-[:DIRECTED]-(p2:Person)
USING INDEX p:Person(name)
USING INDEX p2:Person(name)
WHERE p.name CONTAINS 'John' AND p2.name CONTAINS 'George'
RETURN p.name, p2.name, m.title
// Relationship index hint
MATCH (u:User)-[r:RATED]->(m:Movie)
USING INDEX r:RATED(rating)
WHERE r.rating = 5
RETURN u.name, r.rating, m.title
// Relationship TEXT index hint
MATCH (n:Inventor)-[i:INVENTED_BY]->(inv:Invention)
USING TEXT INDEX i:INVENTED_BY(location)
WHERE i.location = 'Location7'
RETURN n.name, inv.name, i.locationRules:
- Typed hints (
USING RANGE INDEX,USING TEXT INDEX) only valid when the planner can guarantee the type doesn't change results. - Hints do NOT guarantee improvement — PROFILE before/after; measure elapsed ms (not db-hits for TEXT).
- Index not used when predicate compares two node properties (
WHERE p.name = p2.name) — no anchor. - FULLTEXT has no
USING INDEXhint — calldb.index.fulltext.queryNodesexplicitly. - Check query stats first (
CALL db.stats.retrieve('GRAPH COUNTS')) before adding hints.
---
Inspect indexes and constraints
// All indexes — core fields
SHOW INDEXES YIELD name, type, state, labelsOrTypes, properties, populationPercent
WHERE state <> 'ONLINE' OR populationPercent < 100 // building or not ready
// Full details (includes: indexSize, lastRead, readCount, lastWrite, writeCount, indexConfig)
SHOW INDEXES YIELD *
// Filter by type
SHOW RANGE INDEXES YIELD name, state, labelsOrTypes, properties
SHOW TEXT INDEXES YIELD name, state, labelsOrTypes, properties
SHOW FULLTEXT INDEXES YIELD name, state, indexConfig
SHOW VECTOR INDEXES YIELD name, state, populationPercent, indexConfig
SHOW LOOKUP INDEXES YIELD name, state
// Unused index candidates (never read — review for removal)
SHOW INDEXES YIELD name, type, readCount, lastRead
WHERE readCount = 0 AND type <> 'LOOKUP'
RETURN name, type, lastRead
ORDER BY lastRead
// Constraints
SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties
// Check index used in query plan
EXPLAIN MATCH (p:Person {email: $email}) RETURN p
// 'NodeIndexSeek' or 'NodeUniqueIndexSeek' — index used ✓
// 'NodeIndexContainsScan' — TEXT index via CONTAINS ✓
// 'NodeByLabelScan' or 'AllNodesScan' — no index, add one
// PROFILE for timing (run twice; second run = true cost)
PROFILE MATCH (p:Person) WHERE p.name CONTAINS 'Robert' RETURN p.name---
Import pre-flight — create before loading
Create constraints and indexes before bulk import — MERGE during load uses the index for every row.
// 1. Uniqueness constraints first (implicit RANGE index)
CREATE CONSTRAINT person_id IF NOT EXISTS FOR (n:Person) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT movie_id IF NOT EXISTS FOR (n:Movie) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT org_name IF NOT EXISTS FOR (n:Org) REQUIRE n.name IS UNIQUE;
// 2. Additional lookup indexes (non-unique properties used in MATCH/WHERE)
CREATE RANGE INDEX person_email IF NOT EXISTS FOR (n:Person) ON (n.email);
CREATE TEXT INDEX movie_title IF NOT EXISTS FOR (n:Movie) ON (n.title);
// 3. Wait for all to be ONLINE before loading
SHOW INDEXES YIELD name, state WHERE state <> 'ONLINE' RETURN name, state;Performance Anti-Patterns
Load this when optimizing a slow query or reviewing a query before production use.
Anti-Patterns
Severity: [ALWAYS] fix unconditionally. [USUALLY] fix unless confirmed reason not to. [SITUATIONAL] profile first.
| Anti-Pattern | Severity | Problem | Fix |
|---|---|---|---|
MATCH (n) label-free | [ALWAYS] | AllNodesScan | Add label: MATCH (n:Person) — indexes require a label |
MATCH ()-[r]->() label-free rel | [ALWAYS] | Full rel scan | MATCH (n:User)-[r:POSTS]->() |
Assumed stored GDS props (n.pageRank) | [ALWAYS] | Property doesn't exist unless .write ran | Stream via .stream procedure |
CONTAINS/ENDS WITH without a text index | [ALWAYS] | Range index does not support these; causes full label scan | CREATE TEXT INDEX idx FOR (n:Label) ON (n.prop) |
MATCH (u)-[:R]->(t1), (u)-[:R]->(t2) WHERE t1 <> t2 | [USUALLY] | O(n²) pairs | collect(t) AS items WHERE size(items) >= 2 |
UNWIND list AS a UNWIND list AS b WHERE a <> b | [USUALLY] | O(n²) pairs | LIMIT before pairing, or sample list[0..10] |
Chained OPTIONAL MATCH for nested optional data | [USUALLY] | Fan-out multiplies row count | COLLECT { MATCH (a)-[:R]->(b) RETURN b } |
LIMIT only at final RETURN | [USUALLY] | Full traversal runs before limit | Push WITH n LIMIT 100 before expensive joins |
| Cartesian product (two MATCHes, no join) | [USUALLY] | Multiplies all rows | Add join predicate in WHERE |
→ See indexes.md for index type selection, MERGE lock semantics, hints, and SHOW INDEXES YIELD *.
Text indexes vs fulltext indexes
| Index type | Supports | Created with | Queried with |
|---|---|---|---|
| Text index | CONTAINS, ENDS WITH | CREATE TEXT INDEX idx FOR (n:Label) ON (n.prop) | Standard WHERE + optional hint |
| Fulltext index | Lucene tokenized search with scoring | `CREATE FULLTEXT INDEX idx FOR (n:Label1\ | Label2) ON EACH [n.prop1, n.prop2]` |
// Text index
CREATE TEXT INDEX person_bio FOR (n:Person) ON (n.bio)
MATCH (n:Person) USING TEXT INDEX n:Person(bio) WHERE n.bio CONTAINS $s RETURN n
// Fulltext index
CREATE FULLTEXT INDEX entity FOR (n:Person|Company) ON EACH [n.name, n.description]
CALL db.index.fulltext.queryNodes('entity', $query) YIELD node, score
RETURN node.name, score ORDER BY score DESC LIMIT 20EXPLAIN / PROFILE red flags: AllNodesScan, CartesianProduct, NodeByLabelScan, Eager.
For analytics over large sets:
CYPHER 25 runtime=parallel
MATCH (n:Article)
RETURN count(n), avg(n.sentiment)Confirm with EXPLAIN — header must show Runtime PARALLEL. Only for large analytical scans; adds overhead for OLTP short-hop lookups.
Eager Operator
Eager materializes entire intermediate result in memory. Blocks streaming; causes heap pressure at scale.
Common triggers:
| Pattern | Why Eager appears | Fix |
|---|---|---|
MATCH (n:A) ... MERGE (:A {...}) | MERGE on same label as MATCH | collect first, then UNWIND+write |
UNWIND list MERGE (a:X) MERGE (b:X) | Two MERGEs on same label in one row | CALL IN TRANSACTIONS |
MATCH (n:A) CREATE (m:A) | CREATE on same label as MATCH | collect first |
| `FOREACH (x IN list \ | CREATE (:A))` | Write inside FOREACH visible to outer read |
MATCH (n:A)-[]-(m) MERGE (:A {name:'London'}) | Ambiguous label scope | Add specific label to MATCH nodes |
Fix 1: Add specific labels to disambiguate [official — LP Eagerness planner]
// BEFORE -- Eager: planner can't tell if new :City hits :LondonGroup MATCH
MATCH (station:LondonGroup)<-[:CALLS_AT]-(london_calling)
MERGE (london_calling)-[:CALLS_AT_CITY]->(city:City {name: 'London'})
// AFTER -- label :CallingPoint eliminates ambiguity; Eager removed
MATCH (station:LondonGroup)<-[:CALLS_AT]-(london_calling:CallingPoint)
MERGE (london_calling)-[:CALLS_AT_CITY]->(city:City {name: 'London'})Fix 2: collect first, then write
// BEFORE -- triggers Eager
MATCH (u:User {status: 'active'})
MERGE (u)-[:HAS_SESSION]->(s:Session {id: randomUUID()})
// AFTER
CYPHER 25
MATCH (u:User {status: 'active'})
WITH collect(u) AS users
UNWIND users AS u
MERGE (u)-[:HAS_SESSION]->(s:Session {id: randomUUID()})Fix 3: CALL IN TRANSACTIONS — isolates each batch; each transaction is independent
// BEFORE -- double Eager from two MERGEs on same label
CYPHER 25
UNWIND $pairs AS pair
MERGE (a:Person {id: pair.a})
MERGE (b:Person {id: pair.b})
MERGE (a)-[:KNOWS]->(b)
// AFTER
CYPHER 25
UNWIND $pairs AS pair
CALL (pair) {
MERGE (a:Person {id: pair.a})
MERGE (b:Person {id: pair.b})
MERGE (a)-[:KNOWS]->(b)
} IN TRANSACTIONS OF 500 ROWS---
Label Inference [Neo4j 5 / 2025.x]
When the planner underestimates selectivity on multi-label queries:
// Per-query hint
CYPHER inferSchemaParts = most_selective_label
MATCH (admin:Administrator {name: $adminName}),
(resource:Resource {name: $resourceName})
MATCH p=(admin)-[:MEMBER_OF]->()-[:ALLOWED_INHERIT]->(company)
-[:WORKS_FOR|HAS_ACCOUNT]-()-[:WORKS_FOR|HAS_ACCOUNT]-(resource)
RETURN count(p) AS accessCountInstance-wide config: dbms.cypher.infer_schema_parts = MOST_SELECTIVE_LABEL
Impact: uses existing statistics + advanced deduction; can improve OLTP plans from ~13ms → ~80µs on complex multi-hop patterns. Verify with EXPLAIN — plan should show index seeks, not NodeByLabelScan.
---
Batching Best Practices [Neo4j 5 / 2025.x]
Prefer native CALL IN TRANSACTIONS over apoc.periodic.iterate (APOC Core is maintenance-mode).
// Modern pattern — full planner visibility, accurate stats, memory tracking
CYPHER 25
MATCH (n:Person)
CALL (n) {
SET n.score = toInteger(rand() * 20 + 1)
} IN TRANSACTIONS OF 1000 ROWS
ON ERROR CONTINUE
REPORT STATUS AS s
WITH s WHERE s.errorMessage IS NOT NULL
RETURN s.transactionId, s.errorMessage
// Parallel batches [2025.01]
CYPHER 25
LOAD CSV WITH HEADERS FROM 'file:///data.csv' AS row
CALL (row) {
MERGE (:Movie {id: row.id})
} IN 4 CONCURRENT TRANSACTIONS OF 500 ROWS
ON ERROR RETRY FOR 30 SECSON ERROR options: FAIL (default) | CONTINUE | BREAK | RETRY FOR N SECS [2025.03+]
Advantages over apoc.periodic.iterate: memory tracking prevents OOM, planner shows execution plan, accurate query statistics, no double entity ID fetch.
Schema Guardrail Reference
Schema File
<db-name>-schema.json — name after your database (e.g. movies-schema.json). Place anywhere in the project.
Generate from existing database (requires APOC)
pip install neo4j python-dotenv
python scripts/generate_schema.py <db-name>.env (add to .gitignore):
NEO4J_URI=neo4j+s://<instance>.databases.neo4j.io
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=your-password
NEO4J_DATABASE=neo4jBuild interactively (no DB needed)
python scripts/define_schema.pyConvert from existing JSON schema
python scripts/import_neo4j_schema.py path/to/input-schema.jsonAuto-detects: neo4j-graphrag-python SchemaBuilder, graph-schema-introspector, graph-schema-json-js-utils, mcp-neo4j-data-modeling.
---
Schema Format (APOC meta.schema)
{
"schema_retrieved_at": "2026-06-06T10:00:00+00:00",
"value": {
"Theme": {
"type": "node",
"properties": {
"name": { "type": "STRING" },
"theme_id": { "type": "INTEGER" }
},
"relationships": {
"HAS_SET": { "direction": "out", "labels": ["Set"], "properties": {} }
}
},
"Set": {
"type": "node",
"properties": {
"name": { "type": "STRING" },
"id": { "type": "STRING" },
"year": { "type": "INTEGER" },
"pieces": { "type": "INTEGER" }
},
"relationships": {
"HAS_SET": { "direction": "in", "labels": ["Theme"], "properties": {} },
"HAS_MINIFIG": { "direction": "out", "labels": ["Minifig"],
"properties": { "quantity": { "type": "INTEGER" } } }
}
},
"Minifig": {
"type": "node",
"properties": {
"name": { "type": "STRING" },
"fig_num": { "type": "STRING" },
"num_parts": { "type": "INTEGER" }
}
},
"HAS_MINIFIG": {
"type": "relationship",
"properties": { "quantity": { "type": "INTEGER" } }
}
}
}---
Validation Rules
Reason about intent before asking. Ask only when unable to resolve — never generate wrong Cypher silently, but don't stop when a safe interpretation exists.
1. Existence — labels, rel-types, properties must be in schema. On miss: try synonym resolution → structural match → ask.
2. Synonym mapping
- Unambiguous → resolve silently:
ℹ️ Resolved 'Minifigure' → 'Minifig'. - Ambiguous → pick most likely from context, note:
ℹ️ 'Fig' → 'Minifig' (context). Correct if wrong. - No match → surface candidates:
⚠️ 'Character' not found. Did you mean: Theme, Set, Minifig?
3. Property type — valid types: STRING INTEGER FLOAT BOOLEAN DATE DATETIME LOCAL_DATETIME TIME LOCAL_TIME DURATION POINT LIST<TYPE>. On mismatch:
- String against INTEGER (
'unknown','n/a') → rewrite asIS NULLand note - Clearly wrong literal → propose correction and ask
4. Relationship direction — out = (a)-[:R]->(b), in = (b)-[:R]->(a). Wrong direction → correct silently, note:
HAS_SET | Schema: Theme──→Set | Prompt: Set──→Theme | ↩ Corrected5. Generate — Cypher 25; use literals for interactive execution, $param for code generation; return only schema-declared properties.
---
Examples
Valid query
User: "List minifigures in the Cloud City set"
✅ Set | ✅ Minifig | ✅ HAS_MINIFIG (Set→Minifig)
CYPHER 25
MATCH (s:Set {id: $setId})-[:HAS_MINIFIG]->(m:Minifig)
RETURN m.name AS minifigName, m.fig_num AS figNum, m.num_parts AS numParts
ORDER BY m.name
// Parameters: { setId: "10123-1" }Entity not found
User: "Find all Character nodes linked to a Movie"
❌ Character NOT FOUND | ❌ Movie NOT FOUND
Schema nodes: Theme, Set, Minifig
⚠️ Neither 'Character' nor 'Movie' exists in this schema.
Did you mean Set linked to Minifig, or are you querying a different database?Synonym resolved
User: "Find all Minifigures in a set"
ℹ️ Resolved 'Minifigure' → 'Minifig'. Proceeding.
CYPHER 25
MATCH (s:Set {id: $setId})-[:HAS_MINIFIG]->(m:Minifig)
RETURN m.name AS minifigName, m.fig_num AS figNum
// Parameters: { setId: $setId }Type mismatch
User: "Find sets where pieces is 'unknown'"
Set.pieces declared INTEGER, value 'unknown' is a STRING.
Interpreting as null/missing-value check.
ℹ️ Rewritten: WHERE s.pieces IS NULL. Correct if you meant something else.
CYPHER 25
MATCH (s:Set) WHERE s.pieces IS NULL RETURN s.name, s.id---
Commit or ignore schema.json file?
Commit when schema is stable and shared, or needed for CI without a live DB. Ignore (*-schema.json → .gitignore) when schema contains sensitive names or evolves rapidly.
schema_retrieved_at in the file records when the snapshot was taken.
Common Syntax Traps
Load this when debugging a syntax error or validating a query before returning it.
| Invalid | Correct |
|---|---|
ORDER BY n.prop AS alias DESC | ORDER BY n.prop DESC — AS not allowed in ORDER BY |
ORDER BY n.score DESC NULLS LAST | ORDER BY n.score DESC — NULLS LAST is SQL, not Cypher |
ORDER BY preAggVar after aggregating RETURN | Use the RETURN alias: RETURN count(m) AS cnt ORDER BY cnt |
count(r WHERE r.rating = 5) | sum(CASE WHEN r.rating = 5 THEN 1 ELSE 0 END) |
collect(x ORDER BY y) | Preceding ORDER BY y clause, or COLLECT { MATCH ... RETURN x ORDER BY y } |
rank() OVER (PARTITION BY ...) | Not valid — use collect({v:v}) AS ranked UNWIND range(0, size(ranked)-1) AS idx |
UNWIND list AS x WHERE x > 5 | UNWIND list AS x WITH x WHERE x > 5 |
FOREACH ... RETURN | Use UNWIND when you need RETURN |
least(a,b) / greatest(a,b) | CASE WHEN a < b THEN a ELSE b END |
-- SQL comment | // Cypher comment |
FILTER x IN list WHERE ... | [x IN list WHERE ...] — FILTER clause exists (Cypher 25 / 2025.06) but is not a list-comprehension form |
LET x = expr | LET clause valid in Cypher 25 (Neo4j 2025.06+); on older versions use WITH expr AS x |
INSERT (p:Person {name:'A'}) | INSERT is a Cypher 25 synonym for CREATE (Neo4j 2025.06+) but multi-labels must use & not : and dynamic labels/types are not supported; on older versions use CREATE (p:Person {name: 'A'}) |
shortestPath((a)-[*]->(b)) | SHORTEST 1 (a)(()-[]->()){1,}(b) |
allShortestPaths((a)-[*]->(b)) | ALL SHORTEST (a)(()-[]->()){1,}(b) |
id(n) | elementId(n) |
[:REL*1..5] | (()-[:REL]->()){1,5} |
CALL { WITH x ... } | CALL (x) { ... } — importing WITH is deprecated |
apoc.coll.sort(list) | coll.sort(list) — native Cypher 25 built-in |
n.dateProp >= date('2025-01-01') on ZONED DATETIME | Use .year accessor or datetime() literal |
duration.between(d1,d2).inDays | duration.between(d1,d2).days — .inDays does not exist |
WHERE n.x = null | WHERE n.x IS NULL |
WHERE n.x <> null | WHERE n.x IS NOT NULL |
MATCH (n:A) MATCH (m:A) without join predicate | Causes CartesianProduct — add WHERE join condition |
COLLECT { (a)-[:R]->(b) } | COLLECT { MATCH (a)-[:R]->(b) RETURN b } — bare pattern invalid |
COLLECT { MATCH ... RETURN x, y } | COLLECT {} must return exactly one column |
min() / max() as scalar in range() | Use CASE WHEN size(l) < 3 THEN size(l)-1 ELSE 2 END — these are aggregations |
(a)-[:REL]-{2,4}-(b) bare quantifier | Wrap in node group: (a)(()-[:REL]->()){2,4}(b) |
MATCH REPEATABLE ELEMENTS ... {1,} | REPEATABLE ELEMENTS requires bounded {m,n} |
2 IN [1, null, 3] expecting false | Returns null — guard source list with IS NOT NULL |
SET n = r (copy rel to node) | SET n = properties(r) — direct assignment transfers element reference |
n.$key dynamic property | n[$key] — dot notation with parameter is SyntaxError |
MATCH (n) SET n:$label (bare string) | SET n:$($label) — dynamic label requires $() wrapper |
DELETE n on node with relationships | DETACH DELETE n — plain DELETE throws if node has relationships |
SET n = {key: val} for partial update | SET n += {key: val} — = replaces ALL properties |
(a)-[:R]-(b) expecting one direction | Returns matches in both directions — use (a)-[:R]->(b) |
RETURN DISTINCT a, b deduplicates a | RETURN DISTINCT deduplicates complete rows, not individual columns |
CALL IN TRANSACTIONS inside an explicit transaction | Requires auto-commit session |
PERIODIC COMMIT in LOAD CSV | Deprecated — use LOAD CSV ... CALL (...) { } IN TRANSACTIONS OF N ROWS |
toInteger(null) throws | toIntegerOrNull(null) returns null safely |
import json
from datetime import datetime, timezone
SCALAR_TYPES = [
"STRING", "INTEGER", "FLOAT", "BOOLEAN",
"DATE", "DATETIME", "LOCAL_DATETIME", "TIME", "LOCAL_TIME", "DURATION",
"POINT",
]
VALID_TYPES = SCALAR_TYPES + ["LIST"] + [f"LIST<{t}>" for t in SCALAR_TYPES]
def prompt_type(prop_name):
while True:
t = input(f" Type for '{prop_name}' {VALID_TYPES}: ").strip().upper()
if t in VALID_TYPES:
return t
print(f" Invalid type. Choose from: {VALID_TYPES}")
def define_properties():
properties = {}
print(" Properties (leave name blank to finish):")
while True:
name = input(" Property name: ").strip()
if not name:
break
type_ = prompt_type(name)
properties[name] = {
"type": type_,
"indexed": False,
"unique": False,
"existence": False,
}
return properties
def main():
print("\nNeo4j Schema Definition Tool")
print("Builds <db-name>-schema.json by defining your graph schema before the database exists.")
print("=" * 60)
schema = {"value": {}}
node_labels = []
print("\nStep 1: Define Node Labels")
while True:
label = input(" Node label (blank to finish): ").strip()
if not label:
break
print(f" Defining '{label}':")
props = define_properties()
schema["value"][label] = {
"type": "node",
"count": 0,
"properties": props,
"relationships": {},
"labels": [],
}
node_labels.append(label)
print(f" '{label}' added.\n")
if not node_labels:
print("No nodes defined. Exiting.")
return
print(f"\nStep 2: Define Relationships")
print(f" Available nodes: {node_labels}")
rel_types = set()
while True:
rel = input("\n Relationship type (blank to finish): ").strip().upper()
if not rel:
break
from_label = input(f" From node: ").strip()
to_label = input(f" To node: ").strip()
if from_label not in schema["value"]:
print(f" '{from_label}' not found. Skipping.")
continue
if to_label not in schema["value"]:
print(f" '{to_label}' not found. Skipping.")
continue
print(f" Properties for [{rel}] (optional):")
props = define_properties()
schema["value"][from_label]["relationships"][rel] = {
"direction": "out",
"labels": [to_label],
"count": 0,
"properties": {k: {**v, "array": False} for k, v in props.items()},
}
schema["value"][to_label]["relationships"][rel] = {
"direction": "in",
"labels": [from_label],
"count": 0,
"properties": {k: {**v, "array": False} for k, v in props.items()},
}
schema["value"][rel] = {
"type": "relationship",
"count": 0,
"properties": props,
}
rel_types.add(rel)
print(f" ({from_label})-[:{rel}]->({to_label}) added.")
db_name = input("\nDatabase name for schema file (e.g. 'movies', 'supply-chain'): ").strip() or "neo4j"
output_path = f"{db_name}-schema.json"
schema["schema_retrieved_at"] = datetime.now(timezone.utc).isoformat()
with open(output_path, "w", encoding="utf-8") as f:
json.dump(schema, f, indent=2)
print(f"\nSchema saved to {output_path}")
print(f" Nodes: {node_labels}")
print(f" Relationships: {sorted(rel_types)}")
if __name__ == "__main__":
main()
"""
Export APOC meta.schema from a live Neo4j instance.
Usage:
python scripts/generate_schema.py [db-name]
Reads credentials from environment variables or a .env file:
NEO4J_URI (default: bolt://localhost:7687)
NEO4J_USERNAME (default: neo4j)
NEO4J_PASSWORD (required)
NEO4J_DATABASE (default: db-name arg or "neo4j")
Output: <db-name>-schema.json in the current directory.
Add *-schema.json to .gitignore if the schema contains sensitive structure.
"""
import os
import json
import sys
from datetime import datetime, timezone
from neo4j import GraphDatabase
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass # python-dotenv optional; env vars already set take precedence
URI = os.getenv("NEO4J_URI", "bolt://localhost:7687")
USERNAME = os.getenv("NEO4J_USERNAME", "neo4j")
PASSWORD = os.getenv("NEO4J_PASSWORD")
def fetch_and_map_schema(db_name=None):
if not PASSWORD:
print("Error: NEO4J_PASSWORD is not set. Add it to your .env file or environment.")
sys.exit(1)
name = db_name or os.getenv("NEO4J_DATABASE", "neo4j")
print(f"Connecting to {URI} (database: {name}) ...")
try:
with GraphDatabase.driver(URI, auth=(USERNAME, PASSWORD)) as driver:
records, _, _ = driver.execute_query(
"CALL apoc.meta.schema()", database_=name
)
if not records:
print("No schema records returned. Is APOC installed?")
return
raw_schema = records[0].data()
raw_schema["schema_retrieved_at"] = datetime.now(timezone.utc).isoformat()
output_path = f"{name}-schema.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(raw_schema, f, indent=2)
print(f"Schema saved to {output_path}")
except Exception as e:
print(f"Failed: {e}")
sys.exit(1)
if __name__ == "__main__":
db_name = sys.argv[1] if len(sys.argv) > 1 else None
fetch_and_map_schema(db_name)
"""
Converts Neo4j schema JSON formats into an APOC meta.schema-compatible `*-schema.json` file.
Supported formats (auto-detected):
- neo4j-graphrag-python SchemaBuilder JSON
- Neo4j standard graph schema JSON (graph-schema-introspector, graph-schema-json-js-utils,
mcp-neo4j-data-modeling)
Usage:
python scripts/import_neo4j_schema.py <path-to-schema.json>
"""
import json
import os
import sys
from datetime import datetime, timezone
def neo4j_type_to_apoc(type_def):
if not isinstance(type_def, dict):
return "STRING"
mapping = {
"string": "STRING",
"integer": "INTEGER",
"float": "FLOAT",
"boolean": "BOOLEAN",
"date": "DATE",
"datetime": "DATETIME",
"local_datetime": "LOCAL_DATETIME",
"time": "TIME",
"local_time": "LOCAL_TIME",
"duration": "DURATION",
"point": "POINT",
"array": "LIST",
"list": "LIST",
}
return mapping.get(type_def.get("type", "string").lower(), "STRING")
def convert_graphrag(schema):
"""Convert neo4j-graphrag-python SchemaBuilder format to APOC format."""
data = schema.get("schema", schema)
apoc = {"value": {}}
# Parse node types — can be strings or dicts
node_labels = []
for nt in data.get("node_types", []):
if isinstance(nt, str):
label = nt
properties = {"name": {"type": "STRING", "indexed": False, "unique": False, "existence": False}}
else:
label = nt.get("label", nt.get("name", "Unknown"))
properties = {}
for prop in nt.get("properties", []):
if isinstance(prop, str):
properties[prop] = {"type": "STRING", "indexed": False, "unique": False, "existence": False}
else:
properties[prop.get("name", prop.get("token", "prop"))] = {
"type": prop.get("type", "STRING").upper(),
"indexed": False,
"unique": False,
"existence": False,
}
if not properties:
properties["name"] = {"type": "STRING", "indexed": False, "unique": False, "existence": False}
apoc["value"][label] = {
"type": "node",
"count": 0,
"properties": properties,
"relationships": {},
"labels": [],
}
node_labels.append(label)
# Parse relationship types — can be strings or dicts
rel_labels = []
for rt in data.get("relationship_types", []):
label = rt if isinstance(rt, str) else rt.get("label", rt.get("name", "RELATED"))
rel_labels.append(label)
apoc["value"][label] = {"type": "relationship", "count": 0, "properties": {}}
# Wire up directions from patterns: [source, rel, target]
for pattern in data.get("patterns", []):
if len(pattern) != 3:
continue
from_label, rel_token, to_label = pattern
if from_label in apoc["value"]:
apoc["value"][from_label]["relationships"][rel_token] = {
"direction": "out",
"labels": [to_label],
"count": 0,
"properties": {},
}
if to_label in apoc["value"]:
apoc["value"][to_label]["relationships"][rel_token] = {
"direction": "in",
"labels": [from_label],
"count": 0,
"properties": {},
}
return apoc
def resolve_ref(ref, node_labels, node_obj_types):
key = ref.lstrip("#")
if key in node_labels:
return node_labels[key]
if key in node_obj_types:
obj = node_obj_types[key]
label_ref = obj.get("labels", [{}])[0].get("$ref", "").lstrip("#")
return node_labels.get(label_ref, key)
return key
def convert_standard(neo4j_schema):
"""Convert Neo4j standard graph schema JSON format to APOC format."""
graph = neo4j_schema.get("graphSchemaRepresentation", {}).get("graphSchema", {})
node_labels = {nl["$id"]: nl["token"] for nl in graph.get("nodeLabels", [])}
rel_types = {rt["$id"]: rt["token"] for rt in graph.get("relationshipTypes", [])}
node_obj_types = {n["$id"]: n for n in graph.get("nodeObjectTypes", [])}
rel_obj_types = graph.get("relationshipObjectTypes", [])
apoc = {"value": {}}
for nid, nobj in node_obj_types.items():
label_ref = nobj.get("labels", [{}])[0].get("$ref", "").lstrip("#")
label = node_labels.get(label_ref, nid)
properties = {}
for prop in nobj.get("properties", []):
properties[prop["token"]] = {
"type": neo4j_type_to_apoc(prop.get("type", {})),
"indexed": False,
"unique": False,
"existence": not prop.get("nullable", True),
}
apoc["value"][label] = {
"type": "node",
"count": 0,
"properties": properties,
"relationships": {},
"labels": [],
}
for robj in rel_obj_types:
rel_token = rel_types.get(robj["type"]["$ref"].lstrip("#"), "UNKNOWN")
from_label = resolve_ref(robj["from"]["$ref"], node_labels, node_obj_types)
to_label = resolve_ref(robj["to"]["$ref"], node_labels, node_obj_types)
rel_props = {}
for prop in robj.get("properties", []):
rel_props[prop["token"]] = {
"type": neo4j_type_to_apoc(prop.get("type", {})),
"indexed": False,
"unique": False,
"existence": not prop.get("nullable", True),
"array": False,
}
if from_label in apoc["value"]:
apoc["value"][from_label]["relationships"][rel_token] = {
"direction": "out", "labels": [to_label], "count": 0, "properties": rel_props,
}
if to_label in apoc["value"]:
apoc["value"][to_label]["relationships"][rel_token] = {
"direction": "in", "labels": [from_label], "count": 0, "properties": rel_props,
}
apoc["value"][rel_token] = {
"type": "relationship",
"count": 0,
"properties": {k: {kk: vv for kk, vv in v.items() if kk != "array"} for k, v in rel_props.items()},
}
return apoc
def detect_and_convert(schema):
"""Auto-detect schema format and convert to APOC."""
# graphrag format: has 'schema' key with 'node_types' and 'patterns'
data = schema.get("schema", schema)
if "node_types" in data and "patterns" in data:
print("Detected: neo4j-graphrag-python SchemaBuilder format")
return convert_graphrag(schema)
# Neo4j standard JSON format
if "graphSchemaRepresentation" in schema:
print("Detected: Neo4j standard graph schema JSON format")
return convert_standard(schema)
raise ValueError(
"Unrecognised schema format. Supported: neo4j-graphrag-python SchemaBuilder, "
"Neo4j standard graph schema JSON (graphSchemaRepresentation)."
)
def main():
if len(sys.argv) < 2:
print("Usage: python scripts/import_neo4j_schema.py <path-to-schema.json>")
print("Supported formats: neo4j-graphrag-python SchemaBuilder, Neo4j standard graph schema JSON")
sys.exit(1)
input_path = sys.argv[1]
with open(input_path, "r", encoding="utf-8") as f:
schema = json.load(f)
apoc = detect_and_convert(schema)
apoc["schema_retrieved_at"] = datetime.now(timezone.utc).isoformat()
base = os.path.splitext(os.path.basename(input_path))[0]
output_path = f"{base}.json" if base.endswith("-schema") else f"{base}-schema.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(apoc, f, indent=2)
node_count = sum(1 for v in apoc["value"].values() if v.get("type") == "node")
rel_count = sum(1 for v in apoc["value"].values() if v.get("type") == "relationship")
print(f"✅ Converted: {node_count} node types, {rel_count} relationship types")
print(f" Saved to {output_path}")
if __name__ == "__main__":
main()
Related skills
How it compares
Use neo4j-cypher-skill for Cypher authoring and tuning; switch to sibling skills for driver migrations, DB admin, or hybrid vector ranking.
FAQ
Which Neo4j versions does neo4j-cypher-skill support?
The neo4j-cypher-skill skill targets Neo4j 2025.x and 2026.x with Cypher 25 syntax, defaulting to a 2025.01-safe feature set when the server version is unknown and documenting version gates for SEARCH, ACYCLIC paths, and GQL aliases.
How does neo4j-cypher-skill prevent bad graph writes?
The neo4j-cypher-skill skill requires schema inspection or project schema JSON, mandates EXPLAIN before writes, verifies read halves with LIMIT 1, and blocks execution until the developer confirms estimated rows affected through the write execution gate.
Is Neo4j Cypher Skill safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.