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.
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!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 / passwordConnecting 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")✓ 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!")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!")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']})")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']}")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']}")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")✓ 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()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()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']})")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']}")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}")✓ 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 sizeScalability Characteristics
| Query Type | Relational DB | Graph DB | Winner |
|---|---|---|---|
| Single record lookup | 10ms | 15ms | Relational |
| 1-hop relationships | 50ms (1 JOIN) | 20ms | Graph |
| 2-hop relationships | 800ms (2 JOINs) | 25ms | Graph (32x) |
| 3-hop relationships | 2.3s (3 JOINs) | 35ms | Graph (66x) |
| 4-hop relationships | 15s+ (4 JOINs) | 50ms | Graph (300x) |
| Aggregations (SUM, AVG) | 100ms | 200ms | Relational |
| Full table scan | 2s | 5s | Relational |
Choosing Between Graph and Relational
Use this decision framework to choose the right database for your use case:
| Use Case | Best Choice | Why |
|---|---|---|
| Social network | Graph (Neo4j) | Deep relationship traversals (friends-of-friends) |
| Fraud detection | Graph (Neptune) | Identify patterns across transactions and entities |
| Recommendation engine | Graph (Neo4j) | Collaborative filtering, user similarity |
| E-commerce orders | Relational (PostgreSQL) | Transactional integrity, simple relationships |
| Knowledge graph | Graph (Neo4j/Neptune) | Entities and complex relationships |
| Access control (IAM) | Graph | Complex permission hierarchies |
| Financial reporting | Relational | Aggregations, SQL reporting tools |
| Network topology | Graph | Infrastructure dependencies, impact analysis |
| Inventory management | Relational | Simple structure, ACID requirements |
| Master data management | Hybrid | Use both: relational for data, graph for lineage |
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