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.

  • Databases
  • $neo4j-graph
  • 225 lines
  • 2 files
  • ~2,450 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- neo4j-graph

Installs to ~/.agents/skills/neo4j-graph/ for every project on this machine.

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

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.

~/.agents/skills/neo4j-graph/SKILL.md

--- name: neo4j-graph description: Models graph data in Neo4j and writes efficient Cypher - nodes, relationship types and direction from the questions asked, uniqueness constraints and… license: MIT ---

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

  1. 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.
  2. 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.
  3. 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

DecisionRule
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 typeSpecific verbs (:PURCHASED, :REVIEWED) instead of a generic :RELATED {kind}; type filtering is far cheaper than property filtering
DirectionPick the natural reading direction once; queries can traverse either way, so never duplicate a relationship in both directions
Facts about a connectionProperties on the relationship (since, weight)
An event linking 3+ thingsIntermediate node ((:Employment) linking person, company, role, dates)
Time-ordered sequences(:Event)-[:NEXT]->(:Event) linked list or time-tree, only if you traverse in order
LabelsSeveral 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 MATCH clauses produce a Cartesian product; connect them or aggregate between them with WITH.
  • Aggregate early with WITH ... count() to shrink rows before the next expansion.
  • OPTIONAL MATCH for 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 signMeaningFix
NodeByLabelScan / AllNodesScan at the startNo index used for the anchorAdd constraint or index; check property name and type
EagerQuery reads and writes the same data; materializes all rowsSplit into separate statements or batch
CartesianProductDisconnected patternsConnect patterns or use WITH
db hits far above result rowsExpanding through a supernode or wrong directionAnchor 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 Integer objects unless disableLosslessIntegers is 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 CONSTRAINTS lists 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 with id: "42". Normalize types at load time (toInteger, toString).
  • LOAD CSV reads every value as a string; convert dates with date() / datetime().
  • null cannot be stored as a property; SET n.x = null removes 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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem