Graph Databases

When relationships matter more than the data itself.

When Joins Aren't Enough

Finding "friends of friends" in a relational database requires multiple joins that get exponentially slower with each degree of separation. Recommending products based on "users who bought this also bought" needs complex queries across purchase history, user preferences, and product categories. Social network analysis, fraud detection, knowledge graphs, and recommendation engines share a common pattern: relationships are first-class citizens. Graph databases store data as nodes (entities) and edges (relationships), making pathfinding queries such as "how are these two people connected?" dramatically faster, and every traversal far easier to express. This lesson covers Neo4j (the most widely used graph database) and Amazon Neptune (managed graph service). You'll learn the Cypher query language, graph modeling patterns, and, from a benchmark run for this lesson, exactly which queries a graph database wins and which ones PostgreSQL still wins.

The Graph Advantage, honestly: The usual pitch is that graph databases are orders of magnitude faster than SQL for any multi-hop query. Benchmarked on a 100,000-node social graph, that turns out to be false for bounded traversals: an indexed PostgreSQL table beat Neo4j at every depth from 2 to 6 hops. Where the graph database wins overwhelmingly is unbounded search, such as shortest path, where Neo4j was 75x faster. The numbers behind both claims, and the reason for the split, are in the performance section below.
Try it locally (Neo4j 5 + PostgreSQL 16)

You can reproduce the examples in this lesson on your own machine. Neo4j runs everything from the Cypher sections onward; PostgreSQL is only needed for the side-by-side comparison in the next section, where the same question is asked of both engines. Every result shown was verified against these containers (Neo4j 5.26.28, PostgreSQL 16).

# Neo4j with the browser UI on http://localhost:7474
docker run -d --name neo4j-demo \
  -e NEO4J_AUTH=neo4j/testpassword123 \
  -p 7474:7474 -p 7687:7687 neo4j:5

# Cypher shell:
docker exec -it neo4j-demo cypher-shell -u neo4j -p testpassword123

# Neo4j takes a few seconds to accept connections. Wait for it rather
# than guessing:
until docker exec neo4j-demo cypher-shell -u neo4j -p testpassword123 \
      "RETURN 1" >/dev/null 2>&1; do sleep 2; done; echo "neo4j ready"

# ---- PostgreSQL 16, for the side-by-side comparison ----
# The "Graph vs Relational" section below runs the same question against
# both engines, so start this one too if you want to follow along.
docker run -d --name pg-demo \
  -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
  -p 5432:5432 postgres:16-alpine

docker exec -it pg-demo psql -U demo -d demo

# Clean up both:
docker rm -f neo4j-demo pg-demo

Graph Databases vs Relational Databases

Relational databases excel at structured data with fixed schemas. Graph databases excel at connected data where relationships are as important as the data itself.

The Same Data, Modelled Relationally

Three people and three friendships. The entities go in one table and the connections in another, because a many-to-many link has nowhere else to live. Note what the join table is: pure bookkeeping, a construct the domain never asked for.

-- The relational way: entities in one table, connections in another.
CREATE TABLE users (
    user_id  INTEGER PRIMARY KEY,
    name     VARCHAR(50) NOT NULL,
    location VARCHAR(50)
);

-- The join table exists only because SQL has no other way to express
-- a many-to-many link. It is bookkeeping, not domain modelling.
CREATE TABLE friendships (
    user_id   INTEGER REFERENCES users(user_id),
    friend_id INTEGER REFERENCES users(user_id),
    since     DATE NOT NULL,
    PRIMARY KEY (user_id, friend_id)
);

INSERT INTO users VALUES (1,'Alice','NYC'), (2,'Bob','SF'), (3,'Charlie','NYC');
INSERT INTO friendships VALUES
    (1, 2, '2023-01-15'),
    (1, 3, '2023-02-20'),
    (2, 3, '2023-03-10');

-- "Friends of friends": one self-join per degree of separation.
SELECT DISTINCT f2.friend_id
FROM friendships f1
JOIN friendships f2 ON f1.friend_id = f2.user_id
WHERE f1.user_id = 1 AND f2.friend_id != 1;
Expected Output:
┌───────────┐
│ friend_id │
├───────────┤
│         3 │
└───────────┘
(1 row)

The Same Data, Modelled as a Graph

Here the friendship is not a row that happens to hold two keys. It is a typed, directional object with its own properties, stored next to the nodes it connects, and traversing it is a pointer hop rather than an index lookup.

// The graph way: the connection is a first-class object with its own
// type and properties, stored alongside the nodes it joins. Nodes and
// the relationships between them are created in one statement.
CREATE (a:Person {id: 1, name: 'Alice',   location: 'NYC'}),
       (b:Person {id: 2, name: 'Bob',     location: 'SF'}),
       (c:Person {id: 3, name: 'Charlie', location: 'NYC'}),
       (a)-[:FRIENDS_WITH {since: '2023-01-15'}]->(b),
       (a)-[:FRIENDS_WITH {since: '2023-02-20'}]->(c),
       (b)-[:FRIENDS_WITH {since: '2023-03-10'}]->(c);
Expected Output:
0 rows
ready to start consuming query after 115 ms, results consumed after another 0 ms
Added 3 nodes, Created 3 relationships, Set 12 properties, Added 3 labels

"0 rows" is not a failure: CREATE returns nothing because the statement
has no RETURN clause. The summary line underneath is the confirmation.

The counts are worth reading. 12 properties is 3 nodes x 3 (id, name,
location) plus 3 relationships x 1 (since): relationship properties are
counted exactly like node properties, which is the whole point of a
property graph. 3 labels is the :Person label applied three times.

Timings vary per machine and per run.

