Neo4j Graph Modeling skill for Codex
Graph models built from the questions you ask. Codex writes the questions the graph must answer before choosing labels and relationship types, adds uniqueness constraints ahead of any MERGE, and loads data in idempotent UNWIND or CALL IN TRANSACTIONS batches. Queries anchor on indexed lookups with bounded paths, and PROFILE output is read for label scans, Eager operators and supernode expansion before anything ships.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- neo4j-graph Installs to ~/.agents/skills/neo4j-graph/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install neo4j-graph The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project neo4j-graph Installs to .agents/skills/neo4j-graph/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/neo4j-graph.zipunzip -o neo4j-graph.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) neo4j-graph PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $neo4j-graph, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Models graph data in Neo4j and writes efficient Cypher - nodes, relationship types and direction from the questions asked, uniqueness constraints and range, text, full-text and vector indexes, MERGE without duplicates, batched imports with UNWIND and CALL IN TRANSACTIONS, bounded and quantified path patterns, and PROFILE-driven tuning - plus the official Python and JavaScript drivers. Use when designing a graph model, translating relational data into Neo4j, writing or speeding up Cypher queries, loading CSV or JSON into a graph, or wiring a Neo4j driver into an app.
Use it when
- Designing or reviewing a Neo4j graph model
- Writing, reviewing or speeding up Cypher queries
- Importing relational, CSV or JSON data into a graph
- Adding vector or full-text search to an existing Neo4j graph
Use something else when
- Relationships are simple joins a relational schema handles well: use postgres-schema-design
- You only need embeddings search with no graph traversal: use vector-databases
How to check the result
PROFILE each target query and confirm an index seek anchors it, then reload the same data and check node and relationship counts do not change.
The mistake it prevents
Using MERGE on a whole path pattern, which creates duplicate nodes whenever any part of the pattern is missing.
Done well means: the model answers the target questions with short, anchored traversals, every node used as a lookup key has a uniqueness constraint, loads are idempotent and batched, and every important query has been PROFILEd with db hits that scale with the answer size, not the graph size.
Start from the questions
- Read
AGENTS.md, existing Cypher files, migrations (Neo4j-Migrations, Liquibase plugin) and driver setup. Check the server version:CALL dbms.components(). Neo4j moved to calendar versions (2025.x) after 5.26; Cypher syntax below works on 5.x and later. - Write the 5-10 questions the graph must answer as sentences. The nouns become node labels, the verbs become relationship types, and the filters become properties and indexes.
- Sketch the model as an ASCII pattern and walk each question through it before writing DDL:
(:Customer)-[:PLACED]->(:Order)-[:CONTAINS {qty}]->(:Product)-[:IN]->(:Category)
Modeling rules
| Decision | Rule |
|---|---|
| Node or property? | If you traverse through it or it has its own identity (city, tag, company), make it a node; if you only display or filter it, keep a property |
| Relationship type | Specific verbs (:PURCHASED, :REVIEWED) instead of a generic :RELATED {kind}; type filtering is far cheaper than property filtering |
| Direction | Pick the natural reading direction once; queries can traverse either way, so never duplicate a relationship in both directions |
| Facts about a connection | Properties on the relationship (since, weight) |
| An event linking 3+ things | Intermediate node ((:Employment) linking person, company, role, dates) |
| Time-ordered sequences | (:Event)-[:NEXT]->(:Event) linked list or time-tree, only if you traverse in order |
| Labels | Several labels per node are fine for roles (:Person:Employee); avoid labels as data values |
Supernodes (one node with millions of relationships, such as a :Country everyone lives
in) make traversals through them expensive. Avoid traversing through them, split by a
finer node (city, year), or filter on a relationship type before expanding.
Schema: constraints and indexes
CREATE CONSTRAINT customer_id IF NOT EXISTS
FOR (c:Customer) REQUIRE c.id IS UNIQUE; -- also creates the backing index
CREATE INDEX product_sku IF NOT EXISTS FOR (p:Product) ON (p.sku);
CREATE INDEX order_placed IF NOT EXISTS FOR (o:Order) ON (o.placedAt);
CREATE TEXT INDEX product_name_text IF NOT EXISTS FOR (p:Product) ON (p.name);
CREATE FULLTEXT INDEX product_search IF NOT EXISTS
FOR (p:Product) ON EACH [p.name, p.description];
CREATE VECTOR INDEX chunk_embedding IF NOT EXISTS
FOR (c:Chunk) ON c.embedding
OPTIONS { indexConfig: { `vector.dimensions`: 1536, `vector.similarity_function`: 'cosine' } };
SHOW CONSTRAINTS;
SHOW INDEXES YIELD name, type, state, populationPercent;
Create constraints before loading data: MERGE without an index on the key scans every node with that label. Node key and property existence constraints are Enterprise features.
Writing without duplicates
// Wrong: MERGE on the whole pattern creates a new Customer when the relationship is missing
MERGE (c:Customer {id: $cid})-[:PLACED]->(o:Order {id: $oid})
// Right: MERGE each node on its key, then the relationship
MERGE (c:Customer {id: $cid})
ON CREATE SET c.name = $name, c.createdAt = datetime()
MERGE (o:Order {id: $oid})
ON CREATE SET o.placedAt = datetime($placedAt), o.total = $total
MERGE (c)-[:PLACED]->(o)
- MERGE only on the unique key; set everything else with
ON CREATE SET/SET. - Always pass values as parameters (
$cid), never string-concatenated: it prevents Cypher injection and lets the plan cache work. - Use
elementId(n)if you need an internal id;id()is deprecated and ids are reused after deletion. Prefer your own business keys.
Bulk loading
// From the app: send rows in batches of 1k-10k as a list parameter
UNWIND $rows AS row
MERGE (p:Product {sku: row.sku})
SET p.name = row.name, p.price = toFloat(row.price)
WITH p, row
MERGE (cat:Category {name: row.category})
MERGE (p)-[:IN]->(cat);
// From CSV in the import directory; must run as an auto-commit query
// (prefix with :auto in Browser/cypher-shell sessions that wrap transactions)
LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
CALL (row) {
MATCH (c:Customer {id: row.customer_id})
MERGE (o:Order {id: row.order_id})
MERGE (c)-[:PLACED]->(o)
} IN TRANSACTIONS OF 5000 ROWS
ON ERROR CONTINUE REPORT STATUS AS s
RETURN s.errorMessage, count(*);
The CALL (row) { ... } scope syntax is current; older 5.x versions use
CALL { WITH row ... }. For an initial load of tens of millions of rows into an empty
database, neo4j-admin database import full is orders of magnitude faster than Cypher.
Deleting at scale (DETACH DELETE over many nodes) must also be batched with
CALL { ... } IN TRANSACTIONS. Deletes are irreversible: show the user the count of
matched nodes first and get explicit confirmation before running them.
Query patterns
// Anchor on an indexed lookup, then traverse
MATCH (c:Customer {id: $cid})-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)
RETURN p.sku, count(*) AS times ORDER BY times DESC LIMIT 10;
// Recommendations: customers who bought what I bought
MATCH (me:Customer {id: $cid})-[:PLACED]->()-[:CONTAINS]->(p:Product)
<-[:CONTAINS]-()<-[:PLACED]-(other:Customer)
WHERE other <> me
MATCH (other)-[:PLACED]->()-[:CONTAINS]->(rec:Product)
WHERE NOT EXISTS { (me)-[:PLACED]->()-[:CONTAINS]->(rec) }
RETURN rec.sku, count(DISTINCT other) AS score ORDER BY score DESC LIMIT 20;
// Bounded variable length; quantified path pattern form (5.9+)
MATCH (a:Person {id: $a})-[:KNOWS]->{1,3}(b:Person)
RETURN DISTINCT b.id;
// Shortest path (5.21+ syntax; older: shortestPath((a)-[:KNOWS*..6]-(b)))
MATCH p = SHORTEST 1 (a:Person {id: $a})-[:KNOWS]-+(b:Person {id: $b})
RETURN length(p);
// Vector search
CALL db.index.vector.queryNodes('chunk_embedding', 10, $embedding)
YIELD node, score RETURN node.text, score;
Rules:
- Always bound variable-length patterns (
*1..3,{1,3}). Unbounded*on a dense graph enumerates paths exponentially. - Separate unrelated
MATCHclauses produce a Cartesian product; connect them or aggregate between them withWITH. - Aggregate early with
WITH ... count()to shrink rows before the next expansion. OPTIONAL MATCHfor things that may be missing;EXISTS { }subqueries for filters.
Tuning with PROFILE
PROFILE MATCH (c:Customer {id: 'c-42'})-[:PLACED]->(o) RETURN count(o);
Read the plan bottom-up:
| Operator or sign | Meaning | Fix |
|---|---|---|
NodeByLabelScan / AllNodesScan at the start | No index used for the anchor | Add constraint or index; check property name and type |
Eager | Query reads and writes the same data; materializes all rows | Split into separate statements or batch |
CartesianProduct | Disconnected patterns | Connect patterns or use WITH |
| db hits far above result rows | Expanding through a supernode or wrong direction | Anchor on the rarer end; filter by relationship type |
EXPLAIN shows the plan without running; use it for writes.
Drivers
from neo4j import GraphDatabase
driver = GraphDatabase.driver("neo4j://localhost:7687", auth=("neo4j", password))
driver.verify_connectivity()
records, summary, keys = driver.execute_query(
"MATCH (c:Customer {id: $cid})-[:PLACED]->(o) RETURN o.id AS id",
cid="c-42", database_="neo4j", routing_="r")
import neo4j from "neo4j-driver";
const driver = neo4j.driver(uri, neo4j.auth.basic(user, password), { disableLosslessIntegers: true });
const { records } = await driver.executeQuery(
"MATCH (p:Product {sku: $sku}) RETURN p.name AS name",
{ sku }, { database: "neo4j", routing: neo4j.routing.READ });
- Create one driver per app and reuse it; sessions are cheap, drivers are not.
neo4j://enables cluster routing;bolt://talks to a single server.- Always name the database to avoid an extra round trip to resolve the default.
- JavaScript integers come back as
Integerobjects unlessdisableLosslessIntegersis set (safe only when values stay below 2^53).
Verification
- Each target question has a Cypher query, and PROFILE shows an index seek as anchor
-
SHOW CONSTRAINTSlists a uniqueness constraint for every MERGE key - Loading the same file twice leaves node and relationship counts unchanged
- Counts reconcile with the source:
MATCH (o:Order) RETURN count(o)vs source rows - No unbounded variable-length patterns in application queries (
rg "\*\]" -g "*.cypher")
Gotchas
- Property types must match the index:
{id: 42}will not find a node stored withid: "42". Normalize types at load time (toInteger,toString). LOAD CSVreads every value as a string; convert dates withdate()/datetime().nullcannot be stored as a property;SET n.x = nullremoves it, and MERGE on a null key fails.- APOC Core ships with Neo4j; APOC Extended procedures are a separate plugin and often missing on managed services such as Aura.
- Large transactions exhaust heap; keep batches to a few thousand changes each.
Output template
**Questions served:** 7 (listed with their queries in cypher/queries.cypher)
**Model:** (:Customer)-[:PLACED]->(:Order)-[:CONTAINS {qty, price}]->(:Product)-[:IN]->(:Category)
**Schema:** 4 uniqueness constraints, 2 range indexes, 1 full-text, 1 vector (1536, cosine)
**Load:** UNWIND batches of 5k, idempotent (rerun leaves counts unchanged)
**Performance:** slowest query 38 ms, 2.1k db hits (PROFILE attached)
**Open risks:** :Category "Other" has 1.2M relationships; queries avoid expanding through it What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Neo4j Graph Modeling"
short_description: "Graph models built from the questions you ask"
default_prompt: "$neo4j-graph Design a Neo4j model for our customers, orders and products so we can do recommendations" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.