Database Foundations

Neo4j β€” Native Graph for Connected Data

Neo4j is a native graph database where nodes (entities) and relationships (edges) are both first-class citizens β€” not an afterthought bolted onto tables. Born in Sweden in 2007, it was the first commercial native graph database and still owns the category. Its sweet spot is connected data: fraud rings, recommendation engines, knowledge graphs, and social networks. The killer trade-off: graph traversals are 100–1000Γ— faster than SQL JOINs for deep multi-hop relationships, because Neo4j follows physical pointers directly instead of scanning and joining giant tables. The cost: if you need fast bulk inserts or simple flat-table scans, a relational database will serve you better.

8 Think Firsts ~25 SVG Diagrams 24 Sections ~40 Tooltips 5 Exercises
Section 1

TL;DR β€” Neo4j in Plain English

  • Why storing relationships as first-class objects β€” not foreign keys β€” changes query performance by orders of magnitude
  • What the "property graph" model means and how it differs from relational tables
  • Why Neo4j dominates fraud detection, recommendation engines, and social graphs
  • When Neo4j is the right tool β€” and when a relational database beats it

Neo4j's core insight: make the relationship a real object with its own properties, not just a foreign-key number. Follow that pointer directly β€” no JOIN, no table scan β€” and multi-hop queries that would crush SQL become millisecond operations.

PROPERTY GRAPH β€” Nodes + Typed Relationships with Properties Alice :Person age: 31 Β· city: "London" Bob :Person age: 28 Β· city: "Berlin" Carol :Person Acme Corp :Company London :City FRIEND_OF since: 2019 FRIEND_OF WORKS_AT role:"engineer" since: 2020 LIVES_IN Person node Company/City node Typed relationship (carries properties)
In a relational database, the connection between two rows is just a number β€” a foreign key. To use it, the database must look up that number in an index, scan rows in another table, and filter. Do that three hops deep and you're running three nested JOINs across potentially millions of rows. Neo4j stores each relationship as a physical pointer β€” a direct memory reference from node to node. Following three hops means following three pointers. Speed stays roughly constant no matter how large the database grows.
Neo4j uses the "property graph" model. Every node represents an entity (a person, product, city) and can carry key-value properties. Every relationship is a typed, directional connection between two nodes β€” and it too carries its own properties. So (Alice)-[:WORKED_AT {since: 2020, role: "engineer"}]->(Acme) is a single object in the database, not a row in a junction table. Cypher, Neo4j's query language, lets you draw this pattern as ASCII art and the engine finds all matches.
Neo4j shines when your queries are about relationships: "find all fraud rings within 3 hops", "recommend products bought by users similar to this one", "show the shortest path between two topics in a knowledge graph". It struggles when your queries are about aggregations over flat data: "sum revenue for Q3 by region", or "insert 10 million product rows from a CSV". For those, a relational database or a columnar warehouse wins. Use Neo4j when the shape of the data β€” the connections β€” is the interesting part.
Neo4j is a native graph database that stores nodes and relationships as physical objects, not tables β€” making multi-hop traversals 100–1000Γ— faster than SQL JOINs for deeply connected data patterns like fraud rings, social graphs, and recommendation engines.
Section 2

Why You Need This β€” The Fraud Ring Story

You do not need to know anything about graph databases to follow this story. Just follow the dots β€” literally.

The situation: a fraud ring hiding in plain sight

You are building the fraud-detection system for a fintech startup. A new user signs up. The account looks clean β€” real name, real email, real phone. Your rule-based system approves them. They apply for a $5,000 loan. The money disappears overnight.

What actually happened? Let's trace the connections:

The fraudulent user was three hops away from known bad actors. Your rules only looked one hop. The ring was invisible.

Why SQL can't catch this easily

To find that 3-hop fraud ring in a relational database, you write something like this:

-- 3-hop fraud ring detection in SQL (simplified)
SELECT DISTINCT u3.id
FROM users u1
JOIN ip_logins l1 ON u1.id = l1.user_id
JOIN ip_logins l2 ON l1.ip = l2.ip          -- hop 1: same IP
JOIN users u2 ON l2.user_id = u2.id
JOIN phone_links p1 ON u2.id = p1.user_id
JOIN phone_links p2 ON p1.phone = p2.phone  -- hop 2: shared phone
JOIN users u3 ON p2.user_id = u3.id
WHERE u3.is_flagged = true;

That is 6 JOINs for 3 hops. Each JOIN scans a table. With 50 million users, each of those table scans can hit millions of rows. Query time: potentially 10–30 seconds β€” if it finishes at all. Add a fourth hop and you might be waiting minutes. The query planner has no idea which rows are connected; it just scans and matches.

The same query in Neo4j Cypher

MATCH (suspect:User)-[:USES_IP|SHARES_PHONE*1..3]->(flagged:User {isFlagged: true})
WHERE suspect.id = $newUserId
RETURN flagged.id, length(path) AS hops

Neo4j walks the actual relationship pointers starting from the new user's node. It never touches users who aren't connected. With 50 million users and an average of 5 connections each, a 3-hop traversal visits roughly 5Β³ = 125 reachable candidates β€” not millions. Query time: roughly 50 milliseconds. Add more hops and it stays fast, because it's still following pointers, not scanning tables.

SQL β€” 6 Table Scans users (50M) ip_logins (100M rows) ip_logins (scan again) + 3 more JOIN scans below... 6 full table scans Touches: potentially millions of rows Query time: 10–30 seconds 4-hop query: might time out Fraud ring: invisible until too late Neo4j β€” Pointer Walk new user hop 1 hop 1 hop 2 flagged! flagged! Pointer walk β€” ~125 nodes visited Never touches unconnected data Query time: ~50ms βœ“
Think First β€” before you read on: Your social network has 50 million users averaging 200 friends each. A user asks: "Who are all my friends-of-friends-of-friends-of-friends?" (4 hops). Before reading the answer below, estimate the challenge for SQL.

SQL must consider every possible 4-hop path in a table of 50 million users: up to 2004 = 1.6 billion potential join rows in the worst case. In practice the query planner prunes aggressively, but even so it's working with hundreds of millions of candidates and no way to know which rows are "nearby." A graph engine starts at your node and follows only the actual friend edges β€” visiting at most a few hundred thousand real connections at 4 hops. Same logical question, completely different physical work.
SQL's table-scan JOINs become exponentially slower with each relationship hop; Neo4j follows physical pointers and visits only actually-connected nodes, making multi-hop fraud detection and social-graph queries 100–1000Γ— faster.
Section 3

Mental Model β€” Nodes & Relationships as First-Class Citizens

Here is the mental shift that makes everything else click. In a relational database, the connection between two pieces of data is not a real object β€” it is a number (a foreign key) that you use to look something up. In a graph database, the connection is a real thing, just as real as the data it connects. It has a type, a direction, and its own properties.

Foreign keys vs. first-class relationships

Say you want to store the fact that Alice worked at Acme Corp as an engineer from 2020 onwards. In SQL, you need a third table β€” an "employments" junction table β€” with columns for user_id, company_id, role, and start_date. The relationship is a row in a table you had to invent. In Neo4j, the relationship is the object: (Alice)-[:WORKED_AT {role: "engineer", since: 2020}]->(Acme). No junction table. No extra query. The context travels with the edge.

SQL β€” 3 Tables Required Neo4j β€” Nodes + Relationships persons id (PK) name age city companies id (PK) name industry employments ← junction table person_id (FK) company_id (FK) role since (date) Relationship data split across 3 tables 2 JOINs needed to reconstruct one employment Alice :Person Bob :Person Acme Corp :Company WORKED_AT role:"engineer" since: 2020 WORKED_AT role:"analyst" Relationship is one object β€” no join needed role + since travel with the edge

Four design heuristics to live by

Heuristic 1 β€” Nodes for nouns. Person, Movie, City, Product, Account. If it is a "thing" in your domain, model it as a node. Nodes have labels (categories) and properties (attributes). A node can have multiple labels β€” :Person:Employee is perfectly valid.
Heuristic 2 β€” Relationships for verbs. KNOWS, ACTED_IN, LIVES_IN, PURCHASED, FOLLOWS. If it is an action or connection between two things, model it as a typed, directional relationship. The type is always uppercase by convention.
Heuristic 3 β€” Properties on either side. A relationship is not just a pointer β€” it carries data. The "worked at" connection carries since and role. A node carries name, age, city. Neither is more important; put data where it naturally belongs.
Heuristic 4 β€” Direction matters. (Alice)-[:FOLLOWS]->(Bob) means Alice follows Bob. (Bob)-[:FOLLOWS]->(Alice) is a separate relationship, meaning Bob follows Alice. You can query both directions with Cypher, but the direction in storage is fixed and meaningful. Design it to reflect how data actually flows.
In a property graph, both nodes (entities) and relationships (connections) are first-class objects that carry their own properties β€” eliminating junction tables, making relationship data directly accessible, and enabling intuitive multi-hop traversal.
Section 4

Core Concepts β€” Six Terms You Need

Before writing a single Cypher query, you need six vocabulary words. Each one is simple β€” the table below makes them concrete. There are no hidden complexities here; Neo4j deliberately kept the model small so the learning curve stays gentle.

 Node β€” the entity

A node represents a thing in your domain β€” a person, a product, a city, a transaction. Think of it like a row in a SQL table, except nodes are not locked into one table; any node can connect to any other. Each node has zero or more labels that categorise it, and zero or more properties (key-value pairs) that describe it.

Example: (:Person {name: "Alice", age: 31})

 Relationship β€” the connection

A relationship connects exactly two nodes. It always has a type (like KNOWS, PURCHASED, FOLLOWS) written in uppercase, and it always has a direction (from source to target). Crucially, relationships can have properties too β€” so you can store the date a friendship started, the quantity of a purchase, or the strength of a signal right on the edge.

Example: (alice)-[:PURCHASED {qty: 2, date: "2024-03-01"}]->(product)

 Label β€” the category

Labels classify nodes. A node can carry multiple labels simultaneously β€” (:Person:Employee) is both a person and an employee. Labels matter for performance: when you write MATCH (n:Person), Neo4j only searches nodes tagged with :Person instead of scanning the entire graph. Labels are effectively the "table name" concept in Neo4j β€” but more flexible, because a node can belong to many categories at once.

 Property β€” the attribute

Properties are key-value pairs attached to either a node or a relationship. Values can be strings, numbers, booleans, dates, or arrays of these. There is no fixed schema β€” two :Person nodes can have completely different sets of properties. This flexibility is useful during rapid development, but for production systems Neo4j supports constraints (uniqueness, existence) to enforce the structure you actually need.

 Cypher β€” the query language

Cypher is Neo4j's SQL equivalent. Its key insight: queries look like ASCII diagrams of the graph pattern you want to find. MATCH (a:Person)-[:KNOWS]->(b:Person) reads as "find a person connected to another person via a KNOWS relationship." You draw the shape; Neo4j finds every subgraph that matches the shape. In 2024, the ISO published GQL β€” the first international standard for graph query languages β€” based heavily on Cypher. So Cypher is not just a proprietary language; it is the foundation of the global standard.

 Index β€” the speed shortcut

An index speeds up lookups on node labels and properties. Without an index, MATCH (p:Person {name: "Alice"}) scans every :Person node. With a range index on Person.name, Neo4j jumps directly to Alice. Neo4j supports several index types: range (the default β€” handles equality and range scans on most value types; replaced the older B-tree type in Neo4j 5), text (optimized for substring queries on strings), point (for spatial values), full-text (Lucene-backed for natural-language search), and vector (for similarity search with AI embeddings, added in 5.11+). Indexes in Neo4j work on a label+property combination, just like indexes in SQL work on a table+column combination.

Neo4j's model has just six core concepts β€” node, relationship, label, property, Cypher, and index β€” a deliberately minimal vocabulary that covers the full power of the property graph model.
Section 5

The Property Graph Model β€” Anatomy & Alternatives

The "property graph" is not just a marketing name β€” it is a precise data model with specific rules. Understanding what those rules are (and what they are not) will help you design schemas that Neo4j handles efficiently and avoid patterns that feel unnatural in a graph.

The four rules of the property graph model

  1. Nodes have zero or more labels, zero or more properties. A label is a string category tag. A property is a typed key-value pair (string, integer, float, boolean, date, or list of those).
  2. Relationships have exactly one type (a string, uppercase by convention), exactly one direction (from source to target), and zero or more properties. Every relationship has a start node and an end node β€” they cannot be dangling.
  3. Multiple labels per node are allowed. A node can simultaneously be :Person, :Employee, and :Author. This is useful for querying the same node from multiple angles.
  4. No fixed schema by default. Two nodes with the same label can have completely different properties. You can add optional CONSTRAINTS to enforce uniqueness or property existence when you need consistency.