With the graph built, ask the same question the SQL above asked:

// The same question the SQL asked. *2 means "exactly two hops".
MATCH (me:Person {name: 'Alice'})-[:FRIENDS_WITH*2]->(fof)
WHERE fof <> me
RETURN DISTINCT fof.name;
Expected Output:
+-----------+
| fof.name  |
+-----------+
| "Charlie" |
+-----------+

Where the Models Diverge: Depth

Both queries above return Charlie, so at two hops the models are merely different. The gap opens when the depth changes. In Cypher it is a single token, and the query is otherwise untouched:

// Widening the search is one token: *2 becomes *1..3.
MATCH (me:Person {name: 'Alice'})-[:FRIENDS_WITH*1..3]->(other)
WHERE other <> me
RETURN DISTINCT other.name ORDER BY other.name;
Expected Output:
+------------+
| other.name |
+------------+
| "Bob"      |
| "Charlie"  |
+------------+

In SQL it is not one more JOIN, it is a different query. Fixed depth means one self-join per degree, written out in advance; variable or unbounded depth means abandoning that shape for a recursive CTE:

-- The same widening in SQL. Not another JOIN: a different query.
WITH RECURSIVE reachable(id, depth) AS (
    SELECT friend_id, 1 FROM friendships WHERE user_id = 1
    UNION
    SELECT f.friend_id, r.depth + 1
    FROM reachable r JOIN friendships f ON f.user_id = r.id
    WHERE r.depth < 3
)
SELECT DISTINCT u.name FROM reachable r JOIN users u ON u.user_id = r.id
WHERE r.id != 1 ORDER BY u.name;
Expected Output:
┌─────────┐
│  name   │
├─────────┤
│ Bob     │
│ Charlie │
└─────────┘
(2 rows)
Read the comparison honestly

Both results above are correct, and on three rows both are instant. PostgreSQL is perfectly capable of recursive traversal, so this is not a benchmark. What differs is what each model makes easy to express: the graph query reads like the question, and its cost tracks the neighbourhood it walks rather than the size of the join table. Reach for a graph database when traversal is the shape of most of your queries, not because a single friends-of-friends query looks tidier.

Property Graph: Nodes Connected by Typed Relationships

Alice:PersonBob:PersonCharlie:PersonCoding:SkillFRIENDS_WITH {since: 2020}FRIENDS_WITHFRIENDS_WITHHAS_SKILL {years: 5}

Figure 1: In a property graph, both nodes and relationships carry labels and properties. A 'friends of friends' query walks FRIENDS_WITH edges directly (Alice to Bob to Charlie), with no JOIN tables.

When to Use Graph Databases

  • Social networks (friends, followers)
  • Pathfinding ("how are X and Y connected?")
  • Recommendation engines
  • Fraud detection (transaction patterns)
  • Knowledge graphs (entities, concepts)
  • Network analysis (infrastructure, dependencies)
  • Access control (complex permissions)

When to Use Relational Databases

  • Structured data with fixed schema
  • Transactional systems (ACID required)
  • Reporting and analytics (SQL tools)
  • Few relationships per record
  • Simple aggregations and summaries
  • Traversals of known, bounded depth

Neo4j: The Leading Graph Database

Neo4j is the most popular graph database, using the Cypher query language. It's ACID-compliant, supports transactions, and provides excellent visualization tools.

Installing Neo4j Python Driver

# Install the official Neo4j Python driver.
# Python packages belong in a virtualenv. On Debian/Ubuntu a bare
# "pip install" is refused (PEP 668: externally-managed-environment).
python3 -m venv .venv && source .venv/bin/activate
pip install neo4j

# Start the server with the Docker command from "Try it locally" above,
# then open the Neo4j Browser at http://localhost:7474
# Credentials: neo4j / testpassword123

# Neo4j rejects passwords shorter than 8 characters, so "password"
# works but "pass" will not.

Before writing any Python, it is worth opening the Neo4j Browser athttp://localhost:7474 and running a query there. It renders results as an actual graph rather than a table, which makes it the fastest way to check that your data looks the way you think it does.

Neo4j Browser showing the query MATCH p=()-[:FRIENDS_WITH]->() RETURN p LIMIT 25, rendered as three connected Person nodes named Alice, Bob and Charlie, with a results overview reporting 3 nodes and 3 FRIENDS_WITH relationships
Figure 2: The same three people created earlier, drawn by the Neo4j Browser.MATCH p=()-[:FRIENDS_WITH]->() RETURN p returns paths rather than rows, so the Graph tab can draw them; the Table and Raw tabs show the same result in the shapes a driver would receive. The results overview on the right is a quick sanity check: 3 nodes, all labelled :Person, and 3FRIENDS_WITH relationships.

Connecting to Neo4j

from neo4j import GraphDatabase

class Neo4jConnection:
    def __init__(self, uri, user, password):
        self.driver = GraphDatabase.driver(uri, auth=(user, password))

    def close(self):
        self.driver.close()

    def query(self, cypher_query, parameters=None):
        """Execute a Cypher query and return results"""
        with self.driver.session() as session:
            result = session.run(cypher_query, parameters or {})
            return [record.data() for record in result]

# Create connection
conn = Neo4jConnection(
    uri="bolt://localhost:7687",
    user="neo4j",
    password="testpassword123"
)

print("✓ Connected to Neo4j")
Expected Output:
✓ Connected to Neo4j

Creating Nodes

From here the lesson builds one continuous script. Every block below reuses theconn object created above, so append them to the same file rather than running each on its own. The dataset they build, five people and six friendships, is what every later query in this lesson expects to find.

# This block and every one after it continue the script from "Connecting
# to Neo4j" above: conn is the Neo4jConnection created there. Keep them in
# one file, or paste them into the same interpreter session.

