Design a property-graph schema with clear identity keys, relationship semantics, and constraint strategy.
Apply uniqueness constraints so application MERGE paths cannot fork entities.
Connect with the official neo4j JavaScript driver and Python driver using parameterized queries only.
Manage Driver, Session, and transaction lifecycle—including retry-friendly transaction functions.
Implement a complete recommendation mini-project: model, seed, scoring query, integration sketch, tests, and production notes.
Keep secrets out of source code using placeholders and environment-based configuration.
Validate recommendation quality with simple offline checks before shipping.
Core concepts
Schema design for applications
Application graphs succeed when the model matches access patterns, not when it mirrors every
spreadsheet column. Start from questions: “What movies will a user like next?”, “Which users
share taste?”, “How do we ingest ratings safely?” Nodes represent stable entities
(User, Movie,
Person); relationships carry verbs and often properties
(RATED {score, at}. Avoid untyped catch-all edges.
Identity keys must be unique and immutable from the application’s perspective: a UUID or
external auth subject for users, a catalog id for movies. Display titles are not reliable
primary keys (remakes, localizations). Place uniqueness constraints on those keys. Add RANGE
indexes for frequent filters (email lookup, movie slug). Document labels and relationship
types in a short schema README so every service uses the same vocabulary.
The Movie graph you used in lessons 01–09 is a teaching dataset. Production recommendation
features typically extend it with User nodes and
RATED relationships, or a separate ratings subgraph linked
to the same movies. Keep write paths idempotent with MERGE
on constrained keys.
Official drivers, sessions, and transactions
Neo4j supports official drivers for JavaScript/TypeScript, Python, Java, .NET, and Go. This
lesson uses the JavaScript driver (neo4j-driver) and the
Python driver (neo4j package). Create
one Driver per application process. Drivers manage connection pooling and
routing to cluster members. Do not open a new driver per HTTP request.
Acquire a Session for a unit of work, then run queries either as
auto-commit statements or—preferred for writes and multi-statement logic—inside
transaction functions (session.execute_read
/ execute_write in Python;
session.executeRead /
executeWrite in JavaScript). Transaction functions are
retried automatically on transient errors when written as pure functions of the transaction
object.
Always parameterize: pass a parameters map/dict, never concatenate user input into Cypher
strings. Parameterization prevents Cypher injection and improves plan cache reuse. Close
sessions when done; close the driver on process shutdown. Configuration values such as URI,
username, and password must come from environment variables or a secrets manager—examples
below use explicit placeholders only.
JAVASCRIPT
// Official neo4j-driver — placeholders only, no real secretsimport neo4j from'neo4j-driver';
const uri = process.env.NEO4J_URI ?? 'neo4j://localhost:7687';
const user = process.env.NEO4J_USER ?? 'neo4j';
const password = process.env.NEO4J_PASSWORD ?? 'REPLACE_WITH_PASSWORD';
const driver = neo4j.driver(uri, neo4j.auth.basic(user, password));
async function moviesForActor(name) {
const session = driver.session({ defaultAccessMode: neo4j.session.READ });
try {
return await session.executeRead(async (tx) => {
const result = await tx.run(
`MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie)
RETURN m.title AS title ORDER BY title`,
{ name }
);
return result.records.map((r) => r.get('title'));
});
} finally {
await session.close();
}
}
PYTHON
# Official neo4j Python driver — placeholders onlyimport os
from neo4j import GraphDatabase, READ_ACCESS
URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687")
USER = os.environ.get("NEO4J_USER", "neo4j")
PASSWORD = os.environ.get("NEO4J_PASSWORD", "REPLACE_WITH_PASSWORD")
driver = GraphDatabase.driver(URI, auth=(USER, PASSWORD))
def movies_for_actor(name: str):
def work(tx):
result = tx.run(
"""
MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie)
RETURN m.title AS title ORDER BY title
""",
name=name,
)
return [record["title"] for record in result]
with driver.session(default_access_mode=READ_ACCESS) as session:
return session.execute_read(work)
Recommendation mini-project overview
Collaborative filtering on a graph: users who rated the same movies similarly can suggest
unseen titles. Model:
(:User {userId}) with uniqueness on userId
(:Movie {movieId, title}) uniqueness on movieId
(:User)-[:RATED {score}]->(:Movie) with score in 1–5
Seed a few users and ratings, then score candidate movies by counting distinct co-raters or
summing neighbor scores. Production systems add normalization, time decay, content features,
and online metrics—but the graph pattern is the durable core. Integrate the scoring Cypher
behind a read transaction in your API, cache top-N when needed, and test with fixed seed data.
Shared RATED edges create collaborative-filtering paths from a target user to candidate movies.
Worked examples
Dataset note: Expected results use the classic Neo4j Movie graph (37 movies,
133 people) plus the seed ratings below, and can differ if the data changes.
01
Schema constraints and seed ratings
Create constraints, merge users, attach stable movieId values to
existing classic titles, and write RATED edges. Merge the
relationship without baking score into the pattern—set the score
afterward so re-runs update the property instead of creating duplicate relationships when scores differ.
CYPHER
CREATE CONSTRAINT user_id IF NOT EXISTSFOR (u:User) REQUIRE u.userId IS UNIQUE;
CREATE CONSTRAINT movie_id IF NOT EXISTSFOR (m:Movie) REQUIRE m.movieId IS UNIQUE;
MERGE (u1:User {userId: 'u-alice'}) SET u1.name = 'Alice'MERGE (u2:User {userId: 'u-bob'}) SET u2.name = 'Bob'MERGE (u3:User {userId: 'u-cara'}) SET u3.name = 'Cara'WITH u1, u2, u3
MATCH (matrix:Movie {title: 'The Matrix'})
MATCH (cloud:Movie {title: 'Cloud Atlas'})
MATCH (topgun:Movie {title: 'Top Gun'})
SET matrix.movieId = coalesce(matrix.movieId, 'm-matrix'),
cloud.movieId = coalesce(cloud.movieId, 'm-cloud'),
topgun.movieId = coalesce(topgun.movieId, 'm-topgun')
MERGE (u1)-[r1:RATED]->(matrix) SET r1.score = 5MERGE (u1)-[r2:RATED]->(cloud) SET r2.score = 4MERGE (u2)-[r3:RATED]->(matrix) SET r3.score = 5MERGE (u2)-[r4:RATED]->(topgun) SET r4.score = 5MERGE (u3)-[r5:RATED]->(cloud) SET r5.score = 4MERGE (u3)-[r6:RATED]->(topgun) SET r6.score = 3RETURN u1.userId AS a, u2.userId AS b, u3.userId AS c
Expected result
a
b
c
u-alice
u-bob
u-cara
02
Scoring query for Alice
Find movies Alice has not rated, scored from users who share at least one highly rated movie
with her (r.score >= 4 and r2.score >= 4).
The candidate edge is also filtered with WHERE r3.score >= 4 so
only strong neighbor ratings contribute—Cara’s score-3 rating of Top Gun is ignored, leaving
Bob’s score-5 as the sole supporter (score 5, supporters 1). Without that strong-rating filter
the sum would be 8 from two supporters.
CYPHER
MATCH (me:User {userId: 'u-alice'})-[r:RATED]->(m:Movie)
WHERE r.score >= 4MATCH (other:User)-[r2:RATED]->(m)
WHERE other <> me AND r2.score >= 4MATCH (other)-[r3:RATED]->(rec:Movie)
WHERE rec <> m
AND NOT (me)-[:RATED]->(rec)
AND r3.score >= 4RETURN rec.title AS title,
sum(r3.score) AS score,
count(DISTINCT other) AS supporters
ORDER BY score DESC, supporters DESC, title
LIMIT5
Expected result
title
score
supporters
Top Gun
5
1
03
JavaScript integration of the scoring query
Parameterized read transaction returning plain objects for an HTTP handler. No embedded secrets.
JAVASCRIPT
async function recommendFor(userId, limit = 5) {
const session = driver.session({ defaultAccessMode: neo4j.session.READ });
const cypher = `
MATCH (me:User {userId: $userId})-[r:RATED]->(m:Movie)
WHERE r.score >= 4
MATCH (other:User)-[r2:RATED]->(m)
WHERE other <> me AND r2.score >= 4
MATCH (other)-[r3:RATED]->(rec:Movie)
WHERE rec <> m AND NOT (me)-[:RATED]->(rec) AND r3.score >= 4
RETURN rec.title AS title,
sum(r3.score) AS score,
count(DISTINCT other) AS supporters
ORDER BY score DESC, supporters DESC, title
LIMIT $limit`;
try {
return await session.executeRead(async (tx) => {
const result = await tx.run(cypher, {
userId,
limit: neo4j.int(limit),
});
return result.records.map((rec) => ({
title: rec.get('title'),
score: rec.get('score'),
supporters: rec.get('supporters').toNumber?.() ?? rec.get('supporters'),
}));
});
} finally {
await session.close();
}
}
Expected semantic result for userId u-alice
title
score
supporters
Top Gun
5
1
04
Python write path for a new rating
Idempotent rating upsert inside execute_write.
MATCH the constrained movie by movieId
so a missing id returns no row instead of silently creating a title-less movie.
Users may still be merged; relationship score is set after merge.
PYTHON
def upsert_rating(user_id: str, movie_id: str, score: int):
cypher = """
MATCH (m:Movie {movieId: $movie_id})
MERGE (u:User {userId: $user_id})
MERGE (u)-[r:RATED]->(m)
SET r.score = $score
RETURN u.userId AS userId, m.movieId AS movieId, r.score AS score
"""def work(tx):
record = tx.run(
cypher,
user_id=user_id,
movie_id=movie_id,
score=score,
).single()
if record is None:
raise ValueError(f"unknown movieId: {movie_id}")
return dict(record)
with driver.session() as session:
return session.execute_write(work)
# Example call with placeholders / test ids only# upsert_rating("u-alice", "m-topgun", 5)
Expected result of upsert_rating("u-alice", "m-topgun", 5)
userId
movieId
score
u-alice
m-topgun
5
Testing and production notes
How to ship the mini-project safely
Testing. Load a fixture graph (constraints + seed MERGEs) in a disposable
database or Aura free instance. Assert that Alice receives Top Gun before she rates it, and
that after she rates it the title disappears from recommendations. Integration tests should
spin a session, run Cypher, and never print passwords. Prefer testcontainers or an ephemeral
DBMS when CI allows.
Production. Store NEO4J_URI,
NEO4J_USER, and NEO4J_PASSWORD
in a secrets manager. Enable TLS-capable URIs (neo4j+s:// or
bolt+s://) for hosted services. Set connection pool size to
match concurrent request levels. Use read replicas routing for pure recommend paths when on
clusters. Monitor latency and failure rates; PROFILE the scoring query on realistic
cardinality and add indexes on User(userId) and
Movie(movieId) (constraints already provide them).
Security. Never embed API keys, passwords, or cloud tokens in committed
source, lesson notes, or client-side JavaScript bundles. Rate-limit recommendation endpoints,
authenticate callers, and authorize that userId in the query
matches the authenticated subject unless an admin role is present.
Evolution. When you outgrow simple co-rating, consider storing normalized
preference weights, separating implicit plays from explicit stars, or hybridizing with
content features (genres as nodes). The driver lifecycle and parameterization rules do not
change as the Cypher grows more sophisticated.
Pitfalls
One driver per request
Creating drivers in a request handler exhausts resources. Share a process-wide driver and open short-lived sessions instead.
String-built Cypher
Concatenating user ids into query text is an injection risk and breaks plan caching. Always pass parameters.
Missing uniqueness on MERGE keys
Concurrent MERGE without constraints can create duplicate users or movies under race conditions.
Committing real secrets into samples
Tutorial code must use placeholders such as REPLACE_WITH_PASSWORD.
Rotate any credential that ever touched a repository.
Exercises
1. Constraint pair
Write Cypher that ensures uniqueness for User.userId and Movie.movieId using IF NOT EXISTS.
STARTER
CREATE CONSTRAINT ...
CREATE CONSTRAINT ...
2. Recommend for Bob
Adapt the scoring query to u-bob and predict which title should surface given the seed data.
STARTER
MATCH (me:User {userId: 'u-bob'})-[r:RATED]->(m:Movie)
// continue co-rating pattern
3. Parameterized person lookup in JS
Sketch a JavaScript executeRead that looks up a Person by $name and returns born year. Use env placeholders for auth.
Extend upsert_rating so scores outside 1–5 raise a ValueError before opening a session.
STARTER
def upsert_rating(user_id: str, movie_id: str, score: int):
if score < 1or score > 5:
raise ValueError("score must be 1..5")
# then open session / execute_write
Lesson quiz
Five questions. Submit once to see your score and the full answer key.