NODE ANATOMY Alice :Person :Employee age: 31 city: "London" active: true Multiple labels node belongs to multiple categories Properties typed key-value pairs no fixed schema RELATIONSHIP ANATOMY Alice :Person Acme :Company WORKED_AT role: "engineer" since: 2020 Type (single, uppercase) Rel. properties data on the edge itself Direction is fixed in storage (can query both ways in Cypher)

How the property graph compares to alternatives

The property graph is not the only graph data model. Two others appear in the wild enough to be worth knowing:

// Create two Person nodes and connect them with a KNOWS relationship
CREATE (alice:Person {name: "Alice", age: 31, city: "London"})
CREATE (bob:Person {name: "Bob", age: 28, city: "Berlin"})

// Now connect them β€” the relationship carries its own properties
MERGE (alice)-[:KNOWS {since: 2019, strength: "close"}]->(bob)

// Verify: find the relationship you just created
MATCH (a:Person {name: "Alice"})-[r:KNOWS]->(b:Person)
RETURN a.name, r.since, r.strength, b.name
// Result: "Alice" | 2019 | "close" | "Bob"
// A node can have multiple labels at once β€” this person is also an employee
CREATE (alice:Person:Employee {
  name: "Alice",
  employeeId: "E-1042",
  department: "Engineering"
})

// You can query by either label independently
MATCH (p:Person) RETURN p.name           // finds Alice
MATCH (e:Employee) RETURN e.employeeId  // also finds Alice

// Or query the intersection β€” nodes that are BOTH
MATCH (pe:Person:Employee)
RETURN pe.name, pe.department
// Result: "Alice" | "Engineering"
// Relationship properties carry context that doesn't belong on either node
MATCH (alice:Person {name: "Alice"})
MATCH (acme:Company {name: "Acme Corp"})

CREATE (alice)-[:WORKED_AT {
  role: "Senior Engineer",
  since: date("2020-03-01"),
  until: date("2023-11-30"),
  remote: true
}]->(acme)

// Retrieve and filter by relationship property
MATCH (p:Person)-[job:WORKED_AT]->(c:Company)
WHERE job.role STARTS WITH "Senior"
  AND job.remote = true
RETURN p.name, c.name, job.role, job.since
// Works like any property query β€” just on the edge instead of a node
The property graph model gives both nodes and relationships their own types, directions, and properties β€” a precise four-rule model that eliminates junction tables, supports flexible multi-label nodes, and forms the basis of the ISO GQL standard.
Section 6

Cypher β€” Pattern Matching as a Query Language

Most query languages describe operations: SELECT this column FROM this table WHERE this condition. Cypher describes shapes: "find a graph that looks like this." You draw the pattern you want using ASCII art notation, and Neo4j finds every subgraph in the database that matches your drawing. This is a fundamentally different mental model β€” and most developers find it more intuitive for relationship queries than SQL joins.

The visual nature of Cypher vs. SQL verbosity

Cypher β€” "draw the shape" alice KNOWS friend KNOWS fof MATCH (alice:Person {name:"Alice"}) -[:KNOWS]->(friend)-[:KNOWS]->(fof) RETURN fof.name 4 lines β€” looks like the diagram above The code IS the pattern you drew SQL β€” "describe the joins" SELECT DISTINCT u3.name FROM users u1 JOIN friendships f1 ON u1.id = f1.user_a_id JOIN users u2 ON f1.user_b_id = u2.id JOIN friendships f2 ON u2.id = f2.user_a_id 9+ lines β€” must think in joins Add one more hop β†’ two more JOINs

The five Cypher patterns every engineer needs

Cypher has just five fundamental patterns. Memorise these five shapes and you can read and write 90% of real-world Cypher queries.

MATCH … RETURN β€” reading data

MATCH finds all subgraphs that match the pattern you describe. RETURN selects what to send back. It is the Cypher equivalent of SELECT … FROM … WHERE in SQL. You can filter with WHERE, sort with ORDER BY, and paginate with SKIP and LIMIT.

CREATE β€” writing new data

CREATE adds new nodes or relationships. Unlike INSERT INTO in SQL, CREATE always adds something new β€” even if a node with the same properties already exists. Use MERGE instead if you want "create only if not already there."

MERGE β€” upsert (get-or-create)

MERGE is Neo4j's upsert. It tries to find a node or relationship matching the pattern; if none exists, it creates one. This is the most important command for idempotent writes β€” loading data from an external source without creating duplicates. You can pair it with ON CREATE SET (set properties only when creating) and ON MATCH SET (set properties only when matching).

MATCH … SET β€” updating properties

To update existing data, MATCH the node or relationship you want to change, then SET the property. You can set individual properties (SET n.city = "Paris"), add labels (SET n:VIP), or replace all properties at once (SET n = {name: "Alice", city: "Paris"}).

MATCH … DELETE β€” removing data

Delete nodes with MATCH (n:Person {name:"Alice"}) DELETE n. Important rule: you cannot delete a node that still has relationships β€” you must delete the relationships first, or use DETACH DELETE which removes the node and all its relationships in one command. Forgetting this rule is the most common beginner error in Cypher.

// Find all friends-of-friends of Alice who are NOT already her direct friends
// This is a classic social-graph query β€” 3 lines in Cypher, 9+ lines in SQL

MATCH (alice:Person {name: "Alice"})-[:KNOWS]->(friend)-[:KNOWS]->(fof:Person)
WHERE NOT (alice)-[:KNOWS]->(fof)   // exclude people already in her 1-hop network
  AND fof <> alice                  // exclude herself
RETURN fof.name, count(friend) AS mutualFriends
ORDER BY mutualFriends DESC
LIMIT 10

// count(friend) = number of mutual friends β€” useful for ranking suggestions
// "Return the top 10 strangers Alice has the most mutual friends with"
// This is exactly how LinkedIn's "People you may know" works conceptually
// Collaborative filtering β€” "users who bought what Alice bought also bought what?"
// This is the basic engine behind Amazon's "Customers also bought" feature

MATCH (alice:User {id: $userId})-[:PURCHASED]->(product:Product)
      <-[:PURCHASED]-(similar:User)     // users who share at least one purchase
      -[:PURCHASED]->(rec:Product)      // products those users also bought
WHERE NOT (alice)-[:PURCHASED]->(rec)   // Alice hasn't bought the recommendation yet
RETURN rec.name,
       rec.category,
       count(similar) AS sharedBuyers,  // how many similar users bought this
       avg(rec.rating) AS avgRating
ORDER BY sharedBuyers DESC, avgRating DESC
LIMIT 20

// The graph traversal here is 3 hops:
// Alice β†’ her purchases β†’ users who also bought those β†’ their other purchases
// In SQL this requires 3 self-joins across the purchases table β€” one query, but messy
// Shortest path between two people β€” "how is Alice connected to Charlie?"
// Neo4j has a built-in shortestPath() function that uses BFS internally

MATCH path = shortestPath(
  (alice:Person {name: "Alice"})-[:KNOWS*]-(charlie:Person {name: "Charlie"})
)
RETURN path,
       length(path) AS degrees,        // number of hops (the "degrees of separation")
       [n IN nodes(path) | n.name]     // list every person on the path
// Result example: ["Alice", "Bob", "Diana", "Charlie"] | degrees: 3

// allShortestPaths() returns ALL shortest paths if you want alternatives:
MATCH paths = allShortestPaths(
  (alice:Person {name: "Alice"})-[:KNOWS*]-(charlie:Person {name: "Charlie"})
)
RETURN [n IN nodes(paths) | n.name] AS path, length(paths) AS hops
ORDER BY hops
Historical note β€” Cypher became the ISO standard. Cypher was designed by Neo4j engineers around 2011 as a property-graph query language that felt visual and readable. It became widely adopted across graph databases (Amazon Neptune, SAP HANA, Memgraph all support it). In 2024, ISO published GQL (Graph Query Language) β€” the first ISO international standard for graph query languages. GQL is directly based on Cypher's syntax and semantics. So when you learn Cypher today, you are learning a standardised, portable skill, not just a proprietary API.
Cypher lets you draw the graph pattern you want as ASCII art β€” the engine finds every matching subgraph β€” making multi-hop relationship queries dramatically shorter and more readable than equivalent SQL JOINs, with five core keywords covering the full CRUD surface.
Section 7

Index-Free Adjacency: Why Graphs Are Fast

Here is the most important performance fact in all of graph databases: in Neo4j, jumping from one node to a related node costs the same tiny fixed amount of work, no matter how big your data gets. In a relational database, the same jump gets slightly slower as your tables grow β€” every JOIN walks a small tree to find the matching row. Engineers shorthand this as O(1) for the graph (constant cost per hop) versus O(log N) for the relational lookup (cost grows with the size of the data, slowly but steadily). That difference sounds small until you need to do it five times in a row.

Imagine you want to find "friends of friends of friends" β€” a classic social graph query. In SQL you JOIN three tables together, and each JOIN forces the database to look up keys in a B-tree index. With one million rows, each lookup is about 20 steps deep into a tree. Five JOINs = 5 Γ— 20 = 100 index operations, and that cost grows with your data. In Neo4j, every node holds a direct pointer to its relationships β€” like a contact card with arrows already drawn to each friend. Following five hops is five pointer reads, not five index searches. It does not matter if you have one million nodes or one billion: the cost per hop is the same. Engineers call this trick index-free adjacency β€” the connections live inside the data itself, so no index lookup is needed to follow them.

SQL: B-Tree Index Per JOIN root node A node B node C leaf leaf leaf leaf JOIN 1 β†’ O(log N) index lookup JOIN 2 β†’ O(log N) index lookup JOIN 3 β†’ O(log N) index lookup Total: 3 Γ— O(log N) Grows as your table grows Neo4j: Direct Pointer Per Hop Alice :Person Bob :Person Carol :Person ptr ptr Hop 1 β†’ O(1) pointer dereference Hop 2 β†’ O(1) pointer dereference Hop 3 β†’ O(1) pointer dereference Total: 3 Γ— O(1) = O(1) Cost is constant regardless of graph size 1 billion nodes β€” same speed as 1 thousand

The name "index-free adjacency" captures the idea precisely: the adjacency (the connections) is stored in the data structure itself, not in a separate index. Every node record physically contains a pointer to its first relationship. That relationship record contains a pointer to the next relationship for the same node. You traverse a chain, not an index tree.

This is why graph databases shine for deep traversals β€” queries with more than 3 or 4 hops. For a flat query like "find all users with email = 'x@example.com'", SQL with a proper index is perfectly competitive. But ask "find all people within 4 degrees of Alice who bought a product in Alice's category" and Neo4j wins by orders of magnitude.

When SQL still wins: For simple lookups β€” one table, one condition β€” a properly indexed SQL database is essentially equivalent to Neo4j. Index-free adjacency only pays off at 3+ relationship hops. For shallow queries, pick the tool that fits your overall data model, not the one with the fastest graph traversal.
Index-free adjacency means every node holds a direct pointer to its relationships; following a hop is O(1) not O(log N). Five hops in Neo4j cost five pointer reads β€” constant regardless of graph size. This is why Neo4j dominates deep-relationship queries that would require multiple slow B-tree lookups in a relational database.
Section 8

Storage Engine: Native Graph Layout

Most people think of databases as tables on disk. Neo4j thinks of disk as a collection of fixed-size records, each one representing either a node, a relationship, or a property. The format is not rows and columns β€” it is records and pointers. Understanding this layout explains why the performance characteristics described in the previous section are physically real, not just theoretical.

Neo4j maintains four separate stores on disk. Think of each store as a flat file where records sit at predictable offsets. Because each record is a fixed size, finding record number N is just a multiplication: offset = N Γ— record_size. No B-tree needed to find a record by ID β€” just a seek.

NEO4J ON-DISK RECORD LAYOUT NODE STORE 15 bytes / record in-use flag (1B) first_rel_id β†’ REL STORE first_prop_id β†’ PROP STORE RELATIONSHIP STORE 34 bytes / record start_node_id end_node_id rel_type (token id) prev/next rel (start node) prev/next rel (end node) doubly-linked list per node PROPERTY STORE variable-length records key token id value (inline or ptr) next_prop_id TOKEN STORE interned string ids "KNOWS" β†’ id 3 "Person" β†’ id 1 "name" β†’ id 7 first_rel first_prop Traversal = Pointer Chase Node.first_rel β†’ rel record rel.next_rel β†’ next rel record No JOIN. No index lookup. Typical hop latency ~microseconds per hop vs. ms+ for SQL JOIN at scale

Let's walk through each store and why it is designed the way it is.

Node Store β€” 15-byte Fixed Records