# Start from an empty graph. Two reasons this matters:
#   1. The comparison section earlier in this lesson created its own
#      Alice, Bob and Charlie. Without this you end up with two of each.
#   2. CREATE always inserts. It is not idempotent, so re-running this
#      block would duplicate everything again.
conn.query("MATCH (n) DETACH DELETE n")

# Create Person nodes
create_person = """
CREATE (p:Person {
    name: $name,
    age: $age,
    email: $email,
    location: $location
})
RETURN p
"""

# Create multiple people
people = [
    {"name": "Alice", "age": 28, "email": "alice@example.com", "location": "NYC"},
    {"name": "Bob", "age": 32, "email": "bob@example.com", "location": "SF"},
    {"name": "Charlie", "age": 25, "email": "charlie@example.com", "location": "NYC"},
    {"name": "Diana", "age": 30, "email": "diana@example.com", "location": "LA"},
    {"name": "Eve", "age": 27, "email": "eve@example.com", "location": "SF"}
]

print("Creating person nodes...")
for person in people:
    result = conn.query(create_person, person)
    print(f"  Created: {person['name']}")

print("✓ All person nodes created!")
Expected Output:
Creating person nodes...
  Created: Alice
  Created: Bob
  Created: Charlie
  Created: Diana
  Created: Eve
✓ All person nodes created!

Creating Relationships

# Create FRIENDS_WITH relationships
create_friendship = """
MATCH (a:Person {name: $person1})
MATCH (b:Person {name: $person2})
CREATE (a)-[r:FRIENDS_WITH {since: $since}]->(b)
RETURN a.name, b.name, r.since
"""

# Define friendships
friendships = [
    {"person1": "Alice", "person2": "Bob", "since": "2023-01-15"},
    {"person1": "Alice", "person2": "Charlie", "since": "2023-02-20"},
    {"person1": "Bob", "person2": "Charlie", "since": "2023-03-10"},
    {"person1": "Bob", "person2": "Diana", "since": "2023-04-05"},
    {"person1": "Charlie", "person2": "Eve", "since": "2023-05-12"},
    {"person1": "Diana", "person2": "Eve", "since": "2023-06-08"}
]

print("Creating friendship relationships...")
for friendship in friendships:
    result = conn.query(create_friendship, friendship)
    if result:
        print(f"  {friendship['person1']} -> {friendship['person2']}")

print("✓ All friendships created!")
Expected Output:
Creating friendship relationships...
  Alice -> Bob
  Alice -> Charlie
  Bob -> Charlie
  Bob -> Diana
  Charlie -> Eve
  Diana -> Eve
✓ All friendships created!

Cypher Query Language

Cypher is Neo4j's declarative query language, designed to express graph patterns visually. It uses ASCII art to represent nodes and relationships.

Basic Pattern Matching

# Query 1: Find all people
query = """
MATCH (p:Person)
RETURN p.name, p.age, p.location
ORDER BY p.age DESC
"""

result = conn.query(query)

print("All people:")
for record in result:
    print(f"  {record['p.name']}, {record['p.age']} years old, {record['p.location']}")

# Query 2: Find all friendships
query = """
MATCH (a:Person)-[r:FRIENDS_WITH]->(b:Person)
RETURN a.name AS person1, b.name AS person2, r.since AS since
ORDER BY since
"""

result = conn.query(query)

print("\nAll friendships:")
for record in result:
    print(f"  {record['person1']} -> {record['person2']} (since {record['since']})")
Expected Output:
All people:
  Bob, 32 years old, SF
  Diana, 30 years old, LA
  Alice, 28 years old, NYC
  Eve, 27 years old, SF
  Charlie, 25 years old, NYC

All friendships:
  Alice -> Bob (since 2023-01-15)
  Alice -> Charlie (since 2023-02-20)
  Bob -> Charlie (since 2023-03-10)
  Bob -> Diana (since 2023-04-05)
  Charlie -> Eve (since 2023-05-12)
  Diana -> Eve (since 2023-06-08)

Graph Traversal: Friends of Friends

# Find friends of friends (2 degrees)
query = """
MATCH (me:Person {name: $my_name})-[:FRIENDS_WITH*2]->(fof:Person)
WHERE fof <> me
RETURN DISTINCT fof.name AS friend_of_friend
ORDER BY friend_of_friend
"""

result = conn.query(query, {"my_name": "Alice"})

print("Alice's friends of friends:")
for record in result:
    print(f"  {record['friend_of_friend']}")

# Find mutual friends
query = """
MATCH (me:Person {name: $person1})-[:FRIENDS_WITH]->(mutual:Person)
      <-[:FRIENDS_WITH]-(them:Person {name: $person2})
RETURN mutual.name AS mutual_friend
"""

result = conn.query(query, {"person1": "Alice", "person2": "Bob"})

print("\nMutual friends of Alice and Bob:")
for record in result:
    print(f"  {record['mutual_friend']}")
Expected Output:
Alice's friends of friends:
  Charlie
  Diana
  Eve

Mutual friends of Alice and Bob:
  Charlie

Path Queries

# Find shortest path between two people
query = """
MATCH path = shortestPath(
    (start:Person {name: $start_name})-[:FRIENDS_WITH*]-(end:Person {name: $end_name})
)
RETURN [node in nodes(path) | node.name] AS path_names,
       length(path) AS degrees_of_separation
"""

result = conn.query(query, {"start_name": "Alice", "end_name": "Eve"})

if result:
    path = result[0]
    print(f"Shortest path from Alice to Eve:")
    print(f"  Path: {' -> '.join(path['path_names'])}")
    print(f"  Degrees of separation: {path['degrees_of_separation']}")

