Neo4j Graphs skill for Claude Code
Graph models anchored on indexes, traversals bounded. Claude first checks whether the questions are really traversals, then models nouns as labeled nodes and verbs as typed, directed relationships. Constraints come before data, loads MERGE only on constrained keys in batched transactions, and every hot Cypher query is profiled until it starts with an index seek and has no unbounded expansion, cartesian product or Eager step.
- name
neo4j-graphs - size171 lines · ≈2,100 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Models and queries property graphs in Neo4j with Cypher - deciding when a graph database fits, labels vs properties vs relationship types, uniqueness and existence constraints, range/text/full-text/vector indexes, MERGE without duplicates, batched imports with CALL IN TRANSACTIONS, variable-length and quantified path patterns, and query tuning with EXPLAIN/PROFILE (db hits, Eager, cartesian products). Use when designing a graph model, writing or optimizing Cypher, loading CSV data into Neo4j, or deciding between Neo4j and recursive SQL. Try asking
- Model our fraud-detection data as a Neo4j graph and load the CSVs
- This Cypher query takes 40 seconds - read the PROFILE and fix it
- Should our org-chart and permissions queries move to a graph database?
Use it when
- Questions involve multi-hop relationships of variable depth
- Writing or reviewing Cypher that is slow or returns duplicates
- Importing relational or CSV data into Neo4j
- Choosing between Neo4j and recursive SQL for hierarchical data
Skip it when
- The workload is aggregation over many rows - use analytics-sql or clickhouse-analytics
- You only need one tree query in Postgres - use sql-query-tuning with a recursive CTE
How to check the result
Re-running the import leaves node and relationship counts unchanged, and PROFILE for each hot query starts with an index seek with db hits proportional to the result size.
The common mistake it prevents
MERGE on a whole pattern or on several properties creates duplicate nodes whenever any part differs; MERGE each node on its constrained key, then the relationship.
A graph model is right when the questions you ask are traversals - “who is connected to whom through what, within N hops” - and each traversal starts from a node found by an index. Every MERGE key is backed by a uniqueness constraint, and every hot query has a PROFILE with no unbounded expansion and no unexpected Eager operator.
Does a graph fit?
| Signal | Graph (Neo4j) | Relational / document |
|---|---|---|
| Queries follow relationships of variable or unknown depth | yes | recursive CTEs get slow past 3-4 levels on big data |
| Relationship itself carries data and types matter (KNOWS since, WORKS_AT role) | yes | join tables work but multiply |
| Main workload is aggregates over many rows (sums, reports) | no | SQL / columnar |
| Entity lookups by key and simple 1:N | no | any store |
| Fraud rings, recommendations, dependency/impact analysis, access paths, knowledge graphs | yes | - |
If one or two hierarchical queries are the only graph-shaped need, propose a recursive CTE in the existing database first.
Workflow
- [ ] 1. List the questions (traversals) with start node, path shape, and expected fan-out
- [ ] 2. Draw the model: labels, relationship types (with direction), properties
- [ ] 3. Create constraints and indexes before loading data
- [ ] 4. Load with MERGE on constrained keys, batched
- [ ] 5. Write queries; PROFILE each with production-like data
- [ ] 6. Fix plans: anchor on indexes, bound expansions, remove Eager
2. Modeling rules
- Nouns become nodes with a label (
:Person,:Account); verbs become relationships with an UPPER_SNAKE type (:OWNS,:TRANSFERRED_TO). Labels are CamelCase. - Promote a property to a node when you traverse through it or many nodes share it
(
city: "Berlin"on 2M people becomes(:Person)-[:LIVES_IN]->(:City)) - but beware supernodes (below). - Use specific relationship types rather than one generic type with a
typeproperty; the planner can expand by type without reading properties. - Every relationship has a direction; store it once in its natural direction and query
in either direction (
-[:FOLLOWS]-ignores direction). Never create both directions. - Model events or facts that connect more than two things as nodes (an intermediate node):
(:Person)-[:MADE]->(:Transfer {amount, at})-[:TO]->(:Account). - Supernodes (a node with millions of relationships, such as a
:Countryor a celebrity) slow every traversal that passes through them. Split by type, add time-bucket nodes, or filter by relationship type before expanding.
3. Constraints and indexes (Neo4j 5.x / 2025.x syntax)
CREATE CONSTRAINT person_id IF NOT EXISTS
FOR (p:Person) REQUIRE p.id IS UNIQUE;
CREATE CONSTRAINT account_iban IF NOT EXISTS
FOR (a:Account) REQUIRE a.iban IS UNIQUE;
CREATE INDEX person_email IF NOT EXISTS FOR (p:Person) ON (p.email);
CREATE INDEX transfer_at IF NOT EXISTS FOR ()-[t:TRANSFERRED_TO]-() ON (t.at);
CREATE TEXT INDEX company_name_text IF NOT EXISTS FOR (c:Company) ON (c.name);
CREATE FULLTEXT INDEX company_search IF NOT EXISTS FOR (c:Company) ON EACH [c.name, c.aliases];
SHOW CONSTRAINTS; SHOW INDEXES; -- wait until state = ONLINE before loading or benchmarking
- A uniqueness constraint creates its own backing index; do not add a duplicate range index.
- Existence (
IS NOT NULL), property type, and node key constraints are Enterprise Edition features; check the edition before using them. - Full-text indexes are queried through
db.index.fulltext.queryNodes('company_search', 'acme~'), not throughWHERE ... CONTAINS. - Vector indexes exist for embeddings (
CREATE VECTOR INDEX ...); configure dimensions and similarity function to match the embedding model.
4. Loading data
LOAD CSV WITH HEADERS FROM 'file:///transfers.csv' AS row
CALL (row) {
MERGE (src:Account {iban: row.from_iban})
MERGE (dst:Account {iban: row.to_iban})
CREATE (src)-[:TRANSFERRED_TO {amount: toFloat(row.amount), at: datetime(row.at)}]->(dst)
} IN TRANSACTIONS OF 10000 ROWS ON ERROR CONTINUE;
CALL { } IN TRANSACTIONSreplaces the removedUSING PERIODIC COMMIT. Run it as an auto-commit transaction (in Browser/cypher-shell prefix with:autowhere required; in drivers usesession.run, not a managed transaction function).- The
CALL (row) { ... }scope syntax is current; older 5.x versions useCALL { WITH row ... }. Match the server version. - MERGE on the constrained key only, then
SETthe rest:MERGE (p:Person {id: row.id}) SET p.name = row.name. Merging on several properties creates a new node whenever any of them differ. - Merging a full pattern (
MERGE (a)-[:R]->(b:Label {id: ...})) creates the whole pattern if any part is missing, duplicatingb. MERGE nodes first, then MERGE the relationship between bound variables. - For initial bulk loads of tens of millions of rows into an empty database, use
neo4j-admin database import(offline, much faster) instead of LOAD CSV. - Convert types explicitly (
toInteger,date()); CSV values arrive as strings and a string"42"never equals integer42in lookups.
5. Query patterns
// Accounts within 4 hops of a flagged account, with path
MATCH (flagged:Account {iban: $iban})
MATCH p = (flagged)-[:TRANSFERRED_TO]->{1,4}(other:Account)
WHERE all(r IN relationships(p) WHERE r.at >= datetime() - duration('P30D'))
RETURN other.iban, length(p) AS hops
ORDER BY hops LIMIT 100;
// Shortest path between two people
MATCH p = SHORTEST 1 (a:Person {id: $a})-[:KNOWS]-+(b:Person {id: $b})
RETURN [n IN nodes(p) | n.name] AS chain;
// Count without materializing rows
MATCH (c:Company {id: $id})
RETURN c.name, COUNT { (c)<-[:WORKS_AT]-() } AS employees;
- Always pass values as parameters (
$iban): enables plan caching and prevents injection. - Bound every variable-length pattern (
{1,4},*1..4). Unbounded*on a dense graph can explore millions of paths. - Quantified path patterns (
{1,4},+) andSHORTESTare the modern forms (5.9+ and 5.21+);shortestPath()and*1..4still work on older servers. - Use
elementId(n)instead of the deprecatedid(n), and never store either as a durable external key - they can be reused after deletion.
6. Reading PROFILE
PROFILE MATCH (p:Person {email: $email})-[:WORKS_AT]->(c) RETURN c.name;
| Plan symptom | Cause | Fix |
|---|---|---|
NodeByLabelScan or AllNodesScan at the start | no index for the anchor predicate | add index/constraint, or anchor on a different indexed node |
CartesianProduct | two disconnected MATCH patterns | connect them, or confirm the cross product is intended |
Eager in a write query | read/write conflict forces full materialization | split into two queries or batch with CALL IN TRANSACTIONS |
Huge db hits on Expand(All) | supernode or unfiltered relationship types | specify types/direction, filter earlier, remodel supernode |
Filter after expansion on a property | predicate could be applied on an indexed anchor | move predicate onto the starting node |
Compare total db hits and rows before/after; “done” means the anchor is an index seek and db hits scale with the result size, not the graph size.
Gotchas
- Deleting a node with relationships fails;
DETACH DELETEremoves them too. Mass deletes need batching (CALL { ... } IN TRANSACTIONS) and user confirmation. OPTIONAL MATCHfollowed by aggregation can silently produce a row of nulls; check counts withcount(x)(ignores nulls) vscount(*).- Lists in properties cannot be indexed element-wise for traversal; if you query through the list values, they should be nodes.
- Datetimes without time zones compare unexpectedly with zoned values; store
datetime()with zone consistently. - Heap and page cache sizing (
server.memory.heap.max_size,server.memory.pagecache.size) matter more than CPU; useneo4j-admin server memory-recommendationas a starting point.
Verification
SHOW CONSTRAINTSlists a uniqueness constraint for every MERGE key used in loads.- Re-running the load script twice yields the same node and relationship counts
(
MATCH (n) RETURN labels(n), count(*)). - PROFILE of each hot query starts with
NodeIndexSeek/NodeUniqueIndexSeekand has no unintendedCartesianProductorEager. - Results for two or three known paths (hand-checked fixtures) match expectations.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.