Each node occupies exactly 15 bytes on disk. That tiny record contains: a single in-use flag (so Neo4j knows if the slot is occupied), a label store reference (which labels this node has), a pointer to the node's first relationship record, and a pointer to the node's first property record. The fixed size is the key insight β€” looking up node #1,000,000 means seeking to byte offset 1,000,000 Γ— 15. No index needed at all to find a node by ID.

Relationship Store β€” 34-byte Fixed Records

Each relationship record stores: the IDs of its start and end nodes, a token ID for the relationship type (like KNOWS or WORKS_AT), and four more pointers β€” the previous and next relationships for the start node, and the previous and next relationships for the end node. This means each node's relationships form a doubly-linked list. To get all of Alice's relationships, Neo4j reads Alice's first_rel pointer, then follows the chain. It never needs to search.

Property Store β€” Variable-Length Records

Properties (like name: "Alice" or age: 31) live in a separate store. Each property record holds a key token ID (an integer representing the property name), the value (stored inline for small values like integers; a pointer to a string store for long text), and a pointer to the next property. Node and relationship records each carry a first_prop_id pointer that starts this chain. This separation keeps the node and relationship records small and fast to traverse even when nodes carry many properties.

Label and Relationship-Type Tokens

String labels like "Person" or "Company" and relationship type names like "KNOWS" are stored once in a token store and referenced everywhere else by their integer ID. The trick is to write the full word once and then use a tiny number to refer to it everywhere else β€” engineers call this interning. The word "KNOWS" appears once on disk even if a billion relationships are of that type. Relationship records and property records only store the compact integer token ID β€” dramatically reducing storage footprint and improving cache efficiency.

Why fixed-size records matter: Random access to a fixed-size record store is just arithmetic. That's why Neo4j can follow a relationship pointer and land on the exact byte offset in ~microseconds. Variable-size stores (like PostgreSQL TOAST) need extra indirection layers that add latency. Neo4j's design trades some storage flexibility for raw traversal speed.
Neo4j's storage is four fixed-size record stores (node, relationship, property, token), not tables. Nodes hold pointers to their first relationship; relationships form a doubly-linked list per node. Fixed record sizes mean finding a record by ID is a direct byte-offset calculation β€” no index, no scan, just arithmetic and pointer chasing.
Section 9

Cypher Patterns Deep Dive

In Section 5, you learned the basics of Cypher: how to MATCH a pattern, SET properties, and RETURN results. But real-world Neo4j applications need much more. Recommendation engines need to find people "within three degrees". Logistics systems need the shortest route between two cities. Fraud analysts need optional context that may or may not exist. This section takes Cypher from hobbyist to professional.

Think of these patterns as the verbs and sentence structures of the Cypher language. The basic MATCH + RETURN is like knowing nouns and verbs in English. The patterns here are what let you write full paragraphs β€” complex ideas expressed clearly and precisely.

Variable-Length Paths

Sometimes you don't know how many hops a path will take. In a social graph you might want "find anyone Alice knows directly, or through up to three intermediaries." Cypher expresses this as [:KNOWS*1..3] β€” the asterisk means "repeat this relationship type", and the numbers define the minimum and maximum depth. Without this, you'd need to write three separate queries and union them.

MATCH (alice:Person {name:"Alice"})-[:KNOWS*1..3]->(person)
RETURN DISTINCT person.name

Shortest Path

Neo4j has a built-in shortestPath() function. It finds the minimum-hop path between two nodes using Breadth-First Search internally. You can set an upper bound on hops with *..15 to prevent runaway traversals. If no path exists within the limit, the function returns null instead of scanning forever.

MATCH (a:City {name:"London"}), (b:City {name:"Tokyo"})
MATCH p = shortestPath((a)-[:ROUTE*..15]->(b))
RETURN p, length(p) AS hops

OPTIONAL MATCH

In SQL, a LEFT JOIN returns rows even when the joined table has no match β€” the unmatched columns are NULL. OPTIONAL MATCH is Neo4j's equivalent. When the optional pattern does not exist in the graph, the variables from that pattern are set to null instead of the whole row being dropped. This is essential when you want to retrieve optional context β€” like "find all users and, if they have a premium subscription, include its expiry date."

MATCH (u:User)
OPTIONAL MATCH (u)-[:HAS_SUBSCRIPTION]->(s:Subscription)
RETURN u.name, s.expiresAt

Aggregation: count, collect

Cypher aggregation works much like SQL GROUP BY, but you don't write an explicit GROUP BY clause. Any non-aggregated variable in the RETURN clause automatically becomes the grouping key. count(*) counts rows; collect(x) gathers values into a list β€” extremely useful for grouping a node's relationships into an array in one query.

MATCH (p:Product)<-[:PURCHASED]-(u:User)
RETURN p.name, count(u) AS buyers, collect(u.name) AS buyerNames
ORDER BY buyers DESC LIMIT 10

WITH for Multi-Step Pipelines

The WITH clause passes intermediate results from one query step to the next β€” like a pipeline. You can filter, sort, or limit between steps. This is how you write complex multi-phase queries: first find candidates, then filter them, then traverse further. Without WITH you'd need multiple round trips to the database.

MATCH (u:User)-[:PURCHASED]->(p:Product)
WITH u, count(p) AS purchases
WHERE purchases > 5
MATCH (u)-[:LIVES_IN]->(c:City)
RETURN u.name, purchases, c.name

CASE Expressions

Cypher supports inline conditional logic through CASE expressions β€” exactly like SQL's CASE WHEN ... THEN ... ELSE ... END. Use them to label or classify results without multiple queries, or to provide default values when a property might be null.

MATCH (u:User)
RETURN u.name,
  CASE
    WHEN u.age < 18 THEN "minor"
    WHEN u.age < 65 THEN "adult"
    ELSE "senior"
  END AS ageGroup
VARIABLE-LENGTH PATH: [:KNOWS*1..3] from Alice Alice start Bob hop 1 Carol hop 1 Dan hop 2 Eve hop 2 Fay hop 3 Gil hop 3 Hana hop 1 Hop 1 match Hop 2 match Hop 3 match

Find products purchased by people Alice knows (up to 2 hops away) that Alice has NOT yet purchased β€” the classic "friends bought this" recommendation pattern.

// Recommendation: products bought by Alice's extended network
MATCH (alice:User {name:"Alice"})-[:KNOWS*1..2]->(friend:User)
MATCH (friend)-[:PURCHASED]->(product:Product)
WHERE NOT (alice)-[:PURCHASED]->(product)
RETURN product.name, count(DISTINCT friend) AS socialProof
ORDER BY socialProof DESC
LIMIT 10

The WHERE NOT clause excludes products Alice already owns. count(DISTINCT friend) tells you how many different people in her network bought each product β€” a natural relevance score.

Find the fewest-stop flight path between two cities using Neo4j's built-in BFS shortest path. The upper bound of 15 hops prevents the query from scanning the entire graph when no route exists.

// Shortest flight-hop path between two cities
MATCH (origin:City {name:"London"}), (dest:City {name:"Tokyo"})
MATCH p = shortestPath((origin)-[:FLIGHT*..15]->(dest))
RETURN
  [city IN nodes(p) | city.name] AS route,
  length(p) AS stops,
  reduce(total=0, r IN relationships(p) | total + r.distanceKm) AS totalKm

The reduce() call accumulates total distance along the path β€” equivalent to SQL's SUM but applied to a list of relationships found in a single traversal.

Aggregate purchases by product category to find your most popular categories. Uses WITH to first count purchases per product, then groups by category.

// Most popular product categories by purchase volume
MATCH (u:User)-[:PURCHASED]->(p:Product)-[:IN_CATEGORY]->(c:Category)
WITH c, count(DISTINCT p) AS uniqueProducts, count(u) AS totalPurchases
RETURN c.name        AS category,
       uniqueProducts,
       totalPurchases,
       round(toFloat(totalPurchases) / uniqueProducts, 2) AS avgPurchasesPerProduct
ORDER BY totalPurchases DESC
LIMIT 20

The WITH clause materializes the aggregation before the final RETURN. Without it, you couldn't reference uniqueProducts and totalPurchases together in the same projection.

Cypher's advanced patterns β€” variable-length paths, shortestPath, OPTIONAL MATCH, aggregation with collect/count, WITH pipelines, and CASE expressions β€” are what make Neo4j practical for real applications. Each pattern maps to a traversal strategy that the graph engine executes using index-free adjacency, meaning complex multi-hop queries remain fast even on large graphs.
Section 10

Indexes & Constraints

Here is a common misconception: "Neo4j has index-free adjacency, so it doesn't need indexes." That's wrong in a very specific way. Neo4j does NOT need indexes to traverse a graph β€” that's the pointer-chain magic from Section 7. But to start a traversal, you need to find your first node. If you want to match (p:Person {email: 'alice@example.com'}), without an index Neo4j would have to scan every single Person node to find Alice. That would be painfully slow.

Think of it like a subway map. The map itself (the graph structure) tells you every route between any two stations β€” that's index-free adjacency. But to use the map, you first have to find your starting station on the board. An index is the "station finder" β€” the lookup that gets you to node #12,345 so the graph traversal can begin.

Range Index β€” The Default (was B-Tree pre-5.0)

The standard general-purpose index. Created with CREATE INDEX FOR (p:Person) ON (p.email). Supports exact equality lookups (WHERE p.email = '...') and range queries (WHERE p.age > 25). Historically these were called B-tree indexes (and were backed by a B-tree); in Neo4j 5 the B-tree type was replaced by Range, Point, and Text indexes β€” Range covers the most common cases. Conceptually it is similar to a PostgreSQL index on a single column. Use this for any property you will filter on in a MATCH clause.

Full-Text Index β€” Lucene Under the Hood

For substring searches and natural-language matching, Neo4j integrates Apache Lucene. A full-text index lets you do things like "find all Product nodes whose description contains 'bluetooth wireless'". The syntax is slightly different: you query it via a procedure call (CALL db.index.fulltext.queryNodes(...)) rather than a regular WHERE clause. Essential for search features, product catalogs, and any use case where users type free text.

Unique Constraint β€” Uniqueness + Index

A unique constraint guarantees that no two nodes with the same label can share the same property value β€” like CONSTRAINT ON (u:User) ASSERT u.email IS UNIQUE. Creating a unique constraint automatically creates a backing range index (B-tree pre-5.0), so you get fast lookup AND data integrity in one command. This is the right choice for primary-key-style properties like email addresses, user IDs, and product SKUs.

Existence Constraint

An existence constraint mandates that every node of a given label MUST have a specific property β€” for instance, every :Invoice MUST have an amount. Without this, Neo4j's schema-optional model means a node of the same label could be created with missing fields and your application would get null where it expected a value. Existence constraints are a safety net for required fields β€” the equivalent of SQL's NOT NULL.

Vector Index β€” For AI and Embeddings

Introduced in Neo4j 5.11 and reaching general availability in 5.13, the vector index stores high-dimensional float arrays (embeddings produced by ML models like BERT or OpenAI) and supports approximate nearest-neighbour search. This powers use cases like "find the 10 products most semantically similar to this one" β€” the query becomes a graph traversal starting from embedding-similar nodes. It turns Neo4j into a hybrid graph + vector database, eliminating the need for a separate vector store like Pinecone when your data is already graph-shaped.

Indexes find starting nodes β€” not traversal paths. Once Cypher has located the first node (via the index), it follows relationships using index-free adjacency. Indexes only help with the very first MATCH clause that filters by label and property. If your query starts with a known node ID or follows a relationship from an already-found node, the index is not involved at all. Don't over-index β€” each index costs write time and memory.

A range index on Person.email so lookups by email are fast. The EXPLAIN call lets you verify Neo4j will use the index.

// Create range index (default in Neo4j 5; was B-tree pre-5.0)
CREATE INDEX person_email_idx FOR (p:Person) ON (p.email);

// Verify the query planner uses it
EXPLAIN
MATCH (p:Person {email: "alice@example.com"})
RETURN p;
// Output should show "NodeIndexSeek" (not "NodeByLabelScan")

// Optional: give the index a name for easier dropping later
CREATE INDEX person_email_idx IF NOT EXISTS
FOR (p:Person) ON (p.email);

A unique constraint on User.email β€” this simultaneously creates a backing range index and enforces that no two Users share an email.

// Create unique constraint (also creates the backing index)
CREATE CONSTRAINT user_email_unique
FOR (u:User) REQUIRE u.email IS UNIQUE;

// Now this throws an error if email already exists:
CREATE (u:User {email: "alice@example.com", name: "Alice"});

// Use MERGE to do "create if not exists" safely:
MERGE (u:User {email: "alice@example.com"})
ON CREATE SET u.name = "Alice", u.createdAt = datetime()
ON MATCH  SET u.lastSeen = datetime()
RETURN u;