# Find all paths within N degrees
query = """
MATCH path = (start:Person {name: $name})-[:FRIENDS_WITH*1..3]-(connected:Person)
WHERE start <> connected
WITH connected.name AS person, MIN(length(path)) AS degrees
RETURN person, degrees
ORDER BY degrees, person
"""

result = conn.query(query, {"name": "Alice"})

print("\nPeople connected to Alice (within 3 degrees):")
current_degree = None
for record in result:
    if record['degrees'] != current_degree:
        current_degree = record['degrees']
        print(f"  {current_degree} degree{'s' if current_degree > 1 else ''}:")
    print(f"    {record['person']}")
Expected Output:
Shortest path from Alice to Eve:
  Path: Alice -> Charlie -> Eve
  Degrees of separation: 2

People connected to Alice (within 3 degrees):
  1 degree:
    Bob
    Charlie
  2 degrees:
    Diana
    Eve

Real-World Use Case: Product Recommendations

Graph databases excel at recommendation engines. Let's build a simple product recommendation system based on purchase patterns and user similarities.

Creating the Product Graph

# Create product nodes
create_products = """
UNWIND $products AS product
CREATE (p:Product {
    id: product.id,
    name: product.name,
    category: product.category,
    price: product.price
})
"""

products = [
    {"id": "P1", "name": "Laptop", "category": "Electronics", "price": 999},
    {"id": "P2", "name": "Mouse", "category": "Electronics", "price": 29},
    {"id": "P3", "name": "Keyboard", "category": "Electronics", "price": 79},
    {"id": "P4", "name": "Monitor", "category": "Electronics", "price": 299},
    {"id": "P5", "name": "Desk Chair", "category": "Furniture", "price": 199}
]

conn.query(create_products, {"products": products})
print("✓ Created products")

# Create PURCHASED relationships
create_purchases = """
MATCH (u:Person {name: $user})
MATCH (p:Product {id: $product_id})
CREATE (u)-[:PURCHASED {date: $date, rating: $rating}]->(p)
"""

purchases = [
    {"user": "Alice", "product_id": "P1", "date": "2024-01-15", "rating": 5},
    {"user": "Alice", "product_id": "P2", "date": "2024-01-15", "rating": 4},
    {"user": "Bob", "product_id": "P1", "date": "2024-02-10", "rating": 5},
    {"user": "Bob", "product_id": "P3", "date": "2024-02-10", "rating": 5},
    {"user": "Bob", "product_id": "P4", "date": "2024-02-12", "rating": 4},
    {"user": "Charlie", "product_id": "P2", "date": "2024-03-05", "rating": 4},
    {"user": "Charlie", "product_id": "P3", "date": "2024-03-05", "rating": 5},
    {"user": "Diana", "product_id": "P1", "date": "2024-04-01", "rating": 5},
    {"user": "Diana", "product_id": "P5", "date": "2024-04-02", "rating": 5}
]

for purchase in purchases:
    conn.query(create_purchases, purchase)

print("✓ Created purchase relationships")
Expected Output:
✓ Connected to Neo4j
✓ Created products
✓ Created purchase relationships

Collaborative Filtering Recommendations

# Recommendation: "Users who bought X also bought Y"
query = """
MATCH (me:Person {name: $user_name})-[:PURCHASED]->(p:Product)
MATCH (p)<-[:PURCHASED]-(other:Person)-[:PURCHASED]->(rec:Product)
WHERE NOT (me)-[:PURCHASED]->(rec)
  AND me <> other
RETURN rec.name AS product,
       rec.price AS price,
       COUNT(DISTINCT other) AS bought_by_similar_users,
       AVG(other.age) AS avg_buyer_age
ORDER BY bought_by_similar_users DESC, price ASC
LIMIT 5
"""

result = conn.query(query, {"user_name": "Alice"})

print("Recommended products for Alice:")
print("(Based on users who bought similar products)\n")
for i, record in enumerate(result, 1):
    print(f"{i}. {record['product']} - ${record['price']}")
    print(f"   Bought by {record['bought_by_similar_users']} similar users")
    print(f"   Avg buyer age: {record['avg_buyer_age']:.0f}")
    print()
Expected Output:
Recommended products for Alice:
(Based on users who bought similar products)

1. Keyboard - $79
   Bought by 2 similar users
   Avg buyer age: 28

2. Desk Chair - $199
   Bought by 1 similar users
   Avg buyer age: 30

3. Monitor - $299
   Bought by 1 similar users
   Avg buyer age: 32

Friend-Based Recommendations

# Recommendation: "Your friends bought these products"
query = """
MATCH (me:Person {name: $user_name})-[:FRIENDS_WITH*1..2]-(friend:Person)
MATCH (friend)-[r:PURCHASED]->(p:Product)
WHERE NOT (me)-[:PURCHASED]->(p)
  AND me <> friend
WITH p, friend, r
ORDER BY friend.name
RETURN p.name AS product,
       p.price AS price,
       COUNT(DISTINCT friend) AS friend_count,
       AVG(r.rating) AS avg_rating,
       COLLECT(DISTINCT friend.name)[0..3] AS example_friends
ORDER BY friend_count DESC, avg_rating DESC
LIMIT 5
"""

result = conn.query(query, {"user_name": "Alice"})

print("Products your friends bought:")
for i, record in enumerate(result, 1):
    print(f"{i}. {record['product']} - ${record['price']}")
    print(f"   {record['friend_count']} friend(s) bought this")
    print(f"   Avg rating: {record['avg_rating']:.1f}/5")
    print(f"   Friends: {', '.join(record['example_friends'])}")
    print()
