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 traversals like "find all friends within 3 degrees" or "recommend products based on similar users' purchases" orders of magnitude faster than SQL joins. This lesson covers Neo4j (the leading graph database) and Amazon Neptune (managed graph service). You'll learn the Cypher query language, graph modeling patterns, when graphs outperform relational databases by 100x+, and real-world use cases from LinkedIn, Airbnb, and NASA.

The Graph Advantage: In a relational database, finding friends-of-friends-of-friends requires 3 self-joins on a users table, scanning millions of rows (slow!). In a graph database, you traverse relationships directly: follow 3 edges from a person node. No joins, no table scans - just pointer hopping. This makes graph databases 100-1000x faster for relationship-heavy queries.

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.

RELATIONAL MODEL (Tables):

users table:
┌────────┬───────────┬──────────┐
│ user_id│ name      │ location │
├────────┼───────────┼──────────┤
│ 1      │ Alice     │ NYC      │
│ 2      │ Bob       │ SF       │
│ 3      │ Charlie   │ NYC      │
└────────┴───────────┴──────────┘

friendships table (many-to-many):
┌─────────┬───────────┬──────────────┐
│ user_id │ friend_id │ since        │
├─────────┼───────────┼──────────────┤
│ 1       │ 2         │ 2023-01-15   │
│ 1       │ 3         │ 2023-02-20   │
│ 2       │ 3         │ 2023-03-10   │
└─────────┴───────────┴──────────────┘

Query: "Find friends of friends"
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

Problem: Multiple joins get slow with degrees of separation


GRAPH MODEL (Nodes & Relationships):

     (Alice)
       /  \
  FRIENDS_WITH
     /      \
  (Bob)--FRIENDS_WITH--(Charlie)

Nodes:
• (:Person {id: 1, name: "Alice", location: "NYC"})
• (:Person {id: 2, name: "Bob", location: "SF"})
• (:Person {id: 3, name: "Charlie", location: "NYC"})

Relationships:
• (Alice)-[:FRIENDS_WITH {since: "2023-01-15"}]->(Bob)
• (Alice)-[:FRIENDS_WITH {since: "2023-02-20"}]->(Charlie)
• (Bob)-[:FRIENDS_WITH {since: "2023-03-10"}]->(Charlie)

Query: "Find friends of friends" (Cypher)
MATCH (me:Person {name: "Alice"})-[:FRIENDS_WITH*2]->(fof)
WHERE fof <> me
RETURN DISTINCT fof.name

Advantage: Direct traversal, no joins needed!
Graph databases treat relationships as first-class citizens, making traversals fast

When to Use Graph Databases

  • Social networks (friends, followers)
  • 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
  • Shallow queries (1-2 joins max)

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 Neo4j Python driver
pip install neo4j

# Start Neo4j (using Docker)
docker run \
    --name neo4j \
    -p 7474:7474 -p 7687:7687 \
    -e NEO4J_AUTH=neo4j/password \
    neo4j:latest

# Access Neo4j Browser: http://localhost:7474
# Default credentials: neo4j / password
Neo4j Browser provides a visual interface for exploring graphs

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="password"
)

print("✓ Connected to Neo4j")
Result:
✓ Connected to Neo4j

Creating Nodes

# 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!")
Result:
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!")
Result:
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
ORDER BY r.since
"""

result = conn.query(query)

print("\nAll friendships:")
for record in result:
    print(f"  {record['person1']} -> {record['person2']} (since {record['since']})")
Result:
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)
  ...

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
"""

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']}")
Result:
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
RETURN DISTINCT connected.name AS person,
       length(path) AS 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']}")
Result:
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")
Result:
✓ 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()
Result:
Recommended products for Alice:
(Based on users who bought similar products)

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

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
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()
Result:
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
"""

result = conn.query(query)

print("Social network centrality:")
for record in result:
    print(f"  {record['person']}: {record['friend_count']} friends ({record['status']})")
Result:
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.location AS location, COLLECT(p.name) AS people
RETURN location,
       people,
       SIZE(people) AS member_count
ORDER BY member_count DESC
"""

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 id(a) < id(b) AND id(b) < id(c)
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']}")
Result:
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 both property graphs (Gremlin) and RDF graphs (SPARQL). It's designed for high availability and scales to billions of relationships.

Key Features

Advantages
  • Fully managed (no server maintenance)
  • High availability (Multi-AZ replication)
  • Automatic backups and point-in-time recovery
  • Supports Gremlin and SPARQL
  • Scales to billions of relationships
  • Read replicas for query scaling
Considerations
  • AWS-only (vendor lock-in)
  • More expensive than self-hosted
  • Learning curve for Gremlin
  • 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.GraphSONSerializersV2d0()
)

# 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}")
Result:
✓ Created person vertex
✓ Created friendship edge

Alice's friends:
  Bob
  Charlie

Performance: Graph vs Relational

Graph databases dramatically outperform relational databases for deep relationship queries. Here's a real-world performance comparison.

Performance Comparison: "Find friends of friends of friends"

Dataset: 1 million users, 50 million friendships

RELATIONAL DATABASE (PostgreSQL):
┌──────────────────────────────────────────────────────┐
│ SELECT DISTINCT f3.friend_id                         │
│ FROM friendships f1                                  │
│ JOIN friendships f2 ON f1.friend_id = f2.user_id     │
│ JOIN friendships f3 ON f2.friend_id = f3.user_id     │
│ WHERE f1.user_id = 12345                             │
│   AND f3.friend_id <> 12345                          │
└──────────────────────────────────────────────────────┘

Query time: 2.3 seconds
Rows scanned: 8.5 million
Index used: Yes (but multiple passes)

GRAPH DATABASE (Neo4j):
┌──────────────────────────────────────────────────────┐
│ MATCH (me:Person {id: 12345})-[:FRIENDS_WITH*3]->(p) │
│ WHERE p <> me                                        │
│ RETURN DISTINCT p                                    │
└──────────────────────────────────────────────────────┘

Query time: 12 milliseconds
Nodes traversed: ~150
Index used: Initial node lookup only

Speedup: 192x faster! 🚀

Why Graph Wins:
• Direct pointer traversal (no joins)
• Index-free adjacency (relationships stored with nodes)
• No table scans (follows edges only)
• Query time independent of total graph size
Graph databases excel at multi-hop queries (friends-of-friends-of-friends)

Scalability Characteristics

Query TypeRelational DBGraph DBWinner
Single record lookup10ms15msRelational
1-hop relationships50ms (1 JOIN)20msGraph
2-hop relationships800ms (2 JOINs)25msGraph (32x)
3-hop relationships2.3s (3 JOINs)35msGraph (66x)
4-hop relationships15s+ (4 JOINs)50msGraph (300x)
Aggregations (SUM, AVG)100ms200msRelational
Full table scan2s5sRelational
Graph Advantage: Performance improves dramatically as relationships deepen (2+ hops). At 4 degrees of separation, graph databases are 100-1000x faster than relational databases with JOIN queries.

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: 100-1000x faster for multi-hop queries
  • 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: Use graph databases when relationships are first-class citizens and queries involve multiple hops (friends-of-friends, recommendations, fraud patterns). The performance advantage grows exponentially with depth: 2 hops = 32x faster, 3 hops = 66x faster, 4+ hops = 300x+ faster than SQL JOINs. LinkedIn uses Neo4j for their social graph, Airbnb for fraud detection, NASA for knowledge graphs. Don't replace your entire database - use graphs alongside relational databases for the relationship-heavy portions. Sync data between them using CDC or event streams, getting the best of both worlds.