Databases No. 201

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.

  • nameneo4j-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

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.

~/.claude/skills/neo4j-graphs/SKILL.md

--- name: neo4j-graphs description: Models and queries property graphs in Neo4j with Cypher - deciding when a graph database fits, labels vs properties vs relationship types,… license: MIT ---

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?

SignalGraph (Neo4j)Relational / document
Queries follow relationships of variable or unknown depthyesrecursive CTEs get slow past 3-4 levels on big data
Relationship itself carries data and types matter (KNOWS since, WORKS_AT role)yesjoin tables work but multiply
Main workload is aggregates over many rows (sums, reports)noSQL / columnar
Entity lookups by key and simple 1:Nnoany store
Fraud rings, recommendations, dependency/impact analysis, access paths, knowledge graphsyes-

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 type property; 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 :Country or 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 through WHERE ... 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 TRANSACTIONS replaces the removed USING PERIODIC COMMIT. Run it as an auto-commit transaction (in Browser/cypher-shell prefix with :auto where required; in drivers use session.run, not a managed transaction function).
  • The CALL (row) { ... } scope syntax is current; older 5.x versions use CALL { WITH row ... }. Match the server version.
  • MERGE on the constrained key only, then SET the 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, duplicating b. 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 integer 42 in 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}, +) and SHORTEST are the modern forms (5.9+ and 5.21+); shortestPath() and *1..4 still work on older servers.
  • Use elementId(n) instead of the deprecated id(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 symptomCauseFix
NodeByLabelScan or AllNodesScan at the startno index for the anchor predicateadd index/constraint, or anchor on a different indexed node
CartesianProducttwo disconnected MATCH patternsconnect them, or confirm the cross product is intended
Eager in a write queryread/write conflict forces full materializationsplit into two queries or batch with CALL IN TRANSACTIONS
Huge db hits on Expand(All)supernode or unfiltered relationship typesspecify types/direction, filter earlier, remodel supernode
Filter after expansion on a propertypredicate could be applied on an indexed anchormove 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 DELETE removes them too. Mass deletes need batching (CALL { ... } IN TRANSACTIONS) and user confirmation.
  • OPTIONAL MATCH followed by aggregation can silently produce a row of nulls; check counts with count(x) (ignores nulls) vs count(*).
  • 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; use neo4j-admin server memory-recommendation as a starting point.

Verification

  • SHOW CONSTRAINTS lists 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/NodeUniqueIndexSeek and has no unintended CartesianProduct or Eager.
  • 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.

esc