Expected Output:
Products your friends bought:
1. Keyboard - $79
   2 friend(s) bought this
   Avg rating: 5.0/5
   Friends: Bob, Charlie

2. Desk Chair - $199
   1 friend(s) bought this
   Avg rating: 5.0/5
   Friends: Diana

3. Monitor - $299
   1 friend(s) bought this
   Avg rating: 4.0/5
   Friends: Bob

Graph Algorithms and Analytics

Graph databases provide powerful algorithms for analyzing network structure: centrality (who's most influential?), community detection (find clusters), and PageRank (importance).

Degree Centrality (Most Connected)

# Find most connected people (degree centrality)
query = """
MATCH (p:Person)
OPTIONAL MATCH (p)-[r:FRIENDS_WITH]-(friend)
WITH p, COUNT(DISTINCT friend) AS friend_count
RETURN p.name AS person,
       friend_count,
       CASE
         WHEN friend_count >= 3 THEN 'Highly Connected'
         WHEN friend_count >= 2 THEN 'Well Connected'
         ELSE 'Few Connections'
       END AS status
ORDER BY friend_count DESC, person
"""

result = conn.query(query)

print("Social network centrality:")
for record in result:
    print(f"  {record['person']}: {record['friend_count']} friends ({record['status']})")
Expected Output:
Social network centrality:
  Bob: 3 friends (Highly Connected)
  Charlie: 3 friends (Highly Connected)
  Alice: 2 friends (Well Connected)
  Diana: 2 friends (Well Connected)
  Eve: 2 friends (Well Connected)

Community Detection

# Find communities based on location
query = """
MATCH (p:Person)
WITH p ORDER BY p.name
WITH p.location AS location, COLLECT(p.name) AS people
RETURN location,
       people,
       SIZE(people) AS member_count
ORDER BY member_count DESC, location
"""

result = conn.query(query)

print("Communities by location:")
for record in result:
    print(f"  {record['location']}: {record['member_count']} members")
    print(f"    Members: {', '.join(record['people'])}")
    print()

# Find tightly-knit groups (triangles)
query = """
MATCH (a:Person)-[:FRIENDS_WITH]-(b:Person)-[:FRIENDS_WITH]-(c:Person)-[:FRIENDS_WITH]-(a)
WHERE a.name < b.name AND b.name < c.name
RETURN a.name AS person1, b.name AS person2, c.name AS person3
"""

result = conn.query(query)

print("Tightly-knit friend groups (triangles):")
for record in result:
    print(f"  {record['person1']} - {record['person2']} - {record['person3']}")
Expected Output:
Communities by location:
  NYC: 2 members
    Members: Alice, Charlie

  SF: 2 members
    Members: Bob, Eve

  LA: 1 members
    Members: Diana

Tightly-knit friend groups (triangles):
  Alice - Bob - Charlie

Amazon Neptune: Managed Graph Database

Amazon Neptune is a fully managed graph database service that supports property graphs (through both Gremlin and openCypher) and RDF graphs (through SPARQL). It's designed for high availability, offers up to 15 read replicas, and is built to query billions of relationships with millisecond latency.

Key Features

Advantages
  • Fully managed (no server maintenance)
  • High availability (Multi-AZ replication)
  • Automatic backups and point-in-time recovery
  • Supports Gremlin, openCypher and SPARQL
  • Scales to billions of relationships
  • Up to 15 read replicas for query scaling
Considerations
  • AWS-only (vendor lock-in)
  • More expensive than self-hosted
  • Learning curve for Gremlin (openCypher eases the move from Neo4j)
  • Less visualization tools than Neo4j
  • Minimum instance size (cost)

Using Gremlin with Python

from gremlin_python.driver import client, serializer

# Connect to Neptune endpoint
neptune_endpoint = "wss://your-neptune-cluster.region.neptune.amazonaws.com:8182/gremlin"

gremlin_client = client.Client(
    neptune_endpoint,
    'g',
    message_serializer=serializer.GraphSONSerializersV3d0()
)

# Add a vertex (node)
query = """
g.addV('Person')
 .property('name', 'Alice')
 .property('age', 28)
 .property('location', 'NYC')
"""

result = gremlin_client.submit(query).all().result()
print("✓ Created person vertex")

# Add an edge (relationship)
query = """
g.V().has('Person', 'name', 'Alice').as('a')
 .V().has('Person', 'name', 'Bob').as('b')
 .addE('FRIENDS_WITH')
 .from('a').to('b')
 .property('since', '2023-01-15')
"""

result = gremlin_client.submit(query).all().result()
print("✓ Created friendship edge")

# Traversal query
query = """
g.V().has('Person', 'name', 'Alice')
 .out('FRIENDS_WITH')
 .values('name')
"""

result = gremlin_client.submit(query).all().result()

print("\nAlice's friends:")
for friend in result:
    print(f"  {friend}")
Expected Output:
✓ Created person vertex
✓ Created friendship edge

Alice's friends:
  Bob

Performance: Graph vs Relational

The claim you will read everywhere is that graph databases crush relational databases on any multi-hop query. That is only half true, and the half that is false matters. Everything below was measured for this lesson on the same generated graph, loaded into both engines.

Reproducing the Dataset

Four steps: generate the file on your machine, copy it into each container, then load it on each side. The copy step exists because both databases run in Docker and neither can read a file from your host, which is the detail that turns this from a five-minute exercise into a confusing one.

# Step 1 of 4, on your machine. Writes edges.csv next to this script:
# 1,000,000 (user_id, friend_id) pairs, out-degree exactly 10.
import csv
import random

random.seed(42)          # so both engines load byte-identical data

N = 100_000
OUT_DEGREE = 10
edges = set()
for u in range(N):
    friends = set()
    while len(friends) < OUT_DEGREE:
        f = random.randrange(N)
        if f != u:
            friends.add(f)
    for f in friends:
        edges.add((u, f))

with open("edges.csv", "w", newline="") as fh:
    writer = csv.writer(fh)
    writer.writerow(["user_id", "friend_id"])   # both loaders expect a header
    writer.writerows(sorted(edges))

print(f"edges.csv written: {len(edges):,} rows")
# Step 2 of 4. Both databases run in containers, so neither can see a file
# sitting on your machine. Copy it into each one first: this is the step
# that is easy to skip, and it fails in two different confusing ways.
#
#   psql inside the container:  edges.csv: No such file or directory
#   Neo4j:                      Couldn't load the external resource at:
#                               file:///edges.csv
#
# PostgreSQL: anywhere readable will do.
docker cp edges.csv pg-demo:/tmp/edges.csv

# Neo4j: NOT anywhere. file:/// resolves against server.directories.import,
# which is /var/lib/neo4j/import in the official image. A path outside it
# is refused no matter how the URL is written.
docker cp edges.csv neo4j-demo:/var/lib/neo4j/import/edges.csv
-- Step 3 of 4, in psql. Open a session inside the container first:
--
--   docker exec -it pg-demo psql -U demo -d demo
--
-- (-it because psql is interactive; the earlier docker cp used -i only
-- because it just pipes a file through.)
--
-- The benchmark table is called bench_edges, NOT friendships. The
-- comparison section earlier in this lesson already owns that name in
-- this database, and its table is a different shape: it carries a
-- "since" column and foreign keys into users. Reusing the name fails
-- with
--
--   ERROR:  relation "friendships" already exists
--
-- and even if it did not, this data would be rejected: "since" is NOT
-- NULL and ids 0..99999 have no matching users rows.
--
-- Everything stays in the demo database, so the tutorial tables and the
-- benchmark table sit side by side and you never switch connections.
--
-- Deliberately bare: no "since", no foreign keys. This table exists to
-- measure traversal cost, and every extra constraint would measure
-- something else.
CREATE TABLE bench_edges (user_id integer NOT NULL, friend_id integer NOT NULL);

-- \copy is the psql meta-command: it reads the file as the psql PROCESS
-- sees it, so /tmp/edges.csv is the path copied in at step 2. Plain COPY
-- is server side and needs superuser or pg_read_server_files.
\copy bench_edges FROM '/tmp/edges.csv' WITH (FORMAT csv, HEADER true);
-- COPY 1000000

-- Index both directions: the traversal queries walk edges each way.
CREATE INDEX ON bench_edges(user_id);
CREATE INDEX ON bench_edges(friend_id);
// Step 4 of 4, in cypher-shell. Open one inside the container first:
//
//   docker exec -it neo4j-demo cypher-shell -u neo4j -p testpassword123
//
// Same collision as the SQL side, quieter: the tutorial's five :Person
// nodes are still here, and this step adds 100,000 more. Nothing errors,
// the graph just silently contains both, and every earlier query in this
// lesson starts returning strangers. Clear it first.
MATCH (n) DETACH DELETE n;

// Nodes first, then edges, so every MATCH below finds its endpoints.
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.id IS UNIQUE;

UNWIND range(0, 99999) AS id
CALL { WITH id CREATE (:Person {id: id}) } IN TRANSACTIONS OF 10000 ROWS;

// Only the bare filename here: it is resolved inside the import directory.
LOAD CSV WITH HEADERS FROM 'file:///edges.csv' AS row
CALL {
  WITH row
  MATCH (a:Person {id: toInteger(row.user_id)})
  MATCH (b:Person {id: toInteger(row.friend_id)})
  CREATE (a)-[:FRIENDS_WITH]->(b)
} IN TRANSACTIONS OF 5000 ROWS;

The Measurement Harness

Nothing below is quoted from a vendor. This is the script that produced the numbers, so you can run it against your own load and disagree with it. Two details are deliberate: the fetch happens inside the timer, because a driver that streams lazily would otherwise report a query as nearly free, and each measurement is the median of ten runs after a warm-up, so a single unlucky pause does not become the headline.

# The harness that produced the tables below. Needs both drivers:
#   pip install "psycopg[binary]" neo4j
import statistics
import time

import psycopg
from neo4j import GraphDatabase

PG_DSN = "host=localhost dbname=demo user=demo password=demo"
NEO4J_URI, NEO4J_AUTH = "bolt://localhost:7687", ("neo4j", "testpassword123")

# Query A walks out from one person; Query B looks for a path between two.
START = 84599
PATH_FROM, PATH_TO = 42446, 23154
REPS = 10


def sql_n_hops(n):
    """Build the N-self-join query. One JOIN per hop, aliases f1..fN."""
    joins = "\n".join(
        f"JOIN bench_edges f{i} ON f{i-1}.friend_id = f{i}.user_id"
        for i in range(2, n + 1)
    )
    return (f"SELECT DISTINCT f{n}.friend_id\nFROM bench_edges f1\n{joins}\n"
            f"WHERE f1.user_id = %s AND f{n}.friend_id <> %s")


def timed(fn, reps=REPS):
    """Median of REPS runs after one warm-up. Fetching is inside the timer:
    a query that streams lazily would otherwise look free."""
    fn()
    times = []
    for _ in range(reps):
        t0 = time.perf_counter()
        rows = fn()
        times.append((time.perf_counter() - t0) * 1000)
    return statistics.median(times), rows


pg = psycopg.connect(PG_DSN)
driver = GraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH)

