Neo4j, Kuzu and Cypher basics
Create, merge and query a Crumb graph of customers, purchases, products and allergens with Cypher in Kuzu and Neo4j, using parameters and avoiding common traps.
Words in this lessonGraph database · Cypher · Neo4j · Kuzu · Node (in a graph) · Edge (in a graph) · Query parameter
- Graph database
- A database designed to store nodes and relationships and follow them quickly. Like a city map with every road already drawn, rather than a list of addresses you must search each time.
- Cypher
- A query language for graphs where you draw the pattern you are looking for, like (c:Customer)-[:PLACED]->(o). Like sketching the shape of the answer on a whiteboard and asking the database to find every place it appears.
- Neo4j
- A widely used graph database server that you query with Cypher. Like Postgres, but for graphs: a shared database server.
- Kuzu
- An embedded graph database that runs inside your Python program and speaks Cypher. Like SQLite, but for graphs: a database that lives in a file next to your code.
- Node (in a graph)
- One step in a graph: a function that reads the current state and returns the changes it wants to make. Like one station in a bakery kitchen, such as decorating, which reads the ticket and writes down only what it did.
- Edge (in a graph)
- A fixed arrow that says which step runs after another. Like the arrow on a kitchen chart saying that baking always goes to decorating.
- Query parameter
- A placeholder like $name in a query, with the actual value passed separately. Like a printed form with blanks: people fill in the blanks but cannot rewrite the form.
On this page
- Drawing the question instead of coding it
- Two graph databases: Neo4j and Kuzu
- Following one query through the graph
- The real code: Cypher in Kuzu, step by step
- Step 1: connect and declare the schema
- Step 2: CREATE and MERGE
- Step 3: relationships, the MATCH-then-MERGE pattern
- Step 4: MATCH, WHERE, RETURN
- The same thing in Neo4j
- Where it breaks
- Try it yourself
- Common mistakes
- Recap
- Go deeper
The morning after the recall, Arjun wanted to turn his emergency script into something permanent. He loaded the whole season into his list of triples: every order, every item, every allergy and family fact. It came to about 2.3 million triples.
Then he reran the five-hop walk from yesterday. It took almost five minutes.
He worked out why on a notepad. His subjects() helper scans all 2.3 million triples every time it is called. Hop 4 calls it once for each of the 1,240 risky orders. That is nearly 3 billion comparisons for one question. Yesterday’s toy graph had 14 triples, so he never noticed.
And speed was not the only problem. At 10 am Meera sent a new question: “Which products have we sold most of to customers with any nut allergy?” Arjun started writing another nest of loops, got the direction of one arrow wrong, and got a plausible but wrong answer. Every new question meant new, fragile code.
Priya looked at his screen. “You’re hand-writing a database engine, one question at a time. Let’s use a real one, and learn the language it speaks.”
Important
The idea in one sentence: A graph database stores nodes and relationships so that following a relationship is nearly instant, and Cypher lets you ask a multi-hop question by drawing the shape you are looking for, like (c:Customer)-[:PLACED]->(o:Purchase), instead of writing loops.
Drawing the question instead of coding it
Priya wrote two things on the whiteboard. First, how you would ask “which customers bought a product containing an allergen they are allergic to?” in SQL, the language of ordinary databases:
SELECT c.name, o.id, p.name, a.name
FROM customers c
JOIN allergies al ON al.customer_id = c.id
JOIN allergens a ON a.id = al.allergen_id
JOIN product_allergens pa ON pa.allergen_id = a.id
JOIN products p ON p.id = pa.product_id
JOIN order_items oi ON oi.product_id = p.id
JOIN orders o ON o.id = oi.order_id AND o.customer_id = c.id;
Six joins, each one a little puzzle about which column matches which. Then she wrote the same question in Cypher:
MATCH (c:Customer)-[:ALLERGIC_TO]->(a:Allergen)<-[:CONTAINS]-(p:Product),
(c)-[:PLACED]->(o:Purchase)-[:INCLUDES]->(p)
RETURN c.name, o.id, p.name, a.name
“Read it out loud,” she said.
Arjun did. “A customer is allergic to an allergen. A product contains that allergen. The same customer placed a purchase that includes that product. Return their names.” He stopped. “It’s the corkboard. It’s literally a drawing of the corkboard.”
“That’s the whole design of Cypher. Round brackets look like circles, so they are nodes: (c:Customer). Arrows look like arrows, so they are relationships: -[:PLACED]->. You draw the shape, and the database finds every place in the graph where that shape appears.”
Cypher is the query language created for the Neo4j graph database, and it has since become the most common way to query property graphs. An open standard called GQL, published by ISO in 2024, is closely based on it.
Here is the grammar in one table:
| You write | It means |
|---|---|
(c:Customer) |
A node with label Customer, which we’ll call c |
(:Allergen {name: 'peanuts'}) |
A node with label Allergen whose name property is 'peanuts' |
-[:PLACED]-> |
A relationship of type PLACED, pointing right |
<-[:CONTAINS]- |
A relationship of type CONTAINS, pointing left |
-[i:INCLUDES]-> |
A relationship we call i, so we can read its properties, like i.quantity |
, between patterns |
Both patterns must match at the same time, sharing any variables they have in common |
Two graph databases: Neo4j and Kuzu
Priya compared them to something Arjun already knew. “You know Postgres and SQLite? Postgres is a server: it runs on its own, many apps connect to it over the network. SQLite is a file: it runs inside your program, no server at all. Neo4j and Kuzu are the same split, for graphs.”
Neo4j is the most widely used graph database. It runs as a server, comes with Neo4j Browser (a web page where you type Cypher and see your graph drawn as circles and arrows), and has a free cloud tier called AuraDB. It is what you would use for Crumb’s production system, shared by the support bot, the recall tool and the analytics team.
Kuzu is an embedded graph database: pip install kuzu, point it at a folder, and you have a graph database inside your Python process, no server or Docker needed. It speaks Cypher, is fast for analytical queries, and is perfect for learning, notebooks and single-app agents. Two differences matter. Kuzu wants a schema up front: you declare node and relationship tables before inserting data. And in October 2025 the company behind Kuzu stopped developing it and archived the project. The last release still installs and works well for learning, and community forks continue it, but check its status before you build anything long-lived on it.
You will use Kuzu to learn, because it runs anywhere with one install, then see the Neo4j version, which is nearly identical.
Note
Pause and think. Kuzu makes you declare Customer, Product and the allowed relationships before inserting anything. Neo4j lets you create any node with any label at any time. When would Kuzu’s strictness help you?
Show the answer
When something else is writing to the graph, like an LLM extracting facts from conversations (the next lesson). A schema means a typo such as ALERGIC_TO or a made-up label like Allergy is rejected immediately instead of quietly creating a disconnected island that your queries never find. Neo4j can enforce similar rules with constraints, but you have to add them yourself.
Following one query through the graph
Before writing code, Priya traced how the database actually answers the recall query on a small graph: three customers, four orders, four products, three allergens.
Step 1: (c:Customer)-[:ALLERGIC_TO]->(a:Allergen). Find every customer–allergen pair connected by ALLERGIC_TO.
| c | a |
|---|---|
| Sunita | peanuts |
| Asha | tree nuts |
Rohan has no ALLERGIC_TO edges, so he is gone already.
Step 2: <-[:CONTAINS]-(p:Product). Extend each row to products that contain that allergen.
| c | a | p |
|---|---|---|
| Sunita | peanuts | Classic Diwali Hamper |
| Sunita | peanuts | Peanut Chikki |
| Asha | tree nuts | Classic Diwali Hamper |
| Asha | tree nuts | Walnut Brownie |
Step 3: (c)-[:PLACED]->(o:Purchase)-[:INCLUDES]->(p). Keep only rows where this same customer placed an order that includes this same product. The repeated c and p are the glue.
| c | a | p | o |
|---|---|---|---|
| Sunita | peanuts | Classic Diwali Hamper | O1001 |
Sunita never bought chikki, and Asha bought millet cookies, not hampers or brownies. Three rows dropped out. One row is left.
The key to speed is that the database does not scan everything at each step. Each node keeps direct links to its neighbours, so “which products CONTAIN peanuts?” means reading a short list attached to the peanuts node. Following a relationship costs roughly the same whether the graph has a thousand nodes or ten million. Arjun’s Python scanned every triple for every hop. On his 2.3 million triples, the same five-hop recall query ran in Kuzu in well under a second.
The real code: Cypher in Kuzu, step by step
Install it with uv add kuzu.
Step 1: connect and declare the schema
import kuzu
db = kuzu.Database("./crumb_graph") # a folder on disk; created if missing
conn = kuzu.Connection(db)
def show(query: str, params: dict | None = None) -> None:
"""Run a Cypher query and print each result row."""
result = conn.execute(query, params or {})
while result.has_next():
print(result.get_next())
conn.execute("CREATE NODE TABLE Customer(id STRING, name STRING, PRIMARY KEY (id))")
conn.execute("CREATE NODE TABLE Purchase(id STRING, placed_on DATE, PRIMARY KEY (id))")
conn.execute("CREATE NODE TABLE Product(name STRING, price INT64, PRIMARY KEY (name))")
conn.execute("CREATE NODE TABLE Allergen(name STRING, PRIMARY KEY (name))")
conn.execute("CREATE REL TABLE PLACED(FROM Customer TO Purchase)")
conn.execute("CREATE REL TABLE INCLUDES(FROM Purchase TO Product, quantity INT64)")
conn.execute("CREATE REL TABLE CONTAINS(FROM Product TO Allergen, certainty STRING)")
conn.execute("CREATE REL TABLE ALLERGIC_TO(FROM Customer TO Allergen)")
Every node table needs a primary key, a property whose value must be unique. Relationship tables say which labels they connect, and can carry their own properties: quantity on INCLUDES, and certainty (“yes” or “may”) on CONTAINS, which is how the “may contain traces” warning gets stored.
Why Purchase and not Order? Because ORDER is a reserved word in Cypher (you will use it in ORDER BY in a moment). Kuzu refuses a table called Order outright with a parser error. Picking a different name now saves you from quoting it in every query.
Step 2: CREATE and MERGE
CREATE always makes something new.
conn.execute("CREATE (:Customer {id: 'C001', name: 'Sunita Kulkarni'})")
conn.execute("CREATE (:Customer {id: 'C002', name: 'Rohan Mehta'})")
conn.execute("CREATE (:Customer {id: 'C003', name: 'Asha Iyer'})")
MERGE means “find this, and create it only if it does not exist”. Watch it handle a duplicate:
for allergen in ["peanuts", "tree nuts", "gluten", "peanuts"]: # note the duplicate
conn.execute("MERGE (:Allergen {name: $name})", {"name": allergen})
show("MATCH (a:Allergen) RETURN count(a)")
[3]
Four MERGEs, three allergens. That is why you use MERGE whenever data might arrive more than once, which, with an LLM extracting facts from chats, is always.
See the $name? That is a query parameter. The query text stays fixed and the value is passed separately in a dictionary. Never build Cypher by pasting strings together, for the same reason you never build SQL that way: if a value ever comes from a user or an LLM, a crafted value like peanuts'}) DETACH DELETE (n) // could become part of your query. Parameters are always treated as data, never as code.
Products work the same way, with SET to fill in a property:
for name, price in [("Classic Diwali Hamper", 1499), ("Walnut Brownie", 120),
("Millet Cookies", 180), ("Peanut Chikki", 90)]:
conn.execute("MERGE (p:Product {name: $name}) SET p.price = $price",
{"name": name, "price": price})
Step 3: relationships, the MATCH-then-MERGE pattern
To connect two existing nodes, first MATCH both ends, then MERGE (or CREATE) the edge between them:
for product, allergen, certainty in [
("Classic Diwali Hamper", "peanuts", "may"),
("Classic Diwali Hamper", "tree nuts", "yes"),
("Walnut Brownie", "tree nuts", "yes"),
("Walnut Brownie", "gluten", "yes"),
("Peanut Chikki", "peanuts", "yes"),
]:
conn.execute(
"""MATCH (p:Product {name: $product}), (a:Allergen {name: $allergen})
MERGE (p)-[:CONTAINS {certainty: $certainty}]->(a)""",
{"product": product, "allergen": allergen, "certainty": certainty},
)
conn.execute("""MATCH (c:Customer {id: 'C001'}), (a:Allergen {name: 'peanuts'})
MERGE (c)-[:ALLERGIC_TO]->(a)""")
conn.execute("""MATCH (c:Customer {id: 'C003'}), (a:Allergen {name: 'tree nuts'})
MERGE (c)-[:ALLERGIC_TO]->(a)""")
Orders show one more trick: you can create a new node and two relationships in one pattern.
for order_id, cust, day, product, qty in [
("O1001", "C001", "2026-10-28", "Classic Diwali Hamper", 2),
("O1044", "C002", "2026-10-30", "Classic Diwali Hamper", 1),
("O1050", "C002", "2026-10-30", "Peanut Chikki", 4),
("O1203", "C003", "2026-11-01", "Millet Cookies", 3),
]:
conn.execute(
"""MATCH (c:Customer {id: $cust}), (p:Product {name: $product})
CREATE (c)-[:PLACED]->(:Purchase {id: $order_id, placed_on: date($day)})
-[:INCLUDES {quantity: $qty}]->(p)""",
{"cust": cust, "product": product, "order_id": order_id, "day": day, "qty": qty},
)
Step 4: MATCH, WHERE, RETURN
Now the questions. The simplest:
show("MATCH (c:Customer) RETURN c.name")
['Sunita Kulkarni']
['Rohan Mehta']
['Asha Iyer']
WHERE filters, using properties of nodes and relationships. ORDER BY sorts.
show("""MATCH (c:Customer)-[:PLACED]->(o:Purchase)-[i:INCLUDES]->(p:Product)
WHERE o.placed_on >= date('2026-10-01') AND p.price > 100
RETURN c.name, p.name, i.quantity, o.placed_on
ORDER BY o.placed_on""")
['Sunita Kulkarni', 'Classic Diwali Hamper', 2, datetime.date(2026, 10, 28)]
['Rohan Mehta', 'Classic Diwali Hamper', 1, datetime.date(2026, 10, 30)]
['Asha Iyer', 'Millet Cookies', 3, datetime.date(2026, 11, 1)]
Rohan’s ₹90 chikki order is filtered out by p.price > 100.
The recall query, the one you traced by hand:
show("""MATCH (c:Customer)-[:ALLERGIC_TO]->(a:Allergen)<-[:CONTAINS]-(p:Product),
(c)-[:PLACED]->(o:Purchase)-[:INCLUDES]->(p)
RETURN c.name, o.id, p.name, a.name""")
['Sunita Kulkarni', 'O1001', 'Classic Diwali Hamper', 'peanuts']
Exactly the one row from the trace.
“Which products are safe for someone with a tree-nut allergy?” needs a negative pattern: products for which a certain path does not exist.
show("""MATCH (p:Product)
WHERE NOT EXISTS { MATCH (p)-[:CONTAINS]->(:Allergen {name: $allergen}) }
RETURN p.name, p.price
ORDER BY p.price""", {"allergen": "tree nuts"})
['Peanut Chikki', 90]
['Millet Cookies', 180]
And Meera’s 10 am question becomes an aggregation: sum adds up a property across all matching rows, grouped by whatever else you return.
show("""MATCH (p:Product)<-[i:INCLUDES]-(:Purchase)
RETURN p.name, sum(i.quantity) AS units
ORDER BY units DESC""")
['Peanut Chikki', Decimal('4')]
['Classic Diwali Hamper', Decimal('3')]
['Millet Cookies', Decimal('3')]
To filter that to customers with any nut allergy, add a pattern from the purchase back to an allergic customer. That is one extra line of drawing, not another nest of loops.
Note
Pause and think. The recall query uses c twice and p twice, across two comma-separated patterns. What would go wrong if the second pattern used new names, (c2)-[:PLACED]->(o:Purchase)-[:INCLUDES]->(p2)?
Show the answer
The two patterns would no longer be connected. The database would pair every allergic customer–product row with every purchase in the graph, a “cross product”. With three allergy rows and four purchases, you would get 4 × 4 = 16 rows of nonsense instead of 1, and on real data, millions. Reusing variable names is what says “the same customer, the same product”.
The same thing in Neo4j
Start Neo4j locally with Docker:
docker run --name crumb-neo4j -p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/choose-a-long-password neo4j
Open http://localhost:7474 in a browser, log in, and you can paste the Cypher from this lesson straight into Neo4j Browser and watch your graph appear as circles and arrows. (Skip the CREATE NODE TABLE lines: Neo4j does not need them.) From Python, uv add neo4j, keep the password in .env, and:
import os
from dotenv import load_dotenv
from neo4j import GraphDatabase
load_dotenv()
driver = GraphDatabase.driver(
"bolt://localhost:7687", auth=("neo4j", os.environ["NEO4J_PASSWORD"])
)
records, summary, keys = driver.execute_query(
"""MATCH (c:Customer)-[:ALLERGIC_TO]->(a:Allergen {name: $allergen})<-[:CONTAINS]-(p:Product),
(c)-[:PLACED]->(o:Purchase)-[:INCLUDES]->(p)
RETURN c.name AS customer, o.id AS purchase, p.name AS product""",
allergen="peanuts",
database_="neo4j",
)
for record in records:
print(record["customer"], record["purchase"], record["product"])
driver.close()
Parameters go in as keyword arguments (allergen="peanuts"); the driver’s own options end with an underscore (database_=) so they never clash with your parameter names.
Because Neo4j has no schema, add uniqueness rules yourself before loading data, or MERGE cannot protect you (see below):
CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.id IS UNIQUE;
Library APIs change quickly. If a name has moved, check the docs linked below.
Where it breaks
The MERGE trap. This is the most common Cypher bug, and it bit Arjun on day two. MERGE matches the entire pattern you give it, every property included. He wrote:
MERGE (:Customer {id: 'C001', name: 'Sunita K.'})
Sunita already existed as {id: 'C001', name: 'Sunita Kulkarni'}. The whole pattern did not match, because the name differed, so MERGE tried to create a second Sunita. Kuzu stopped it with Found duplicated primary key value C001. Neo4j without a constraint would have happily created a duplicate, and from then on every query would see two Sunitas, each with half her orders. The fix is a habit: MERGE on the key alone, then SET everything else.
MERGE (c:Customer {id: 'C001'})
ON CREATE SET c.name = 'Sunita Kulkarni'
Variable-length paths can explode. Cypher lets you write -[*1..5]->, “any path of one to five hops”. It is powerful and dangerous: in a well-connected graph, the number of possible paths grows very fast with each extra hop. Always put an upper bound on the length, and prefer named relationship types to wildcards.
Dialects differ. Neo4j and Kuzu both speak Cypher, but not identically. Kuzu needs a schema and calls a node’s label label(n); Neo4j uses labels(n) and has a large function library Kuzu lacks. Test queries on the database you will actually run.
Building SQL-style tables in a graph. A graph shines when relationships are the point. If your question is “total sales per store per day”, that is a table question, and your existing SQL database is the right tool. Keep orders in the order system, and project into the graph only the relationships you need to walk.
Try it yourself
Using the Kuzu graph from this lesson, write Cypher for each question, then run it.
- Which customers have bought something that may contain an allergen (certainty
'may')? - For each allergen, how many products contain it? Sort from most to fewest.
Show the answer
MATCH (c:Customer)-[:PLACED]->(:Purchase)-[:INCLUDES]->(p:Product)-[k:CONTAINS]->(a:Allergen)
WHERE k.certainty = 'may'
RETURN DISTINCT c.name, p.name, a.name
You should get Sunita and Rohan, both with the Classic Diwali Hamper and peanuts. DISTINCT removes repeated rows if someone bought the hamper twice.
MATCH (p:Product)-[:CONTAINS]->(a:Allergen)
RETURN a.name, count(p) AS products
ORDER BY products DESC
You should get tree nuts 2, peanuts 2, gluten 1 (tree nuts and peanuts may appear in either order, since they tie).
Common mistakes
- MERGE with every property. MERGE on the unique key, then SET the rest.
- Pasting values into query strings. Use
$parameters, always, and especially for anything from a user or an LLM. - New variable names where you meant the same node. Reuse variables to connect patterns; otherwise you get a cross product.
- Naming a label
Order. It is a reserved word. PickPurchaseor similar. - Unbounded
[*]paths. Put a maximum length on every variable-length pattern. - No uniqueness constraints in Neo4j. Add them before loading data.
Recap
- A graph database stores relationships so that following one is nearly instant, however large the graph.
- Cypher describes a question as a drawing:
(nodes)in brackets,-[:RELATIONSHIPS]->as arrows;MATCHfinds the shape,WHEREfilters,RETURNpicks the output. CREATEalways adds;MERGEfinds-or-creates, matching the whole pattern, so merge on keys and SET the rest.- Neo4j is a server with a visual browser, ideal for shared production graphs; Kuzu is an embedded, schema-first database, ideal for learning and single-app agents, but no longer actively developed by its original team.
- Use parameters, bound your paths, and reuse variable names to connect patterns.
At the end of the day Arjun showed Meera the Neo4j Browser. The Diwali hamper sat in the middle of the screen, with lines fanning out to orders and allergens. “It’s the corkboard, but the database draws it. And instead of writing a new program for every question you ask, I write three lines describing the shape of the answer.” He typed her 10 am question; the answer came back before he finished turning his chair.
Meera tapped the screen. “Where do these allergy lines come from? Did someone type them in?” They had: Arjun had copied them from memory by hand. Thousands more were mentioned in chats every week. Priya smiled. “Then tomorrow, the agent draws its own lines.” Next lesson, Arjun gives the support agent graph memory: it extracts relationships from conversations, works out that “groundnuts” and “peanuts” are the same thing, and queries the graph when it needs to.
Go deeper
- Neo4j documentation, including getting started, AuraDB Free and the Python driver.
- Cypher manual, the full reference for MATCH, MERGE, WHERE and more.
- Kuzu on GitHub, the source and documentation links for the embedded database used here.
- LangChain documentation, which has graph database integrations for Neo4j and others.