If you’ve spent your career writing SQL, you already know how to model the world in tables, rows, and foreign keys. But when you start working with Neo4j or other graph databases, that relational muscle memory can actually get in your way.
The biggest hurdle in learning Cypher isn’t the syntax — it’s the mental model.
This guide is designed to bridge that gap. We’re going to map what you already know (SQL) directly to what you need to learn (Cypher), starting from the basics and moving up to queries that would make a traditional relational database sweat.
Here is the only translation that really matters:
In SQL, relationships are implied by matching IDs across tables. You have to write the join logic every time.
In Cypher, relationships are physical edges saved in the database. You don’t join; you traverse.
To make this concrete, let’s look at a standard E-commerce/Social platform.
Relational Schema (What you’re used to):
users(id, name, city)products(id, name, price)orders(id, user_id, date)order_items(order_id, product_id) — join tablereviews(user_id, product_id, rating, text) — join table with metadatafollows(follower_id, followee_id) — self-join table
Graph Schema (What we’re building):
In a graph, those join tables disappear. Instead, we have Nodes (entities) and Relationships (verbs connecting them).
(:User)-[:PLACED]->(:Order)
(:Order)-[:CONTAINS]->(:Product)
(:User)-[:REVIEWS {rating, text}]->(:Product)
(:User)-[:FOLLOWS]->(:User)Before we translate, here’s how you draw graph patterns in Cypher using ASCII art.
Nodes are wrapped in parentheses:
(u:User)
uis the variable name (like a SQL alias).Useris the Label (like a table name).
Relationships are wrapped in brackets:
-[r:PLACED]->
ris the variable.PLACEDis the relationship type.->indicates the direction of the relationship.
Combine them to form a path:
(u:User)-[:PLACED]->(o:Order)
Let’s translate common SQL patterns directly into Cypher.
SQL:
SELECT id, name FROM users LIMIT 10;Cypher:
MATCH (u:User)
RETURN u.id, u.name
LIMIT 10SQL:
SELECT name FROM users WHERE city = ‘Seattle’ ORDER BY name;Cypher:
MATCH (u:User)
WHERE u.city = “Seattle”
RETURN u.name
ORDER BY u.name(Note: Cypher can also do inline filtering: MATCH (u:User {city: "Seattle"}))
SQL:
SELECT name FROM products WHERE LOWER(name) LIKE ‘%laptop%’;Cypher:
MATCH (p:Product)
WHERE toLower(p.name) CONTAINS “laptop”
RETURN p.nameForeign keys disappear here. We just describe the visual connection.
SQL:
SELECT u.name, o.id, o.date
FROM users u
JOIN orders o ON o.user_id = u.id;Cypher:
MATCH (u:User)-[:PLACED]->(o:Order)
RETURN u.name, o.id, o.dateJoin tables (order_items) completely vanish.
SQL:
SELECT o.id, p.name
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id;Cypher:
MATCH (o:Order)-[:CONTAINS]->(p:Product)
RETURN o.id, p.nameThis is where Cypher starts to look much cleaner than SQL. Let’s find out what a user actually bought.
SQL:
SELECT u.name, p.name
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id;Cypher:
MATCH (u:User)-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)
RETURN u.name, p.nameIn our graph, REVIEWS is a relationship that holds properties (rating).
SQL:
SELECT u.name, r.rating, p.name
FROM users u
JOIN reviews r ON r.user_id = u.id
JOIN products p ON p.id = r.product_id
WHERE r.rating >= 4;Cypher:
MATCH (u:User)-[r:REVIEWS]->(p:Product)
WHERE r.rating >= 4
RETURN u.name, r.rating, p.nameSQL:
SELECT u.name, o.id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;Cypher:
MATCH (u:User)
OPTIONAL MATCH (u)-[:PLACED]->(o:Order)
RETURN u.name, o.idCypher infers the grouping key automatically based on what you RETURN.
SQL:
SELECT city, COUNT(*) as user_count
FROM users
GROUP BY city;Cypher:
MATCH (u:User)
RETURN u.city, count(u) AS user_countCypher uses the WITH clause to pass intermediate results down the pipeline. Think of WITH as a temporary CTE.
SQL:
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.name
HAVING COUNT(o.id) >= 5;Cypher:
MATCH (u:User)-[:PLACED]->(o:Order)
WITH u, count(o) AS order_count
WHERE order_count >= 5
RETURN u.name, order_countSQL:
SELECT u1.name, u2.name
FROM users u1
JOIN follows f ON f.follower_id = u1.id
JOIN users u2 ON u2.id = f.followee_id;Cypher:
MATCH (u1:User)-[:FOLLOWS]->(u2:User)
RETURN u1.name, u2.nameFind users who have reviewed a specific product.
SQL:
SELECT u.name FROM users u
WHERE EXISTS (
SELECT 1 FROM reviews r
JOIN products p ON p.id = r.product_id
WHERE r.user_id = u.id AND p.name = ‘Mechanical Keyboard’
);Cypher:
MATCH (u:User)
WHERE EXISTS {
MATCH (u)-[:REVIEWS]->(:Product {name: “Mechanical Keyboard”})
}
RETURN u.nameGathering rows into a list/array is a first-class feature in Cypher.
SQL:
SELECT u.name, array_agg(p.name) as reviewed_products
FROM users u
JOIN reviews r ON r.user_id = u.id
JOIN products p ON p.id = r.product_id
GROUP BY u.name;Cypher:
MATCH (u:User)-[:REVIEWS]->(p:Product)
RETURN u.name, collect(p.name) AS reviewed_productsTranslating SQL is great, but graphs exist to solve problems that make relational databases miserable. Here are queries that are natural in Cypher but a nightmare in SQL.
Question: Who are the friends-of-friends-of-friends of Alice? (Up to 3 hops away)
In SQL, this requires recursive CTEs or heavy hardcoded union/join logic. In Cypher, it’s just a number:
MATCH (alice:User {name: “Alice”})-[:FOLLOWS*1..3]->(connection:User)
RETURN DISTINCT connection.nameQuestion: Show me the exact route of connections between User A and User B.
SQL returns rows and columns. It doesn’t understand “paths” natively. Cypher can return the whole journey as an object:
MATCH path = (u1:User {name: “Alice”})-[:FOLLOWS*]->(u2:User {name: “Dave”})
RETURN path
LIMIT 1Question: Users who bought this item also bought what else?
In SQL, this is a massive self-join against the heavy order_items table. In Cypher, it’s a simple zigzag pattern:
MATCH (target:Product {name: “Coffee Grinder”})<-[:CONTAINS]-(:Order)<-[:PLACED]-(otherUser:User)
MATCH (otherUser)-[:PLACED]->(:Order)-[:CONTAINS]->(rec:Product)
WHERE rec.id <> target.id
RETURN rec.name, count(rec) as frequency
ORDER BY frequency DESC
LIMIT 5Forgetting
LIMIT: When testing, never just runMATCH (n) RETURN n. In a large graph, the browser will try to render thousands of nodes and crash. Always useLIMIT 25.Ignoring Direction: Arrows matter.
(u)-[:PLACED]->(o)works.(u)<-[:PLACED]-(o)will return 0 rows. If you don’t care about direction, leave the arrowhead off:(u)-[:FOLLOWS]-(u2).Returning Full Nodes Instead of Properties:
RETURN usends the whole JSON object back. In production APIs, be explicit:RETURN u.id, u.name.Forgetting
DISTINCTon Multi-Hop Queries: When you traverse multiple paths, you will hit the same nodes multiple times. Always useRETURN DISTINCTunless you actually want to count the number of paths.Thinking in Tables: If your instinct is to artificially map an ID from one node into the properties of another node just to link them — stop. Create a relationship edge instead.
Want to see if it clicked? Try writing the Cypher for these questions based on our schema, without looking up the syntax!
Find the names of all Products that have a price greater than 100.
Return the names of Users who have placed an order, along with the total number of orders they’ve placed, sorted highest to lowest.
Find all Users who gave a 1-star
REVIEWto any product, and return the User’s name and the Product’s name.Challenge: Find “Mutuals” — Users who follow someone, who also follows them back.
(Hint for the challenge: You can draw arrows pointing both ways in the same MATCH block!)
Final Takeaway: Stop visualizing spreadsheets. Start visualizing whiteboards. When you map out your domain as circles connected by lines on a whiteboard, you’ve essentially already written your Cypher query.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.