print(f"{'hops':>5} {'PostgreSQL':>12} {'Neo4j':>12} {'rows(pg)':>10} {'rows(neo)':>10}")
for hops in (2, 3, 4, 5, 6):
    query = sql_n_hops(hops)
    cypher = (f"MATCH (me:Person {{id: $start}})-[:FRIENDS_WITH*{hops}]->(p:Person) "
              f"WHERE p.id <> $start RETURN DISTINCT p.id")

    def run_pg():
        with pg.cursor() as cur:
            cur.execute(query, (START, START))
            return len(cur.fetchall())

    def run_neo():
        with driver.session() as session:
            return len(list(session.run(cypher, start=START)))

    pg_ms, pg_rows = timed(run_pg)
    neo_ms, neo_rows = timed(run_neo)
    print(f"{hops:>5} {pg_ms:>10.1f}ms {neo_ms:>10.1f}ms {pg_rows:>10,} {neo_rows:>10,}")


# ---- QUERY B: unbounded shortest path ----------------------------------
BFS_SQL = """
WITH RECURSIVE bfs(node, depth) AS (
    SELECT %s, 0
  UNION
    SELECT f.friend_id, b.depth + 1
    FROM bfs b JOIN bench_edges f ON f.user_id = b.node
    WHERE b.depth < 6
)
SELECT MIN(depth) FROM bfs WHERE node = %s
"""
SHORTEST_CYPHER = """
MATCH p = shortestPath(
    (x:Person {id: $x})-[:FRIENDS_WITH*..6]->(y:Person {id: $y})
)
RETURN length(p) AS len
"""