A vector index for semantic similarity search on product embeddings. Requires Neo4j 5.13+ and a pre-computed embedding array stored as a node property.

// Create vector index (768-dim embeddings, cosine similarity)
CREATE VECTOR INDEX product_embedding_idx
FOR (p:Product) ON (p.embedding)
OPTIONS {
  indexConfig: {
    `vector.dimensions`: 768,
    `vector.similarity_function`: "cosine"
  }
};

// Query: find 10 products most similar to a given embedding vector
CALL db.index.vector.queryNodes(
  "product_embedding_idx",
  10,                       -- top K results
  $queryEmbedding           -- float[] parameter from application
) YIELD node AS product, score
RETURN product.name, product.price, score
ORDER BY score DESC;
Indexes in Neo4j serve one purpose: finding the starting nodes of a traversal efficiently. Range indexes (the Neo4j 5 default, replacing the older B-tree type) handle equality and range lookups; text and full-text indexes enable substring/Lucene search; unique constraints enforce data integrity while doubling as indexes; vector indexes bring AI similarity search to your graph. Once a traversal begins, indexes play no further role β€” the graph structure takes over.
Section 11

Transactions & ACID

When a bank moves money from your account to someone else's, two things must happen together β€” money leaves your account AND money arrives in theirs. If only one half happens, money has effectively vanished or appeared. The four guarantees that prevent that kind of broken-in-the-middle state are bundled under the acronym ACID (Atomicity, Consistency, Isolation, Durability). Most NoSQL databases sacrifice some or all of these in exchange for speed or horizontal scale. Neo4j makes the opposite bet: full ACID transactions, including in clusters. This is a big deal and a deliberate choice. Graph data β€” particularly fraud graphs, compliance audit trails, and identity systems β€” is often mission-critical. You cannot have a transaction that partially transfers money or half-links an identity node.

ACID stands for four guarantees. Think of them as the four rules a trustworthy bank should follow: all operations in one action succeed together, the account balances always add up correctly, your in-progress work doesn't corrupt other people's in-progress work, and once the bank says "done" the data is saved even if the power goes out.

TRANSACTION LIFECYCLE BEGIN CREATE (n:Node) operation 1 SET n.name = ... operation 2 CREATE ()-[r]->( ) operation 3 ? COMMIT WAL flush β†’ durable ROLLBACK all ops undone Durability mechanism: Write-Ahead Log (WAL) Every change is written to a WAL file BEFORE modifying data pages. On crash, Neo4j replays the WAL to restore committed state.

Atomicity β€” All or Nothing

Every statement inside a transaction either ALL succeed or ALL fail together. If you create a fraud node, link it to an account, and set a risk score β€” and the third step fails β€” Neo4j rolls back the node creation and the relationship too. Your graph never ends up in a half-written state. This is why Neo4j fits financial workflows: a money transfer that completes the debit but fails the credit is a catastrophe in SQL too, and Neo4j prevents it equally.

Consistency β€” Constraints Always Enforced

Neo4j enforces all constraints you have defined (unique, existence, type) at commit time. A transaction that would violate a uniqueness constraint on User.email is rejected before the commit completes. The graph is always left in a valid state according to your schema β€” you never get partial data that only looks consistent because nothing went wrong yet.

Isolation β€” Read-Committed by Default

