The 60 Neo4j questions interviewers actually ask, with direct answers, runnable Cypher, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Neo4j is a native graph database that models data as nodes (entities), relationships (the connections between them), and properties (key-value data on both). Instead of splitting connected data across tables and reassembling it with JOINs, Neo4j stores relationships directly on disk, so traversing from one node to its neighbors is a constant-time pointer hop no matter how large the dataset grows. You query it with Cypher, a declarative language where you draw the pattern you want using ASCII-art arrows like (a)-[:KNOWS]->(b). In interviews, Neo4j questions probe how well you understand the property graph model, Cypher pattern matching, index and constraint design, and when a graph beats a relational store, not memorized syntax. This page collects the 60 questions that come up most, each with a direct answer and runnable Cypher. If you're still building fundamentals, the official Getting Started guide from Neo4j is the canonical path; increasingly the first database round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Neo4j Course for Beginners
Video: Neo4j Course for Beginners (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Neo4j certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Neo4j is a native graph database that stores data as nodes, relationships, and properties instead of tables. It's built for connected data: social networks, recommendations, fraud detection, and anything where the links between records matter as much as the records.
It solves the problem relational databases struggle with: querying many hops of relationships. Where a SQL query needs a JOIN per hop, Neo4j stores relationships directly and follows them like pointers, so traversal stays fast as the graph grows.
Key point: Lead with the one-line definition, then The concrete use case. Interviewers open with this to hear how you frame connected data versus tables.
Watch a deeper explanation
Video: Training Series: Introduction to Neo4j (Neo4j, YouTube)
The property graph model has three parts. Nodes are the entities (a Person, a Product), relationships connect two nodes and always carry a type and a direction, and properties are key-value pairs stored on both nodes and relationships. Labels group nodes into categories on top of that, and together these pieces describe both the things and the links between them.
Labels group nodes into categories, like :Person or :Movie, and one node can carry several labels. This is what makes Cypher patterns readable: you describe entities, the links between them, and the data on each.
CREATE (asha:Person {name: 'Asha', age: 31})
CREATE (hyring:Company {name: 'Hyring'})
CREATE (asha)-[:WORKS_AT {since: 2023}]->(hyring)Key point: The all three parts (nodes, relationships, properties) plus labels. Skipping labels is the usual sign someone has only read about Neo4j, not used it.
Cypher is Neo4j's declarative query language. You describe the pattern you want with ASCII-art arrows, and the engine figures out how to find it. So (a)-[:KNOWS]->(b) reads almost like a diagram of two people connected by a KNOWS relationship, which is what makes reading and writing graph queries feel visual rather than abstract.
It borrows ideas from SQL (clauses like WHERE, ORDER BY, and aggregation) but centers on graph patterns. The core read clause is MATCH, and the core write clauses are CREATE and MERGE.
MATCH (p:Person)-[:KNOWS]->(friend:Person)
WHERE p.name = 'Asha'
RETURN friend.nameWatch a deeper explanation
Video: Neo4j Cypher Basics: Reading and Writing Data (Neo4j, YouTube)
A node represents an entity, something that exists on its own: a person, a product, an account. A relationship connects exactly two nodes, always has a type (like :BOUGHT), and always has a direction from a start node to an end node, so it describes how two entities relate rather than existing independently the way a node does.
Both can hold properties. A relationship's properties often describe the connection itself, like a rating on a :REVIEWED relationship or a date on :WORKS_AT.
Key point: The detail the question needs: relationships always have a direction stored, even though you can query them ignoring direction. Say both facts.
A label is a tag that groups nodes into a category, written with a colon like :Person. Labels let you scope queries to one kind of node, and they're what indexes and constraints attach to, so labeling nodes well is the foundation of both fast lookups and data integrity.
A node can have zero, one, or several labels at once. A node might be both :Person and :Employee, and a query can match on either.
MATCH (e:Person:Employee)
RETURN e.name
// add a label to an existing node
MATCH (p:Person {name: 'Asha'})
SET p:ManagerCREATE always adds new data, so run it twice with the same values and you get two identical nodes. MERGE is get-or-create: it looks for the pattern, uses it if it already exists, and creates it only if it doesn't. That difference is what makes MERGE the safe choice when importing records that might already be in the graph.
Use CREATE when you know the data is new, and MERGE when you want to avoid duplicates, like importing records that may already exist. MERGE pairs with ON CREATE SET and ON MATCH SET to set properties differently in each case.
MERGE (p:Person {email: 'asha@example.com'})
ON CREATE SET p.created = timestamp()
ON MATCH SET p.lastSeen = timestamp()
RETURN pKey point: The trap is calling MERGE 'CREATE if not exists' and stopping. Mention that MERGE matches on the WHOLE pattern, so a partial MERGE can create more than you expect.
Watch a deeper explanation
Video: Introduction to Neo4j: a hands-on crash course (Neo4j, YouTube)
MATCH finds patterns in the graph. You draw nodes and relationships as a pattern, and Cypher returns every subgraph that fits it. It's the read counterpart to CREATE and the starting point of almost every query, because you have to locate data before you can filter, return, update, or delete it.
MATCH followed by WHERE filters results, and RETURN shapes the output. Without a MATCH, there's nothing to filter or return from an existing graph.
MATCH (m:Movie)<-[:ACTED_IN]-(a:Person)
WHERE m.released > 2000
RETURN m.title, collect(a.name) AS castHow a Cypher MATCH query runs
Start MATCH from the most selective node so the traversal touches the fewest paths.
RETURN specifies what a query sends back: node properties, whole nodes, relationships, paths, or computed values. It's the last clause in a read query and shapes the result set, similar to SELECT in SQL, so choosing what to RETURN is how you turn a matched pattern into exactly the columns your application needs.
You can return expressions, alias columns with AS, and use DISTINCT to drop duplicate rows. Write queries that only fail don't RETURN anything, like a pure CREATE or DELETE.
MATCH (p:Person)
RETURN DISTINCT p.city AS city, count(*) AS people
ORDER BY people DESCWHERE filters the rows produced by MATCH, keeping only those that meet a condition. It supports comparisons, boolean logic with AND and OR, string predicates like STARTS WITH and CONTAINS, list membership with IN, and existence checks on whole patterns, so you can filter on structure as well as on property values.
A useful trick: WHERE can also filter on whether a pattern exists, so you can keep only people who have (or don't have) a certain relationship without returning it.
MATCH (p:Person)
WHERE p.age > 25 AND p.name STARTS WITH 'A'
AND EXISTS { (p)-[:WORKS_AT]->(:Company) }
RETURN p.nameFirst match the two nodes you want to connect, then use CREATE or MERGE with the relationship pattern between them. The relationship needs a type and a direction, and it can carry its own properties like a date or a weight. Matching the nodes before connecting them is what keeps you from accidentally creating duplicates.
A common beginner bug is running CREATE on the full pattern when the nodes already exist, which duplicates the nodes. Match them first, then create only the relationship.
MATCH (a:Person {name: 'Asha'}), (b:Person {name: 'Ben'})
MERGE (a)-[:KNOWS {since: 2021}]->(b)Key point: Show that you MATCH existing nodes before connecting them. Creating the whole pattern in one CREATE is the classic duplicate-node mistake.
DELETE removes a node or relationship, but Neo4j refuses to delete a node that still has relationships, to protect graph integrity and avoid dangling connections. DETACH DELETE removes the node and all its relationships in one step, which is what you want when you're deleting an entity that's still connected to the rest of the graph.
For relationships alone, plain DELETE is fine. For nodes with connections, you either delete the relationships first or use DETACH DELETE.
// fails if the person has any relationships
MATCH (p:Person {name: 'Asha'}) DELETE p
// removes the node and its relationships
MATCH (p:Person {name: 'Asha'}) DETACH DELETE pWatch a deeper explanation
Video: Neo4j Course for Beginners (freeCodeCamp.org, YouTube)
SET assigns properties or adds labels: SET p.age = 32 updates a property, SET p:VIP adds a label, and SET p += {city: 'Pune'} merges a map of properties into the node. REMOVE does the opposite, deleting a named property or stripping a label, so together they cover every in-place change to existing nodes.
SET with = on a map replaces all properties, while += merges. Mixing those up is a frequent source of accidentally wiped properties.
MATCH (p:Person {name: 'Asha'})
SET p.age = 32, p += {city: 'Pune'}
SET p:VIP
REMOVE p.tempFlagEvery relationship is stored with a direction, from a start node to an end node, and there's no such thing as a truly undirected relationship on disk. Direction always exists physically even when your query chooses to ignore it, which is why you model direction to carry meaning like who follows whom.
But you can query ignoring direction by omitting the arrow: (a)-[:KNOWS]-(b) matches the relationship whichever way it points. So you store direction once and read it either way.
// direction ignored in the match
MATCH (a:Person {name: 'Asha'})-[:KNOWS]-(other)
RETURN other.nameKey point: The precise answer is 'stored directed, queryable undirected.' Saying relationships can be undirected is a small but noticed error.
Both are declarative, and Cypher deliberately reuses SQL ideas so WHERE, ORDER BY, LIMIT, and aggregation all feel familiar. The big difference is how you express connections: SQL matches keys with JOINs at query time, while Cypher draws the relationship directly as a pattern and follows it as a stored link, which is why multi-hop queries stay short and fast.
A multi-hop query that needs several JOINs in SQL becomes a single readable pattern in Cypher, and it stays fast because Neo4j follows stored relationships instead of matching keys at query time.
| Concept | SQL | Cypher |
|---|---|---|
| Find records | SELECT ... FROM | MATCH ... RETURN |
| Connect data | JOIN ON keys | Pattern with -[:REL]-> |
| Filter | WHERE | WHERE |
| Insert | INSERT INTO | CREATE / MERGE |
A graph is a set of nodes connected by relationships, the mathematical structure behind networks: cities linked by roads, people linked by friendships, web pages linked by hyperlinks. It captures both the things and the connections between them, which is exactly the shape of data where relationships carry as much meaning as the entities.
Neo4j is a property graph specifically, meaning nodes and relationships carry properties and relationships are typed and directed. That's richer than a plain graph of anonymous edges.
Recommendation engines (people who bought this also bought), fraud detection (spotting rings of connected accounts), social networks, network and IT operations mapping, identity and access graphs, and the knowledge graphs behind search and AI systems. Each of these lives or dies on relationship queries, which is why they land on a graph rather than a set of tables.
The common thread is that the value lives in the connections. If your important questions are about how things relate across several hops, a graph fits.
Key point: Pick two use cases and explain why the graph helps, not just name-drop. 'Fraud detection because you traverse rings of shared attributes' beats a list.
ORDER BY sorts the result rows by one or more expressions, ascending by default or DESC for descending. LIMIT caps how many rows come back, and SKIP discards rows from the start, so combining SKIP and LIMIT is how you page through a large result set one screen at a time.
The order is ORDER BY, then SKIP, then LIMIT. Getting the sequence right matters when you page through results.
MATCH (p:Person)
RETURN p.name, p.age
ORDER BY p.age DESC
SKIP 10 LIMIT 5Aggregation functions like count(), sum(), avg(), min(), max(), and collect() group rows automatically. Any non-aggregated expression in the same RETURN or WITH becomes a grouping key, so you get grouped results without ever writing an explicit GROUP BY the way you would in SQL.
collect() is the graph-specific favorite: it gathers values into a list, which is how you fold a node's neighbors into one row.
MATCH (a:Person)-[:ACTED_IN]->(m:Movie)
RETURN a.name, count(m) AS films, collect(m.title) AS titles
ORDER BY films DESCNeo4j Browser is the built-in web tool for running Cypher and visualizing results as a graph. You type queries, see nodes and relationships drawn out, and inspect properties, which makes it the default place to explore a database and test queries.
It's separate from Neo4j Desktop (which manages database instances) and Bloom (a business-user visualization tool). In interviews people usually mean Browser when they say they explored the graph.
WITH passes results from one part of a query to the next, like a pipe between stages. It lets you aggregate, filter, or reshape data mid-query and then keep going with the reduced result. Anything you don't carry through WITH is dropped from scope, which is both its rule and the source of most WITH confusion.
The common use is filtering on an aggregate: you can't put count() in WHERE directly, so you aggregate with WITH, then filter the result.
MATCH (p:Person)-[:KNOWS]->(friend)
WITH p, count(friend) AS friends
WHERE friends > 5
RETURN p.name, friendsKey point: The 'filter on an aggregate' example is what interviewers hope you reach for. It shows you understand WHY WITH exists, not just that it passes variables along.
On both nodes and relationships. A property is a key-value pair, and values can be strings, numbers, booleans, temporal types, spatial points, or arrays of those. Neo4j doesn't store nested maps as property values, so anything structured beyond an array usually becomes its own related node rather than a field.
Storing data on relationships is a real modeling tool: a rating on :REVIEWED, a weight on :ROUTE, or a since date on :WORKS_AT keeps information about the connection where it belongs.
DISTINCT removes duplicate rows from a result. Because a graph pattern can reach the same node through several different paths, a plain RETURN often repeats the same value many times, and DISTINCT collapses those down to one row each so the output reflects unique matches rather than the number of paths that led to them.
It applies to whole rows in RETURN, and it also works inside aggregations like count(DISTINCT x), which counts unique values rather than every occurrence.
MATCH (p:Person)-[:VISITED]->(c:City)
RETURN DISTINCT c.name AS city
// unique cities per person
MATCH (p:Person)-[:VISITED]->(c:City)
RETURN p.name, count(DISTINCT c) AS citiesKey point: Mention count(DISTINCT x), not just row-level DISTINCT. Interviewers use this to check whether you understand why patterns produce duplicate rows.
For candidates with working experience: Cypher depth, data modeling, indexing, and the questions that separate users from understanders.
Index-free adjacency means each node stores direct physical pointers to its relationships and neighboring nodes. To hop from a node to its neighbors, Neo4j just follows those pointers rather than looking anything up in a global index, so a traversal is closer to pointer chasing in memory than to a database search.
This is why traversal cost depends on how many relationships you walk, not on how large the whole graph is. A friend-of-friend query on a graph of ten million people costs the same as on ten thousand, which relational JOINs can't match.
Key point: This is the single most important internals concept. If you can explain WHY deep traversals stay fast, you've answered the 'why not just use SQL' question before it's asked.
A relationship pattern with *min..max walks a variable number of hops instead of a fixed one. [:KNOWS*1..3] matches paths of one to three KNOWS relationships, [:KNOWS*] matches any depth, and [:KNOWS*2] matches exactly two hops, so one pattern can express reachability across a range of distances.
These power reachability and friend-of-friend queries. Leave the bounds off and a big graph can explode into huge path counts, so set an upper bound and consider shortestPath for the shortest connection.
MATCH path = (a:Person {name: 'Asha'})-[:KNOWS*1..3]->(b:Person)
WHERE b.name = 'Ben'
RETURN path
LIMIT 5Key point: Unbounded * on a dense graph is a real performance trap. the upper bound and shortestPath matters.
Use the shortestPath() function around a variable-length pattern, and it returns one shortest path between the two matched nodes. allShortestPaths() returns every path tied for shortest instead of just one, which matters when several routes share the same minimum length and you need to see all of them.
shortestPath is optimized and stops early, so it's far cheaper than matching all variable-length paths and sorting by length. Reach for it whenever the question is 'how are these two connected.'
MATCH (a:Person {name: 'Asha'}), (b:Person {name: 'Ben'})
MATCH p = shortestPath((a)-[:KNOWS*]-(b))
RETURN p, length(p) AS hopsNeo4j 5 has range indexes (the general-purpose default for equality and range lookups), text indexes (for STRING predicates like CONTAINS and ENDS WITH), point indexes (spatial), full-text indexes (analyzed search), and lookup indexes on labels and relationship types. You pick the index type by the kind of predicate the query runs, and range is the one you reach for most.
You need an index on any property you use to find starting nodes, like an id or email. Without one, the query scans every node with the label. Indexes speed up finding entry points, not the traversal itself, which is already fast.
CREATE INDEX person_email IF NOT EXISTS
FOR (p:Person) ON (p.email)
// verify it is used
EXPLAIN MATCH (p:Person {email: 'asha@example.com'}) RETURN pKey point: The key distinction: indexes help you FIND anchor nodes, traversal is fast on its own. Candidates who think indexes speed up relationship-following don't get the model.
Node property uniqueness (no two :Person nodes share the same email), node and relationship property existence (the property must be present, an Enterprise feature), and node key constraints (a combination of properties is unique and present, also Enterprise). These are the tools that enforce data integrity in a schema-flexible store where nothing else forces a shape.
A uniqueness constraint also creates a backing index, so it enforces integrity and speeds lookups at once. It's the standard way to guarantee, for example, one node per user id.
CREATE CONSTRAINT person_email_unique IF NOT EXISTS
FOR (p:Person) REQUIRE p.email IS UNIQUEEXPLAIN shows the planner's chosen execution plan with estimated rows, without ever running the query. PROFILE actually runs it and reports the real rows and db hits per operator, which is how you find the step that's actually expensive rather than the one you assumed was. So EXPLAIN is for a quick plan check and PROFILE is for measuring.
Use EXPLAIN to sanity-check that a query hits an index before running it against production, and PROFILE to measure and tune. The metric to watch is db hits: fewer is better.
| EXPLAIN | PROFILE | |
|---|---|---|
| Runs the query | No | Yes |
| Row counts | Estimated | Actual |
| Reports db hits | No | Yes |
| Use for | Plan check before running | Measuring real cost |
Key point: Say PROFILE runs the query and EXPLAIN doesn't. People who confuse the two haven't tuned a real query.
collect() folds many rows into a single list, and UNWIND does the reverse by expanding a list into one row per element. Together they let you group data into lists and then flatten it back out, and they're the pair you use constantly, from reshaping results to importing a batch of records passed in as one list parameter.
UNWIND is also the standard way to process a parameter list, like importing many records from a single query, by unwinding the list and creating a node per element.
UNWIND [{name: 'Asha'}, {name: 'Ben'}] AS row
MERGE (:Person {name: row.name})
// collect is the inverse
MATCH (p:Person)-[:KNOWS]->(f)
RETURN p.name, collect(f.name) AS friendsOPTIONAL MATCH is the graph version of a left outer join. It tries to match a pattern, and when nothing matches, it returns nulls for that part instead of dropping the row entirely. That's the difference from a plain MATCH, which would silently discard any row where the pattern failed to match anything.
Use it when you want every node from the first match even if the optional part is missing, like listing all people with their manager where some people have no manager.
MATCH (p:Person)
OPTIONAL MATCH (p)-[:REPORTS_TO]->(mgr:Person)
RETURN p.name, mgr.name AS managerKey point: The 'left outer join' framing lands instantly with anyone from a SQL background and shows you understand the null behavior.
Turn nouns into nodes and verbs into relationships. A relational join table that only holds two foreign keys usually becomes a direct relationship in the graph, which removes a whole table and the JOINs through it. So instead of normalizing into many tables, you model the domain the way you'd draw it on a whiteboard.
Ask what questions you'll ask most. Model so the frequent traversals are short and direct. Attributes you filter or return live as properties; things you traverse to live as related nodes.
Parameters replace literal values with named placeholders like $email. They let Neo4j reuse a single compiled query plan across many calls instead of replanning for every new value, which is a real performance win under load because query planning isn't free and repeats add up fast on a busy service.
They also prevent Cypher injection, the graph equivalent of SQL injection, by keeping user input out of the query text. Application code should always parameterize, never build query strings.
// parameter passed from the driver as {email: 'asha@example.com'}
MATCH (p:Person {email: $email})
RETURN pKey point: Mention both reasons: plan-cache reuse AND injection safety. Naming only one is a partial answer.
LOAD CSV reads a CSV file row by row and lets you build the graph in Cypher. WITH HEADERS gives you named columns to reference, and the usual shape is to MERGE nodes on a stable key and then MERGE the relationships between them, so re-running the import updates rather than duplicating data.
For large files, wrap it in CALL { ... } IN TRANSACTIONS (or the older USING PERIODIC COMMIT) so it commits in batches instead of one giant transaction that exhausts memory. Create constraints first so the MERGE lookups are indexed.
LOAD CSV WITH HEADERS FROM 'file:///people.csv' AS row
CALL {
WITH row
MERGE (p:Person {id: row.id})
SET p.name = row.name
} IN TRANSACTIONS OF 1000 ROWSAPOC (Awesome Procedures On Cypher) is Neo4j's standard extension library of procedures and functions. It covers work that plain Cypher can't express directly: dynamic labels and relationship types decided at runtime, JSON and data import helpers, graph refactoring, and batching large operations with apoc.periodic.iterate. Think of it as the utility belt most real projects end up depending on.
Reach for it when you need dynamic behavior (a relationship type decided at runtime) or utility operations that would be painful in plain Cypher. It's installed separately, so confirm it's available before relying on it.
// create a relationship whose type is a variable
MATCH (a:Person {name: 'Asha'}), (b:Company {name: 'Hyring'})
CALL apoc.create.relationship(a, 'WORKS_AT', {since: 2023}, b) YIELD rel
RETURN relNeo4j is ACID. Every Cypher statement runs inside a transaction that either commits fully or rolls back on error, so the graph never ends up half-updated. Drivers let you group several statements into one explicit transaction when you need them to succeed or fail together, which is how you keep related writes consistent.
For big writes, you split work into batches so a single transaction doesn't hold too much in memory. CALL { ... } IN TRANSACTIONS runs sub-batches, each committing on its own.
Traversing a relationship costs the same in either direction, because each node stores its relationships both ways. So you model direction for meaning (a FOLLOWS b is not the same as b FOLLOWS a), never for speed, and you store the relationship once rather than adding a mirror copy to make traversal faster.
Pick the direction that reads naturally for your domain, store it once, and query with or without the arrow as each question needs. Don't create two relationships to represent one undirected link.
Key point: The 'model for meaning, not speed' point plus 'don't double up relationships' is the mature answer. It shows you know traversal is symmetric in cost.
Cypher has no GROUP BY. When a RETURN or WITH mixes aggregation functions with plain expressions, the plain expressions automatically become the grouping keys and the aggregation runs once per group. It's concise, but it also means adding an extra non-aggregated column silently changes how your results are grouped.
So RETURN p.city, count(*) groups by city. This is elegant but a common surprise: adding an extra non-aggregated column silently changes the grouping.
MATCH (p:Person)-[:LIVES_IN]->(c:City)
RETURN c.name AS city, count(p) AS residents
ORDER BY residents DESCA property that was never set is simply absent, and reading it returns null. There's no schema forcing every node to carry every property, so two :Person nodes can hold completely different property sets. That flexibility is a feature of the model, but it means your queries have to handle missing properties rather than assume them.
null propagates through expressions and comparisons: null = null is null, not true. Use IS NULL and IS NOT NULL to test, and coalesce() to supply a default. OPTIONAL MATCH is the main source of intentional nulls.
MATCH (p:Person)
RETURN p.name, coalesce(p.nickname, p.name) AS display
ORDER BY displayScalar functions like coalesce() and size(); list functions like range() and reduce(); string functions like toLower() and split(); path functions like length() and nodes(); and predicate functions all() and any() that test a condition across a list. You don't need them memorized, but knowing which category solves a problem speeds up query writing a lot.
For counting, count() is aggregation; for the number of elements in a list, use size(). Confusing those two is a frequent stumble.
Directly, with a relationship. Where a relational schema needs a junction table (a student_course table holding two foreign keys), Neo4j uses one typed relationship: (student)-[:ENROLLED_IN]->(course), with no extra table and no JOIN through it. That single modeling move is one of the clearest wins a graph gives you over a relational store.
If the connection itself has attributes, like a grade or an enrollment date, they become properties on that relationship. If it has its own identity and further connections, you promote it to a node instead.
MATCH (s:Student {id: $sid}), (c:Course {code: $code})
MERGE (s)-[e:ENROLLED_IN]->(c)
SET e.enrolledOn = date(), e.grade = nullKey point: The 'junction table becomes one relationship' insight is exactly what a modeling question is screening for. Add the 'promote to a node when it has its own identity' nuance for production signal.
CASE returns a value based on conditions, like a conditional expression. The simple form compares one value against options; the generic form (CASE WHEN condition THEN result) evaluates arbitrary boolean tests. It's how you branch inside a RETURN or SET.
MATCH (p:Person)
RETURN p.name,
CASE
WHEN p.age < 18 THEN 'minor'
WHEN p.age < 65 THEN 'adult'
ELSE 'senior'
END AS bracketRun PROFILE and read the operators bottom to top, looking for the ones with the most db hits and rows. The usual culprits are a full label scan where an index should anchor the query, an unbounded variable-length path, or a cartesian product from two disconnected patterns.
Tuning a slow Cypher query
The goal is fewer db hits at each step. Re-PROFILE after every change so you know which one helped.
Key point: the method (PROFILE, find the hot operator, fix, re-measure) more than any single trick is the technical point.
Applications use official drivers (Python, Java, JavaScript, Go, .NET, and more) that speak Bolt, a binary protocol built for Neo4j. You create one driver, open sessions from it, run parameterized Cypher inside transactions, and read back records. The driver handles connection pooling, so the pattern is one long-lived driver per application, not one per request.
The driver manages a connection pool, so you create one driver per application and reuse it. HTTP access exists too, but Bolt is the default for performance.
from neo4j import GraphDatabase
driver = GraphDatabase.driver('bolt://localhost:7687', auth=('neo4j', 'password'))
with driver.session() as session:
result = session.run(
'MATCH (p:Person {email: $email}) RETURN p.name AS name',
email='asha@example.com')
print([r['name'] for r in result])advanced rounds probe internals, scaling, design judgment, and production scars. Expect every answer here to draw a follow-up.
Neo4j uses fixed-size record stores: a node store, a relationship store, a property store, and label and token stores. Fixed-size records mean a node's position is a simple offset, so the engine jumps straight to it. Each relationship record links back to both its nodes and to the previous and next relationships in each node's chain.
That doubly linked relationship chain is what makes index-free adjacency work physically: following a node's relationships is pointer arithmetic through the store, not a lookup. Understanding this is what separates a Neo4j user from someone who knows the engine.
Key point: the storage layout back connects to index-free adjacency. The whole point of fixed-size records and relationship chains is fast pointer traversal.
Vertically first, since a graph traversal benefits from RAM: size the page cache to hold the working set, and the hot graph is served from memory. Horizontally, Neo4j Enterprise uses a causal cluster with one primary (writes) and read replicas (reads), giving read scale-out and high availability.
For write scale-out and datasets past one machine, Neo4j Fabric shards the graph across databases and queries them together. Sharding a connected graph is genuinely hard, so the honest answer is that most workloads scale up and read-replicate long before they shard.
Key point: The mature note is that sharding a connected graph is hard, so most teams scale up and read-replicate first. Saying 'just shard it' signals inexperience.
A single instance is fully ACID: transactions are serializable and durable. In a causal cluster, writes go to a primary and replicate to others via the Raft protocol, so the cluster is strongly consistent for writes but reads on replicas can lag slightly.
Neo4j solves the read-your-own-writes problem with bookmarks: after a write, the driver gets a bookmark, and passing it to the next read guarantees that read sees at least that write. That's the causal in causal cluster.
Key point: Bookmarks and causal consistency are the senior-level detail. Anyone can say ACID; explaining read-your-writes across replicas shows real cluster experience.
A cartesian product happens when a single MATCH contains two patterns with no shared variable connecting them. Neo4j pairs every match of the first with every match of the second, so the row count multiplies and the query crawls. The planner even warns about it.
Fix it by connecting the patterns through a relationship, or by splitting them with WITH so each is evaluated and reduced before the next. If you truly need the cross product, make that intent explicit.
// accidental cartesian product: a and b are unrelated
MATCH (a:Person), (b:Company) RETURN a, b
// fixed: connect them, or use WITH to sequence
MATCH (a:Person)-[:WORKS_AT]->(b:Company) RETURN a, bThe planner inserts an Eager operator when a query reads and writes the same data in a way where lazy streaming could corrupt results. Eager forces the whole upstream result to materialize before the write runs, which protects correctness but can blow up memory on a large write.
It commonly appears in LOAD CSV imports that both MATCH and CREATE overlapping patterns. You spot it in PROFILE, then restructure the query, often splitting reads and writes into separate passes, to remove it.
Key point: Knowing Eager exists, why the planner adds it, and how to spot it in PROFILE is a strong production signal. Most candidates have never heard of it.
A supernode is a node with an enormous number of relationships, like a celebrity followed by millions or a popular tag on every article. Traversing through it forces the engine to scan a huge relationship list, which slows queries that touch it.
Handling it: rely on Neo4j's dense-node storage that groups relationships by type and direction so it can skip to the relevant group, filter by relationship type and direction early, add properties that let you prune before expanding, or restructure the model to introduce intermediate nodes that break up the fan-out.
Key point: the supernode problem is useful because is a senior tell. Pair it with a concrete mitigation (type/direction filtering or an intermediate node), not just the definition.
The Graph Data Science (GDS) library adds graph algorithms that run over an in-memory projection of the graph: centrality (PageRank, betweenness), community detection (Louvain, label propagation), pathfinding (Dijkstra, A*), similarity, and node embeddings. It's a separate analytical layer from the transactional Cypher you write day to day, aimed at questions about structure and influence.
The workflow is project a subgraph into memory, run an algorithm, then write results back or stream them. It's how teams do recommendations, fraud ring detection, and influence analysis at scale, distinct from the transactional Cypher you use day to day.
CALL gds.graph.project('social', 'Person', 'KNOWS')
CALL gds.pageRank.stream('social')
YIELD nodeId, score
RETURN gds.util.asNode(nodeId).name AS person, score
ORDER BY score DESC LIMIT 10Hints like USING INDEX, USING SCAN, and USING JOIN tell the planner how to execute a query when its statistics lead it to a bad plan. USING INDEX forces a specific index, which helps when the planner picks a label scan over an available index.
The senior caveat: hints are a last resort. Usually the real fix is updating statistics, restructuring the query, or adding the right index. A hardcoded hint can become wrong as data grows, so document why it's there.
MATCH (p:Person)
USING INDEX p:Person(email)
WHERE p.email = $email
RETURN pTwo patterns. For time-series, link events in a chain or them connects to shared time-tree nodes (year, month, day) so range queries traverse the tree instead of scanning every event. For versioning, keep the current entity as one node and attach historical states as related :State or :Version nodes with valid-from and valid-to properties.
The choice depends on your queries. If you ask 'what did this look like on a date', a versioning model with dated relationships answers it by traversal; if you ask 'all events in March', a time tree does.
Neo4j Enterprise supports online backups with neo4j-admin database backup, which captures a consistent snapshot without stopping the database and can take incremental backups afterward. Community edition uses offline dumps with neo4j-admin database dump while the database is stopped, so the tradeoff there is a maintenance window versus the always-on backups Enterprise gives you.
Restore replays a backup or loads a dump into a fresh database. A real answer includes testing restores regularly, because a backup you've never restored is a hope, not a plan.
Neo4j Enterprise has role-based access control down to fine granularity: you grant roles permissions on databases, and can restrict access to specific labels, relationship types, and even properties, so a role sees only part of the graph. Community edition has basic user authentication without the fine-grained roles.
Beyond RBAC, you parameterize all Cypher to block injection, run over encrypted Bolt connections, and integrate with LDAP or SSO for user management. Property-level security is the feature people forget Neo4j has.
Model first, don't mirror. Map tables to node labels, foreign keys and pure join tables to relationships, and columns to properties, but redesign around the traversals you actually run rather than copying the schema one-to-one. Then export tables to CSV and import with LOAD CSV or the neo4j-admin bulk importer, creating constraints before the import so MERGE is indexed.
The honest scope note: you usually migrate the connected part of the domain, the part that hurts in SQL, and leave flat transactional tables where they are. A polyglot setup is common, not a failure.
Key point: The insight the question needs is 'model for your queries, don't copy the schema.' A one-to-one table-to-label port that keeps join tables as nodes misses the point of moving.
Three pools matter: the heap (for query execution and transaction state), the page cache (which holds graph and index data from disk), and off-heap transaction memory. The page cache should be large enough to hold the working set of the graph, because a graph served from the page cache is fast and one hitting disk on every hop is not.
The rule of thumb is size the page cache to the store files you traverse hot, give the heap enough for concurrent queries without long garbage-collection pauses, and leave RAM for the OS. neo4j-admin can estimate the page cache from store size.
When the data isn't connected. Flat, tabular, aggregation-heavy workloads (analytics over independent rows, ledgers, reporting) are what relational and columnar stores are built for, and forcing them into a graph adds complexity for no traversal benefit. If your important queries never walk relationships, a graph is the wrong tool no matter how interesting it looks.
Also skip it for pure key-value caching (Redis fits), for large binary or document blobs, and for workloads that are almost entirely bulk aggregate scans rather than relationship walks. Being able to say when a graph is the wrong tool is a stronger signal than defending it everywhere.
| Workload | Better fit | Why |
|---|---|---|
| Deeply connected traversals | Neo4j | Relationships are free to follow |
| Flat aggregate reporting | Relational / columnar | Optimized for scans and GROUP BY |
| Simple cache lookups | Redis | Low-latency key-value |
| Large documents / blobs | Document / object store | Built for big nested payloads |
Key point: Answering 'when NOT to use it' honestly is the strong move. Interviewers distrust anyone who claims a graph fits every problem.
Neo4j takes write locks on the nodes and relationships a transaction modifies, so concurrent transactions touching the same data serialize rather than corrupt each other. Reads see the version they started with and aren't blocked by writers, which keeps read-heavy workloads fast even while writes are happening on the same graph.
Two things bite in production: deadlocks when transactions grab locks in different orders (Neo4j detects and aborts one, so retry with backoff), and lock contention on a hot node that every write touches. The fix for contention is often a model change to spread writes across more nodes.
Key point: deadlock detection with retry, and hot-node lock contention, matters.
Clarify the fraud pattern first, because that drives the model. Then represent accounts, devices, cards, and addresses as nodes, and shared usage as relationships, so fraud rings show up as tightly connected clusters sharing devices or addresses. Real-time checks run Cypher traversals from a new transaction to see how many hops it connects to known-bad entities; batch analysis runs GDS community detection to surface rings no single query would catch.
Close on the operational edges: index the entry points (account id, device fingerprint) so lookups are seeks, bound the traversals to keep real-time checks fast, and feed scores back as properties or into a downstream model. Structuring the answer as pattern, model, real-time versus batch, operations is what the question is really scoring.
// accounts sharing a device with a flagged account, within 2 hops
MATCH (bad:Account {flagged: true})-[:USED_DEVICE]->(d:Device)<-[:USED_DEVICE]-(suspect:Account)
WHERE suspect <> bad
RETURN suspect.id, count(DISTINCT d) AS sharedDevices
ORDER BY sharedDevices DESCKey point: The The technical sequence is (clarify, model, real-time vs batch, operations) more than any single Cypher line.
A range index stores property values in sorted order, so the planner can satisfy an ORDER BY on that property, or a range filter like age > 30, by reading the index in order instead of sorting the whole result. You confirm it in PROFILE: an index-backed plan skips the Sort operator.
It stops helping once the query has already expanded through relationships, because the traversal result isn't in index order anymore. So an index accelerates the anchor step and its ordering, not a sort applied after a multi-hop expansion, where an explicit Sort is unavoidable.
CREATE INDEX person_age IF NOT EXISTS FOR (p:Person) ON (p.age)
// planner can read the index in order, no Sort operator
PROFILE MATCH (p:Person)
WHERE p.age > 30
RETURN p.name ORDER BY p.ageKey point: The nuance that separates seniors: an index orders the anchor scan, but a sort after relationship expansion still costs a Sort operator. Saying 'an index always removes the sort' is the giveaway that someone hasn't read a real plan.
Neo4j wins when data is highly connected and your queries walk many hops of relationships, which is where a relational database piles up expensive JOINs. It trades the mature transactional tooling and rigid schema of a relational store for a flexible model where relationships carry no query-time cost. Against other graph databases, the split is mostly query language and model: Neo4j uses the property graph and Cypher, while RDF stores like Amazon Neptune or GraphDB use triples and SPARQL. Knowing these trade-offs out loud is itself an interview signal: it shows you pick a store on merits, not habit.
| System | Data model | Query language | Best at |
|---|---|---|---|
| Neo4j | Property graph (nodes, relationships) | Cypher | Deep relationship traversal, connected data |
| PostgreSQL / MySQL | Tables (rows, columns) | SQL | Structured transactional data, aggregations |
| MongoDB | Documents (JSON) | MQL | Nested documents, flexible schema per record |
| Amazon Neptune | Property graph and RDF | Cypher/Gremlin and SPARQL | Managed graph on AWS, RDF triples |
| Redis | Key-value and structures | Commands | Caching, low-latency simple lookups |
Prepare in layers, and practice out loud. Most Neo4j rounds move from concept questions about the graph model to live Cypher on a whiteboard or in Neo4j Browser, then a data-modeling or performance discussion, so rehearse each stage rather than only reading answers.
The typical Neo4j interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Neo4j questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works