def bfs_pg():
    with pg.cursor() as cur:
        cur.execute(BFS_SQL, (PATH_FROM, PATH_TO))
        return cur.fetchone()[0]


def shortest_neo():
    with driver.session() as session:
        record = session.run(SHORTEST_CYPHER, x=PATH_FROM, y=PATH_TO).single()
        return record["len"] if record else None


pg_ms, pg_depth = timed(bfs_pg)
neo_ms, neo_len = timed(shortest_neo)
print(f"\nQuery B, shortest path {PATH_FROM} -> {PATH_TO}")
print(f"  PostgreSQL {pg_ms:8.1f} ms   (depth found: {pg_depth})")
print(f"  Neo4j      {neo_ms:8.1f} ms   (path length: {neo_len})")
print(f"  -> Neo4j {pg_ms / neo_ms:.0f}x faster")

# Why the CTE is slow: count what it actually materialises.
with pg.cursor() as cur:
    cur.execute("""
        WITH RECURSIVE bfs(node, depth) AS (
            SELECT %s, 0
          UNION
            SELECT f.friend_id, b.depth + 1
            FROM bfs b JOIN bench_edges f ON f.user_id = b.node
            WHERE b.depth < 6
        )
        SELECT count(*) FROM bfs
    """, (PATH_FROM,))
    print(f"  (node, depth) rows the CTE materialises: {cur.fetchone()[0]:,}")

pg.close()
driver.close()
Expected Output:
 hops   PostgreSQL        Neo4j   rows(pg)  rows(neo)
    2        0.5ms        5.1ms        100        100
    3        1.2ms       23.6ms        998        998
    4       14.7ms      170.5ms      9,479      9,479
    5       64.1ms      999.7ms     61,304     61,304
    6      256.7ms     1724.9ms     99,805     99,805

Query B, shortest path 42446 -> 23154
  PostgreSQL    332.4 ms   (depth found: 6)
  Neo4j           4.4 ms   (path length: 6)
  -> Neo4j 75x faster
  (node, depth) rows the CTE materialises: 171,355

Your absolute numbers will differ; the shape should not. Note rows(pg)
and rows(neo) agreeing at every depth: that is the SQL/Cypher
equivalence discussed below, confirmed rather than assumed. Both engines
also agree the shortest path is 6 hops, so the two are answering the
same question and only the strategy differs.

Query A: Fixed-Depth Traversal

"Everyone exactly N hops away." Both engines have an obvious way to express this, and the two ways look nothing alike.

-- Fixed-depth traversal: everyone exactly N hops from 84599.
-- Written out at N = 3. Each extra hop adds one JOIN and moves the
-- alias in both the SELECT and the final WHERE.
SELECT DISTINCT f3.friend_id
FROM bench_edges f1
JOIN bench_edges f2 ON f1.friend_id = f2.user_id
JOIN bench_edges f3 ON f2.friend_id = f3.user_id
WHERE f1.user_id = 84599
  AND f3.friend_id <> 84599;
// The same depth, as a variable-length pattern. Widening it is one
// token; the SQL opposite requires another JOIN and two edits.
MATCH (me:Person {id: 84599})-[:FRIENDS_WITH*3]->(p:Person)
WHERE p.id <> 84599
RETURN DISTINCT p.id;
These are not quite the same question

Cypher applies relationship isomorphism: one matched path may not reuse the same relationship twice. A chain of SQL joins has no such rule, so it walks freely, including back and forth along a single edge. On a two-node cycle1->2->1, asked for three hops from node 1, SQL returns node 2 (it reuses 1->2) while Cypher returns nothing at all.

On this dataset it makes no difference: with a million random edges and out-degree 10, both engines returned identical DISTINCT counts at every depth, which is why the table below has one "rows" column rather than two. On a graph rich in short cycles they would diverge, and SQL would be the side overcounting.

HopsPostgreSQLNeo4jRows returnedNeo4j slower by
20.5 ms5.1 ms10010.2x
31.2 ms23.6 ms99819.7x
414.7 ms170.5 ms9,47911.6x
564.1 ms999.7 ms61,30415.6x
6256.7 ms1,724.9 ms99,8056.7x