By default, Neo4j uses read-committed isolation: a running transaction can see changes committed by other transactions, but cannot see uncommitted work. This means "dirty reads" (reading someone else's in-progress changes) are prevented. Full serializable isolation is available but carries a higher performance cost β€” use it for financial scenarios where two concurrent transactions must not interleave at all.

Durability β€” WAL + Checkpoints

When you COMMIT, Neo4j writes to the Write-Ahead Log before acknowledging success. If the process crashes one millisecond after your commit, the WAL survives on disk and Neo4j replays it on restart. Periodic checkpoints flush in-memory pages to the data store, keeping WAL replay time bounded. This is the same durability mechanism PostgreSQL and most serious databases use β€” your committed data is safe against crashes.

When you run multiple Neo4j servers together for fault tolerance, durability goes a step further. Before saying "yes, your write is saved," the leading server first asks the others "did you record this too?" β€” and waits for a majority to say yes. Neo4j calls this multi-server setup a Causal Cluster, and the rule that "a majority must agree before we count it as saved" is part of an algorithm called Raft. The majority itself is called a quorum. This means a single server crash immediately after your commit cannot lose your data β€” the other core members already have it.

A single Cypher statement sent to Neo4j runs inside an implicit (auto-commit) transaction. The driver wraps it in BEGIN/COMMIT for you. Good for quick writes and one-shot reads.

// This single statement is automatically wrapped in a transaction
MERGE (u:User {email: $email})
ON CREATE SET
  u.name      = $name,
  u.createdAt = datetime()
RETURN u.email, u.createdAt

With the Java driver it looks like session.run("MERGE ..."). With the Python driver: session.run("MERGE ..."). Both auto-commit on success.

For multi-step operations that must all succeed or all fail together, use an explicit transaction. The Python driver pattern below shows a transaction function β€” Neo4j retries it automatically on transient errors (like a leader failover).

# Python driver: explicit transaction with retry
def transfer_funds(tx, from_id, to_id, amount):
    tx.run("""
        MATCH (src:Account {id: $from_id})
        WHERE src.balance >= $amount
        SET src.balance = src.balance - $amount
    """, from_id=from_id, amount=amount)

    tx.run("""
        MATCH (dst:Account {id: $to_id})
        SET dst.balance = dst.balance + $amount
    """, to_id=to_id, amount=amount)

    tx.run("""
        CREATE (:Transfer {
          fromId: $from_id, toId: $to_id,
          amount: $amount, ts: datetime()
        })
    """, from_id=from_id, to_id=to_id, amount=amount)
    # All three run in ONE transaction β€” all-or-nothing

with driver.session() as session:
    session.write_transaction(transfer_funds, "acc-001", "acc-002", 500)

In a cluster, a write goes to the leader while reads can go to any replica. Without a bookmark, you might read from a replica that hasn't replicated your write yet β€” a "stale read". Bookmarks solve this by passing a causal consistency token from the write session to the read session.

# Write to leader; get bookmark
with driver.session(database="neo4j") as session:
    session.write_transaction(
        lambda tx: tx.run("CREATE (u:User {name: $name})", name="Alice")
    )
    bookmark = session.last_bookmarks()  # capture the causal marker

# Read with bookmark β†’ guaranteed to see Alice's node
with driver.session(
    database="neo4j",
    bookmarks=bookmark          # wait until replica catches up to this point
) as session:
    result = session.run("MATCH (u:User {name:'Alice'}) RETURN u")
    print(result.single())

The bookmarks parameter tells the read replica: "do not process this query until you have applied at least this transaction." This gives you read-your-own-writes consistency in a distributed cluster without routing all reads to the leader.

Neo4j provides full ACID transactions β€” atomicity (all-or-nothing), consistency (constraints enforced), isolation (read-committed default), and durability (WAL + checkpoints). In a Causal Cluster, Raft consensus ensures writes survive individual server failures. Bookmarks allow applications to maintain read-your-own-writes consistency without routing all traffic to the write leader.
Section 12

Cluster Architecture: Causal & Autonomous

A single Neo4j server will get you a long way β€” the in-memory caching and pointer-based traversal are highly efficient. But production systems need fault tolerance, and read-heavy workloads benefit from scale-out. Neo4j Enterprise offers two clustering models. The first, Causal Cluster, is mature and battle-tested. The second, Autonomous Cluster (Neo4j 5.0+), targets planet-scale sharding. This section explains how each works and why graph sharding is uniquely difficult.

NEO4J CAUSAL CLUSTER TOPOLOGY Application Bolt driver (routing-aware) WRITE READ CORE SERVERS β€” Raft Consensus Group Core 1 LEADER handles all writes quorum = 2 of 3 replicates via Raft Core 2 FOLLOWER votes in elections can serve reads Core 3 FOLLOWER votes in elections can serve reads Raft Raft READ REPLICAS (async) RR 1 async-replicated read-only RR 2 async-replicated read-only async replication Write path Driver β†’ Leader β†’ Raft quorum 2 of 3 cores must ACK commit then β†’ async replicas Read path Driver auto-routes to any RR or any follower core reads scale with replica count Fault Tolerance 3-core cluster tolerates 1 failure 5-core tolerates 2 failures replica loss doesn't affect writes

Let's break down each component and why it is designed the way it is.

Core Servers β€” The Write Quorum

Core servers are the small group of Neo4j machines that vote on every write. Before a write is accepted, a majority of them must agree it has been recorded β€” a process called Raft consensus (the same agreement algorithm used by Kubernetes' etcd and modern Kafka). The majority itself is called a quorum. In a 3-core cluster, quorum = 2. This means one core can crash and writes still complete β€” the remaining two form a quorum. If two cores fail simultaneously, the cluster pauses writes (it picks safety over availability β€” the CP choice in the well-known CAP trade-off, where databases must choose between Consistency and Availability when the network breaks). Three cores is the minimum for fault tolerance; five cores tolerate two simultaneous failures.

Read Replicas β€” Horizontal Read Scale

Read replicas receive a continuous stream of committed transactions from a core server and apply them asynchronously. They are read-only β€” you cannot write to a replica. Because replication is asynchronous, replicas may lag by milliseconds to seconds behind the leader. The Bolt driver uses routing tables (published by the cluster) to automatically direct write queries to the leader and read queries to any available replica. Adding replicas scales your read capacity linearly.

Routing Driver β€” Intelligent Client

The Neo4j Bolt driver is routing-aware. On first connection it fetches a routing table from the cluster β€” a map of which servers accept reads and which accept writes. The driver then routes automatically: a write session goes to the leader, a read session picks a replica with load balancing. If a server fails, the driver refreshes its routing table and retries transparently. This means your application code does not need to manage cluster topology manually.

A note on the harder problem: sharding a graph is fundamentally difficult. In a relational database, you can shard by user ID β€” row X goes to shard A, row Y goes to shard B. Relationships don't cross shards. In a graph database, a relationship between two nodes might need to cross shard boundaries, and every cross-shard hop requires a network round-trip, destroying the pointer-walk performance advantage. This is why Neo4j (and most graph databases) started as single-server or leader-follower architectures.

Autonomous Cluster (Neo4j 5.0+) and Fabric: Neo4j's answer to the sharding problem is the Autonomous Cluster, which manages graph partitioning internally, and Fabric (an earlier feature) which lets you federate queries across multiple Neo4j databases. These allow truly large-scale deployments (billions of nodes, multiple geographic regions) but add operational complexity. For most applications β€” even those with tens or hundreds of millions of nodes β€” a well-tuned single server or Causal Cluster is sufficient. Reach for sharding only when your graph genuinely cannot fit on the largest available server.
Neo4j Causal Cluster uses Raft consensus across typically 3 or 5 core servers for fault-tolerant writes, plus any number of read replicas for horizontal read scale. A routing-aware Bolt driver automatically directs writes to the leader and reads to replicas. Graph sharding is inherently complex because relationships cross shard boundaries; Neo4j's Autonomous Cluster (5.0+) addresses this but should be adopted only when a single-server or causal-cluster deployment truly cannot meet scale requirements.
Section 13

Real-World Use Cases

Graph databases shine hardest when the relationship is the data. In a social network, the interesting question is not "how old is Alice?" β€” it's "who does Alice know, who do those people know, and is there a fraud ring hiding in those connections?" That's a relationship question. And that's exactly what Neo4j was built to answer.

Below are six canonical places where teams reach for Neo4j β€” and a quick explanation of why graphs win in each case.

USE CASE β†’ GRAPH CAPABILITY Fraud Detection Recommendations Knowledge Graphs Social Networks Identity & Access Supply Chain Graph Traversal Neo4j Multi-hop reachability Collaborative filtering Semantic entity linking Friend-of-friend paths Transitive permissions Route / dependency hops

Fraud Detection

Fraudsters rarely work alone. They create dozens of fake accounts that share the same IP address, phone number, or shipping address β€” a "fraud ring". In a relational database, detecting this means joining accounts to IP addresses, joining again to other accounts on the same IP, and so on. By the third hop, you're doing a nested join across millions of rows and the query might take minutes.

In Neo4j, you draw the pattern: (a1:Account)-[:SHARED_IP]->(ip)<-[:SHARED_IP]-(a2:Account) and the engine follows direct pointers. Multi-hop ring detection that would stall SQL can run in seconds. Banks like HSBC and ANZ adopted Neo4j specifically for this reason.

Recommendation Engines

The classic recommendation problem: "users who bought what you bought also bought this". This is a graph pattern β€” you need to traverse from a user to their purchases, then from those products to other users who bought them, then to what those users bought that the original user hasn't. That's a four-hop graph walk.

Netflix, Spotify, and LinkedIn all use graph-style algorithms internally. Neo4j's GDS library ships collaborative-filtering and similarity algorithms so you can run these patterns without exporting data to a separate ML system. The graph is the model.

Knowledge Graphs

A knowledge graph connects entities by their semantic relationships: "Einstein" BORN_IN "Ulm", "Ulm" PART_OF "Germany", "Germany" PART_OF "Europe". Wikipedia's Wikidata, Google's Knowledge Graph, and large enterprise ontologies all use this structure.

The power is inference: once you have the relationships, you can answer questions like "list all scientists born in countries that are part of the EU" purely by following edges β€” no full-text search, no hand-coded rules. Neo4j handles these multi-hop semantic queries naturally.

Social Networks

Social platforms need to answer questions like "how am I connected to this person?", "who are my second-degree connections?", and "who are the most influential people in my network?" These are all shortest-path and centrality queries β€” the bread and butter of graph databases.

The "degrees of separation" query β€” find the shortest path between two users β€” runs in milliseconds on a well-modeled Neo4j graph even over hundreds of millions of users, because it only follows the pointers it needs rather than scanning a relationship table.

Identity & Access Management

Modern permissions are rarely flat. A user belongs to a group, the group has a role, the role grants access to resources, and some resources inherit permissions from parent containers. Checking "can Alice read this file?" means walking that permission chain β€” which is exactly a graph traversal.

Security tools like AWS IAM Analyzer and internal IAM audit platforms increasingly use graph representations. In Neo4j you can ask "show me all users who can transitively access this sensitive resource" in a single Cypher query, which would require recursive CTEs (and careful performance tuning) in SQL.

Supply Chain & Logistics

Every product depends on components, which depend on sub-components from specific suppliers in specific countries. When a geopolitical event disrupts one supplier, you need to know: which of my products are affected? What's the alternative route? This is a dependency graph and a shortest-path problem.

Graph traversal lets teams quickly map "blast radius" β€” if component X becomes unavailable, traverse upward through all assemblies that depend on it. Route optimization (shortest or cheapest delivery path) maps directly to weighted shortest-path algorithms Neo4j has built in.

Graph databases win when relationships β€” not rows β€” are the interesting part of the query. Fraud detection, recommendations, knowledge graphs, social networks, IAM, and supply chains all share the same root need: efficient multi-hop traversal across connected data.
Section 14

Graph Algorithms (GDS Library)

Neo4j ships with the Graph Data Science (GDS) library β€” a collection of roughly 65 graph algorithms you can run directly inside the database. The key insight: instead of exporting your graph to a separate analytics system, you run the algorithms where the data already lives. That cuts out the ETL pipeline entirely and keeps results fresh.

Most GDS algorithms don't run directly on your live database. Instead, they take a copy of just the nodes and relationships they need and load that copy into memory β€” so the heavy maths doesn't slow down ordinary queries. Neo4j calls this in-memory copy a projected graph. The pattern is always the same three steps: project just the slice you need, run the algorithm on the projection, then write results back into your real graph as node properties so Cypher queries can use them. Here are the five main algorithm families.

Centrality

Which nodes are most important? Centrality measures how "connected" or "influential" a node is. The two most common are PageRank (made famous by Google) β€” which says a node is important if important nodes point to it β€” and betweenness centrality, which finds "bottleneck" nodes: nodes that lie on many shortest paths between other nodes. If you remove a high-betweenness node, the network breaks into isolated clusters.

Use case: finding key influencers in a social graph, or identifying critical suppliers in a supply chain whose failure cascades furthest.

Community Detection

Which nodes naturally cluster together? Community detection finds groups of nodes that are more tightly connected to each other than to the rest of the graph β€” without you specifying how many groups to expect. Louvain modularity is the most popular: it iteratively merges nodes into communities to maximize a quality metric. Label propagation is faster and works well at scale.

Use case: customer segmentation, topic discovery in a knowledge graph, or finding fraud rings (they form tightly connected sub-communities).

Path Finding

What's the best route between two nodes? GDS includes Dijkstra shortest path, A* search (uses a heuristic to search faster), and all-paths enumeration. Each answers a slightly different question: Dijkstra finds the single cheapest route; A* gets there faster with a good heuristic; all-paths gives you every possible route (useful for impact analysis).

Use case: route planning in logistics, "degrees of separation" in social networks, dependency chain analysis in IAM.

Similarity

Which nodes are most alike? Similarity algorithms compare nodes based on their relationships or properties. Jaccard similarity measures the overlap of two nodes' neighbor sets β€” if Alice and Bob both follow 80% of the same people, their Jaccard score is high. Cosine similarity does the same thing but for weighted or vectorized properties.

Use case: "users similar to me" for recommendations; finding duplicate entities in a knowledge graph; grouping similar products.

Link Prediction

Which edges are likely missing from the graph? Link prediction algorithms score pairs of nodes by how likely they are to be connected, based on shared neighbors, graph distance, and structural features. If two users share 30 mutual friends but aren't connected, the algorithm predicts a likely connection.

Use case: "People you may know" features, predicting missing citations in a knowledge graph, identifying likely but undetected fraud edges.

Scale note: GDS can run in-memory (full projected graph in RAM) or on-disk for very large graphs. With appropriate hardware, GDS can reportedly handle graphs with billions of nodes and relationships for centrality and community algorithms β€” though performance varies significantly by algorithm and data shape. Always benchmark with your actual data.

GDS in Practice β€” Three Algorithm Examples

Step 1: project the relevant sub-graph (Person nodes and FOLLOWS relationships). Step 2: run PageRank. Step 3: write scores back so you can query them with plain Cypher.

pagerank_influencers.cypher
// 1. Project the sub-graph into GDS memory
CALL gds.graph.project(
  'social-graph',          // name for this projection
  'Person',                // node label to include
  'FOLLOWS'                // relationship type to include
);

// 2. Run PageRank and write scores back to each node
CALL gds.pageRank.write(
  'social-graph',
  {
    maxIterations: 20,
    dampingFactor: 0.85,        // standard Google PageRank value
    writeProperty: 'pageRankScore'
  }
)
YIELD nodePropertiesWritten, ranIterations;

// 3. Query the top 10 influencers
MATCH (p:Person)
RETURN p.name AS influencer, p.pageRankScore AS score
ORDER BY score DESC
LIMIT 10;

// 4. Clean up the projection when done (free memory)
CALL gds.graph.drop('social-graph');

The dampingFactor: 0.85 is the standard PageRank value β€” it models a random surfer who follows links 85% of the time and jumps to a random node 15% of the time. This prevents nodes with no outgoing links from accumulating infinite score.

Louvain finds communities without you specifying how many. It iteratively merges nodes to maximize "modularity" β€” a measure of how much denser connections are inside communities versus across them.

louvain_communities.cypher
// Project graph with relationship weight
CALL gds.graph.project(
  'weighted-social',
  'Person',
  {
    INTERACTS: { properties: 'weight' }  // weight = interaction frequency
  }
);

// Run Louvain β€” write communityId to each node
CALL gds.louvain.write(
  'weighted-social',
  {
    writeProperty: 'communityId',
    relationshipWeightProperty: 'weight'
  }
)
YIELD communityCount, modularity;
// communityCount tells you how many clusters were found
// modularity (0–1) tells you how strong the community structure is

// Query: who is in community 42?
MATCH (p:Person { communityId: 42 })
RETURN p.name, p.communityId
ORDER BY p.name;

// Count community sizes
MATCH (p:Person)
RETURN p.communityId AS community, count(*) AS size
ORDER BY size DESC;

A high modularity score (above ~0.3) means the communities are meaningful β€” nodes really are more tightly connected within their group. A low score suggests the graph doesn't have strong community structure.

Jaccard similarity compares two nodes by their shared neighbors. Two users with 80% the same purchase history score 0.8; two users with nothing in common score 0.

jaccard_recommendations.cypher
// Project: User nodes and PURCHASED relationships
CALL gds.graph.project(
  'purchase-graph',
  ['User', 'Product'],
  'PURCHASED'
);

// Run node similarity (Jaccard) and write top-5 similar users per user
CALL gds.nodeSimilarity.write(
  'purchase-graph',
  {
    writeRelationshipType: 'SIMILAR_TO',
    writeProperty: 'score',
    topK: 5                   // keep top 5 similar users per node
  }
)
YIELD nodesCompared, relationshipsWritten;

// Now recommend: products bought by similar users but not by Alice
MATCH (alice:User { name: 'Alice' })-[:SIMILAR_TO]->(similar:User),
      (similar)-[:PURCHASED]->(product:Product)
WHERE NOT (alice)-[:PURCHASED]->(product)
RETURN product.name AS recommendation,
       avg(similar.score) AS relevance
ORDER BY relevance DESC
LIMIT 10;

This three-step pattern β€” project β†’ run algorithm β†’ query results β€” is the GDS workflow. The similarity scores are stored as relationship properties so downstream Cypher queries can use them directly, just like any other graph data.

Neo4j's GDS library runs 65+ graph algorithms (centrality, community detection, path finding, similarity, link prediction) directly inside the database β€” no ETL needed. The project β†’ run β†’ write-back pattern makes algorithm results first-class graph data queryable with Cypher.
Section 15

Performance & Tuning

Neo4j performance comes down to three big ideas: keep the hot part of your graph in memory, index your entry points, and understand what your queries are actually doing. Most performance problems trace back to one of these being misconfigured.

NEO4J PERFORMANCE β€” KEY LEVERS Page Cache Fit hot graph store size + 10% pagecache.size πŸ’‘ most impactful JVM Heap Query working set 8–16 GB typical heap.initial/max set initial = max Indexes Entry-point lookup Range / text / point CREATE INDEX ⚠ without = full scan PROFILE / EXPLAIN See actual row counts Find surprise scans PROFILE MATCH ... use before tuning EXPECTED THROUGHPUT (well-tuned, hot working set) Single node: ~50–200K reads/sec Cluster reads scale linearly Writes bottleneck on leader

Page Cache β€” The #1 Lever

Neo4j stores nodes, relationships, and properties in binary store files on disk. The page cache keeps hot pages of these files in RAM so graph traversals never hit disk. When the page cache is large enough to hold your entire working set (the part of the graph your queries actually touch), reads are purely in-memory and extremely fast.

A rough starting point: size server.memory.pagecache.size to fit your store files plus ~10% for growth (Neo4j's official heuristic) β€” ideally enough RAM to hold the entire hot working set. The default is 50% of available memory; the neo4j-admin server memory-recommendation tool gives a tailored number for your hardware. Monitor the cache hit ratio β€” if it's below ~95%, your cache is too small and queries are hitting disk constantly.

JVM Heap

Neo4j runs on the JVM, so query execution, GDS algorithms, and transaction state all live in the JVM heap. A heap that's too small causes frequent garbage collection pauses that show up as latency spikes. Too large, and GC pauses become long stop-the-world events.

A common recommendation is 8–16 GB for production. Crucially: set server.memory.heap.initial_size equal to server.memory.heap.max_size β€” this avoids the JVM spending startup time growing the heap and prevents GC-triggered heap resizing at runtime.

Index Your Entry Points

Every Cypher query needs a starting node. If you write MATCH (p:Person {email: 'alice@example.com'}) without an index on Person.email, Neo4j scans every Person node in the database. On a large graph this can be catastrophically slow β€” and is by far the most common performance mistake.

Create indexes for every property you use as a traversal starting point: CREATE INDEX FOR (p:Person) ON (p.email). Neo4j 5 supports range indexes (equality + range, the default), text indexes (for substring queries), point indexes (for spatial data), full-text indexes (Lucene-backed), and vector indexes (for embeddings). EXPLAIN your query first β€” the plan shows whether it's using an index or doing a NodeByLabelScan.

PROFILE vs EXPLAIN

EXPLAIN MATCH ... shows the query plan Neo4j intends to use β€” like SQL's EXPLAIN β€” without executing it. It tells you whether indexes are used, which operators are in the plan, and roughly how expensive each step is estimated to be.

PROFILE MATCH ... actually executes the query and annotates the plan with actual row counts and database hits. The key thing to look for: a NodeByLabelScan with millions of rows is a missing index. An Expand with unexpectedly high row counts means the traversal pattern is producing a Cartesian explosion β€” refine your WHERE clauses or relationship direction.

Bolt Connection Pooling

Neo4j uses the Bolt binary protocol for driver connections. Each connection carries some overhead β€” establishing too many connections per instance (or too few) degrades performance. The official drivers (Java, Python, JavaScript, Go, .NET) all include built-in connection pools.

Tune maxConnectionPoolSize on the driver side for your application's concurrency. A typical starting point is 50–100 connections per application instance. Too low and requests queue; too high and Neo4j spends threads managing idle connections. Monitor active vs idle connections alongside query latency p99.

Neo4j performance is mostly about page cache sizing (fit your hot working set in RAM), indexing every traversal entry point, and using PROFILE to find unexpected full scans. A well-tuned single node can reportedly handle ~50–200K reads/second on a hot working set; cluster read replicas scale reads linearly.
Section 16

Schema Modeling Patterns

Graph schema feels more flexible than SQL β€” you don't write CREATE TABLE first. But that flexibility is a trap if you design without discipline. A well-modeled graph is fast, readable, and easy to query. A poorly modeled one has super-nodes that kill performance and queries that are hard to write.

Here are five common modeling patterns, with the reasoning behind each.

BAD β€” rich properties on a plain relationship GOOD β€” reified "Like" node User :User Product :Product LIKES timestamp, rating, source, campaign… Can't traverse FROM the Like Can't add its own relationships User :User Like timestamp, rating Product :Product GAVE FOR Like node can have its own edges (e.g. -[:INSPIRED_BY]->Campaign)

Property vs Node β€” When to Promote

The first question when modeling any piece of data: should this be a property on an existing node, or its own node with relationships? The rule of thumb: if the value is unique to each entity and you never query "all entities with this value", keep it as a property. If many entities share the value and you want to find them by it, promote it to a node.

Example: person.eyeColor = 'brown' is fine as a property if you rarely filter by eye colour. But Genre in a music app should be a node β€” you constantly want "all songs in genre X" and "genres this artist spans". Promoting Genre to a node gives you a natural index point and lets Genre have its own properties.

Reified Relationships

When a relationship itself needs rich data β€” or when you need to attach other relationships to that relationship β€” convert it from a plain edge into a node. This is called "reification" (making a thing out of a connection).

Plain edge: (User)-[:LIKES]->(Product). Reified: (User)-[:GAVE]->(Like {timestamp, rating})-[:FOR]->(Product). Now the Like node can have its own edges β€” for example, (Like)-[:INSPIRED_BY]->(Campaign). You can also query "all Likes with rating > 4" directly, which is awkward if rating lives on a relationship property and you have millions of them.

Time-Versioned Relationships

Data changes over time but you often need the history. A naive model just overwrites: update the relationship. But if you need to know "where did Alice live in 2019?", you've lost that data.

The time-versioned pattern adds from and to properties to the relationship: (Alice)-[:LIVED_AT {from: '2015', to: '2021'}]->(London) and (Alice)-[:LIVED_AT {from: '2021', to: null}]->(Berlin). to: null means "current". Querying the current address is WHERE r.to IS NULL; querying history is WHERE r.from <= targetDate AND (r.to IS NULL OR r.to >= targetDate).

Label Hierarchies

Neo4j nodes can carry multiple labels simultaneously. A manager is also an employee who is also a person. You can model this as :Person:Employee:Manager β€” all three labels on one node. Queries can match at any level: MATCH (p:Person) finds everyone; MATCH (m:Manager) finds only managers.

This avoids the complexity of inheritance hierarchies in relational databases (single-table vs joined-table inheritance). Just stack labels. The caveat: don't go overboard β€” more labels means more index maintenance. Three to four levels deep is usually the practical limit before it becomes confusing.

Dense Node Mitigation

A "super-node" (also called a dense node) is one node connected to millions of other nodes β€” imagine a node representing "United States" in a geography graph, or "Top 40 Hits" in a music graph. When you traverse through a super-node, Neo4j has to scan all of its millions of relationships to find the ones that match your pattern.

Mitigation strategies: add a bucket layer (split the super-node into temporal or category sub-nodes), use relationship properties to filter early in the query, or add relationship indexes (Neo4j 5.x supports these). The most important thing: PROFILE your queries β€” an unexpectedly large Expand step is the warning sign that you've hit a dense node.

SUPER NODES β€” the #1 graph anti-pattern. A single node with millions of relationships becomes a traversal bottleneck. Every query that passes through it must scan all those edges. Profile early. If any node has relationship counts in the hundreds of thousands, redesign the schema to distribute that connectivity across bucket or intermediate nodes before the problem hits production.
Good graph schema design promotes values to nodes when shared, reifies relationships when they need their own data, versions temporal state with from/to properties, stacks labels for inheritance, and actively avoids super-nodes by distributing dense connectivity across intermediate buckets.
Section 17

Operations & Backups

Running Neo4j in production means thinking about six things: how do you back it up, how do you know it's healthy, how do you upgrade it without downtime, who can access what, how do you manage a cluster, and what tools do operators actually use day-to-day?

Backups

Neo4j has two backup modes. Online backup uses neo4j-admin database backup and works while the database is running β€” it streams a consistent snapshot without taking the database offline. This is what you should use for production scheduled backups. Offline dump uses neo4j-admin database dump and requires stopping the database first; the result is a portable archive suitable for migration, cloning environments, or disaster recovery.

For Causal Cluster deployments, backups are typically taken from a read replica (not the leader) to avoid adding load to the primary write path. Store backups offsite or in object storage (S3, Azure Blob) and test restoration regularly β€” an untested backup is not a backup.

backup_commands.sh
# Online backup (database stays running)
neo4j-admin database backup \
  --to-path=/backups/neo4j \
  --database=neo4j

# Offline dump (database must be stopped)
neo4j-admin database dump \
  --to-path=/backups/neo4j-dump.tar \
  --database=neo4j

# Restore from dump
neo4j-admin database load \
  --from-path=/backups/neo4j-dump.tar \
  --database=neo4j \
  --overwrite-destination=true

Monitoring

Neo4j exposes metrics via JMX (Java Management Extensions) and a Prometheus-compatible endpoint. The metrics that matter most for day-to-day operations are:

  • Page cache hit ratio β€” should be above ~95%. Below this means too many disk reads.
  • Transaction throughput β€” transactions per second, split by read vs write.
  • Query latency p99 β€” the 99th-percentile query time catches slow outliers that averages hide.
  • Active transactions β€” a growing number of long-running transactions is a warning sign.
  • Heap usage β€” sustained high heap usage before GC triggers indicates a need for more memory or a GC tuning pass.

Grafana dashboards are available in Neo4j's community GitHub; Prometheus scraping can be enabled in neo4j.conf with a few config lines.

Upgrades

Minor version upgrades (e.g. 5.x β†’ 5.y) in a Causal Cluster can be done as rolling upgrades: take one server offline at a time, upgrade it, bring it back, repeat. The cluster stays online throughout. Major version upgrades (e.g. 4.x β†’ 5.x) typically require an offline migration with a store conversion step β€” plan for a maintenance window and test the upgrade procedure in a staging environment first.

Always read the upgrade notes for your target version. Neo4j occasionally changes storage formats or deprecates configuration keys, and the migration tooling (neo4j-admin database migrate) handles the conversion but must be run explicitly.

Security & RBAC

Neo4j 4.0+ includes a full RBAC (role-based access control) system with fine-grained privileges. You can control read/write access at the node label level, the relationship type level, and even the individual property level. A typical regulated-industry setup might allow an analytics role to read :Transaction nodes but block access to the accountNumber property on those nodes entirely.

Roles are managed via Cypher admin commands: CREATE ROLE analyst; GRANT MATCH {*} ON GRAPH * NODES Transaction TO analyst; DENY READ {accountNumber} ON GRAPH * NODES Transaction TO analyst. Combine with TLS on all Bolt and HTTP connections, and network segmentation (never expose Bolt port 7687 directly to the internet).

Cluster Operations

Neo4j Causal Cluster (Enterprise) uses a Raft-based consensus protocol for writes. The cluster has one leader (handles writes), and any number of followers and read replicas. Key operations:

  • Adding a server: configure it with the cluster's discovery address, start it, and it joins automatically and reseeds from an existing member.
  • Removing a server: use dbms.cluster.coreMemberIds() to identify it, then gracefully drain its connections before stopping.
  • Leader elections: happen automatically if the leader becomes unreachable; the cluster elects a new leader within seconds as long as a majority (quorum) of core members are available.
  • Replica reseeding: a new read replica pulls a full backup from a core member at first start, then catches up via transaction log streaming.

Neo4j Browser & Bloom

Neo4j Browser is the web-based Cypher IDE bundled with every Neo4j installation β€” available at http://localhost:7474. It lets you run Cypher queries, visualize results as a force-directed graph, explore schema, and view query plans. It's the first tool every developer opens when starting with Neo4j.

Neo4j Bloom is a separate visual exploration tool aimed at business users who don't want to write Cypher. It provides a natural-language search interface ("show me all customers who bought from suppliers in Germany") and lets users visually navigate the graph. Bloom is useful for demos, stakeholder exploration, and investigative work like fraud analysis.

RBAC in regulated industries: Neo4j's property-level RBAC is granular enough to pass financial and healthcare compliance requirements. You can allow an analyst to see that a transaction exists (node visibility) without revealing the account number (property-level DENY). This kind of column-level security would require custom views and complex permission logic in most SQL databases.
Production Neo4j operations cover online backups (no downtime), Prometheus/JMX monitoring (target >95% page cache hit ratio), rolling cluster upgrades for minor versions, fine-grained property-level RBAC for compliance, and cluster management via Raft-based leader election. Neo4j Browser and Bloom are the primary operator/analyst tools.
Section 18

Neo4j vs Alternatives

The graph database landscape has thinned significantly since 2020 β€” several early competitors were acquired or shut down. What's left is a short list of serious options, each with a distinct reason to exist. Neo4j remains the most widely adopted native graph database, but it's not always the right answer.

WHICH GRAPH DATABASE FITS YOUR SITUATION? Your use case? Pick based on context Neo4j Native graph, rich GDS Neptune Managed AWS, RDF+PG ArangoDB Multi-model (graph+doc) JanusGraph Distributed, open source TigerGraph Analytics at scale PG AGE Postgres extension General-purpose graph workloads AWS-native, no ops overhead Graph + docs in one DB Huge distributed graph, open-source Real-time graph analytics at scale Already on Postgres

Amazon Neptune

Neptune is Amazon's fully managed graph database service β€” you don't provision instances, manage upgrades, or handle replication. It supports two graph models: the property graph (with openCypher or Gremlin as query language) and RDF (with SPARQL for semantic/knowledge graph use cases). This dual-model support is unique among managed options.

Choose Neptune when: your entire stack is on AWS, you don't want to operate Neo4j yourself, or you have a knowledge graph / semantic web use case that benefits from SPARQL and RDF standards. Trade-off: GDS algorithm library doesn't exist in Neptune; for deep graph analytics, Neo4j still leads.

ArangoDB

ArangoDB is a "multi-model" database: it handles graph, document (JSON), and key-value workloads in a single engine with a single query language (AQL β€” ArangoDB Query Language). The appeal is operational simplicity when your application needs both a document store and a graph store and you'd rather not run two separate databases.

Choose ArangoDB when: your use case genuinely mixes graph traversal with document retrieval, or when the overhead of two separate systems (Neo4j + MongoDB/Postgres) is a concern. Trade-off: native graph performance is generally not as fast as a pure native graph engine for deeply connected traversals.

JanusGraph

JanusGraph is an open-source distributed graph database that runs on top of existing distributed storage backends β€” typically Apache Cassandra (for scale) or Apache HBase (for Hadoop ecosystems). Because storage is decoupled, it can theoretically handle graphs with hundreds of billions of edges. It uses the Gremlin traversal language (Apache TinkerPop standard).

Choose JanusGraph when: you need a truly distributed open-source graph (no license cost), have existing Cassandra or HBase infrastructure, or need to run at a scale where Neo4j's single-instance model is insufficient. Trade-off: operational complexity is significantly higher β€” you're managing JanusGraph plus a distributed storage cluster.

TigerGraph

TigerGraph was designed from the ground up for graph analytics β€” specifically real-time analytics on very large graphs. It uses its own query language (GSQL) and its own MPP (massively parallel processing) execution engine. Where Neo4j's GDS runs algorithms in-database on a single or clustered instance, TigerGraph distributes the graph itself across nodes and runs algorithms in parallel across the full cluster.

Choose TigerGraph when: your primary use case is real-time graph analytics at billion-edge scale with tight latency requirements. Trade-off: smaller community, proprietary query language with a steeper learning curve than Cypher, and licensing costs that rival Neo4j Enterprise.

PostgreSQL Apache AGE

Apache AGE (A Graph Extension) adds graph query capabilities directly to PostgreSQL. It lets you create a "graph" in Postgres and query it with an openCypher-compatible syntax alongside regular SQL. The graph data is stored in normal Postgres tables under the hood.

Choose AGE when: your application is already built on Postgres and the graph component is a relatively small, secondary use case β€” for example, a social feature inside a primarily relational product. Trade-off: because it's built on top of Postgres's row store, deeply nested traversals will not match a native graph engine's performance. It's a "good enough graph for a Postgres shop", not a replacement for a dedicated graph database.

The GQL standard (ISO 39075, 2024): For years, each graph database spoke its own query language β€” Cypher (Neo4j), Gremlin (TinkerPop), GSQL (TigerGraph), SPARQL (RDF). In 2024, ISO published GQL (Graph Query Language) as the first international standard for property graph queries. GQL is heavily influenced by Cypher. Over the next few years, most alternatives are expected to converge on GQL compatibility, which will make it easier to switch databases or run queries across multiple graph systems.
Neptune fits AWS-native teams wanting a managed service or RDF/SPARQL support. ArangoDB is for mixed graph+document workloads in one engine. JanusGraph is open-source and distributed on Cassandra/HBase. TigerGraph targets massive-scale real-time graph analytics. PostgreSQL AGE is a lightweight option when graph is a small part of a Postgres app. GQL (ISO 2024) is converging the industry toward a standard query language.
Section 19

Tools & Drivers β€” Your Neo4j Toolbox

Neo4j ships with a surprisingly complete toolbox. Whether you are a developer writing code, an analyst clicking through data visually, or an ops engineer keeping the database healthy, there is a dedicated tool for you. Here is the rundown of the six tools you will reach for most, followed by working code samples for the three most common driver languages.

Typical Neo4j Development Workflow Neo4j Browser Query + visual graph exploration Drivers (Bolt) Java / JS / Python Go / .NET neo4j-admin Backup, restore, bulk import AuraDB (Cloud) Managed Neo4j, zero ops overhead Explore β†’ Build β†’ Operate β†’ Deploy Neo4j Bloom sits alongside Browser for non-developer visual exploration; cypher-shell is a CLI alternative to Browser GDS (Graph Data Science) library plugs into the server and is accessed via Cypher procedure calls from any driver

Neo4j Browser

The official web interface bundled with every Neo4j installation. You open it at http://localhost:7474 and get a full Cypher editor with syntax highlighting, auto-complete, and β€” the feature that makes it memorable β€” an interactive graph visualisation of your query results. Instead of seeing rows in a table, you see nodes as circles and relationships as arrows, which you can drag, expand, and explore. It is your first stop for understanding unfamiliar data, prototyping Cypher queries, and debugging whether your data model looks the way you intended. Every query also shows a summary panel with timing, rows returned, and database hits β€” a quick sanity check before optimising.

cypher-shell

A lightweight command-line interface for running Cypher β€” think of it as the terminal equivalent of Neo4j Browser, but without the visual graph rendering. You launch it with cypher-shell -u neo4j -p password and get a REPL where you type Cypher statements and see tabular results. It is particularly useful in scripts and automated pipelines because it accepts input via stdin (echo "MATCH (n) RETURN count(n);" | cypher-shell ...) and outputs plain text that is easy to parse. Use it for health-check scripts, one-off data corrections, and any situation where you are SSHed into a server without a browser.

Neo4j Bloom

A point-and-click graph exploration tool aimed at business analysts, data scientists, and anyone who does not write Cypher. Instead of queries, you use natural language search phrases and a visual canvas to navigate the graph. You can define "perspectives" β€” curated views of the graph that hide complexity and surface business-relevant nodes and relationships. Bloom is part of Neo4j's commercial offering, though it connects to any Neo4j database. It is most valuable when the people who need to explore the data are not developers β€” fraud investigators who need to trace connections, or knowledge graph analysts who are identifying clusters visually.

Official Drivers (Bolt protocol)

Neo4j ships first-party drivers for Java, JavaScript/TypeScript, Python, Go, and .NET β€” all communicating over the Bolt protocol, a compact binary wire protocol designed specifically for graph database communication (more efficient than HTTP/JSON for the repeated round-trips graph queries need). Each driver manages a connection pool automatically, so you do not spin up a new TCP connection for every query. They also handle causal consistency: when you write data on one cluster member, the driver can guarantee a subsequent read goes to a replica that already has that write β€” eliminating the class of bugs where you insert a node and immediately fail to find it. Use the official drivers; community wrappers exist but lag behind in features and bug fixes.

neo4j-admin

The operational command-line tool that ships with Neo4j. The commands you will reach for most: neo4j-admin database backup creates a consistent online backup (no downtime needed on Enterprise Edition). neo4j-admin database restore brings a backup back. neo4j-admin database import bulk-loads CSV files into a new database β€” orders of magnitude faster than running LOAD CSV for millions of rows, because it bypasses the transaction log and writes SSTables directly. neo4j-admin server report packages diagnostics (config, logs, metrics) into a zip for support. Think of neo4j-admin as the ops engineer's toolkit; developers rarely need it, but it is irreplaceable in production.

AuraDB β€” Managed Cloud

Neo4j's own fully managed cloud service. You create a database in minutes (Free tier available), connect with any official driver, and never think about installation, upgrades, backup scheduling, or cluster management. AuraDB runs on AWS, GCP, and Microsoft Azure (across 60+ cloud regions), and the Free tier is generous enough for learning and small projects. AuraDB Professional and Enterprise add SLAs, private networking, and larger instance sizes. It is the fastest path from "I want to try Neo4j" to "I have a running database" β€” especially useful when you want to follow along with the examples in this guide without installing anything locally.

Driver Code Examples

# Connect to a local Neo4j instance
cypher-shell -u neo4j -p secret123

# Once inside the REPL β€” find friends-of-friends
neo4j@neo4j> MATCH (me:Person {name:"Alice"})-[:FRIEND_OF*2]->(fof)
             WHERE NOT (me)-[:FRIEND_OF]->(fof) AND me <> fof
             RETURN fof.name, fof.city
             LIMIT 10;

# Run non-interactively from a shell script
echo "MATCH (n:Person) RETURN count(n) AS total;" \
  | cypher-shell -u neo4j -p secret123 --format plain
from neo4j import GraphDatabase

# Create a driver β€” connection pool is managed automatically
driver = GraphDatabase.driver(
    "bolt://localhost:7687",
    auth=("neo4j", "secret123")
)

def find_friends_of_friends(tx, name: str) -> list[dict]:
    result = tx.run(
        """
        MATCH (me:Person {name: $name})-[:FRIEND_OF*2]->(fof)
        WHERE NOT (me)-[:FRIEND_OF]->(fof) AND me <> fof
        RETURN fof.name AS name, fof.city AS city
        LIMIT 10
        """,
        name=name,
    )
    return [{"name": r["name"], "city": r["city"]} for r in result]

# Sessions are lightweight wrappers; always use `with` so they close
with driver.session(database="neo4j") as session:
    suggestions = session.execute_read(find_friends_of_friends, "Alice")
    for s in suggestions:
        print(f"  {s['name']} ({s['city']})")

driver.close()
import neo4j from "neo4j-driver";

// A single driver instance per application β€” it manages a connection pool
const driver = neo4j.driver(
  "bolt://localhost:7687",
  neo4j.auth.basic("neo4j", "secret123")
);

async function findFriendsOfFriends(name) {
  const session = driver.session({ database: "neo4j" });
  try {
    const result = await session.run(
      `MATCH (me:Person {name: $name})-[:FRIEND_OF*2]->(fof)
       WHERE NOT (me)-[:FRIEND_OF]->(fof) AND me <> fof
       RETURN fof.name AS name, fof.city AS city
       LIMIT 10`,
      { name }
    );
    return result.records.map((r) => ({
      name: r.get("name"),
      city: r.get("city"),
    }));
  } finally {
    await session.close();  // always close β€” returns connection to pool
  }
}

const suggestions = await findFriendsOfFriends("Alice");
suggestions.forEach((s) => console.log(`${s.name} (${s.city})`));

await driver.close();
Neo4j Browser (visual), cypher-shell (CLI), Bloom (no-code), official Bolt drivers (Java/JS/Python/Go/.NET), neo4j-admin (ops), and AuraDB (managed cloud) cover every use case from first exploration to production operations.
Section 20

Common Misconceptions

Graph databases carry a lot of baggage β€” myths that spread because most engineers learned databases through relational systems and map their existing mental model onto everything new. Each misconception below has a crisp factual correction and the reasoning chain behind it. Clear these up early and you will make far better decisions about when to reach for Neo4j.

1. "Graph traversals are slow because they're recursive."

This conflates algorithm complexity with implementation. A naive recursive SQL self-join is slow because each hop requires a new JOIN β€” scanning an index or heap page to find matching foreign keys. Neo4j uses index-free adjacency: each node stores direct physical pointers to its adjacent relationships. Following a hop does not touch any index; it dereferences a memory pointer, which is an O(1) operation. A 4-hop traversal is four pointer dereferences, not four index scans. In benchmarks, this regularly makes Neo4j 10–100Γ— faster than a relational database for queries that span multiple hops on large graphs.

2. "Cypher is a proprietary non-standard query language."

This was partially true historically, but in 2024 GQL (ISO/IEC 39075) became the first international standard for graph query languages β€” and it is heavily based on Cypher. Neo4j drove the standardisation effort and contributed Cypher's core syntax. The pattern-matching syntax you learn in Cypher today transfers directly to GQL, and to any other database that implements the standard. Far from being a dead-end proprietary language, Cypher was the primary input to an ISO standard β€” the same way SQL was standardised from IBM System R's query language.

3. "Neo4j doesn't support ACID transactions."

Neo4j has supported full ACID transactions since version 1. Single-instance deployments use a write-ahead log and a locking mechanism for isolation. Clustered deployments (Causal Cluster) add Raft consensus: a write must be acknowledged by a majority of Core members before it is committed, which guarantees durability even if a minority of nodes fail simultaneously. This is the same consensus algorithm used by etcd (Kubernetes) and Apache Kafka's KRaft mode β€” so the ACID guarantees are robust and well-understood.

4. "Neo4j is just for analytics β€” not for real-time OLTP."

The opposite is closer to the truth. Neo4j's core engine is optimised for the live, request-by-request workload your app makes every time a user clicks something β€” finding the shortest fraud path, resolving a permission chain, looking up a product's recommendation set in under 10 ms. Engineers call this kind of fast user-facing workload OLTP (Online Transaction Processing). The slower number-crunching across the whole dataset for reports and ML β€” PageRank, community detection, link prediction β€” is called OLAP (Online Analytical Processing), and Neo4j handles that through the optional Graph Data Science (GDS) library. You can run GDS projections on a copy of the graph without blocking the live OLTP queries. The right mental model is OLTP-first with OLAP as an optional layer.

5. "Any data with relationships should go in a graph DB."

Relationships are everywhere β€” everything in a relational database has foreign keys. The key question is not do I have relationships but do I traverse relationships deeply and frequently? If your queries almost always start from a single known entity and read its direct properties (one hop), SQL with indexed foreign keys is plenty fast and far simpler to operate. Graph databases pay off when multi-hop traversal is the dominant query pattern β€” 3+ hops, unknown depth, or "find paths between any two nodes." Use the right tool for your access patterns, not just for your data model.

6. "Neo4j won't scale to large data sets."

A single Neo4j instance routinely handles hundreds of millions of nodes and billions of relationships β€” well beyond most application needs. Read scalability comes from adding Read Replicas: they receive the transaction log and serve read queries without touching the primary Core cluster. Write scalability for most workloads is handled by choosing appropriate shard-friendly data models. For very large-scale sharded deployments, Neo4j's Autonomous Cluster (introduced in 5.x) adds automatic sharding. The "won't scale" myth usually comes from early Neo4j versions (circa 2010–2012); the architecture has advanced substantially.

Neo4j is ACID-compliant, uses index-free adjacency for O(1) hops, is optimised for real-time OLTP, and scales to billions of relationships. Cypher is now the basis of the ISO GQL standard. The one truth: only reach for a graph DB when multi-hop traversal dominates your access patterns.
Section 21

Real-World Disasters & Lessons

The best way to learn what not to do is to study the failures others already paid for. Every one of these disasters happened in a real production system. The patterns are common enough that if you skip this section, you are likely to repeat at least one of them.

Super-Node Problem vs. Aggregator Fix BAD β€” Super-Node TENANT 1M relationships Any query touching TENANT must scan all 1M relationships β€” O(n) per query GOOD β€” Aggregator Nodes TENANT 3 shards UserGroup-A ~333k users UserGroup-B ~333k users UserGroup-C ~333k users Queries target one group β€” bounded traversal
Disaster 1 β€” The Super-Node Performance Cliff

A SaaS startup modelled multi-tenancy with a single :TENANT node connected to every user via a HAS_USER relationship. At launch this looked fine β€” a few hundred users per tenant. A year later the largest tenant hit 1 million users. Queries starting from MATCH (t:Tenant {id:$id})-[:HAS_USER]->(u) had to load all 1 million relationship records to find anything β€” essentially an O(n) scan disguised as a graph traversal. Pages that loaded in 20 ms degraded to 8 seconds.

Lesson: Model with traversal cardinality in mind. When a node is likely to accumulate an unbounded number of relationships, partition it early using intermediary aggregator nodes or time-bucketed sub-nodes. Use apoc.node.degree in a regular job to detect super-nodes before they become a crisis. The rule of thumb: any node regularly traversed from in production queries should have fewer than roughly 100 000 direct relationships.

Disaster 2 β€” The Property Explosion (Tags as Properties)

A content platform stored article tags as a list property: article.tags = ['ai','ml','db','cloud',...]. Querying for all articles tagged 'ai' required scanning every :Article node and filtering the list in memory. There was no way to index into a list value efficiently. Response times grew linearly with article count.

Lesson: In graph data modelling, anything you want to traverse to or filter on should be a node, not a property. Refactored model: (article:Article)-[:TAGGED_WITH]->(tag:Tag {name:'ai'}). Now a query for all AI articles is a one-hop traversal from the indexed Tag node. Rule: if a property value is a list and you will ever query into that list, convert it to nodes.

Disaster 3 β€” Unbounded Shortest-Path Timeouts

A social platform ran MATCH p = shortestPath((a:Person {id:$a})-[*]-(b:Person {id:$b})) RETURN p β€” no maximum depth, on a sparse disconnected graph (not all users were reachable from each other). When a and b had no path, Cypher had to exhaust the entire reachable subgraph before returning null β€” queries timed out at 30 seconds regularly.

Lesson: Always bound variable-length traversals: [*1..6]. Pick a meaningful maximum depth for your domain (six degrees of separation for social graphs; two or three for access control). An unbounded path query on a disconnected or sparse graph is a denial-of-service bug waiting to happen.

Disaster 4 β€” Missing Entry-Point Indexes

A developer ran MATCH (p:Person {email: $email}) RETURN p in production β€” finding a user by email to start a traversal. Without a CREATE INDEX FOR (p:Person) ON (p.email), this triggered a full label scan: reading every :Person node on disk and filtering by email. With 50 million users, every login involved scanning 50 million nodes. The fix was a one-line Cypher statement; the oversight cost 3Γ— database CPU for six months.

Lesson: Every property you use in a MATCH ... WHERE clause that anchors the start of a query pattern needs an index. Run EXPLAIN on every query before going to production and verify the plan shows NodeIndexSeek, not NodeByLabelScan.

Disaster 5 β€” Even-Number Raft Cluster Split-Brain Near Miss

An engineering team deployed a Causal Cluster with 4 Core members (2 in each data centre) for what felt like symmetric fault tolerance. A network partition between the two DCs created two groups of 2. Neither group had a majority (3 of 4), so Raft correctly prevented a split-brain β€” but the cluster lost write availability entirely until the partition healed. The team had accidentally built a cluster that went read-only on any cross-DC partition.

Lesson: Always use an odd number of Core members (3, 5, or 7). With 3 members across two DCs (2+1), a partition still leaves one side with a majority and the cluster remains writable. With 5 members (3+2), one DC can be lost entirely and the cluster survives. Never deploy an even number of cores.

Five recurring disasters: super-nodes (partition early), list properties (convert to nodes), unbounded paths (always add depth limit), missing entry-point indexes (verify NodeIndexSeek in EXPLAIN), and even Raft cores (always odd: 3, 5, 7).
Section 22

Performance & Best Practices Recap

If there were one section to print out and stick above your monitor, this would be it. Every point below is a distillation of a real performance issue or architectural lesson. None of them require exotic tuning β€” they are standard practice for any Neo4j deployment beyond a toy project.

Neo4j Production Best Practices β€” Quick Reference β‘  Index entry points Every node property used in MATCH..WHERE anchors needs a CREATE INDEX. Check for NodeIndexSeek in EXPLAIN. β‘‘ Bound variable-length paths Use [*1..5] not [*]. Unbounded paths on sparse graphs cause timeouts and full-graph scans. β‘’ Detect super-nodes early Run apoc.node.degree checks regularly. Partition or add aggregator nodes before degree hits ~100k. β‘£ PROFILE every slow query PROFILE <query> shows actual db hits per operator. Target operators with high db hits first. β‘€ Relationships are first-class Put properties on relationships when they belong to the edge (e.g. FRIEND_OF {since:2019}). β‘₯ Use GDS for analytics Don't re-implement PageRank or community detection. GDS library provides 65+ production-ready algorithms. ⑦ Odd Raft core count Always 3, 5, or 7 Core members. Even numbers risk losing write quorum on a symmetric partition. β‘§ Size the page cache for hot data Set dbms.memory.pagecache.size to fit the working set. Random I/O on graph traversals is extremely costly.

Model relationships as first-class citizens

When a fact belongs to the connection between two things rather than to either thing alone, put it on the relationship. A friendship that started in 2019 is a property of the friendship, not of either person β€” so store it as [:FRIEND_OF {since: 2019}]. This keeps nodes lean and makes queries like "find friendships formed after 2020" a filter on a relationship property scan, not a cross-node join.

Index every entry point

The first MATCH clause in every query needs an indexed anchor. Without one, Neo4j must scan every node with that label. Use CREATE INDEX FOR (n:Label) ON (n.property). After creating the index, run EXPLAIN MATCH (n:Label {prop:$v}) RETURN n and verify the plan shows NodeIndexSeek, not NodeByLabelScan. Do this before going to production, not after the first slow query alert fires.

Bound variable-length paths

Never write -[*]-> in a production query. Always specify a range like -[*1..5]->. The upper bound tells the query engine to stop expanding at depth 5 even if more nodes exist. Pick the bound that matches your domain: six degrees of separation uses [*1..6], access-control chain checks typically need [*1..3]. Without a bound, a single query on a connected graph can visit millions of nodes.

PROFILE every slow query

Prefix any Cypher query with PROFILE to get a full execution plan with actual database hits per operator (not estimated β€” real numbers from the last run). Find the operator with the highest db hits count and optimise that first β€” it is almost always a missing index, an unbounded traversal, or a super-node. EXPLAIN gives the estimated plan without running the query; use it for cheap pre-production checks.

Use GDS for analytics work

The Graph Data Science library ships 65+ algorithms β€” PageRank, Betweenness Centrality, Louvain community detection, link prediction, node embeddings, and more. Running them in Cypher from scratch would be both slow and bug-prone. GDS projects a named in-memory graph from your Neo4j database, runs the algorithm on that projection (without blocking OLTP), and writes results back as node properties. Use it for batch analytics, ML feature generation, and graph-based recommendations.

Size the page cache generously

Neo4j's page cache holds recently accessed node and relationship store pages in RAM. Graph traversals are notorious for random I/O β€” each hop could land anywhere on disk. If the hot working set fits in the page cache, hops become RAM reads (nanoseconds). If not, they become SSD random reads (hundreds of microseconds). Rule of thumb: set server.memory.pagecache.size to at least the size of your most frequently accessed portion of the graph. Monitor cache hit ratio with metrics/neo4j.page_cache.*; aim for >99%.

Eight rules that cover 90% of Neo4j performance work: index entry points, bound paths, detect super-nodes, PROFILE slow queries, use relationship properties, reach for GDS instead of DIY algorithms, keep Raft cores odd, and size the page cache for the working set.
Section 23

Frequently Asked Questions

These are the questions that come up most often when engineers first encounter Neo4j β€” in interviews, in architecture reviews, and in onboarding sessions. Each answer is written for someone who already knows relational databases but is new to graph systems.

Q1: When does Neo4j actually make sense?

Neo4j makes sense when multi-hop relationship traversal is your dominant query pattern β€” meaning most of your interesting queries cross 3 or more hops, or require finding paths of unknown depth. Classic examples: fraud detection (trace a chain of related accounts), recommendation engines (friends-of-friends-who-bought-X), access control (resolve nested group memberships), knowledge graphs (follow concept relationships), and network/IT dependency mapping (which services depend on which). If your queries almost always start from a known ID and fetch that entity's direct properties, SQL is simpler and equally fast.

Q2: Neo4j or a relational database with foreign keys?

For 1–2 hops, a well-indexed relational database is competitive and much simpler to operate. The inflection point is around 3+ hops: each additional JOIN multiplies the rows that SQL must consider (Cartesian product explosion), while Neo4j's index-free adjacency keeps each hop O(1) regardless of depth. Rule of thumb: if your critical queries regularly traverse 3 or more relationships, benchmark both. Neo4j typically wins there by 10Γ— to 100Γ—. If your deepest queries are 2 hops, keep SQL β€” you get better tooling, more hiring availability, and a simpler operational story.

Q3: Community Edition vs. Enterprise Edition β€” what is the real difference?

Community Edition is genuinely useful and open-source (GPL-3). It includes the full Cypher query engine, ACID transactions, all core data model features, and a single-server deployment. Enterprise Edition adds: Causal Clustering (multi-primary Raft consensus + Read Replicas), hot backup (online backup without downtime), RBAC (role-based access control with fine-grained privileges), property-level security, Neo4j Ops Manager for cluster monitoring, and the CDC (Change Data Capture) API. For production systems with high availability or compliance requirements, you need Enterprise. For learning, prototyping, and internal tooling at low scale, Community is sufficient.

Q4: What is GQL and why should I care?

GQL (ISO/IEC 39075, published 2024) is the first international standard for graph query languages β€” the graph world's equivalent of SQL. It was developed by ISO/IEC JTC 1/SC 32 and is heavily influenced by Cypher (Neo4j drove the process). If you learn Cypher today, you are essentially learning the future standard. Practically: any vendor that adopts GQL will be readable to you. Longer term, GQL should make graph skills as transferable between databases as SQL skills are between relational databases today.

Q5: Can I run Neo4j embedded (in-process)?

Yes. The Neo4j Embedded API lets you include Neo4j as a Java library inside your application β€” no separate server process. It is useful for unit tests (spin up an in-memory database, run tests, discard), for desktop applications, and historically for applications that needed ultra-low-latency local graph access. In modern architectures, running Neo4j as a separate server (or AuraDB) is almost always preferred β€” you get operational separation, the ability to query from multiple services, and easier upgrades. Embedded is a niche but valid choice when you genuinely need in-process speed or have no ops infrastructure.

Q6: How do I migrate from a relational database to Neo4j?

The mapping is conceptually straightforward: tables β†’ labels (each table's rows become nodes of that label), foreign keys β†’ relationships (a FK column becomes a typed relationship between two nodes), JOIN queries β†’ MATCH patterns (SELECT with JOINs becomes a Cypher pattern). In practice, use apoc.load.jdbc (from the APOC library) to pull data from a JDBC source and create nodes/relationships incrementally. For bulk initial loads, export to CSV and use neo4j-admin database import, which is orders of magnitude faster than transactional inserts. Plan your new data model carefully before migrating β€” the relational schema optimised for joins will not automatically be the right graph model.

Q7: What is APOC and do I need it?

APOC stands for "Awesome Procedures On Cypher" β€” a community library of hundreds of stored procedures and functions that extend Cypher with capabilities the core language does not include. Categories include: string manipulation (apoc.text.*), JSON/XML parsing, advanced graph algorithms, date/time utilities, data import from external sources (apoc.load.json, apoc.load.jdbc), batch operations (apoc.periodic.iterate for processing millions of nodes without memory blowout), and more. In practice, almost every production Neo4j deployment uses APOC. It comes pre-installed on AuraDB and is a one-command install on self-hosted Neo4j. Learn the APOC basics early β€” it saves enormous amounts of custom code.

Q8: Does Neo4j support vector search for AI/RAG applications?

Yes, since Neo4j 5.13 (late 2023), vector indexes are a first-class feature. You create a vector index on a node property that stores an embedding array, then query it with db.index.vector.queryNodes(...) to find the k nearest neighbours. The real power comes from combining vector search with graph traversal in a single query: find the semantically similar documents and follow their graph relationships to surface contextually connected information. This "GraphRAG" pattern (graph + RAG) is increasingly popular for AI applications that need both semantic similarity and structured relational context β€” something a pure vector database cannot provide.

Use Neo4j when multi-hop traversal dominates; SQL competes up to 2-3 hops. Community Edition covers single-server OLTP; Enterprise adds clustering and RBAC. GQL (ISO 2024) is standardised Cypher. APOC is practically mandatory. Vector indexes since 5.13 enable GraphRAG patterns.