<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ RONI DAS - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ RONI DAS - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Thu, 20 Aug 2026 22:01:59 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/ronidas/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Knowledge Graph with Python and Neo4j [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ Most of the data you work with is really about relationships. A customer belongs to an account. An incident affects a service. An engineer owns a repository. You store all of that in tables, and for a ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-knowledge-graph-with-python-and-neo4j-handbook/</link>
                <guid isPermaLink="false">6a873f054742a7cecc0617f4</guid>
                
                    <category>
                        <![CDATA[ knowledge graph ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Neo4j ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ database ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ RONI DAS ]]>
                </dc:creator>
                <pubDate>Thu, 20 Aug 2026 17:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f21a22a9-c9e9-4ed6-899e-60639e8d2c01.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most of the data you work with is really about relationships. A customer belongs to an account. An incident affects a service. An engineer owns a repository. You store all of that in tables, and for a long time that works perfectly well.</p>
<p>Then someone asks a question like this one:</p>
<blockquote>
<p><strong>Which engineers have recent context on the services affected by last night's incident?</strong></p>
</blockquote>
<p>That question is easy to understand and hard to write. In SQL it becomes four or five joins. Each join builds an intermediate result that is wider than the answer you actually want, and then throws most of it away. The query gets slower as your tables grow, and it gets harder to read every time you come back to it.</p>
<p>A graph database is built for that question.</p>
<p>In this handbook you will build a working knowledge graph from an empty database, load real data into it from Python, and write the queries that make the idea click.</p>
<p>You'll also learn the parts that tutorials usually skip: how to decide what becomes a node, why your first data model is probably wrong, how to make loading fast, and how to read a query plan when something is slow.</p>
<p>You don't need any graph experience to follow along. If you've written SQL, you already know enough.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943177482/cf9ad7b4-0762-4099-a1b2-e789768ea08a.png" alt="join vs traversal" style="display:block;margin:0 auto" width="3360" height="2356" loading="lazy">

<p>The same question asked of the same data, two ways. On the left, a relational database matches rows at query time and throws most of them away. On the right, a graph follows connections that were already stored when the data was written. The rest of this handbook is really about that difference.</p>
<p>All the code and the dataset are in one place: <a href="https://github.com/ronidas39/knowledge-graph-python-neo4j">github.com/ronidas39/knowledge-graph-python-neo4j</a>. Every script in this handbook runs, and every number is measured against the committed dataset. You can clone it and reproduce it all as you read.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-data-well-use">The Data We'll Use</a></p>
</li>
<li><p><a href="#heading-the-words-youll-need">The Words You'll Need</a></p>
</li>
<li><p><a href="#heading-what-youre-building">What You're Building</a></p>
</li>
<li><p><a href="#heading-what-a-graph-database-actually-stores">What a Graph Database Actually Stores</a></p>
</li>
<li><p><a href="#heading-index-free-adjacency-the-idea-that-makes-it-fast">Index-free Adjacency, the Idea That Makes it Fast</a></p>
</li>
<li><p><a href="#heading-when-a-graph-is-the-wrong-choice">When a Graph is the Wrong Choice</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-neo4j-and-the-python-driver">How to Set Up Neo4j and the Python Driver</a></p>
</li>
<li><p><a href="#heading-the-modeling-decision-that-matters-most">The Modeling Decision That Matters Most</a></p>
</li>
<li><p><a href="#heading-three-modeling-mistakes-almost-everyone-makes">Three Modeling Mistakes Almost Everyone Makes</a></p>
</li>
<li><p><a href="#heading-modeling-backwards-from-your-questions">Modeling Backwards From Your Questions</a></p>
</li>
<li><p><a href="#heading-three-modeling-patterns-worth-knowing-early">Three Modeling Patterns Worth Knowing Early</a></p>
</li>
<li><p><a href="#heading-loading-data-from-python">Loading Data From Python</a></p>
</li>
<li><p><a href="#heading-loading-at-scale-with-unwind">Loading at Scale with UNWIND</a></p>
</li>
<li><p><a href="#heading-loading-from-a-csv-file">Loading From a CSV File</a></p>
</li>
<li><p><a href="#heading-updating-and-deleting">Updating and Deleting</a></p>
</li>
<li><p><a href="#heading-working-with-neo4j-data-types">Working with Neo4j Data Types</a></p>
</li>
<li><p><a href="#heading-your-first-cypher-queries">Your First Cypher Queries</a></p>
</li>
<li><p><a href="#heading-the-multi-hop-query-that-justifies-the-whole-thing">The Multi-Hop Query That Justifies the Whole Thing</a></p>
</li>
<li><p><a href="#heading-variable-length-paths-and-how-to-keep-them-safe">Variable Length Paths and How to Keep Them Safe</a></p>
</li>
<li><p><a href="#heading-what-an-index-actually-is">What an Index Actually is</a></p>
</li>
<li><p><a href="#heading-constraints-and-the-trap-that-will-catch-you">Constraints, and the Trap That Will Catch You</a></p>
</li>
<li><p><a href="#heading-what-the-planner-does-with-your-query">What the Planner Does With Your Query</a></p>
</li>
<li><p><a href="#heading-six-problems-youll-actually-hit">Six Problems You'll Actually Hit</a></p>
</li>
<li><p><a href="#heading-transactions-and-what-happens-when-things-fail">Transactions and What Happens When Things Fail</a></p>
</li>
<li><p><a href="#heading-testing-code-that-talks-to-a-graph">Testing Code That Talks to a Graph</a></p>
</li>
<li><p><a href="#heading-from-graph-to-knowledge-graph">From Graph to Knowledge Graph</a></p>
</li>
<li><p><a href="#heading-why-ai-systems-keep-rediscovering-graphs">Why AI Systems Keep Rediscovering Graphs</a></p>
</li>
<li><p><a href="#heading-building-a-knowledge-graph-from-text">Building a Knowledge Graph from Text</a></p>
</li>
<li><p><a href="#heading-the-complete-script">The Complete Script</a></p>
</li>
<li><p><a href="#heading-where-to-go-next">Where to Go Next</a></p>
</li>
</ul>
<h2 id="heading-the-data-well-use">The Data We'll Use</h2>
<p>Every example in this handbook runs against the same small dataset, so you can follow along from the first query to the last without ever loading something new.</p>
<p>It models a software team, because that's a domain most readers can check against their own experience. <strong>It's entirely made up, thought:</strong> no real company, service, or person appears in it, and the email addresses use <code>example.com</code> (this is reserved by RFC 2606 precisely so documentation can't accidentally point at somebody's real address).</p>
<table>
<thead>
<tr>
<th>Kind</th>
<th>How many</th>
<th>What they are</th>
</tr>
</thead>
<tbody><tr>
<td><code>Engineer</code></td>
<td>6</td>
<td>Five who own a service, and one who owns nothing</td>
</tr>
<tr>
<td><code>Service</code></td>
<td>4</td>
<td>payments, checkout, auth, search</td>
</tr>
<tr>
<td><code>Team</code></td>
<td>3</td>
<td>Platform, Commerce, Discovery</td>
</tr>
<tr>
<td><code>Incident</code></td>
<td>1</td>
<td>INC-4471, which affected payments and checkout</td>
</tr>
</tbody></table>
<p>The data are connected by four relationship types:</p>
<table>
<thead>
<tr>
<th>Relationship</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>OWNS</code></td>
<td>An engineer is responsible for a service</td>
</tr>
<tr>
<td><code>MEMBER_OF</code></td>
<td>An engineer belongs to a team</td>
</tr>
<tr>
<td><code>DEPENDS_ON</code></td>
<td>A service needs another service to work</td>
</tr>
<tr>
<td><code>AFFECTS</code></td>
<td>An incident hits a service</td>
</tr>
</tbody></table>
<p>Fourteen nodes and sixteen relationships for thirty records in total. That's deliberately tiny, because at this size you can hold the whole graph in your head and check every answer by eye. This is exactly what you want while the ideas are new. Nothing here behaves differently at a million nodes. It's only slower to verify.</p>
<p>Two details are worth noticing before they matter later. <strong>One engineer owns nothing</strong>, which is the only reason the <code>OPTIONAL MATCH</code> example has anything to show. And <strong>Commerce has exactly one member, who is also an owner</strong>, which turns out to expose a Cypher trap that silently drops rows. Neither is an accident.</p>
<p>The complete loading script is at the end of this handbook, and you can run it before reading any further if you'd rather have the data in front of you.</p>
<h2 id="heading-the-words-youll-need">The Words You'll Need</h2>
<p>Every term in this handbook is defined where it first appears, but it helps to have them in one place. If you've never touched a graph database, read this table once and come back to it whenever a word stops making sense.</p>
<table>
<thead>
<tr>
<th>Term</th>
<th>What it means</th>
<th>Official reference</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Graph</strong></td>
<td>A collection of things and the connections between them. In computing it means data stored as points joined by lines, not as rows in tables. Your contacts app is a graph. So is a road map.</td>
<td><a href="https://neo4j.com/docs/getting-started/">Getting Started</a></td>
</tr>
<tr>
<td><strong>Graph database</strong></td>
<td>A database that stores those connections directly on disk, as records, instead of working them out at query time by matching values. Neo4j is one.</td>
<td><a href="https://neo4j.com/docs/getting-started/">Getting Started</a></td>
</tr>
<tr>
<td><strong>Node</strong></td>
<td>One thing in your data. An engineer, a service, an order. The rough equivalent of a row.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/patterns/">Patterns</a></td>
</tr>
<tr>
<td><strong>Relationship</strong></td>
<td>A stored connection between exactly two nodes. It always has a direction and a type, such as <code>OWNS</code>. The rough equivalent of a foreign key, except it's a real record you can walk along.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/patterns/">Patterns</a></td>
</tr>
<tr>
<td><strong>Property</strong></td>
<td>A key and value stored on a node or a relationship, such as <code>name: "Ada"</code>. The rough equivalent of a column value.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/values-and-types/temporal/">Values and types</a></td>
</tr>
<tr>
<td><strong>Label</strong></td>
<td>A tag that groups nodes, such as <code>Engineer</code>. It's how you say "look only at engineers". The rough equivalent of a table name.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/patterns/">Patterns</a></td>
</tr>
<tr>
<td><strong>Cypher</strong></td>
<td>Neo4j's query language, the equivalent of SQL. Instead of describing joins, you draw the shape you're looking for, like <code>(a)-[:OWNS]-&gt;(b)</code>.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/">Cypher Manual</a></td>
</tr>
<tr>
<td><strong>Traversal</strong></td>
<td>Following relationships from one node to the next. This is what a graph database does instead of joining.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/patterns/">Patterns</a></td>
</tr>
<tr>
<td><strong>Hop</strong></td>
<td>One step along one relationship. "Three hops away" means three relationships between the two nodes.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/">Cypher Manual</a></td>
</tr>
<tr>
<td><strong>Bolt</strong></td>
<td>The network protocol Neo4j speaks to drivers, the way HTTP is the protocol a browser speaks. It runs on port 7687 by default, which is why connection strings look like <code>bolt://host:7687</code>.</td>
<td><a href="https://neo4j.com/docs/bolt/current/">Bolt protocol</a></td>
</tr>
<tr>
<td><strong>Driver</strong></td>
<td>The library your program uses to talk to the database over Bolt. For Python that's the <code>neo4j</code> package.</td>
<td><a href="https://neo4j.com/docs/python-manual/current/">Python driver manual</a></td>
</tr>
<tr>
<td><strong>Neo4j Browser</strong></td>
<td>The web interface for running Cypher and seeing results drawn as a graph. It ships with the database on port 7474.</td>
<td><a href="https://neo4j.com/docs/operations-manual/current/">Operations Manual</a></td>
</tr>
<tr>
<td><strong>Aura</strong></td>
<td>Neo4j's managed cloud service, where they run the database for you. Has a free tier.</td>
<td><a href="https://neo4j.com/docs/aura/">Aura docs</a></td>
</tr>
<tr>
<td><strong>MERGE</strong></td>
<td>The Cypher command meaning "find this, or create it if it's not there". The single most important command for loading data safely.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/clauses/merge/">MERGE</a></td>
</tr>
<tr>
<td><strong>Constraint</strong></td>
<td>A rule the database enforces, such as "every engineer email must be unique". Creating one also creates an index.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/schema/constraints/">Constraints</a></td>
</tr>
<tr>
<td><strong>Index</strong></td>
<td>A lookup structure that lets the database find a node by a property value without checking every node.</td>
<td><a href="https://neo4j.com/docs/cypher-manual/current/planning-and-tuning/">Planning and tuning</a></td>
</tr>
<tr>
<td><strong>Index-free adjacency</strong></td>
<td>The property that makes traversal fast: because relationships are stored as records pointing at both nodes, following one is a read rather than a search.</td>
<td><a href="https://neo4j.com/docs/getting-started/">Getting Started</a></td>
</tr>
</tbody></table>
<p>Two conventions are used throughout, and they're worth knowing before you meet them:</p>
<p><strong>Relationship types are written in</strong> <code>SCREAMING_SNAKE_CASE</code> (<code>OWNS</code>, <code>MEMBER_OF</code>) and <strong>labels in</strong> <code>PascalCase</code> (<code>Engineer</code>, <code>Service</code>). Neo4j doesn't enforce either, but every codebase and every piece of documentation follows them, so matching the convention makes your queries readable to everyone else.</p>
<p>The full language reference lives in the <a href="https://neo4j.com/docs/cypher-manual/current/">Cypher Manual</a>, and it's genuinely good. When something in this handbook raises a question, that's where to look next.</p>
<h2 id="heading-what-youre-building">What You're Building</h2>
<p>Before any of the parts, here's the shape of the whole thing. Four moving parts: the data you start with, the Python driver that loads it, the graph that Neo4j stores, and the answers that come back out in a form a language model can use without inventing anything.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943179978/21db905b-4dcc-4b02-ad35-e8ef6c8bb7a8.png" alt="system architecture" style="display:block;margin:0 auto" width="3720" height="1316" loading="lazy">

<p>Reading left to right: <strong>your data</strong> is CSV files, an existing database, or plain text a model pulls triples out of. <strong>The Python driver</strong> is one driver object for the whole application, <code>execute_query()</code> to run Cypher, and UNWIND to batch a thousand rows into one round trip. <strong>Neo4j</strong> is where it lands, and it runs identically on Docker, EC2 or Aura because only the connection URI changes. Constraints and indexes are created here before the load, never after.</p>
<p>What you get back is multi-hop answers that hold up at 75,500 nodes, with a path behind each one you can cite.</p>
<p>Three things worth noting: first, you don't need all of it on day one, since Docker, the driver and a handful of nodes is already a working system. Also, every number here was measured against the committed 75,500 node dataset on Neo4j 5.26.29 Community, not estimated. And the arrows only go one way, because nothing in this handbook writes back from the model into the graph, which is a boundary worth keeping until you trust the extraction.</p>
<p><strong>On which version to install:</strong> don't worry about matching mine exactly. Everything here was measured on Neo4j 5.26.29 Community, and 5.26 is the long-term support release, which Neo4j supports until June 2028. From 2025 onward they name releases by date instead, so you'll see 2025.01, 2025.02 and so on rather than 5.27. Those are fully compatible with the Cypher and the drivers used here, so the queries in this handbook run unchanged on them.</p>
<p>Two things do vary, and neither is about the version number. Timings depend on your machine, so treat my numbers as ratios rather than targets. And the constraints beyond <code>IS UNIQUE</code> need Enterprise, which is an edition difference rather than a version one. The <code>neo4j:5</code> Docker tag used below gives you the latest 5.x, which is a good default.</p>
<p>You don't need all of it on day one. Docker, the driver, and a handful of nodes is already a working system. Everything else in this handbook is what you add when the graph stops fitting in your head.</p>
<h2 id="heading-what-a-graph-database-actually-stores">What a Graph Database Actually Stores</h2>
<p>A graph database stores three things. That's genuinely all of it.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943183278/cd2c6362-b70a-4377-989b-6494f32b1df7.png" alt="graph anatomy" style="display:block;margin:0 auto" width="3360" height="2082" loading="lazy">

<p>The drawing works one concrete example. An <code>Engineer</code> node holds <code>name: "Ada"</code> and an email. An arrow labelled <code>OWNS</code> carries <code>since: 2026-03-01</code>. A <code>Service</code> node holds <code>name: "payments"</code>. Callouts point at each piece in turn. They name which part is the node, which is the label, which is the property, and which is the relationship. The last one they name is the property that sits on the relationship rather than on either end.</p>
<p>The panel underneath contrasts that last one with tables, and it's the piece with no clean relational equivalent. To record that Ada has owned payments since March, a relational schema needs a join table you invented only because rows can't point at each other.</p>
<p><strong>Nodes</strong> are the things in your domain: an engineer, service, incident, or team.</p>
<p><strong>Relationships</strong> connect exactly two nodes. Every relationship has a direction and a type. An engineer OWNS a service. An incident AFFECTS a service. The direction is stored, and you'll see shortly that you can traverse a relationship in either direction regardless of how it was stored.</p>
<p><strong>Properties</strong> are key and value pairs. They live on nodes and on relationships. An engineer node might carry a name and an email. An OWNS relationship might carry the date that ownership started, which is a fact about the connection rather than about either end of it.</p>
<p>Nodes also carry <strong>labels</strong>, which group them. A node labelled <code>Engineer</code> is an engineer. A node can have more than one label. Labels are how you tell the database to look only at engineers instead of scanning everything you have ever stored.</p>
<p>Here's the same small piece of information in both worlds.</p>
<table>
<thead>
<tr>
<th>Concept</th>
<th>Relational</th>
<th>Graph</th>
</tr>
</thead>
<tbody><tr>
<td>A thing</td>
<td>A row in a table</td>
<td>A node</td>
</tr>
<tr>
<td>The kind of thing</td>
<td>Which table it is in</td>
<td>A label on the node</td>
</tr>
<tr>
<td>A fact about the thing</td>
<td>A column value</td>
<td>A property</td>
</tr>
<tr>
<td>A connection</td>
<td>A foreign key, or a join table</td>
<td>A relationship, stored on disk</td>
</tr>
<tr>
<td>A fact about a connection</td>
<td>A column on the join table</td>
<td>A property on the relationship</td>
</tr>
</tbody></table>
<p>That last row is worth pausing on. In a relational schema, saying "Ada has owned payments since March" needs a column on the join table, and that join table is an implementation detail you invented to work around the fact that rows can't point at each other. In a graph, it's a property on the relationship, which is exactly where the fact belongs.</p>
<h2 id="heading-index-free-adjacency-the-idea-that-makes-it-fast">Index-free Adjacency, the Idea That Makes it Fast</h2>
<p>This is the one piece of theory worth understanding properly, because everything else follows from it.</p>
<p>In a relational database, a relationship between two rows is a <strong>value you match at query time</strong>. The <code>orders</code> table has a <code>customer_id</code>, and when you join, the database looks up matching values. It's good at this. There are indexes and query planners and decades of optimisation behind it. But it's still, fundamentally, a search.</p>
<p>In a graph database, a relationship is a <strong>record stored on disk that points directly at both of its nodes</strong>. When the database walks from a node to its neighbour, it doesn't search for the neighbour. It follows a pointer.</p>
<p>The name for this is <strong>index-free adjacency</strong>.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943186070/6cdb2ee9-51ff-4970-b0e8-a4db0b61fd15.png" alt="relationship on disk" style="display:block;margin:0 auto" width="3320" height="2168" loading="lazy">

<p>This is where the connection physically lives. Relationally it's a value, a foreign key the database has to find. In a graph it's a pointer beside the node, so following it is a read rather than a search.</p>
<p>The consequence is the thing that matters. Because traversal follows pointers out of nodes you already have in hand, the cost of a traversal is proportional to the size of the part of the graph you touch, not the size of the graph in total. A database ten times larger doesn't make a two-hop query slower.</p>
<p>Compare that with a join. Each additional join reads another table and builds a wider intermediate result. Adding a hop adds work that scales with your data volume.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943188611/44add40c-e831-4dd0-81a1-cbb900d81dd7.png" alt="cost curves" style="display:block;margin:0 auto" width="3120" height="1968" loading="lazy">

<p>Two curves on the same axes: cost of one query against how much data the database holds. The four-join line climbs steeply as the data grows. The two-hop traversal line stays low and nearly flat. At the small end they sit almost on top of each other, which is the note the figure makes: on a laptop with test data both look fine, and that's why this surprises people in production.</p>
<p>One key caveat drawn on the figure itself: <strong>The axes carry no units, because none were measured, and no benchmark is being claimed.</strong> The point is the shape of the two curves, which follows from how each one works.</p>
<p>This is why the difference shows up as your data grows rather than on your laptop with test data. Both approaches look fine on ten thousand rows.</p>
<p>A relational database is excellent at answering questions about <strong>sets of rows</strong>. A graph database is excellent at answering questions about <strong>paths between things</strong>. Most systems have both kinds of question, which is why most companies end up running both kinds of database.</p>
<h2 id="heading-when-a-graph-is-the-wrong-choice">When a Graph is the Wrong Choice</h2>
<p>Every graph tutorial on the internet tells you graphs are wonderful. Here's the other half, because knowing when not to use something is what separates an engineer from an enthusiast.</p>
<p><strong>Use something else when your queries are aggregations over big uniform sets.</strong> "Total revenue by region by month" is a relational or columnar question. A graph will answer it, and it will be slower and more awkward than a warehouse would be.</p>
<p><strong>Use something else when your data has no meaningful relationships.</strong> A table of log lines is a table of log lines. Modeling each one as a node connected to nothing buys you nothing and costs you storage.</p>
<p><strong>Use something else when you need one thing to be extremely fast and nothing else.</strong> A key-value store answering "give me session 4471" will beat everything, because it does exactly one thing.</p>
<p>A graph is the right choice when the connections are the point. Fraud rings, recommendations, access control, dependency analysis, lineage, org structures, supply chains, and knowledge graphs for AI systems. These share one trait: the interesting questions are about how things connect, and the number of hops isn't fixed in advance.</p>
<p>If your query never goes more than one hop, you probably don't need a graph. If your query goes three hops and the number of hops depends on the data, you almost certainly do.</p>
<h2 id="heading-how-to-set-up-neo4j-and-the-python-driver">How to Set Up Neo4j and the Python Driver</h2>
<p>For this project, you need a database and a driver.</p>
<h3 id="heading-option-a-neo4j-aura-no-installation">Option A: Neo4j Aura, No Installation</h3>
<p>The fastest route is <strong>Neo4j Aura</strong>, Neo4j's managed cloud service. There's nothing to install, and there's a genuinely free tier.</p>
<p>Go to <code>console.neo4j.io</code>, sign in, and choose <strong>Create instance</strong>. You'll be shown several tiers side by side, and this is the screen to read carefully rather than click through:</p>
<table>
<thead>
<tr>
<th>Tier</th>
<th>Cost</th>
<th>What you get</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Free</strong></td>
<td>$0</td>
<td>Up to 200,000 nodes and 400,000 relationships. Limited memory and vCPU. Limited backups. <strong>Auto-deleted after 30 days of inactivity.</strong></td>
</tr>
<tr>
<td>Professional</td>
<td>From $0.09 per GB-hour</td>
<td>Monitoring, predefined roles, 7 day backups, graph algorithms</td>
</tr>
<tr>
<td>Business Critical</td>
<td>From $0.20 per GB-hour</td>
<td>Advanced monitoring, custom roles, IP filtering, SSO, 30 day backups, 99.95% uptime SLA</td>
</tr>
</tbody></table>
<p>Pick Free for this handbook. 200,000 nodes is far more than anything here needs.</p>
<p><strong>Watch the running total at the bottom of that page.</strong> The console shows a live hourly rate and a projected monthly cost, and both update as you change tiers.</p>
<p>A paid tier can read as roughly $0.36 per hour. That is about $259 a month if you leave it running. It's very easy to click past that while concentrating on the instance name. If you only want to learn, the number at the bottom should say $0.</p>
<p>Once you confirm, Aura shows you a credentials dialog exactly once:</p>
<ul>
<li><p>Username, which is always <code>neo4j</code></p>
</li>
<li><p>A long generated password</p>
</li>
<li><p>A warning that reads "Note that the password will not be available after this point"</p>
</li>
</ul>
<p>That warning is literal. Click <strong>Download and continue</strong> to save a <code>.txt</code> file with the connection details, or copy the password somewhere safe first. If you lose it, you can't retrieve it, you can only reset it.</p>
<p>The downloaded file looks like this:</p>
<pre><code class="language-bash">NEO4J_URI=neo4j+s://xxxxxxxx.databases.neo4j.io
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=&lt;your generated password&gt;
NEO4J_DATABASE=neo4j
AURA_INSTANCEID=xxxxxxxx
AURA_INSTANCENAME=demo
</code></pre>
<p>The instance then shows <strong>Creating...</strong> in the console and takes a few minutes. During that window the hostname already resolves in DNS and port 7687 already accepts TCP connections, but the database behind it isn't up yet, so a driver will fail with <code>Unable to retrieve routing information</code>. That error during the first few minutes means "not ready", not "misconfigured". Wait and retry rather than changing your connection string.</p>
<p>The <code>+s</code> in <code>neo4j+s://</code> means the connection is encrypted and the server's certificate is verified. Aura requires encryption, and that verification is the only difference from a local instance that matters for this handbook.</p>
<h3 id="heading-if-aura-refuses-to-connect-and-youre-sure-its-running">If Aura Refuses to Connect and You're Sure it's Running</h3>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943191739/06f47fe4-7bf9-4914-9667-32d8e16f095c.png" alt="tls interception" style="display:block;margin:0 auto" width="3360" height="1950" loading="lazy">

<p>Aura is healthy, the browser connects, Python won't. Something on the network, usually a corporate proxy, VPN or antivirus, terminates your TLS connection, reads it, and re-encrypts it with its own certificate. Your browser was told to trust that certificate. The driver wasn't, so it correctly refuses and you get <code>ServiceUnavailable: Unable to retrieve routing information</code> while the database was fine throughout.</p>
<p>There's one failure here that wastes people hours, because the error message points at the wrong thing.</p>
<p>You connect, and the driver says:</p>
<pre><code class="language-text">neo4j.exceptions.ServiceUnavailable: Unable to retrieve routing information
</code></pre>
<p>"Routing" sounds like a cluster problem, so people go and check the instance, recreate it, and try a different region. Often none of that is the cause.</p>
<p>Check the certificate directly:</p>
<pre><code class="language-python">import socket, ssl
ctx = ssl.create_default_context()
with socket.create_connection(("xxxxxxxx.databases.neo4j.io", 7687), timeout=15) as raw:
    with ctx.wrap_socket(raw, server_hostname="xxxxxxxx.databases.neo4j.io") as s:
        print("TLS OK", s.version())
</code></pre>
<p>If that prints something like <code>CERTIFICATE_VERIFY_FAILED: self-signed certificate in certificate chain</code>, the database is fine. <strong>Something on your network is intercepting TLS.</strong> Corporate proxies, some VPNs, and several antivirus products do this: they terminate your encrypted connection, inspect it, and re-encrypt it with their own certificate. Your browser trusts that certificate because the software installed its root into the system store. Python does not, because it ships its own trust store.</p>
<p>You have three options, in order of preference.</p>
<p><strong>1. Add the interceptor's root certificate to Python's trust store</strong>, which is the correct fix and keeps verification on:</p>
<pre><code class="language-bash">export SSL_CERT_FILE=/path/to/corporate-root.pem
</code></pre>
<p><strong>2. Use a network that's not intercepted</strong>, such as a mobile hotspot, which is the quickest way to confirm the diagnosis.</p>
<p><strong>3. Fall back to</strong> <code>neo4j+ssc://</code>, which encrypts but accepts a self-signed certificate:</p>
<pre><code class="language-python">driver = GraphDatabase.driver("neo4j+ssc://xxxxxxxx.databases.neo4j.io", auth=AUTH)
</code></pre>
<p>The <code>ssc</code> stands for self-signed certificate. Your traffic is still encrypted, but the driver no longer checks who's on the other end, so anyone already intercepting can keep doing it undetected. <strong>Use it to unblock yourself while learning, and don't ship it to production.</strong></p>
<p>Every Aura query in this handbook was verified over exactly this route, on a network that turned out to be running TLS inspection.</p>
<h3 id="heading-option-b-docker-one-command">Option B: Docker, One Command</h3>
<p>If you would rather keep everything on your machine, Docker is the shortest path. Everything in this handbook was written and tested against exactly this container.</p>
<pre><code class="language-bash">docker run -d --name neo4j-graphbook \
  -p 7474:7474 -p 7687:7687 \
  -v neo4jdata:/data \
  neo4j:5
</code></pre>
<p>Port 7474 serves Neo4j Browser, the query UI you'll use in a moment. Port 7687 is Bolt, the binary protocol the Python driver speaks.</p>
<p>Set the initial password on the volume <strong>before</strong> the database starts for the first time, because the setting is ignored once a database exists:</p>
<pre><code class="language-bash">docker volume create neo4jdata
docker run --rm -v neo4jdata:/data neo4j:5 \
  neo4j-admin dbms set-initial-password yourpassword
</code></pre>
<p>Then open <code>http://localhost:7474</code> and sign in with <code>neo4j</code> and that password.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943193881/fc6804d9-9b51-40d1-8090-e96668ad8ce8.png" alt="port shadowing" style="display:block;margin:0 auto" width="3320" height="2128" loading="lazy">

<p>We have two panels here.</p>
<ol>
<li><p>What you believe: your script dials <code>bolt://localhost:7687</code> and reaches the Docker container running <code>neo4j:5</code> with your data.</p>
</li>
<li><p>What's happening: a native Neo4j, usually Neo4j Desktop, is already listening on <code>127.0.0.1:7687</code>, so it shadows the Docker port mapping and your container is never reached at all. Your script authenticates against that other database, and the driver reports an authentication failure. Nothing in that message mentions ports.</p>
</li>
</ol>
<p>Find out who holds it with <code>lsof -nP -iTCP:7687 -sTCP:LISTEN</code>. If something else owns it, move your container with <code>docker run -p 7475:7474 -p 7688:7687 neo4j:5</code> and connect on 7688 instead.</p>
<p><strong>A trap worth knowing about:</strong> if you already run Neo4j Desktop, or any other Neo4j, it's probably already listening on 7687. A native process holding that port takes precedence over a Docker port mapping, and the symptom is confusing: the container starts fine, Browser loads, and your driver reports an authentication failure, because it's quietly talking to the <em>other</em> database.</p>
<p>If that happens, map the container somewhere else with <code>-p 7475:7474 -p 7688:7687</code> and point your driver at <code>bolt://localhost:7688</code>. Check what holds the port with <code>lsof -nP -iTCP:7687 -sTCP:LISTEN</code>.</p>
<h3 id="heading-option-c-a-cloud-server-you-control">Option C: a Cloud Server You Control</h3>
<p>There is a third option worth walking through, because it's closer to how you would actually run this for a team, and because it teaches you what the other two hide. You put Neo4j on a small Linux server in the cloud.</p>
<p>Everything below is exactly what I ran to produce the screenshots in this handbook. It uses AWS, but the shape is identical on any provider.</p>
<h4 id="heading-step-1-find-out-which-account-youre-about-to-spend-money-in">Step 1. Find out which account you're about to spend money in.</h4>
<p>This sounds obvious and it's the step people skip.</p>
<pre><code class="language-bash">aws sts get-caller-identity
aws configure get region
</code></pre>
<p>The first prints the account number and the user. The second prints the region. If either isn't what you expected, stop and fix your profile before creating anything.</p>
<h4 id="heading-step-2-find-the-current-linux-image">Step 2. Find the current Linux image.</h4>
<p>Instead of hardcoding an image ID from a blog post, ask AWS for the latest one:</p>
<pre><code class="language-bash">aws ssm get-parameters \
  --names /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
  --query 'Parameters[0].Value' --output text
</code></pre>
<p>An AMI is a machine image, the template your server boots from. Image IDs differ per region and change over time, which is why you look it up rather than copy it.</p>
<h4 id="heading-step-3-create-a-firewall-that-only-lets-you-in">Step 3. Create a firewall that only lets you in.</h4>
<p>This is the step that matters most, and it's the one that gets people breached.</p>
<pre><code class="language-bash">MYIP=$(curl -s https://checkip.amazonaws.com)/32

SG=$(aws ec2 create-security-group \
  --group-name neo4j-demo-sg \
  --description "Neo4j demo, locked to my IP" \
  --vpc-id &lt;your-default-vpc-id&gt; \
  --query GroupId --output text)

for port in 22 7474 7687; do
  aws ec2 authorize-security-group-ingress \
    --group-id $SG --protocol tcp --port $port --cidr $MYIP
done
</code></pre>
<p>A security group is a firewall attached to the server. Port 22 is SSH, 7474 is Neo4j Browser, 7687 is Bolt. The <code>--cidr $MYIP</code> part restricts every one of them to your own address.</p>
<p><strong>Don't replace that with</strong> <code>0.0.0.0/0</code><strong>.</strong> That means "the entire internet". Databases left open on default ports are found by automated scanners within hours, not weeks, and an open Neo4j is a full read and write handle on your data.</p>
<h4 id="heading-step-4-boot-the-server-and-install-neo4j-automatically">Step 4. Boot the server and install Neo4j automatically.</h4>
<p>A user-data script is a shell script the server runs once, on first boot, as root.</p>
<pre><code class="language-bash">#!/bin/bash
dnf install -y docker
systemctl enable --now docker

# ask the instance what its own public address is
TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 300")
PUBIP=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/public-ipv4)

docker run -d --name neo4j --restart unless-stopped \
  -p 7474:7474 -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/ChangeThisPassword \
  -e NEO4J_server_default__listen__address=0.0.0.0 \
  -e NEO4J_server_bolt_advertised__address=$PUBIP:7687 \
  -e NEO4J_server_http_advertised__address=$PUBIP:7474 \
  neo4j:5
</code></pre>
<p>Three details in there are the whole reason this section exists.</p>
<p><code>169.254.169.254</code> is the instance metadata service, a special address every AWS server can reach to ask questions about itself. Here it is asking for its own public IP.</p>
<p><code>NEO4J_server_default__listen__address=0.0.0.0</code> tells Neo4j to accept connections from outside the machine. By default it listens only on localhost, and without this your server would be running perfectly and refusing every connection.</p>
<p>The <strong>advertised address</strong> settings are the subtle one. Neo4j Browser is a web page served by the server, and when it opens a Bolt connection it uses the address the server advertises. If the server advertises <code>localhost</code>, the Browser running in <em>your</em> laptop's browser will try to connect to <em>your</em> laptop. Setting the advertised address to the public IP is what makes a remote Browser work at all.</p>
<p>Note the double underscores. In Neo4j's environment variables, a dot in a config key becomes an underscore and a real underscore becomes a double underscore, so <code>server.default_listen_address</code> becomes <code>NEO4J_server_default__listen__address</code>.</p>
<h4 id="heading-step-5-launch-it">Step 5. Launch it.</h4>
<pre><code class="language-bash">aws ec2 run-instances \
  --image-id &lt;ami-from-step-2&gt; \
  --instance-type t3.medium \
  --key-name &lt;your-key-pair&gt; \
  --security-group-ids $SG \
  --associate-public-ip-address \
  --user-data file://userdata.sh \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=neo4j-demo}]'
</code></pre>
<p><code>t3.medium</code> gives 2 CPUs and 4GB of memory, which is comfortable for learning. Neo4j will start on 1GB but you'll fight it.</p>
<p>Boot, package install, and image pull took about 90 seconds. Poll until the Browser answers rather than guessing:</p>
<pre><code class="language-bash">until curl -s -o /dev/null -w "%{http_code}" http://&lt;public-ip&gt;:7474 | grep -q 200; do
  sleep 10
done
</code></pre>
<h4 id="heading-step-6-delete-it-when-youre-finished">Step 6. Delete it when you're finished.</h4>
<p>A server you forgot about bills every hour, forever.</p>
<pre><code class="language-bash">aws ec2 terminate-instances --instance-ids &lt;instance-id&gt;
aws ec2 delete-security-group --group-id $SG
</code></pre>
<p>I can't stress this enough for anyone learning on their own account: set a billing alarm, and terminate the moment you're done. The instance used for this handbook existed for under an hour and cost a few cents, but only because I deleted it after.</p>
<h3 id="heading-the-driver">The Driver</h3>
<pre><code class="language-bash">pip install neo4j
</code></pre>
<p>That installs the official driver. At the time of writing it's version 6.x and supports Python 3.10 and above.</p>
<h3 id="heading-connecting">Connecting</h3>
<p>The driver object is expensive to create and cheap to reuse. Create one when your program starts, and keep it. Creating a driver per request is a common and costly mistake, because each one builds its own connection pool.</p>
<pre><code class="language-python">from neo4j import GraphDatabase

URI = "neo4j+s://xxxxxxxx.databases.neo4j.io"
AUTH = ("neo4j", "your-password")

with GraphDatabase.driver(URI, auth=AUTH) as driver:
    driver.verify_connectivity()
    print("Connected")
</code></pre>
<p>There are two things worth doing every time:</p>
<p><code>verify_connectivity()</code> fails immediately with a clear error if the URI or the password is wrong. Without it, your first failure happens inside a query, where the error is less obvious and harder to attribute.</p>
<p>Using the driver as a context manager, with <code>with</code>, closes it cleanly when the block exits. In a long-running service you would instead create the driver at startup and close it during shutdown.</p>
<p>Never put credentials in your source. Read them from the environment:</p>
<pre><code class="language-python">import os
from neo4j import GraphDatabase

driver = GraphDatabase.driver(
    os.environ["NEO4J_URI"],
    auth=(os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD"]),
)
</code></pre>
<h2 id="heading-the-modeling-decision-that-matters-most">The Modeling Decision That Matters Most</h2>
<p>Before you write a single row of data you have to decide what becomes a node, what becomes a property, and what becomes a relationship.</p>
<p>This is the part that decides whether your graph is a pleasure or a problem six months from now. It's also the part that no query optimiser can fix for you later.</p>
<p>Here are the rules:</p>
<p><strong>Make it a node if you'll ever ask a question about it.</strong> If you want to know which engineers work on the payments service, then the payments service is a node. If you want to count incidents by severity, severity is a candidate for a node.</p>
<p><strong>Make it a property if it only ever describes something else.</strong> The timestamp on an incident is a property. Nobody asks a database to find all the things that happened at 14:32 and then traverse outwards from that moment.</p>
<p><strong>Make it a relationship if it connects two nodes and you want to walk it.</strong> Ownership connects an engineer to a service, and the entire point is walking from one to the other, so it's a relationship.</p>
<p>A useful test: <strong>can you imagine drawing an arrow to it?</strong> If yes, it's probably a node. Nobody draws an arrow to a timestamp.</p>
<p>Another useful test: <strong>would you ever want to attach something else to it?</strong> Teams have managers, budgets, and charters. That's three arrows waiting to happen, which means a team is a node, not a string.</p>
<h3 id="heading-relationship-direction">Relationship Direction</h3>
<p>Every relationship in Neo4j has a direction. You store <code>(:Engineer)-[:OWNS]-&gt;(:Service)</code> because an engineer owns a service and not the other way round.</p>
<p>Direction matters when you write the data. It matters much less when you query, because you can traverse against the stored direction, and you can ignore direction entirely.</p>
<pre><code class="language-cypher">// follow the stored direction
MATCH (e:Engineer)-[:OWNS]-&gt;(s:Service) RETURN e, s

// traverse against it: start from the service
MATCH (s:Service)&lt;-[:OWNS]-(e:Engineer) RETURN s, e

// ignore direction entirely
MATCH (e:Engineer)-[:OWNS]-(s:Service) RETURN e, s
</code></pre>
<p>Those three return the same pairs. Store the direction that reads naturally as an English sentence, and stop worrying about it.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943196770/6bf41f5e-07b8-45f6-845a-ba847f9f4a49.png" alt="relationship direction" style="display:block;margin:0 auto" width="3360" height="1372" loading="lazy">

<p>Three patterns matching identical data: walking the stored direction, walking against it, and dropping the arrowhead to ignore direction. All three return Ada and payments.</p>
<p>That third one is the debugging move. If a query returns nothing and you expected rows, drop the arrowheads. If rows appear, direction was the cause. If not, you've ruled out the likeliest suspect in ten seconds. Direction does matter when you write: <code>MERGE (a)-[:OWNS]-&gt;(b)</code> and the reverse create two different facts, and only one is true.</p>
<h3 id="heading-properties-on-relationships">Properties on Relationships</h3>
<p>This is the feature people forget exists, and it's often the cleanest answer.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943199122/bc69217f-bb75-40ac-a1af-ac4eac54118d.png" alt="relationship properties" style="display:block;margin:0 auto" width="3580" height="2008" loading="lazy">

<p>One fact, stored two ways. In tables, <code>since</code> lives on an <code>ownership</code> join table that isn't part of your domain and exists only because rows can't point at each other. In a graph it sits on the connection, and you can query it directly: <code>MATCH (e:Engineer)-[r:OWNS]-&gt;(s:Service) WHERE r.since &lt; date() - duration('P1Y')</code> gives you everyone who has owned something for more than a year.</p>
<pre><code class="language-cypher">MERGE (e:Engineer {email: 'ada@example.com'})-[r:OWNS]-&gt;(s:Service {name: 'payments'})
  SET r.since = date('2026-03-01'), r.primary = true
</code></pre>
<p>Now you can ask who has owned a service for longer than a year, without inventing a join table to hold the fact.</p>
<h2 id="heading-three-modeling-mistakes-almost-everyone-makes">Three Modeling Mistakes Almost Everyone Makes</h2>
<p>I've watched these three mistakes happen more times than any others, and each one is easy to avoid once you've seen it.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943202043/5509c707-9bf2-4739-9ed1-6ada4388190a.png" alt="modelling mistake" style="display:block;margin:0 auto" width="3200" height="1968" loading="lazy">

<p>Almost every first graph model makes this one: storing a connection as a property because it looks simpler. It can't be traversed, can't carry facts of its own, and turns into string matching.</p>
<h3 id="heading-mistake-1-storing-a-connection-as-a-property">Mistake #1: Storing a Connection as a Property</h3>
<p>You give each engineer a <code>team</code> property holding the string <code>"platform"</code>.</p>
<p>This works right up until you want to know what else the platform team owns. Now you're matching strings scattered across thousands of nodes. Worse, the moment someone writes <code>"Platform"</code> with a capital P, you've silently created a second team, and no error was raised.</p>
<p>The fix is to make the team a node and connect engineers to it. Both problems disappear at once, and you gain somewhere to hang the team's manager and budget later.</p>
<p>The general form of this mistake: <strong>anything you want to traverse must be a relationship</strong>. A property holding a list of identifiers is a graph database pretending to be a spreadsheet.</p>
<h3 id="heading-mistake-2-one-generic-relationship-type-for-everything">Mistake #2: One Generic Relationship Type for Everything</h3>
<p>You create a <code>RELATED_TO</code> relationship and put a <code>type</code> property on it to say what kind of relation it is.</p>
<p>This looks flexible. It's the opposite. Neo4j narrows the search by relationship type before it walks anything, so <code>-[:OWNS]-&gt;</code> is fast. Filtering on a property means walking every <code>RELATED_TO</code> relationship first, then discarding most of them, which is exactly the row-scanning behaviour you moved to a graph to avoid.</p>
<p>Name your relationships for what they mean: <code>OWNS</code>, <code>AFFECTS</code>, <code>MEMBER_OF</code>, or <code>DEPENDS_ON</code>. Specific types are both faster and self documenting.</p>
<h3 id="heading-mistake-3-making-everything-a-node">Mistake #3: Making Everything a Node</h3>
<p>This is the overcorrection, and it's its own problem.</p>
<p>If a value only ever describes one node, and you never search for it independently, it's a property. Creating a node for every timestamp gives you a much larger graph, slower traversals, and nothing whatsoever in return.</p>
<p>The test remains the same. Will you ask a question about it, or attach something to it? If not, it's a property.</p>
<h2 id="heading-modeling-backwards-from-your-questions">Modeling Backwards From Your Questions</h2>
<p>Here's a technique that will save you a rewrite.</p>
<p>Don't start by modeling your domain. Start by writing down the questions the graph has to answer, in plain English, before you draw anything.</p>
<p>For our example:</p>
<ol>
<li><p>Which services did this incident affect?</p>
</li>
<li><p>Who owns those services?</p>
</li>
<li><p>Which teams do those owners belong to?</p>
</li>
<li><p>Which services depend on the one that broke?</p>
</li>
<li><p>Who has been on call for this service in the last month?</p>
</li>
</ol>
<p>Now check your model against the list. Every question should be a path you can trace with your finger. If a question requires a join across two properties, or a scan of every node of some label, the model is wrong for that question.</p>
<p>Question five is a good example of why this matters. "On call in the last month" is a fact about a period of time connecting a person and a service. That's a relationship with properties on it, and if you had modeled on-call as a boolean property on the engineer, you would've discovered the problem after loading your data instead of before.</p>
<p>Relational modeling teaches you to normalise first and query later. Graph modeling works better in the other direction.</p>
<h2 id="heading-three-modeling-patterns-worth-knowing-early">Three Modeling Patterns Worth Knowing Early</h2>
<p>Once the basics land, three patterns cover most of what you'll hit in real data.</p>
<h3 id="heading-when-a-relationship-needs-more-than-two-ends">When a Relationship Needs More Than Two Ends</h3>
<p>A relationship connects exactly two nodes. Sometimes a fact connects three or more.</p>
<p>"Ada was on call for payments during March" involves a person, a service, and a time window. You can't hang that off a single relationship without losing something.</p>
<p>The pattern is to promote the fact itself to a node:</p>
<pre><code class="language-cypher">MERGE (e:Engineer {email: 'ada@example.com'})
MERGE (s:Service {name: 'payments'})
CREATE (r:OnCallRotation {start: date('2026-03-01'), end: date('2026-03-31')})
MERGE (e)-[:SERVED]-&gt;(r)
MERGE (r)-[:FOR_SERVICE]-&gt;(s)
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943204974/7c021397-05a5-4cc5-a585-ece032246029.png" alt="nary intermediate node" style="display:block;margin:0 auto" width="3360" height="2128" loading="lazy">

<p>"Ada was on call for payments during March" has three participants and a relationship has two ends. Forced onto one <code>ON_CALL</code>, it breaks in April, because a second rotation needs a second relationship between the same nodes and nothing can hang off either. Promote the fact to a node and it gets three relationships, so anything can attach. The signal is wanting to put a property on a relationship that describes something other than that exact pair.</p>
<p><code>OnCallRotation</code> is sometimes called an intermediate node, a reified relationship, or a hyper-edge. The name doesn't matter. What matters is that a fact with three participants becomes a node with three relationships, and now you can attach more to it later, such as who swapped in halfway through.</p>
<p>The signal that you need this: you find yourself wanting to put a property on a relationship that describes something other than that exact pair of nodes.</p>
<h3 id="heading-versioning-when-facts-change-over-time">Versioning, When Facts Change Over Time</h3>
<p>Graphs are easy to update in place, which makes it tempting to overwrite. If history matters, don't.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943207486/ebcf3146-22f0-4c8b-8d17-a9f23e56e5f7.png" alt="temporal versioning" style="display:block;margin:0 auto" width="3360" height="1420" loading="lazy">

<p>Ownership changes hands, and pointing the relationship at the new person erases that anyone else ever held it. The alternative closes the old relationship with an end date and opens a new one, so history survives. Overwriting is what happens if you don't decide.</p>
<p>The usual pattern is to keep the relationship and mark it closed rather than deleting it:</p>
<pre><code class="language-cypher">// close the old ownership rather than deleting it
MATCH (e:Engineer {email: $old})-[r:OWNS]-&gt;(s:Service {name: $service})
WHERE r.until IS NULL
SET r.until = date()

// open a new one
MATCH (e:Engineer {email: $new}), (s:Service {name: $service})
MERGE (e)-[r2:OWNS]-&gt;(s)
  ON CREATE SET r2.since = date()
</code></pre>
<p>Current ownership is then <code>WHERE r.until IS NULL</code>, and history is still there when someone asks who owned this last year. The cost is that every query about "now" needs that filter, so decide deliberately rather than by accident.</p>
<h3 id="heading-hierarchies-which-graphs-are-unusually-good-at">Hierarchies, Which Graphs Are Unusually Good At</h3>
<p>Trees are painful in SQL and trivial here. An organisation, a category tree, a folder structure, and a dependency chain are all the same shape.</p>
<pre><code class="language-cypher">// everyone under a given manager, at any depth
MATCH path = (m:Engineer {email: $email})&lt;-[:REPORTS_TO*1..10]-(report:Engineer)
RETURN report.name AS name, length(path) AS depth
ORDER BY depth, name
</code></pre>
<p>Naming the path with <code>path =</code> is what lets you call <code>length()</code> on it, which returns the number of relationships traversed and therefore how far down the tree each person sits.</p>
<p>This is the query that makes people switch. In SQL it's a recursive common table expression that most engineers have to look up every time. Here it's one line, and changing the depth is changing a number.</p>
<h2 id="heading-loading-data-from-python">Loading Data From Python</h2>
<p>The modern driver gives you one method for running a query: <code>execute_query</code>. It manages sessions and retries for you, and it's the right default.</p>
<p>Start with a single engineer and a single service.</p>
<pre><code class="language-python">driver.execute_query(
    """
    MERGE (e:Engineer {email: $email})
      SET e.name = $name
    MERGE (s:Service {name: $service})
    MERGE (e)-[:OWNS]-&gt;(s)
    """,
    email="ada@example.com",
    name="Ada",
    service="payments",
    database_="neo4j",
)
</code></pre>
<p>Three things in that snippet deserve attention.</p>
<h3 id="heading-merge-rather-than-create">MERGE Rather Than CREATE</h3>
<p><code>CREATE</code> always makes a new node. Run your loading script twice and you have two identical engineers, two identical services, and a mess.</p>
<p><code>MERGE</code> looks for a node matching the pattern and creates one only if nothing matches. That makes the script safe to run again, which you'll want the very first time it fails halfway through a load.</p>
<p>The rule of thumb: <code>CREATE</code> when you know the thing is new, <code>MERGE</code> when you're loading from a source that might contain something you already have.</p>
<h3 id="heading-merge-on-identity-then-set-everything-else">Merge on Identity, Then Set Everything Else</h3>
<p>Look carefully at where the properties are.</p>
<pre><code class="language-python">MERGE (e:Engineer {email: $email})
  SET e.name = $name
</code></pre>
<p>The <code>MERGE</code> is on <code>email</code> alone, and the name is applied afterwards with <code>SET</code>.</p>
<p>If you had merged on both email and name, then the day someone changes their name you would create a second node rather than updating the first. You would end up with two Adas, connected to different things, and no error to tell you.</p>
<p><strong>Merge on the property that identifies the node. Set the rest.</strong></p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943210745/4bef13c4-8f7b-4c11-981c-bf264a9c61ab.png" alt="merge key" style="display:block;margin:0 auto" width="3360" height="1576" loading="lazy">

<p>Two scripts that both run without error and both report success. The left merges on email and name together. The right merges on email alone and sets the name afterwards.</p>
<p>Load them once and they look identical. Then Ada marries and changes her name to Ada Okonjo, same email. On the left the pattern no longer matches, because the name differs, so MERGE creates a second node. Her ownerships are now split across both, and every query about her returns part of the truth.</p>
<p>On the right the email still matched, so MERGE found the existing node and SET overwrote the name, and her relationships stay attached to the node they were always on.</p>
<p>The rule: merge on the property that identifies the node and nothing else, and set everything that merely describes it. If a value can change while the thing stays the same thing, it doesn't belong in the key. You can catch this whole class of bug by loading your data twice and asserting the node count is identical, which costs three lines.</p>
<p>There's a matching variant when you want different behaviour on first insert versus update:</p>
<pre><code class="language-cypher">MERGE (e:Engineer {email: $email})
  ON CREATE SET e.name = $name, e.created = datetime()
  ON MATCH  SET e.name = $name, e.last_seen = datetime()
</code></pre>
<h3 id="heading-parameters-never-string-formatting">Parameters, Never String Formatting</h3>
<p>The values are passed separately as <code>$email</code> and <code>$name</code>. Never build a query by concatenating strings.</p>
<p>This protects you from injection, which is the obvious reason. There's a second reason that matters for performance: Neo4j caches query plans keyed on the query text. Parameterised queries have identical text every time, so the plan is compiled once and reused. String-formatted queries produce a new plan for every distinct value, which fills the plan cache with garbage and recompiles constantly.</p>
<h2 id="heading-loading-at-scale-with-unwind">Loading at Scale with UNWIND</h2>
<p>One node at a time means one network round trip per node. Loading ten thousand records that way is slow, and almost all of the time is spent waiting rather than working.</p>
<p>Send a list instead and let Cypher loop inside the database.</p>
<pre><code class="language-python">rows = [
    {"email": "ada@example.com",   "name": "Ada",   "service": "payments"},
    {"email": "linus@example.com", "name": "Linus", "service": "checkout"},
    {"email": "grace@example.com", "name": "Grace", "service": "payments"},
]

driver.execute_query(
    """
    UNWIND $rows AS row
    MERGE (e:Engineer {email: row.email})
      SET e.name = row.name
    MERGE (s:Service {name: row.service})
    MERGE (e)-[:OWNS]-&gt;(s)
    """,
    rows=rows,
    database_="neo4j",
)
</code></pre>
<p><code>UNWIND</code> takes a list and turns it into rows, so everything after it runs once per element, all inside a single transaction and a single round trip.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943213878/e0c6c996-c416-44ae-8751-315a28083a64.png" alt="unwind round trips" style="display:block;margin:0 auto" width="3240" height="2128" loading="lazy">

<p>What makes a bulk load slow isn't the writing, it's the waiting between writes. One statement per row is a network round trip per row. One UNWIND sends the batch in a single trip and lets the database loop internally.</p>
<p>This is not a small optimisation. Writing 1,000 rows to the 75,500 node dataset, one statement per row against a single <code>UNWIND</code>:</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Round trips</th>
<th>Time</th>
</tr>
</thead>
<tbody><tr>
<td>One statement per row</td>
<td>1,000</td>
<td>2,758 ms</td>
</tr>
<tr>
<td>One <code>UNWIND</code></td>
<td>1</td>
<td>64 ms</td>
</tr>
</tbody></table>
<p>Forty-three times faster, on a database running on the same machine as the client, where a round trip costs almost nothing. Run it yourself and you'll get a different multiple, somewhere in the same region: a clean checkout on this machine measured sixty-six.</p>
<p><strong>The gap grows with distance.</strong> I ran the same comparison against a managed instance in another city and measured 91,722 ms against 150 ms, which is 613 times. Nothing about the work changed. What changed is that each of the 1,000 round trips now pays for a journey across the country and back. A minute and a half became a seventh of a second.</p>
<p>That's the real lesson: the cost of chattiness isn't fixed. It is however far away your database happens to be, multiplied by how many times you talk to it.</p>
<p>For a real load, batch it. One enormous transaction holds every change in memory until it commits, and a transaction containing a million updates is a good way to exhaust the heap.</p>
<pre><code class="language-python">def load_in_batches(driver, rows, batch_size=5000):
    query = """
    UNWIND $rows AS row
    MERGE (e:Engineer {email: row.email})
      SET e.name = row.name
    MERGE (s:Service {name: row.service})
    MERGE (e)-[:OWNS]-&gt;(s)
    """
    for start in range(0, len(rows), batch_size):
        batch = rows[start:start + batch_size]
        driver.execute_query(query, rows=batch, database_="neo4j")
        print(f"loaded {start + len(batch)} of {len(rows)}")
</code></pre>
<p>A few thousand rows per batch is a reasonable starting point. Tune it by watching memory rather than by guessing.</p>
<h2 id="heading-loading-from-a-csv-file">Loading From a CSV File</h2>
<p>Most real data starts life in a spreadsheet or an export. There are two ways to get it in, and picking the wrong one is a common source of frustration.</p>
<h3 id="heading-option-1-read-it-in-python-send-it-with-unwind">Option #1: Read it in Python, Send it with UNWIND</h3>
<p>This is the one to reach for by default. You already know how it works, it runs anywhere, and you can clean the data on the way through.</p>
<pre><code class="language-python">import csv

def load_csv(driver, path, batch_size=5000):
    with open(path, newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))

    query = """
    UNWIND $rows AS row
    MERGE (e:Engineer {email: row.email})
      SET e.name = row.name
    MERGE (s:Service {name: row.service})
    MERGE (e)-[:OWNS]-&gt;(s)
    """
    for start in range(0, len(rows), batch_size):
        driver.execute_query(query, rows=rows[start:start + batch_size], database_="neo4j")
</code></pre>
<p><code>csv.DictReader</code> gives you a dictionary per row keyed by the header names, which is exactly the shape <code>UNWIND</code> wants.</p>
<p>One warning that catches everyone: <strong>every value from a CSV is a string.</strong> A column of numbers arrives as <code>"42"</code>, not <code>42</code>, and a column of dates arrives as <code>"2026-03-01"</code>. If you store them raw you'll later write comparisons that silently do the wrong thing, because <code>"9" &gt; "10"</code> is true when both are strings. Convert as you read:</p>
<pre><code class="language-python">for row in rows:
    row["headcount"] = int(row["headcount"]) if row["headcount"] else None
</code></pre>
<h3 id="heading-option-3-load-csv-which-runs-inside-the-database">Option #3: LOAD CSV, Which Runs Inside the Database</h3>
<p>Cypher can read a file itself. This is faster for very large files because the data never travels through your Python process.</p>
<pre><code class="language-cypher">LOAD CSV WITH HEADERS FROM 'file:///engineers.csv' AS row
CALL {
  WITH row
  MERGE (e:Engineer {email: row.email})
    SET e.name = row.name
  MERGE (s:Service {name: row.service})
  MERGE (e)-[:OWNS]-&gt;(s)
} IN TRANSACTIONS OF 1000 ROWS
</code></pre>
<p><code>CALL { ... } IN TRANSACTIONS OF 1000 ROWS</code> is the important part. Without it the whole file is one transaction, which is how people run a large import and watch it exhaust memory.</p>
<p>There are two constraints on <code>LOAD CSV</code> that surprise people:</p>
<p>First, the file has to be somewhere the database can reach, not somewhere you can reach. <code>file:///</code> means the import directory <em>on the server</em>. On Docker that means mounting a folder into the container with <code>-v $(pwd)/data:/var/lib/neo4j/import</code>. On Aura you can't use local files at all, so the URL must be a publicly reachable <code>https://</code> address.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943216000/82b43543-57b9-48c0-8581-c03881d3cc2f.png" alt="csv strings" style="display:block;margin:0 auto" width="3280" height="2088" loading="lazy">

<p>Every CSV value arrives as a string, including numbers. Nothing errors and no warning appears, so <code>"9" &gt; "10"</code> is true and your filter quietly returns the wrong rows. Cast on the way in.</p>
<p>Second, everything is still a string. Cypher has conversion functions for this:</p>
<pre><code class="language-cypher">LOAD CSV WITH HEADERS FROM 'https://example.com/services.csv' AS row
MERGE (s:Service {name: row.name})
  SET s.headcount = toInteger(row.headcount),
      s.launched  = date(row.launched)
</code></pre>
<p><code>toInteger</code>, <code>toFloat</code>, <code>date</code> and <code>datetime</code> are the ones you'll use constantly. <code>toInteger</code> returns <code>null</code> rather than throwing on a value it can't parse, which is convenient and also means a column full of typos will quietly become a column full of nulls. Check your data after loading:</p>
<pre><code class="language-cypher">MATCH (s:Service) WHERE s.headcount IS NULL RETURN count(*) AS unparsed
</code></pre>
<h2 id="heading-updating-and-deleting">Updating and Deleting</h2>
<p>Loading is only half of it. Data changes, and the commands that change it have sharp edges.</p>
<h3 id="heading-changing-properties">Changing Properties</h3>
<p><code>SET</code> adds or overwrites a property. <code>REMOVE</code> takes one away entirely, which is different from setting it to null.</p>
<pre><code class="language-cypher">MATCH (e:Engineer {email: $email})
SET e.name = $name, e.updated = datetime()
REMOVE e.legacy_id
</code></pre>
<p>There's a shorthand that overwrites several properties at once from a map:</p>
<pre><code class="language-cypher">MATCH (e:Engineer {email: $email})
SET e += $props
</code></pre>
<p><code>+=</code> merges the map into the node, leaving properties you didn't mention alone. Plain <code>=</code> <strong>replaces the entire property set</strong>, silently deleting anything not in your map. That difference has cost people real data, so it's worth reading twice.</p>
<h3 id="heading-deleting">Deleting</h3>
<p>You can't delete a node that still has relationships. Neo4j refuses, because leaving a dangling relationship would corrupt the graph.</p>
<pre><code class="language-cypher">// fails if the engineer owns anything
MATCH (e:Engineer {email: $email}) DELETE e
</code></pre>
<p><code>DETACH DELETE</code> removes the relationships and then the node:</p>
<pre><code class="language-cypher">MATCH (e:Engineer {email: $email}) DETACH DELETE e
</code></pre>
<p>It's handy, and dangerous for exactly the same reason. Run the <code>MATCH</code> on its own with <code>RETURN</code> first and look at what comes back, every time.</p>
<p>To wipe a whole database while experimenting:</p>
<pre><code class="language-cypher">MATCH (n) DETACH DELETE n
</code></pre>
<p>That's fine on a few thousand nodes and a bad idea on millions, because it builds one enormous transaction. For a large reset, drop the database or delete in batches with <code>CALL { ... } IN TRANSACTIONS</code>.</p>
<h2 id="heading-working-with-neo4j-data-types">Working with Neo4j Data Types</h2>
<p>Neo4j stores more than strings and numbers, and using the right type saves you from parsing dates out of text later.</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Example</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>String, Integer, Float, Boolean</td>
<td><code>'payments'</code>, <code>42</code>, <code>1.5</code>, <code>true</code></td>
<td>As expected</td>
</tr>
<tr>
<td>List</td>
<td><code>['a','b','c']</code></td>
<td>Homogeneous lists of primitives</td>
</tr>
<tr>
<td>Date, DateTime, Time</td>
<td><code>date('2026-03-01')</code>, <code>datetime()</code></td>
<td>Real temporal types, comparable and sortable</td>
</tr>
<tr>
<td>Duration</td>
<td><code>duration('P30D')</code></td>
<td>Periods, which you can add to a date</td>
</tr>
<tr>
<td>Point</td>
<td><code>point({latitude: 51.5, longitude: -0.12})</code></td>
<td>Spatial, with a distance function</td>
</tr>
</tbody></table>
<p>A property can't hold a map or a node. If you find yourself wanting nested structure inside a property, that nested thing is usually asking to be a node.</p>
<p>Temporal types are the ones that earn their keep immediately:</p>
<pre><code class="language-cypher">MATCH (e:Engineer)-[r:OWNS]-&gt;(s:Service)
WHERE r.since &lt; date() - duration('P1Y')
RETURN e.name, s.name, duration.between(r.since, date()).years AS years
</code></pre>
<p>Comparing dates as dates, rather than as strings you hope sort correctly, removes a whole category of bug.</p>
<p>On the Python side the driver converts these for you. <code>date</code> and <code>datetime</code> come back as <code>neo4j.time</code> objects, which have <code>.to_native()</code> if you want Python's own <code>datetime</code>:</p>
<pre><code class="language-python">records, _, _ = driver.execute_query(
    "MATCH (e:Engineer)-[r:OWNS]-&gt;(s:Service) WHERE r.since IS NOT NULL RETURN r.since AS since",
    database_="neo4j",
)
for r in records:
    print(r["since"], "-&gt;", r["since"].to_native())
</code></pre>
<h2 id="heading-your-first-cypher-queries">Your First Cypher Queries</h2>
<p>Cypher looks a little like SQL in places, but its central idea is different. You draw the shape you're looking for, and the database finds every part of the graph matching that shape.</p>
<p>Patterns use parentheses for nodes and arrows for relationships:</p>
<pre><code class="language-cypher">(e:Engineer)-[:OWNS]-&gt;(s:Service)
</code></pre>
<p>Read it aloud: an engineer node, an OWNS relationship pointing out of it, and a service node at the other end. The pattern is the query.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943218402/fade2e09-2b03-4b21-ba0e-90d79ebc2691.png" alt="cypher pattern anatomy" style="display:block;margin:0 auto" width="3360" height="1944" loading="lazy">

<p>Five conventions on <code>(e:Engineer)-[:OWNS]-&gt;(s:Service)</code>. Round brackets are a node. <code>e</code> is an optional variable, named only if you want it back. <code>:Engineer</code> is a label, narrowing to that kind first. Square brackets and an arrow are a relationship and its stored direction. <code>:OWNS</code> is the type, and Neo4j narrows by type first, which is why specific types are fast.</p>
<p>Said aloud: "an engineer, who owns a service." The SQL equivalent says how to reconstruct the connection. The Cypher says what the connection is.</p>
<h3 id="heading-finding-things">Finding Things</h3>
<pre><code class="language-python">records, summary, keys = driver.execute_query(
    """
    MATCH (e:Engineer)-[:OWNS]-&gt;(s:Service {name: $service})
    RETURN e.name AS name, e.email AS email
    ORDER BY name
    """,
    service="payments",
    database_="neo4j",
)

for record in records:
    print(record["name"], record["email"])
</code></pre>
<p><code>execute_query</code> returns three things: the records, a summary, and the keys that were returned.</p>
<p>Most of the time you want the records, which is why you'll often see the other two discarded with underscores.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943220876/8c9c25e9-4844-4420-b46e-14331427abd8.png" alt="multihop table" style="display:block;margin:0 auto" width="4400" height="788" loading="lazy">

<p>Neo4j Browser running the multi-hop query, with the results as a table. It's the same query you wrote above, with the parameter filled in by hand. That's what you do when you're exploring in the browser rather than calling from Python.</p>
<p>The query starts at incident <code>INC-4471</code>, follows <code>AFFECTS</code> out to the services it touched, then follows <code>OWNS</code> backwards to the engineers who own them. The rows that come back are those engineers' names and email addresses, sorted by name.</p>
<p>The same query, just run in Neo4j Browser. Two columns come back, <code>name</code> and <code>email</code>, one row per engineer.</p>
<h3 id="heading-filtering">Filtering</h3>
<p><code>WHERE</code> works much as you would expect.</p>
<pre><code class="language-cypher">MATCH (e:Engineer)-[r:OWNS]-&gt;(s:Service)
WHERE r.since &lt; date('2026-01-01') AND s.tier = 'critical'
RETURN e.name, s.name, r.since
</code></pre>
<p>Note that you can filter on a property of the relationship, <code>r.since</code>, as easily as on a property of a node. That's the payoff for modeling the fact where it belongs.</p>
<h3 id="heading-counting-and-grouping">Counting and Grouping</h3>
<p>Cypher has no <code>GROUP BY</code>. Aggregation is implicit: anything you return that's not an aggregate becomes the grouping key.</p>
<pre><code class="language-cypher">MATCH (t:Team)&lt;-[:MEMBER_OF]-(e:Engineer)-[:OWNS]-&gt;(s:Service)
RETURN t.name AS team, count(DISTINCT s) AS services
ORDER BY services DESC
</code></pre>
<p>That returns one row per team, because <code>t.name</code> is the only non-aggregate in the <code>RETURN</code>.</p>
<h3 id="heading-when-something-might-not-be-there">When Something Might Not Be There</h3>
<p><code>MATCH</code> drops rows that don't match the whole pattern. If you want engineers whether or not they own anything, use <code>OPTIONAL MATCH</code>, which is the closest equivalent to a left outer join.</p>
<pre><code class="language-cypher">MATCH (e:Engineer)
OPTIONAL MATCH (e)-[:OWNS]-&gt;(s:Service)
RETURN e.name AS name, collect(s.name) AS services
</code></pre>
<p>Engineers who own nothing come back with an empty list rather than vanishing from the result.</p>
<h2 id="heading-the-multi-hop-query-that-justifies-the-whole-thing">The Multi-Hop Query That Justifies the Whole Thing</h2>
<p>Now let's return to the question from the very beginning.</p>
<p>An incident affected some services. Who has context on those services?</p>
<pre><code class="language-python">records, _, _ = driver.execute_query(
    """
    MATCH (i:Incident {ref: $ref})-[:AFFECTS]-&gt;(:Service)&lt;-[:OWNS]-(e:Engineer)
    RETURN DISTINCT e.name AS name, e.email AS email
    """,
    ref="INC-4471",
    database_="neo4j",
)
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943223173/418b0723-595f-4013-ba13-7641ea9db3b3.png" alt="traversal iso" style="display:block;margin:0 auto" width="3200" height="1588" loading="lazy">

<p>One incident, two hops, and six nodes read. The work is the small pile standing on each step, not anything proportional to how much data the database holds.</p>
<p>Read the pattern from left to right and it's close to the English sentence.</p>
<p>Here's that query run against a live Neo4j Aura instance from the terminal:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943225485/04c4f3e9-1572-47e5-a7b2-11ff258c91c9.png" alt="terminal multihop" style="display:block;margin:0 auto" width="3000" height="984" loading="lazy">

<p>Same query again, this time from <code>cypher-shell</code> against Aura instead of the browser, returning the identical three names: <code>"Ada Okonjo"</code>, <code>"Grace Lin"</code> and <code>"Linus Berg"</code>.</p>
<p>Start at the incident, follow AFFECTS to the services it hit, then follow OWNS backwards to the engineers who own them.</p>
<p>The arrow pointing left, <code>&lt;-[:OWNS]-</code>, is doing real work. Ownership was stored from engineer to service, so reaching the engineers from the services means traversing against the stored direction.</p>
<p>Getting this backwards is the single most common reason a beginner's query returns nothing at all. If a query returns an empty result and you expected rows, check your arrow directions first.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943228032/5fc8a639-07d2-473f-b001-bfc698490c76.png" alt="graph result" style="display:block;margin:0 auto" width="4400" height="1360" loading="lazy">

<p>This is the same result drawn as a graph instead of a table, in Neo4j Browser. The incident sits at one end, the services it affected in the middle, and the engineers who own those services at the other end. The path the query walked is visible as a shape rather than as rows.</p>
<p>Now widen it. Which whole teams are behind the affected services?</p>
<p>Here's the query most people write first. <strong>It's wrong, and it fails silently</strong>, which is why it's worth showing.</p>
<pre><code class="language-cypher">// WRONG: silently drops teams. Explanation below.
MATCH (i:Incident {ref: $ref})-[:AFFECTS]-&gt;(:Service)&lt;-[:OWNS]-(:Engineer)
      -[:MEMBER_OF]-&gt;(t:Team)&lt;-[:MEMBER_OF]-(e:Engineer)
RETURN DISTINCT t.name AS team, e.name AS name
ORDER BY team, name
</code></pre>
<p>Run that against the dataset in this handbook and it returns three rows, all from the Platform team. The Commerce team is missing, even though Linus owns <code>checkout</code> and <code>checkout</code> was affected.</p>
<h3 id="heading-relationship-uniqueness-the-trap-that-hides-answers">Relationship Uniqueness, the Trap That Hides Answers</h3>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943230817/46852bb9-0a7f-4765-b57c-527f96dd128d.png" alt="relationship uniqueness" style="display:block;margin:0 auto" width="3400" height="2248" loading="lazy">

<p>We have two versions side by side here. The single pattern looks correct and <strong>returns three rows</strong>. Split into two patterns joined by <code>WITH</code>, the same question <strong>returns four</strong>. The drawing traces why: the pattern has to walk out along a <code>MEMBER_OF</code> relationship and back along the same one, and Cypher discards that match rather than reusing the relationship.</p>
<p>Splitting the pattern lifts the restriction because the rule applies within one pattern, not across the query, and <code>WITH DISTINCT</code> keeps the extra rows from duplicating.</p>
<p>Cypher guarantees that <strong>a single pattern won't traverse the same relationship twice</strong>. This is called relationship isomorphism, and it exists to stop patterns looping back on themselves forever.</p>
<p>Look at what that means for Commerce. Its only member is Linus, and Linus is also the owner. To match, the pattern has to walk out of Linus along his <code>MEMBER_OF</code> relationship to reach the team, and then walk back down the very same relationship to reach a member. That's the same relationship twice, so Cypher discards the row.</p>
<p>There's no error or warning, just a quieter answer than the truth.</p>
<p>The fix is to break the single pattern into two, so the rule no longer spans both halves:</p>
<pre><code class="language-cypher">MATCH (i:Incident {ref: $ref})-[:AFFECTS]-&gt;(:Service)&lt;-[:OWNS]-(:Engineer)-[:MEMBER_OF]-&gt;(t:Team)
WITH DISTINCT t
MATCH (t)&lt;-[:MEMBER_OF]-(e:Engineer)
RETURN t.name AS team, e.name AS name
ORDER BY team, name
</code></pre>
<p><code>WITH</code> ends one pattern and begins another. The second <code>MATCH</code> starts fresh, so the owner's own membership is available again.</p>
<p>That version returns four rows, including Commerce and Linus.</p>
<h3 id="heading-does-it-still-hold-at-scale">Does it Still Hold at Scale?</h3>
<p>A fair objection to everything above is that fourteen nodes proves nothing. So here is the same multi-hop query, unchanged, against the 75,500 node dataset:</p>
<pre><code class="language-text">33 engineers returned, 150 database accesses, 4.6 ms
</code></pre>
<p>The graph is roughly five thousand times larger. The query is identical, and it still touches around a hundred and fifty things.</p>
<p>That's index-free adjacency doing exactly what was promised at the top of this article. The work is proportional to the neighbourhood you walk, not to the size of the database you walk it in. A join across three tables of that size would have to consider vastly more rows to answer the same question.</p>
<p>You can reproduce this yourself. The dataset is committed to the <a href="https://github.com/ronidas39/knowledge-graph-python-neo4j">companion repository</a>, and <code>benchmark.py</code> runs this measurement along with the others in this article.</p>
<p><strong>The general lesson:</strong> whenever a pattern leaves a node and comes back to the same kind of node, ask whether the two halves could ever be the same relationship. If they could, split the query with <code>WITH</code>. This is the most common source of silently incomplete results in Cypher, and it's very hard to spot by reading, because the query looks correct and returns plausible data.</p>
<p>Four hops, still readable as a sentence. Writing the equivalent in SQL means several joins plus a distinct, and changing "two steps" to "three steps" means rewriting it.</p>
<h2 id="heading-variable-length-paths-and-how-to-keep-them-safe">Variable Length Paths and How to Keep Them Safe</h2>
<p>Sometimes you don't know how many hops you need. Service dependencies are the classic case: payments depends on auth, auth depends on the user store, and you want everything downstream of a failure.</p>
<pre><code class="language-cypher">MATCH (s:Service {name: $name})&lt;-[:DEPENDS_ON*1..4]-(affected:Service)
RETURN DISTINCT affected.name
</code></pre>
<p>The <code>*1..4</code> means follow between one and four <code>DEPENDS_ON</code> relationships.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943234236/4dd2cc87-1c11-4ef7-9d59-d67a917e8123.png" alt="variable length paths" style="display:block;margin:0 auto" width="3320" height="2088" loading="lazy">

<p>Always bound a variable length path. Each hop multiplies what the last one reached, so <code>[:DEPENDS_ON*]</code> has nothing to stop it while <code>[:DEPENDS_ON*1..4]</code> does. On a connected graph the unbounded version doesn't return slowly, it stops being a query you can wait for.</p>
<p><strong>Always put an upper bound on it.</strong> An unbounded <code>*</code> on a well-connected graph can walk an enormous portion of the database, and the query that was instant on your test data will hang on production data. This is the single most common way people make a graph database look slow.</p>
<p>Here's what each extra pair of hops costs, starting from the most depended-upon service in the 75,500 node dataset, which has 10,039 <code>DEPENDS_ON</code> relationships between services:</p>
<table>
<thead>
<tr>
<th>Bound</th>
<th>Services reached</th>
<th>Database accesses</th>
</tr>
</thead>
<tbody><tr>
<td><code>*1..2</code></td>
<td>30</td>
<td>290</td>
</tr>
<tr>
<td><code>*1..4</code></td>
<td>133</td>
<td>1,620</td>
</tr>
<tr>
<td><code>*1..6</code></td>
<td>481</td>
<td>6,388</td>
</tr>
</tbody></table>
<p>Look at what happens between two hops and six. The reach grows more than fifteen fold, and the work grows twenty two fold. Nothing about the query changed except two characters.</p>
<p>That's the shape to keep in your head. Reach grows geometrically, and work grows with it. On a denser graph than this one the multiplier is larger, which is why an unbounded <code>*</code> on a social graph or a dependency graph can go from fast to hopeless with no warning at all, and why the failure arrives in production rather than on your laptop: your test data was not connected enough to hurt you.</p>
<p>I have deliberately not given you timings for these three. At this size they all complete in two to four milliseconds and the differences between them are measurement noise, not signal. The database access counts are the honest comparison, and unlike the timings, they'll be identical on your machine.</p>
<p>You can also ask for the shortest connection between two nodes, which is a genuinely hard query in SQL and a one liner here:</p>
<pre><code class="language-cypher">MATCH p = shortestPath(
  (a:Engineer {email: $from})-[:MEMBER_OF|OWNS*..6]-(b:Engineer {email: $to})
)
RETURN [n IN nodes(p) | coalesce(n.name, n.email)] AS hops
</code></pre>
<p>That returns the chain of things connecting two people. Recommendation engines, fraud detection, and access analysis are all variations on this one query.</p>
<h2 id="heading-what-an-index-actually-is">What an Index Actually is</h2>
<p>Before we use one, it's worth being clear about what an index is, because almost every performance problem in this article traces back to this one idea.</p>
<p>Think about a textbook of nine hundred pages. You want the part about photosynthesis. You have two options: you can start at page one and read forward until you find it, or you can turn to the index at the back, find "photosynthesis, 412", and go straight to page 412.</p>
<p>Both find the same page. One reads up to nine hundred pages, the other reads two.</p>
<p>A database index is that back-of-the-book index. It's a second, separate structure that the database maintains alongside your data, which maps a property value to the nodes that have it. You don't query the index directly and you don't have to tell Cypher to use it. You create it once, and from then on the planner uses it when it helps.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943236492/9aecbf0a-5e0e-4993-aece-fa1b6d68adea.png" alt="index book analogy" style="display:block;margin:0 auto" width="3280" height="1768" loading="lazy">

<p>On the left, <code>AllNodesScan</code>: sixty pages read, one of them useful, and the other fifty-nine still read. On the right, <code>NodeUniqueIndexSeek</code>: two reads, the index entry and then the page.</p>
<p>The figure also carries the number this handbook measures later, on the 75,500 node dataset: <strong>151,002 database accesses became 3.</strong> And the part worth remembering is that you never tell Cypher to use an index. You create it once, and from then on the planner reaches for it when it helps.</p>
<p>Here's the same lookup done three ways, against the 75,500 node dataset. All three find exactly one engineer, and all three return the same answer. What changes is how much work the database does to get there.</p>
<p><strong>One: no label, no index.</strong></p>
<pre><code class="language-cypher">PROFILE MATCH (n) WHERE n.email = 'eng25000@example.com' RETURN n.name
</code></pre>
<pre><code class="language-text">operator            details                       est     rows   dbHits
ProduceResults      `n.name`                     3775        1        0
  Projection        n.name AS `n.name`           3775        1        1
    Filter          n.email = $autostring_0      3775        1    75500
      AllNodesScan  n                           75500    75500    75501
</code></pre>
<p><code>AllNodesScan</code> is the database reading every node it has. All 75,500 of them, including every service, team, and incident, none of which could possibly have an email. Then <code>Filter</code> checks the email property on every one. <strong>Total: 151,002 database accesses to find one node.</strong></p>
<p><strong>Two: with a label, still no index.</strong></p>
<pre><code class="language-cypher">PROFILE MATCH (e:Engineer) WHERE e.email = 'eng25000@example.com' RETURN e.name
</code></pre>
<pre><code class="language-text">operator               details                    est     rows   dbHits
ProduceResults         `e.name`                  2500        1        0
  Projection           e.name AS `e.name`        2500        1        1
    Filter             e.email = $autostring_0   2500        1    50000
      NodeByLabelScan  e:Engineer               50000    50000    50001
</code></pre>
<p><code>NodeByLabelScan</code> is better. It reads only the 50,000 engineers instead of all 75,500 nodes. But it still reads every single one. <strong>Total: 100,002 accesses.</strong> The label narrowed the haystack. It didn't stop us searching it straw by straw.</p>
<p><strong>Three: with an index.</strong></p>
<pre><code class="language-cypher">CREATE CONSTRAINT engineer_email IF NOT EXISTS
FOR (e:Engineer) REQUIRE e.email IS UNIQUE
</code></pre>
<pre><code class="language-cypher">PROFILE MATCH (e:Engineer) WHERE e.email = 'eng25000@example.com' RETURN e.name
</code></pre>
<pre><code class="language-text">operator                 details                                        est   rows   dbHits
ProduceResults           `e.name`                                         1      1        0
  Projection             e.name AS `e.name`                               1      1        1
    NodeUniqueIndexSeek  UNIQUE e:Engineer(email) WHERE email = $auto      1      1        2
</code></pre>
<p>The scan and the filter are both gone, replaced by a single <code>NodeUniqueIndexSeek</code>. <strong>Total: 3 database accesses.</strong></p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943240334/ee25f5a1-c736-404e-90bf-79a5ac0ecf20.png" alt="scan vs seek ladder" style="display:block;margin:0 auto" width="3360" height="1194" loading="lazy">

<p>Here we have one lookup done three ways, finding one engineer among 50,000 in a graph of 75,500 nodes, measured with PROFILE on Neo4j 5.26.29 Community. All three return the identical answer. What changes is the work: reading every node of the label, a scan narrowed by property, or an index seek straight to it.</p>
<p>Three, against a hundred and fifty-one thousand. That's the entire argument for indexes in one table:</p>
<table>
<thead>
<tr>
<th>How</th>
<th>Operator</th>
<th>Database accesses</th>
</tr>
</thead>
<tbody><tr>
<td>No label, no index</td>
<td><code>AllNodesScan</code></td>
<td>151,002</td>
</tr>
<tr>
<td>Label, no index</td>
<td><code>NodeByLabelScan</code></td>
<td>100,002</td>
</tr>
<tr>
<td>Index</td>
<td><code>NodeUniqueIndexSeek</code></td>
<td>3</td>
</tr>
</tbody></table>
<p>On my machine, that was 35.4 ms without the index and 4.0 ms with it, so about nine times faster.</p>
<p><strong>But</strong> <strong>be careful how you quote numbers like these.</strong> The database did 33,334 times less work, but it didn't run 33,334 times faster, because a single query also pays for connection handling, planning and returning the result, none of which the index changes. The work ratio is the durable claim. The speed ratio depends on your hardware, your cache, and what else the server is doing.</p>
<p><strong>You won't get nine.</strong> When I ran this same benchmark again from a clean checkout, the same query on the same data measured seventeen times faster rather than nine. The database access counts were identical to the digit: 100,002 and 3, both times.</p>
<p>That contrast is the entire point. Database accesses are a property of your data and your query, so they reproduce exactly. Milliseconds are a property of the machine you happened to run on, so they do not. When you're comparing two ways of writing a query, compare the accesses.</p>
<h3 id="heading-the-index-types-neo4j-gives-you">The Index Types Neo4j Gives You</h3>
<p>Most tutorials show you one kind of index and stop. Neo4j 5 has six, and picking the wrong one is the same as having none, because the planner will quietly ignore an index that can' t answer your predicate.</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Use it for</th>
<th>Created with</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Range</strong></td>
<td>Exact matches, ranges, <code>STARTS WITH</code>, sorting. The default.</td>
<td><code>CREATE INDEX ... FOR (n:Label) ON (n.prop)</code></td>
</tr>
<tr>
<td><strong>Text</strong></td>
<td><code>CONTAINS</code> and <code>ENDS WITH</code> on string properties</td>
<td><code>CREATE TEXT INDEX ...</code></td>
</tr>
<tr>
<td><strong>Point</strong></td>
<td>Distance and bounding box queries on geographic points</td>
<td><code>CREATE POINT INDEX ...</code></td>
</tr>
<tr>
<td><strong>Token lookup</strong></td>
<td>Finding nodes by label or relationships by type</td>
<td>Exists by default, two of them</td>
</tr>
<tr>
<td><strong>Full-text</strong></td>
<td>Searching <em>inside</em> text, ranked by relevance. Powered by Lucene.</td>
<td><code>CREATE FULLTEXT INDEX ...</code></td>
</tr>
<tr>
<td><strong>Vector</strong></td>
<td>Nearest-neighbour search over embeddings</td>
<td><code>CREATE VECTOR INDEX ...</code></td>
</tr>
</tbody></table>
<p>The one that catches people is the difference between range and text. A range index handles <code>STARTS WITH</code> perfectly well, because names sharing a prefix sit next to each other in sorted order, the same way "photosynthesis" and "photosphere" are neighbours in a book index. It cannot help with <code>CONTAINS</code> or <code>ENDS WITH</code>, because the thing you are searching for could be anywhere inside the value, and a sorted structure gives you no way to narrow that down. That's what a text index is for.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943242850/9746cba9-9e22-4e6c-a2bf-668e9f67e9a2.png" alt="index type decision" style="display:block;margin:0 auto" width="3360" height="992" loading="lazy">

<p>We have six index types and the question each answers. The wrong type is the same as no index, because the planner quietly ignores an index that can't answer your predicate and nothing tells you it happened.</p>
<p>If you write no type at all, you get a range index, which is the right default for the overwhelming majority of cases:</p>
<pre><code class="language-cypher">CREATE INDEX service_tier IF NOT EXISTS FOR (s:Service) ON (s.tier)
</code></pre>
<p>You can also index more than one property at once, which is called a composite index:</p>
<pre><code class="language-cypher">CREATE INDEX service_tier_name IF NOT EXISTS FOR (s:Service) ON (s.tier, s.name)
</code></pre>
<p>A composite index isn't the same as two separate indexes. It's one structure sorted by tier first and then by name inside each tier, like a phone book ordered by city and then surname. It's excellent when you filter on both, and useless if you filter only on the second one, because you can't look up a surname in a phone book that is grouped by city without going through every city.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943245742/7d5c7c45-ee00-478b-8eed-05cbf3c04cd1.png" alt="composite index" style="display:block;margin:0 auto" width="3280" height="1808" loading="lazy">

<p>A composite index covers a combination of properties, and their order decides which queries it serves. Filtering on the first property alone can use it. Filtering only on the second can't.</p>
<p>Relationships can be indexed too, using the same syntax with a relationship pattern:</p>
<pre><code class="language-cypher">CREATE INDEX owns_since IF NOT EXISTS FOR ()-[r:OWNS]-() ON (r.since)
</code></pre>
<p>To see what you have, ask:</p>
<pre><code class="language-cypher">SHOW INDEXES
</code></pre>
<h3 id="heading-why-your-index-isnt-being-used">Why Your Index Isn't Being Used</h3>
<p>An index that exists but is never used is the most frustrating case, because everything looks correct. There are four usual reasons, and a <code>PROFILE</code> tells you which one you have.</p>
<ol>
<li><p><strong>You indexed a different property from the one you filter on.</strong> An index on <code>email</code> does nothing for a query filtering on <code>name</code>.</p>
</li>
<li><p><strong>Your predicate can't use that index type.</strong> <code>CONTAINS</code> against a range index is the classic. The index exists, the planner looks at it, and correctly concludes it can't help.</p>
</li>
<li><p><strong>You wrapped the property in a function.</strong> <code>WHERE toLower(e.email) = 'x'</code> can't use an index on <code>e.email</code>, because the index stores the original values, not the lowercased ones. Store a normalised copy of the property and index that instead.</p>
</li>
<li><p><strong>You didn't give the node a label.</strong> Indexes are defined on a label. <code>MATCH (n) WHERE n.email = ...</code> has no label to work with, which is exactly why the first example above scanned every node in the database.</p>
</li>
</ol>
<h2 id="heading-constraints-and-the-trap-that-will-catch-you">Constraints, and the Trap That Will Catch You</h2>
<p>An index makes lookups fast. A <strong>constraint</strong> makes a rule impossible to break. They're different jobs, and the reason they get discussed together is that in Neo4j one of them quietly does the other.</p>
<p>Every <code>MERGE</code> has to check whether a matching node already exists. Without an index, that check scans every node carrying the label.</p>
<p>On a thousand nodes you won't notice. At a hundred thousand your import will crawl, and the reason won't be obvious because nothing is broken. It's simply doing an enormous amount of unnecessary work.</p>
<p>Create a uniqueness constraint on the property you merge on. It enforces correctness and creates the supporting index at the same time.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943247840/6b9f5808-3267-4998-aaab-f59c65c3e0ef.png" alt="constraint effect" style="display:block;margin:0 auto" width="3360" height="1314" loading="lazy">

<p>Here we have two runs of the same existence check, before and after a constraint. Without one, answering "does this engineer already exist" means reading every Engineer node and comparing the email, keeping one match and discarding the rest, then doing it all again for the next row. The plan shows <code>NodeByLabelScan</code>. With a uniqueness constraint the database creates a supporting index, so it goes straight to the node or straight to nothing and never looks at the others. The plan shows <code>NodeUniqueIndexSeek</code>.</p>
<p>At a thousand nodes you won't notice. At a hundred thousand the import crawls and nothing in the output explains why. The cost is the same either way, so there is no reason to skip it.</p>
<p>To check what yours is doing, put PROFILE in front of the query and look at the bottom operator. <code>NodeByLabelScan</code> on a starting node almost always means a missing index, and it's the single most common finding in a slow Cypher query.</p>
<p>You can prove the second half of that sentence rather than take my word for it:</p>
<pre><code class="language-cypher">SHOW INDEXES YIELD name, type, owningConstraint
WHERE owningConstraint IS NOT NULL
RETURN name, type, owningConstraint
</code></pre>
<pre><code class="language-text">name             type     owningConstraint
engineer_email   RANGE    engineer_email
incident_ref     RANGE    incident_ref
service_name     RANGE    service_name
team_name        RANGE    team_name
</code></pre>
<p>Four constraints, four range indexes created automatically, each owned by its constraint. This is why the loading script in this article never creates those indexes separately: doing so would be redundant, and Neo4j would reject it as a conflict.</p>
<p>Neo4j offers four kinds of constraint:</p>
<table>
<thead>
<tr>
<th>Constraint</th>
<th>Enforces</th>
</tr>
</thead>
<tbody><tr>
<td><code>IS UNIQUE</code></td>
<td>No two nodes with this label share this property value</td>
</tr>
<tr>
<td><code>IS NOT NULL</code></td>
<td>The property must be present</td>
</tr>
<tr>
<td><code>IS NODE KEY</code></td>
<td>Both of the above, over one or more properties together</td>
</tr>
<tr>
<td><code>IS :: TYPE</code></td>
<td>The property must be of a given type, such as <code>STRING</code></td>
</tr>
</tbody></table>
<p><strong>Here's the trap:</strong> only the first one works on Neo4j Community Edition, which is what you get from the Docker image in this article. The other three are Enterprise features. Aura runs Enterprise, so they work there.</p>
<p>That means the same script can succeed against Aura and fail against your local Docker container, which is a genuinely confusing thing to hit when you are learning. This is what it looks like:</p>
<pre><code class="language-text">Neo.DatabaseError.Schema.ConstraintCreationFailed
Unable to create Constraint( type='NODE PROPERTY EXISTENCE', schema=(:Engineer {name}) ):
Property existence constraint requires Neo4j Enterprise Edition
</code></pre>
<p>That's not your mistake. It's an edition limit, and the message says so if you read to the end of the line.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943250520/20bcc7a4-5d0d-46a5-9e2d-1bc1840fa8a3.png" alt="constraint editions" style="display:block;margin:0 auto" width="3360" height="1174" loading="lazy">

<p><code>IS UNIQUE</code> works on Community Edition, which is what the Docker image in this handbook gives you, and it also creates the backing index. The figure lists three others that Community refuses: <code>IS NOT NULL</code> for property existence, <code>IS NODE KEY</code> for unique-and-present across one or more properties, and a property type constraint such as requiring a STRING. All three need Enterprise.</p>
<p>Aura runs Enterprise, so the same script can succeed there and fail on your laptop. That isn't your mistake, and the refusal says so if you read to the end: <code>Neo.DatabaseError.Schema.ConstraintCreationFailed</code>, followed by the words Enterprise Edition.</p>
<p>Everything in this handbook uses only <code>IS UNIQUE</code>, so all of it runs on Community.</p>
<pre><code class="language-cypher">CREATE CONSTRAINT engineer_email IF NOT EXISTS
FOR (e:Engineer) REQUIRE e.email IS UNIQUE
</code></pre>
<p>Do this <strong>before</strong> you load, not after.</p>
<p>For properties you filter on frequently but which aren't unique, create a plain index:</p>
<pre><code class="language-cypher">CREATE INDEX service_tier IF NOT EXISTS
FOR (s:Service) ON (s.tier)
</code></pre>
<p>A sensible starting set for our model:</p>
<pre><code class="language-cypher">CREATE CONSTRAINT engineer_email IF NOT EXISTS FOR (e:Engineer) REQUIRE e.email IS UNIQUE;
CREATE CONSTRAINT service_name  IF NOT EXISTS FOR (s:Service)  REQUIRE s.name  IS UNIQUE;
CREATE CONSTRAINT incident_ref  IF NOT EXISTS FOR (i:Incident) REQUIRE i.ref   IS UNIQUE;
CREATE CONSTRAINT team_name     IF NOT EXISTS FOR (t:Team)     REQUIRE t.name  IS UNIQUE;
</code></pre>
<p>Run these from Python once at setup time:</p>
<pre><code class="language-python">CONSTRAINTS = [
    "CREATE CONSTRAINT engineer_email IF NOT EXISTS FOR (e:Engineer) REQUIRE e.email IS UNIQUE",
    "CREATE CONSTRAINT service_name  IF NOT EXISTS FOR (s:Service)  REQUIRE s.name  IS UNIQUE",
    "CREATE CONSTRAINT incident_ref  IF NOT EXISTS FOR (i:Incident) REQUIRE i.ref   IS UNIQUE",
    "CREATE CONSTRAINT team_name     IF NOT EXISTS FOR (t:Team)     REQUIRE t.name  IS UNIQUE",
]

for statement in CONSTRAINTS:
    driver.execute_query(statement, database_="neo4j")
</code></pre>
<p><code>IF NOT EXISTS</code> makes that block safe to run on every startup.</p>
<h2 id="heading-what-the-planner-does-with-your-query">What the Planner Does With Your Query</h2>
<p>Cypher is a declarative language. You describe the shape of the answer you want, and you never say how to find it. That's a real convenience, and it has one consequence worth understanding: something has to decide how.</p>
<p>That something is the <strong>query planner</strong>.</p>
<p>When you send a query, Neo4j parses it, then considers the different ways it could be executed. For our multi-hop query it could start from the incident and walk out to the engineers, or start from all the engineers and walk in towards the incident. Both produce identical results. One touches a handful of nodes and the other touches fifty thousand.</p>
<p>The planner picks between them using <strong>statistics</strong> it keeps about your data: how many nodes carry each label, how many relationships of each type exist, and how many distinct values a given indexed property has. From those it estimates how many rows each possible step would produce, and chooses the plan with the lowest estimated cost. This is why it is called a cost-based planner, and why the header of every plan says <code>Planner COST</code>.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943253280/c1482f32-db21-4249-80f2-f3234d4415e9.png" alt="planner pipeline" style="display:block;margin:0 auto" width="3560" height="824" loading="lazy">

<p>Cypher is declarative, so you never say how to find anything. Something still chooses, and that choice is where fast and slow are decided. A query plan is that decision, written down.</p>
<p>The important consequence for you: <strong>the planner is guessing.</strong> Educated guessing, from real statistics, but guessing. When its guess is badly wrong, you get a slow query, and the plan is where you can see that happening.</p>
<h3 id="heading-explain-and-profile">EXPLAIN and PROFILE</h3>
<p>Two keywords let you see the plan, and the difference between them matters.</p>
<p><code>EXPLAIN</code> <strong>plans the query without running it.</strong> You get the operators the planner chose and its row estimates. Nothing is executed, nothing is read, and no data is changed. It costs essentially nothing, so you can use it on a query you suspect might run for an hour.</p>
<p><code>PROFILE</code> <strong>plans the query and then runs it.</strong> You get everything <code>EXPLAIN</code> gives you plus what actually happened: real row counts and real database hits per operator.</p>
<p>Here's the same query both ways.</p>
<pre><code class="language-cypher">EXPLAIN MATCH (e:Engineer)-[:OWNS]-&gt;(s:Service {tier:'critical'}) RETURN count(e) AS c
</code></pre>
<pre><code class="language-text">operator               details                       est   rows   dbHits
ProduceResults         c                               1      ?        ?
  EagerAggregation     count(e) AS c                   1      ?        ?
    Filter             e:Engineer                   2401      ?        ?
      Expand(All)      (s)&lt;-[anon_0:OWNS]-(e)       2401      ?        ?
        Filter         s.tier = $autostring_0        250      ?        ?
          NodeByLabelScan  s:Service                5000      ?        ?
</code></pre>
<p>Every <code>rows</code> and <code>dbHits</code> value is a question mark, because nothing ran. Now with <code>PROFILE</code>:</p>
<pre><code class="language-text">operator               details                       est   rows   dbHits
ProduceResults         c                               1      1        0
  EagerAggregation     count(e) AS c                   1      1        0
    Filter             e:Engineer                   2401   7573     7573
      Expand(All)      (s)&lt;-[anon_0:OWNS]-(e)       2401   7573    17871
        Filter         s.tier = $autostring_0        250    786     5000
          NodeByLabelScan  s:Service                5000   5000     5001
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943255710/52a4ec28-a29e-4b43-ab9a-67e73da2898a.png" alt="explain vs profile" style="display:block;margin:0 auto" width="3360" height="1068" loading="lazy">

<p>EXPLAIN plans it, PROFILE runs it. Operators and estimates are identical because the planner decided the same either way. What EXPLAIN can't give you is what actually happened, which is the number you need when the estimate was wrong.</p>
<p>Use <code>EXPLAIN</code> when you want to know what the database intends to do, or when running the query would be expensive or destructive. Use <code>PROFILE</code> when you want to know what it actually did.</p>
<p><code>EXPLAIN</code> has a second use that's worth more than it sounds: it parses and plans without touching data, so it is the fastest possible check that a query is even valid. You can run every Cypher string in your codebase through <code>EXPLAIN</code> as a test, and catch typos and renamed properties before they reach production.</p>
<p>That's exactly what the <code>check_cypher.py</code> script in the <a href="https://github.com/ronidas39/knowledge-graph-python-neo4j">companion repository</a> does: it pulls every Cypher block out of this article, 39 of them, runs each through <code>EXPLAIN</code>, and fails if a single one is invalid.</p>
<h3 id="heading-reading-a-plan-start-at-the-bottom">Reading a Plan: Start at the Bottom</h3>
<p>This is the single thing that makes plans readable, and it's the opposite of what most people assume.</p>
<p><strong>A query plan is read from the bottom up.</strong> The bottom row is the leaf operator, where data enters. Each row above it receives rows from the row below, does something to them, and passes the result upward. The top row, always <code>ProduceResults</code>, is where the answer leaves the database.</p>
<p>So in the plan above, reading it the right way round:</p>
<ol>
<li><p><code>NodeByLabelScan</code> reads all 5,000 services. This is the leaf: it's where rows come from.</p>
</li>
<li><p><code>Filter</code> keeps only the critical ones, 786 of the 5,000.</p>
</li>
<li><p><code>Expand(All)</code> follows <code>OWNS</code> backwards from each of those to the engineers, producing 7,573 rows.</p>
</li>
<li><p><code>Filter</code> checks that each is really an <code>Engineer</code>.</p>
</li>
<li><p><code>EagerAggregation</code> counts them.</p>
</li>
<li><p><code>ProduceResults</code> hands back the single number.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943257995/b90fdb81-ca67-4328-8eff-122d080087ea.png" alt="plan read bottom up" style="display:block;margin:0 auto" width="3000" height="2048" loading="lazy">

<p>A plan is read from the bottom up. The bottom row is where rows enter, and each row above receives them, changes them and passes them on, up to <code>ProduceResults</code>. Reading it top down is why plans look like noise at first.</p>
<p>Indentation shows the parent and child relationship. An operator's children sit one level deeper than it does. Most operators have exactly one child. A few, like joins, have two, and their right-hand input is shown first and indented deeper.</p>
<h3 id="heading-what-the-columns-mean">What the Columns Mean</h3>
<table>
<thead>
<tr>
<th>Column</th>
<th>What it tells you</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Operator</strong></td>
<td>The kind of work being done: a scan, a seek, an expand, a filter</td>
</tr>
<tr>
<td><strong>Id</strong></td>
<td>A stable number for cross-referencing within this plan</td>
</tr>
<tr>
<td><strong>Details</strong></td>
<td>The specific thing: which label, which pattern, which predicate</td>
</tr>
<tr>
<td><strong>Estimated Rows</strong></td>
<td>How many rows the planner <em>thought</em> this step would produce</td>
</tr>
<tr>
<td><strong>Rows</strong></td>
<td>How many it <em>actually</em> produced. <code>PROFILE</code> only</td>
</tr>
<tr>
<td><strong>DB Hits</strong></td>
<td>How much work the storage engine did. <code>PROFILE</code> only</td>
</tr>
<tr>
<td><strong>Memory (Bytes)</strong></td>
<td>Peak memory for this operator. <code>PROFILE</code> only</td>
</tr>
<tr>
<td><strong>Page Cache Hits/Misses</strong></td>
<td>How often data was found in memory instead of on disk</td>
</tr>
</tbody></table>
<p>Two of these are misread often enough to be worth spelling out.</p>
<p><strong>DB hits aren't rows.</strong> A database hit counts low-level accesses in the storage engine: reading a node, reading a property, or reading an index entry. A single returned row can cost many hits. Look again at the <code>Expand(All)</code> line above: 7,573 rows, 17,871 hits. The row count is your result size, the hit count is the price you paid for it.</p>
<p><strong>Page cache hits and misses show whether the data was in memory.</strong> A miss means the database had to go to disk. On a first run against cold data you'll see mostly misses, and on a second run mostly hits, which is why comparing timings between a cold and a warm run tells you nothing useful. This column is an Enterprise Edition feature, so on the Community Docker image in this article it reads <code>0/0</code> throughout. That's not a bug and it doesn't mean your cache is empty.</p>
<h3 id="heading-the-most-useful-thing-in-the-whole-plan">The Most Useful Thing in the Whole Plan</h3>
<p>Compare <strong>Estimated Rows</strong> against <strong>Rows</strong>.</p>
<p>The estimate is what the planner believed when it chose this plan. The row count is the truth. When they're close, the planner made its decision with a good picture of your data. When they diverge badly, it chose a plan for a dataset that doesn't exist, and that's very often the real reason a query is slow.</p>
<p>Look at the numbers from the profile above:</p>
<table>
<thead>
<tr>
<th>Operator</th>
<th>Estimated</th>
<th>Actual</th>
<th>Off by</th>
</tr>
</thead>
<tbody><tr>
<td><code>NodeByLabelScan</code></td>
<td>5,000</td>
<td>5,000</td>
<td>correct</td>
</tr>
<tr>
<td><code>Filter</code> on <code>tier</code></td>
<td>250</td>
<td>786</td>
<td>3.1x under</td>
</tr>
<tr>
<td><code>Expand(All)</code></td>
<td>2,401</td>
<td>7,573</td>
<td>3.2x under</td>
</tr>
</tbody></table>
<p>The planner guessed that filtering services down to the critical ones would leave 250 of 5,000. In our data it leaves 786, because roughly 15% of services are critical rather than the 5% its default assumption implies. That error then flows upward: because it expected 250 services it expected about 2,401 engineers, and got 7,573.</p>
<p>Here the consequence is harmless. On a bigger query, a three-fold underestimate at the bottom of a plan is exactly how the planner talks itself into a strategy that falls apart, because it believed it was joining a small thing to a big thing when it was really joining two big things.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943260911/c197f4b6-3fba-40f4-aabe-c8e1ed9fcae3.png" alt="estimated vs actual" style="display:block;margin:0 auto" width="3360" height="1098" loading="lazy">

<p>Estimated Rows is what the planner believed when it chose this plan. Rows is what happened. Where they diverge is usually where a slow query is explained, because the planner optimised for a shape the data didn't have.</p>
<p>If estimates are consistently wrong across your queries, the statistics behind them may be stale.</p>
<p><strong>So the habit worth building is:</strong> run <code>PROFILE</code>, read from the bottom, and check the estimate against the truth at every step. You aren't looking for a big number. You're looking for the first place the planner was surprised.</p>
<h3 id="heading-three-tells-worth-recognising">Three Tells Worth Recognising</h3>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943263531/1567ddd5-ad42-48d4-9f41-242d1a0b0ff9.png" alt="profile plan" style="display:block;margin:0 auto" width="4400" height="1360" loading="lazy">

<p>This is PROFILE output in Neo4j Browser, showing the operator chain with estimated and actual row counts beside each step. This is the real output the <code>NodeUniqueIndexSeek</code> explanation refers to.</p>
<p>Beyond the estimate check, three specific things in a plan should catch your eye.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943266391/78671cc3-1f92-4d7b-9bd0-f4570a71069c.png" alt="plan tells" style="display:block;margin:0 auto" width="3400" height="2128" loading="lazy">

<p>What specific operators tell you when you see them. <code>NodeByLabelScan</code> on a starting node means no index is being used. Each entry pairs the symptom with the cause and the fix.</p>
<p><code>NodeByLabelScan</code> means the database read every node with that label. On a starting node this almost always means a missing index. It's the single most common finding.</p>
<p><strong>A row count that explodes and then collapses:</strong> if one step produces two hundred thousand rows and the next reduces it to forty, you're generating work and throwing it away. Usually the pattern can be reordered so the selective part happens first.</p>
<p><code>CartesianProduct</code> means two parts of your pattern aren't connected, so the database is combining every row on the left with every row on the right. It's nearly always an accident, and it's nearly always the reason a query went from milliseconds to minutes.</p>
<p>All three have the same shape as a fix: give the planner a cheaper way in. An index turns a scan into a seek, a reordered pattern makes the selective step happen first, and a missing relationship in the pattern removes the cartesian product.</p>
<h2 id="heading-six-problems-youll-actually-hit">Six Problems You'll Actually Hit</h2>
<p>These are the ones that cost people an afternoon. None of them produce an obvious error message, which is exactly why they're worth listing.</p>
<h3 id="heading-the-query-returns-nothing-and-you-expected-rows">The Query Returns Nothing and You Expected Rows</h3>
<p>Check your arrow directions first. <code>(a)-[:OWNS]-&gt;(b)</code> and <code>(a)&lt;-[:OWNS]-(b)</code> are different questions, and the second one is what you want when you're starting from the thing that's owned. If you're unsure, drop the arrowheads entirely and use <code>-[:OWNS]-</code>, which matches either direction. If rows appear, direction was the problem.</p>
<h3 id="heading-the-query-returns-fewer-rows-than-the-truth">The Query Returns Fewer Rows Than the Truth</h3>
<p>This is the relationship uniqueness trap from earlier in this handbook. If a pattern leaves a node and comes back to the same kind of node, and both halves could be the same relationship, Cypher discards those matches without a word. Split the pattern with <code>WITH</code>.</p>
<h3 id="heading-a-query-that-was-instant-is-suddenly-slow">A Query That Was Instant is Suddenly Slow</h3>
<p>Look for <code>CartesianProduct</code> in <code>PROFILE</code>. It means two parts of your pattern aren't connected to each other, so every row on the left is being combined with every row on the right. Usually a variable was forgotten, or two <code>MATCH</code> clauses were written where one pattern was meant.</p>
<h3 id="heading-merge-created-a-duplicate">MERGE Created a Duplicate</h3>
<p>You merged on more than the identifying property. <code>MERGE (e:Engineer {email: $email, name: $name})</code> treats a changed name as a different node. Merge on identity, then <code>SET</code> the rest.</p>
<h3 id="heading-merge-is-unbearably-slow">MERGE is Unbearably Slow</h3>
<p>You have no index on the property you merge on, so every merge scans every node with that label. Create the constraint before loading, not after.</p>
<h3 id="heading-the-whole-import-ran-out-of-memory">The Whole Import Ran Out of Memory</h3>
<p>You put everything in one transaction. Batch it. A few thousand rows per transaction is a sane default, and <code>CALL { ... } IN TRANSACTIONS</code> lets Cypher do the batching for you inside a single query.</p>
<p>Here's a short checklist worth keeping next to you:</p>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>First thing to check</th>
</tr>
</thead>
<tbody><tr>
<td>No rows</td>
<td>Arrow direction</td>
</tr>
<tr>
<td>Too few rows</td>
<td>Relationship uniqueness, split with <code>WITH</code></td>
</tr>
<tr>
<td>Sudden slowness</td>
<td><code>PROFILE</code> for <code>CartesianProduct</code></td>
</tr>
<tr>
<td>Duplicate nodes</td>
<td>Merging on more than the identity</td>
</tr>
<tr>
<td>Slow <code>MERGE</code></td>
<td>Missing constraint or index</td>
</tr>
<tr>
<td>Out of memory</td>
<td>One giant transaction</td>
</tr>
</tbody></table>
<h2 id="heading-transactions-and-what-happens-when-things-fail">Transactions and What Happens When Things Fail</h2>
<p><code>execute_query</code> wraps each call in its own transaction and retries it automatically if it hits a transient error such as a leader election in a cluster. For the majority of work, that's exactly what you want and you don't need to think about it.</p>
<p>Here's what actually happens across the driver, the session and the database, including the case everyone worries about: a write that fails halfway.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943270013/1636e385-6717-4a1c-a397-a1eb77ec6c24.png" alt="transaction lifecycle" style="display:block;margin:0 auto" width="3000" height="1902" loading="lazy">

<p>From your code through the driver and session to Neo4j. One driver per application with <code>GraphDatabase.driver(uri, auth)</code>, then a session per unit of work. The session is cheap and short-lived, the driver expensive and long-lived, and swapping those round is a common cause of slow applications.</p>
<p>The important part is the middle. Once a transaction begins, nothing it has written is visible or durable until it commits. A failure at step nine doesn't leave you with half a graph, it leaves you with the graph you started with.</p>
<p>When you need several statements to succeed or fail together, manage the transaction yourself:</p>
<pre><code class="language-python">def reassign_service(tx, service, from_email, to_email):
    tx.run(
        """
        MATCH (:Engineer {email: $from_email})-[r:OWNS]-&gt;(s:Service {name: $service})
        DELETE r
        """,
        from_email=from_email, service=service,
    )
    tx.run(
        """
        MATCH (e:Engineer {email: $to_email}), (s:Service {name: $service})
        MERGE (e)-[:OWNS {since: date()}]-&gt;(s)
        """,
        to_email=to_email, service=service,
    )

with driver.session(database="neo4j") as session:
    session.execute_write(reassign_service, "payments", "ada@example.com", "grace@example.com")
</code></pre>
<p><code>execute_write</code> runs your function inside one transaction. If any statement raises, the whole thing rolls back and the graph is left as it was. It also retries the function on transient failures, which is why the work goes in a function rather than inline: it may be executed more than once, so it must be safe to repeat.</p>
<p>That last point is worth saying plainly: <strong>any function you hand to</strong> <code>execute_write</code> <strong>must be idempotent</strong>, which means running it twice has the same effect as running it once. A retry starts your function again from the top, so anything that increments a counter or appends to a list will do it twice. This is another reason to reach for <code>MERGE</code> rather than <code>CREATE</code> inside one.</p>
<h2 id="heading-testing-code-that-talks-to-a-graph">Testing Code That Talks to a Graph</h2>
<p>Graph code is easy to write and easy to get subtly wrong, as the relationship uniqueness trap earlier in this handbook showed. Tests are how you find that class of bug once rather than repeatedly.</p>
<h3 id="heading-dont-mock-the-database">Don't Mock the Database</h3>
<p>The temptation is to mock the driver and assert that your function called it with a particular string. Resist it. That test passes when your Cypher is wrong, which is precisely the failure you need to catch. The bugs in graph code are almost never in the Python around the query. They're in the query.</p>
<p>Run tests against a real Neo4j. It starts in seconds in Docker, and the whole point is to exercise the query engine.</p>
<h3 id="heading-give-each-test-a-clean-graph">Give Each Test a Clean Graph</h3>
<pre><code class="language-python">import os
import pytest
from neo4j import GraphDatabase

@pytest.fixture(scope="session")
def driver():
    d = GraphDatabase.driver(
        os.environ.get("NEO4J_TEST_URI", "bolt://localhost:7687"),
        auth=("neo4j", os.environ["NEO4J_TEST_PASSWORD"]),
    )
    d.verify_connectivity()
    yield d
    d.close()

@pytest.fixture(autouse=True)
def clean(driver):
    """Wipe before every test so tests cannot leak into each other."""
    driver.execute_query("MATCH (n) DETACH DELETE n", database_="neo4j")
</code></pre>
<p>The driver is created once for the whole session, because it's expensive. The wipe runs before every test, because a test that depends on another test's leftovers will pass alone and fail in a suite.</p>
<h3 id="heading-test-the-thing-that-actually-broke">Test the Thing That Actually Broke</h3>
<p>A useful test is one that would have caught a real bug. Here's the one for the trap from earlier:</p>
<pre><code class="language-python">def test_teams_includes_a_team_whose_only_member_is_the_owner(driver):
    driver.execute_query(
        """
        MERGE (e:Engineer {email: 'linus@example.com'}) SET e.name = 'Linus'
        MERGE (s:Service {name: 'checkout'})
        MERGE (t:Team {name: 'Commerce'})
        MERGE (i:Incident {ref: 'INC-1'})
        MERGE (e)-[:OWNS]-&gt;(s)
        MERGE (e)-[:MEMBER_OF]-&gt;(t)
        MERGE (i)-[:AFFECTS]-&gt;(s)
        """,
        database_="neo4j",
    )

    teams = teams_involved(driver, "INC-1")

    # The single-pattern version returns [] here, with no error at all.
    assert [t["team"] for t in teams] == ["Commerce"]
</code></pre>
<p>That test is worth more than a dozen tests of your Python. It encodes a specific, silent, hard-to-spot failure, and it will fail loudly if anyone ever "simplifies" the query back into one pattern.</p>
<h3 id="heading-assert-on-counts-as-well-as-contents">Assert on Counts as Well as Contents</h3>
<p>Silent under-fetching is the characteristic graph bug, so assert how many rows you got, not only that the ones you got look right:</p>
<pre><code class="language-python">def test_load_is_idempotent(driver):
    load(driver)
    _, summary, _ = driver.execute_query(
        "MATCH (e:Engineer) RETURN count(e) AS c", database_="neo4j"
    )
    first = driver.execute_query("MATCH (e:Engineer) RETURN count(e) AS c", database_="neo4j")[0][0]["c"]

    load(driver)   # run it again
    second = driver.execute_query("MATCH (e:Engineer) RETURN count(e) AS c", database_="neo4j")[0][0]["c"]

    assert first == second, "loading twice created duplicates, so a MERGE key is wrong"
</code></pre>
<p>That single assertion catches the most expensive loading mistake there is, which is merging on more than the identifying property.</p>
<p>The same graph, seen as a data model in Neo4j Browser against the live Aura instance:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943272688/17cd6c67-22bb-4049-9095-2ef5916a558f.png" alt="data model" style="display:block;margin:0 auto" width="4400" height="1360" loading="lazy">

<p><code>CALL db.schema.visualization()</code> running in the Aura console, which draws the shape of whatever is currently in the database. It shows four node labels, <code>Engineer</code>, <code>Incident</code>, <code>Service</code> and <code>Team</code>, joined by four relationship types: an incident <code>AFFECTS</code> a service, a service <code>DEPENDS_ON</code> another service, an engineer <code>OWNS</code> a service, and an engineer is a <code>MEMBER_OF</code> a team. The property keys in use are <code>email</code>, <code>name</code>, <code>ref</code> and <code>summary</code>.</p>
<p>This is the same model you built locally, running on the managed service, and it's a quick way to check that a load did what you expected.</p>
<h2 id="heading-from-graph-to-knowledge-graph">From Graph to Knowledge Graph</h2>
<p>Everything so far has been a graph database. A <strong>knowledge graph</strong> is what you get when the nodes represent real entities from your domain and the relationships represent meaningful facts about them, so that the graph itself is a model of what you know.</p>
<p>The step up from one to the other is mostly about where the data comes from. Instead of loading rows from a table, you extract entities and relationships from documents, tickets, wikis, code, or conversations.</p>
<p>The mechanics you've already learned don't change:</p>
<pre><code class="language-python">def add_fact(driver, subject, predicate_service, source_doc):
    driver.execute_query(
        """
        MERGE (e:Engineer {email: $subject})
        MERGE (s:Service {name: $service})
        MERGE (e)-[r:OWNS]-&gt;(s)
          ON CREATE SET r.source = $source, r.extracted = datetime()
        """,
        subject=subject, service=predicate_service, source=source_doc,
        database_="neo4j",
    )
</code></pre>
<p>Notice <code>r.source</code>. When facts are extracted rather than entered, <strong>recording where each fact came from isn't optional</strong>. You'll need it the first time somebody asks why the graph believes something, and you'll need it when a source document is corrected and you have to find everything derived from it.</p>
<p>Two habits make extracted graphs survivable:</p>
<ul>
<li><p><strong>Store provenance on the relationship.</strong> Which document, which version, when.</p>
</li>
<li><p><strong>Keep extraction idempotent.</strong> Re-running over the same document must not duplicate facts, which is exactly what <code>MERGE</code> on an identifying property gives you.</p>
</li>
</ul>
<h2 id="heading-why-ai-systems-keep-rediscovering-graphs">Why AI Systems Keep Rediscovering Graphs</h2>
<p>This is the part that makes graphs suddenly relevant to people who have never touched one.</p>
<p>The standard way to give a language model access to your data is to embed your documents as vectors and retrieve the chunks most similar to the question. This works well, and it fails in a specific and predictable way.</p>
<p>Similarity retrieval can tell you that two things are related. It can't tell you how.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943275441/b6277b72-99cc-4f1c-b55e-6a1037e21ae6.png" alt="vector vs graph" style="display:block;margin:0 auto" width="3360" height="1618" loading="lazy">

<p>This is why neither retrieval method is enough alone, and what order to combine them in.</p>
<p>Vector search alone finds four documents that are each related to the question and none of which contain the answer. The chain from incident to service to owner to team spans all four, so no single chunk holds it and nothing scores highly enough to be retrieved together.</p>
<p>Graph traversal alone is exact once it starts: hop one goes from the incident to payments and checkout, hop two to Ada and Grace, hop three to the Platform team. The problem is starting, because "last night's payments incident" is a phrase, not a node, and the graph has never seen that wording.</p>
<p>Used together, in order: embed the question and find which entities it's about, which handles wording the graph has never seen. Traverse out from those entities, where relationships are stored so the chain is read rather than inferred. Hand back a small, precise set of facts with their provenance instead of five paragraphs of loosely related prose.</p>
<p>Similarity search can tell you that two things are related. It can't tell you how, which is why these answers degrade into confident guesses exactly when the reasoning gets interesting.</p>
<p>Ask "who should I talk to about last night's payments incident" and a vector store returns the chunks that look most like that sentence. It has no representation of the fact that the incident affected a service, that the service is owned by an engineer, and that the engineer is on a team. Each of those facts might live in a different document, and no single chunk contains the chain.</p>
<p>A graph stores the chain explicitly. Multi-hop questions become traversals, and the answer is derived rather than guessed.</p>
<p>The two aren't rivals, and treating them as rivals is a mistake. The pattern that works in practice is to use both:</p>
<table>
<thead>
<tr>
<th>Job</th>
<th>Best tool</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Find the entry point from fuzzy language</td>
<td>Vector search</td>
<td>Handles wording the graph has never seen</td>
</tr>
<tr>
<td>Traverse from that entry point to related facts</td>
<td>Graph</td>
<td>Relationships are stored, not inferred</td>
</tr>
<tr>
<td>Answer "what is connected to what, and how"</td>
<td>Graph</td>
<td>Paths are the query</td>
</tr>
<tr>
<td>Answer "what does this passage say"</td>
<td>Vector search</td>
<td>The text is the answer</td>
</tr>
</tbody></table>
<p>In practice the pattern is: embed the text, use similarity to work out <strong>which entities</strong> the question is about, then traverse the graph from those entities to assemble the context you hand to the model.</p>
<p>Neo4j can hold the vectors too, which keeps both halves in one place. You create a vector index over a property holding the embedding:</p>
<pre><code class="language-cypher">CREATE VECTOR INDEX service_notes IF NOT EXISTS
FOR (s:Service) ON (s.embedding)
OPTIONS {indexConfig: {
  `vector.dimensions`: 1536,
  `vector.similarity_function`: 'cosine'
}}
</code></pre>
<p>Then the hybrid query becomes one round trip: similarity finds the entry points, and the traversal does the rest.</p>
<pre><code class="language-python">def context_for_question(driver, question_embedding, k=3):
    records, _, _ = driver.execute_query(
        """
        // 1. vector search finds the services the question is about
        CALL db.index.vector.queryNodes('service_notes', $k, $embedding)
        YIELD node AS s, score

        // 2. the graph supplies what similarity cannot: how things connect
        OPTIONAL MATCH (s)&lt;-[:OWNS]-(owner:Engineer)-[:MEMBER_OF]-&gt;(t:Team)
        OPTIONAL MATCH (s)&lt;-[:AFFECTS]-(i:Incident)
        RETURN s.name AS service, score,
               collect(DISTINCT owner.name) AS owners,
               collect(DISTINCT t.name)     AS teams,
               collect(DISTINCT i.ref)      AS incidents
        ORDER BY score DESC
        """,
        embedding=question_embedding, k=k, database_="neo4j",
    )
    return [dict(r) for r in records]
</code></pre>
<p>Read what each half contributes. The vector index answers "which services does this question seem to be about", which a graph alone can't do because the user's wording won't match your node names.</p>
<p>The traversal then answers "who owns them, which teams, what broke recently", which similarity alone can't do because those facts live in different documents and no single chunk contains the chain.</p>
<p>The result you hand the model is a small, precise set of connected facts rather than five paragraphs of loosely related prose. That's usually the difference between an answer and a plausible guess.</p>
<p><strong>A note on honesty in the output:</strong> because every fact came out of the graph, you can cite it. Passing the relationship provenance along with the facts lets the model say where each claim came from, and lets you check it when it gets one wrong.</p>
<p>The same argument explains why durable memory for AI agents keeps ending up shaped like a graph.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943278708/fca8d9a1-5c17-46a7-91de-4cd7866ce6cb.png" alt="agent memory graph" style="display:block;margin:0 auto" width="3360" height="1530" loading="lazy">

<p>The example is three notes. <code>note-03</code> says "We decided to use Mongo for payments", <code>note-09</code> says "Mira moved payments onto Postgres", <code>note-14</code> says "Payments storage reviewed, no action". Ask "what database does payments use" and, as loose text, all three look equally relevant, so the agent picks one.</p>
<p>Drawn as a graph, the newer Decision node <code>use Postgres</code> has a <code>SUPERSEDES</code> edge pointing at the Mongo decision and an <code>APPLIES_TO</code> edge pointing at the payments Service. The ordering that was invisible in prose is now a stored fact the agent can follow.</p>
<p>An agent that remembers needs to know that a decision was made, who made it, what it superseded, and what depends on it. Those are relationships with direction and properties. Storing them as loose text and hoping similarity search reconstructs them is how agents end up confidently contradicting themselves.</p>
<p>None of this requires new skills. It's the same modeling discipline from earlier in this handbook, applied to facts extracted from text instead of rows from a table. Which is why the modeling section is the one worth re-reading.</p>
<h2 id="heading-building-a-knowledge-graph-from-text">Building a Knowledge Graph from Text</h2>
<p>So far every fact arrived as a tidy Python dictionary. Real knowledge graphs are usually built from prose: incident write-ups, wiki pages, tickets, commit messages, and support threads.</p>
<p>The extraction step is where people either build something durable or build a mess. Three rules keep it durable, and here's where each of them sits in the pipeline:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943281113/e7348bd7-2004-47ce-b4aa-a68f96791604.png" alt="ingestion pipeline" style="display:block;margin:0 auto" width="3360" height="550" loading="lazy">

<p>Raw text goes to an extractor, which produces candidate entities and relationships, which are merged into the graph. The stages are separable, which matters because the extractor is the part you'll swap and re-run.</p>
<p>Notice where the gate is. The schema check happens <strong>before</strong> anything is written, not after. Once an invented relationship type is in the graph it's indistinguishable from a real one, and you'll be cleaning it up by hand.</p>
<h3 id="heading-rule-1-extract-into-a-fixed-schema-not-a-free-for-all">Rule #1: Extract into a Fixed Schema, Not a Free-for-All</h3>
<p>If you let an extractor invent relationship types, you'll end up with <code>OWNS</code>, <code>owns</code>, <code>IS_OWNER_OF</code> and <code>RESPONSIBLE_FOR</code> all meaning the same thing, and no query will ever find all four.</p>
<p>Decide your vocabulary first, and make the extractor choose from it:</p>
<pre><code class="language-python">NODE_LABELS = ["Engineer", "Service", "Incident", "Team"]
REL_TYPES = ["OWNS", "AFFECTS", "MEMBER_OF", "DEPENDS_ON"]
</code></pre>
<p>Whatever does the extraction (a language model, a regex, or a human), its job is to emit triples that use only those names. Anything else gets rejected rather than written.</p>
<h3 id="heading-rule-2-every-extracted-fact-carries-its-source">Rule #2: Every Extracted Fact Carries its Source</h3>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786943283291/d5254326-f4eb-45d5-a6a1-148d42f7c0f9.png" alt="extraction provenance" style="display:block;margin:0 auto" width="3360" height="2128" loading="lazy">

<p>Three stages, left to right: documents go in, extraction emits triples using a fixed vocabulary, and the merge records where each fact came from.</p>
<p>The detail the drawing turns on is the split between <code>ON CREATE</code> and <code>ON MATCH</code>. The source is written once, when the fact is first created, while the freshness timestamp updates every time the same fact is seen again. That way re-running over the same document doesn't overwrite the original provenance.</p>
<p>It pays off when a document turns out to be wrong, because matching on the source property lets you retract every fact that came from it in one query. The step people skip is the confidence score: store it, then actually use it downstream, because a guess at 0.4 must not read as a confirmed fact.</p>
<p>When a human types data in, you can ask them. When a machine extracts it, you can't, and someone will eventually ask "why does the graph think Ada owns checkout?"</p>
<pre><code class="language-python">def write_triple(driver, subject_email, rel_type, object_name, source_doc, confidence):
    if rel_type not in REL_TYPES:
        raise ValueError(f"refusing unknown relationship type: {rel_type}")

    driver.execute_query(
        f"""
        MERGE (e:Engineer {{email: $subject}})
        MERGE (s:Service {{name: $object}})
        MERGE (e)-[r:{rel_type}]-&gt;(s)
          ON CREATE SET r.source = $source,
                        r.confidence = $confidence,
                        r.extracted_at = datetime()
          ON MATCH  SET r.last_seen = datetime()
        """,
        subject=subject_email, object=object_name,
        source=source_doc, confidence=confidence,
        database_="neo4j",
    )
</code></pre>
<p>Two things about that snippet deserve a warning.</p>
<p>The relationship type is the <strong>one</strong> thing in Cypher you can't pass as a parameter. <code>-[r:$type]-&gt;</code> isn't valid, which is why it's interpolated into the string.</p>
<p>That's exactly the pattern that causes injection bugs, so the <code>if rel_type not in REL_TYPES</code> check above it is not decoration. It's the only thing making the interpolation safe. Never build that string from raw model output without checking it against a fixed list first.</p>
<p><code>ON CREATE</code> and <code>ON MATCH</code> let you record provenance once and freshness every time, which means re-running extraction over the same document does not overwrite the original source.</p>
<h3 id="heading-rule-3-make-re-extraction-safe">Rule #3: make Re-extraction Safe</h3>
<p>You will re-run extraction. Documents get corrected, your prompt improves, or a bug gets fixed. If a second run duplicates everything, the graph is worthless.</p>
<p>Because every write above is a <code>MERGE</code> on an identifying property, re-running is safe by construction. That's the same idempotency property from the loading section, and it matters far more here.</p>
<p>To retract facts from a document that has changed:</p>
<pre><code class="language-cypher">MATCH ()-[r]-&gt;()
WHERE r.source = $source_doc
DELETE r
</code></pre>
<p>Then re-extract. Deleting by source is only possible because you stored the source, which is the whole argument for rule two.</p>
<h3 id="heading-a-caution-on-confidence">A Caution on Confidence</h3>
<p>If your extractor emits a confidence score, store it, and then <strong>actually use it</strong>. A graph that mixes facts a human confirmed with facts a model guessed at 0.4 confidence, and treats them identically at query time, will produce confident wrong answers.</p>
<pre><code class="language-cypher">MATCH (e:Engineer)-[r:OWNS]-&gt;(s:Service)
WHERE r.confidence IS NULL OR r.confidence &gt; 0.8
RETURN e.name, s.name
</code></pre>
<p><code>r.confidence IS NULL</code> keeps the hand-entered facts, which have no score because nobody guessed them.</p>
<h2 id="heading-the-complete-script">The Complete Script</h2>
<p>Here's everything from this handbook as one runnable file. It creates the constraints, loads the data, and answers the question from the introduction. If you've followed along, this is the whole thing in one place.</p>
<pre><code class="language-python">"""A minimal knowledge graph, end to end."""

import os
from neo4j import GraphDatabase

URI = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
AUTH = (
    os.environ.get("NEO4J_USER", "neo4j"),
    os.environ["NEO4J_PASSWORD"],
)

CONSTRAINTS = [
    "CREATE CONSTRAINT engineer_email IF NOT EXISTS FOR (e:Engineer) REQUIRE e.email IS UNIQUE",
    "CREATE CONSTRAINT service_name  IF NOT EXISTS FOR (s:Service)  REQUIRE s.name  IS UNIQUE",
    "CREATE CONSTRAINT incident_ref  IF NOT EXISTS FOR (i:Incident) REQUIRE i.ref   IS UNIQUE",
    "CREATE CONSTRAINT team_name     IF NOT EXISTS FOR (t:Team)     REQUIRE t.name  IS UNIQUE",
]

PEOPLE = [
    {"email": "ada@example.com",   "name": "Ada Okonjo",   "service": "payments", "team": "Platform"},
    {"email": "grace@example.com", "name": "Grace Lin",    "service": "payments", "team": "Platform"},
    {"email": "linus@example.com", "name": "Linus Berg",   "service": "checkout", "team": "Commerce"},
    {"email": "mira@example.com",  "name": "Mira Haddad",  "service": "auth",     "team": "Platform"},
    {"email": "tom@example.com",   "name": "Tom Ferreira", "service": "search",   "team": "Discovery"},
]

# One engineer who owns nothing, so the OPTIONAL MATCH example has something to
# show. Without her, that query looks identical to a plain MATCH.
UNASSIGNED = {"email": "nadia@example.com", "name": "Nadia Rossi"}

# Service dependencies, which the variable length path example walks.
DEPENDENCIES = [
    {"upstream": "auth",     "downstream": "payments"},
    {"upstream": "auth",     "downstream": "checkout"},
    {"upstream": "payments", "downstream": "checkout"},
    {"upstream": "search",   "downstream": "checkout"},
]

INCIDENT = {"ref": "INC-4471", "summary": "Elevated 5xx on card capture",
            "services": ["payments", "checkout"]}


def setup(driver):
    """Constraints first. They enforce correctness and create the indexes
    that stop MERGE from scanning every node."""
    for statement in CONSTRAINTS:
        driver.execute_query(statement, database_="neo4j")


def load(driver):
    """People and teams, then the unassigned engineer, then dependencies,
    then the incident. Four round trips for the whole dataset."""
    driver.execute_query(
        """
        UNWIND $rows AS row
        MERGE (e:Engineer {email: row.email})
          SET e.name = row.name
        MERGE (s:Service {name: row.service})
        MERGE (t:Team {name: row.team})
        MERGE (e)-[:OWNS]-&gt;(s)
        MERGE (e)-[:MEMBER_OF]-&gt;(t)
        """,
        rows=PEOPLE, database_="neo4j",
    )
    driver.execute_query(
        "MERGE (e:Engineer {email: $email}) SET e.name = $name",
        **UNASSIGNED, database_="neo4j",
    )
    driver.execute_query(
        """
        UNWIND $rows AS row
        MATCH (u:Service {name: row.upstream}), (d:Service {name: row.downstream})
        MERGE (d)-[:DEPENDS_ON]-&gt;(u)
        """,
        rows=DEPENDENCIES, database_="neo4j",
    )
    driver.execute_query(
        """
        MERGE (i:Incident {ref: $ref}) SET i.summary = $summary
        WITH i
        UNWIND $services AS svc
        MATCH (s:Service {name: svc})
        MERGE (i)-[:AFFECTS]-&gt;(s)
        """,
        **INCIDENT, database_="neo4j",
    )


def who_has_context(driver, ref):
    """The question from the introduction, in one pattern."""
    records, _, _ = driver.execute_query(
        """
        MATCH (i:Incident {ref: $ref})-[:AFFECTS]-&gt;(:Service)&lt;-[:OWNS]-(e:Engineer)
        RETURN DISTINCT e.name AS name, e.email AS email
        ORDER BY name
        """,
        ref=ref, database_="neo4j",
    )
    return [dict(r) for r in records]


def teams_involved(driver, ref):
    """Split into two patterns on purpose. A single pattern would hit the
    relationship uniqueness rule and silently drop any team whose only
    member is also the owner."""
    records, _, _ = driver.execute_query(
        """
        MATCH (i:Incident {ref: $ref})-[:AFFECTS]-&gt;(:Service)&lt;-[:OWNS]-(:Engineer)-[:MEMBER_OF]-&gt;(t:Team)
        WITH DISTINCT t
        MATCH (t)&lt;-[:MEMBER_OF]-(e:Engineer)
        RETURN t.name AS team, collect(e.name) AS members
        ORDER BY team
        """,
        ref=ref, database_="neo4j",
    )
    return [dict(r) for r in records]


def main():
    with GraphDatabase.driver(URI, auth=AUTH) as driver:
        driver.verify_connectivity()
        setup(driver)
        load(driver)

        print("Engineers with context on INC-4471:")
        for row in who_has_context(driver, "INC-4471"):
            print(f"  {row['name']:&lt;14} {row['email']}")

        print("\nTeams involved:")
        for row in teams_involved(driver, "INC-4471"):
            print(f"  {row['team']:&lt;10} {', '.join(row['members'])}")


if __name__ == "__main__":
    main()
</code></pre>
<p>Run it with your password in the environment rather than in the file:</p>
<pre><code class="language-bash">export NEO4J_PASSWORD='your-password'
python3 knowledge_graph.py
</code></pre>
<p>Note <code>os.environ["NEO4J_PASSWORD"]</code> with square brackets rather than <code>.get()</code>. That's deliberate. It fails loudly at startup if the variable is missing, instead of quietly trying to connect with <code>None</code> and giving you a confusing authentication error.</p>
<h2 id="heading-where-to-go-next">Where to Go Next</h2>
<p>You now have the pieces that matter: a data model you can defend, a loading script that's safe to re-run, queries that traverse instead of joining, indexes that keep them fast, and a way to find out why something is slow.</p>
<p>Here are three suggestions for what to do with that:</p>
<p><strong>Start with a domain you already understand.</strong> Modeling is the hard part, and it's far easier to judge whether a model is right when you already know what questions the data should answer. Your own codebase, your team's services, or your reading list are all better first projects than a dataset you downloaded.</p>
<p><strong>Write the questions before the model.</strong> It takes ten minutes and it will save you a rewrite. This remains the single highest-leverage habit in this whole handbook.</p>
<p><strong>Then point something at it that's not a person.</strong> Once your data is modeled properly, wiring a language model to traverse it is a much smaller step than it sounds, because the hard part was never the model. It was knowing what the things are and how they connect.</p>
<p><strong>The companion repository is</strong> <a href="https://github.com/ronidas39/knowledge-graph-python-neo4j"><strong>github.com/ronidas39/knowledge-graph-python-neo4j</strong></a><strong>.</strong> It has the complete script, the 75,500 node dataset as committed CSVs, the benchmark behind every number in this article, and a checker that runs all 39 Cypher blocks through <code>EXPLAIN</code>. Clone it, run <code>verify_dataset.py</code>, and you'll know your data matches mine before you trust a single measurement.</p>
<p>If you want to go deeper, I write about system design at <a href="https://systemdesign.academy">systemdesign.academy</a> and publish longer engineering tutorials on <a href="https://www.youtube.com/@totaltechnologyzonne">my YouTube channel</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