PostgreSQL wins at every depth, and the reason is worth stating plainly: a btree index lookup is an adjacency lookup, and a nested-loop join over a cached index is extremely cheap. Do not over-read the last column in the middle rows, though, since between 2 and 5 hops the ratio wanders between 10x and 20x with no clean trend and moves from run to run. The durable feature is the bottom row, where the lead collapses below 7x: at 6 hops the traversal reaches 99,805 of the 100,000 nodes, so neither engine is really traversing any more, both are just reading the graph.

Query B: Unbounded Shortest Path

"How is this person connected to that one?" This is the question that reverses the result.

-- Unbounded shortest path, as a breadth-first recursive CTE.
-- Note what is missing: any notion of where the target is.
WITH RECURSIVE bfs(node, depth) AS (
    SELECT 42446, 0
  UNION
    SELECT f.friend_id, b.depth + 1
    FROM bfs b
    JOIN bench_edges f ON f.user_id = b.node
    WHERE b.depth < 6
)
SELECT MIN(depth) FROM bfs WHERE node = 23154;
// shortestPath is a single built-in, and it knows both endpoints.
MATCH p = shortestPath(
    (x:Person {id: 42446})-[:FRIENDS_WITH*..6]->(y:Person {id: 23154})
)
RETURN length(p);
EngineTimeAnswer
PostgreSQL332.4 msdepth 6
Neo4j4.4 ms (75x faster)length 6

Both agree the answer is 6, so this is a fair race, and the recursive CTE loses it badly for a structural reason: it has no idea where it is going. It expands the entire frontier to depth 6, materialising 171,355 (node, depth) rows from one start node in a 100,000-node graph. Neo4j's shortestPath runs a bidirectional breadth-first search from both endpoints and stops the moment the two frontiers meet, touching a fraction of that.

Graph databases win on search, not on joins

That is the whole finding, and it contradicts the usual pitch. When you know the shape of the traversal, a relational database with the right index is hard to beat, because the join it performs is the same pointer-chase the graph engine performs. When you do not know the shape, when the query is "find a path" or "how are these connected", the graph engine's algorithms are in a different class.

Measured on PostgreSQL 16.14 and Neo4j 5.26.28, both in Docker on one machine, over a generated graph of 100,000 people and 1,000,000 FRIENDS_WITH edges, out-degree exactly 10, random.seed(42). Warm cache, median of 10 runs, fetch included in the timing.

Which of these numbers should you trust?

Properties of the data, reproducible anywhere: every row count, the shortest path length of 6, and the 171,355 rows the CTE materialises. Generate the graph with seed 42 and you will get exactly those, on any machine, every time.

Properties of a machine, yours will differ: every millisecond above. The same query can vary by 2x between runs on identical hardware. Read the shape rather than the digits, and if your own run reverses either conclusion, the interesting part is why. Shared page cache, heap size and out-degree all move these numbers, and the harness is right there so you can find out instead of taking this on faith.

Recommendation

Do not migrate to a graph database on the strength of a benchmark you did not run. If your queries are bounded ("friends of friends", "two levels of org chart"), a well indexed relational table is likely to be faster and is certainly simpler to operate. Reach for a graph database when your queries are open-ended searches over relationships, when the traversal depth is not known in advance, or when the queries are so awkward to express in SQL that the recursive CTEs become the maintenance problem. Query expressiveness, not raw speed, is the most reliable reason to choose Cypher.

Choosing Between Graph and Relational

Use this decision framework to choose the right database for your use case:

Use CaseBest ChoiceWhy
Social networkGraph (Neo4j)Deep relationship traversals (friends-of-friends)
Fraud detectionGraph (Neptune)Identify patterns across transactions and entities
Recommendation engineGraph (Neo4j)Collaborative filtering, user similarity
E-commerce ordersRelational (PostgreSQL)Transactional integrity, simple relationships
Knowledge graphGraph (Neo4j/Neptune)Entities and complex relationships
Access control (IAM)GraphComplex permission hierarchies
Financial reportingRelationalAggregations, SQL reporting tools
Network topologyGraphInfrastructure dependencies, impact analysis
Inventory managementRelationalSimple structure, ACID requirements
Master data managementHybridUse both: relational for data, graph for lineage
Hybrid Approach: Many production systems use both! PostgreSQL for transactional data (orders, inventory), Neo4j for relationships (recommendations, social graph). Sync data between them using CDC or event streams.

Key Takeaways

  • Graph databases: Store data as nodes (entities) and edges (relationships)
  • Neo4j: Leading graph DB with Cypher query language
  • Amazon Neptune: Managed graph service (Gremlin/SPARQL)
  • Performance: 75x faster for unbounded shortest path; slower than indexed SQL for bounded hops
  • Use cases: Social networks, recommendations, fraud detection
  • Cypher: Declarative query language using ASCII art patterns
  • Traversal: Direct pointer hopping (no joins!)
  • Trade-off: Great for relationships, slower for aggregations
Remember: Reach for a graph database when the traversal depth is open-ended, not merely when relationships exist. Measured on a 100,000-person social graph, PostgreSQL with a btree index beat Neo4j at every bounded depth from 2 to 6 hops, while Neo4j finished an unbounded shortest path 75x faster. NASA's lessons-learned knowledge graph runs on Neo4j, and Neptune powers fraud detection and knowledge graphs at AWS customers. It is worth knowing that LinkedIn went the other way: rather than adopting an off-the-shelf graph database for its Economic Graph, it spent four years building its own, LIquid, which now serves around 270 billion edges. Don't replace your entire database. Use a graph alongside a relational database for the relationship-heavy portions, and sync between them with CDC or event streams.