Neo4j Interview Questions and Answers
Last updated:
Check out 40 of the most common Neo4j interview questions, then take an AI-powered practice interview
Q1What is Neo4j and how does the property graph model differ from a relational database?
BasicFundamentals
Answer
Neo4j is a native graph database, meaning storage, query engine, and indexes are all designed around graph traversal rather than table joins. The data model is the property graph, which has four primitives: (1) Nodes, entities like a User or Order, each with one or more labels and a property map. (2) Relationships, typed, directed connections between two nodes, like (:User)-[:PLACED]->(:Order). Relationships are first-class, they have their own ID, type, and property map. (3) Labels, node category tags like :User or :Product, used by indexes and the query planner. (4) Properties, key-value pairs on nodes and relationships, stored in a separate file from the topology so traversal can read connectivity without paging in property bytes.
Versus relational: in Postgres, finding 'friends of friends of friends' requires three JOINs on a user_friends table, each one re-scanning the index and growing the intermediate result set; in Neo4j, the engine follows pointers between adjacent records in constant time per hop, a property called 'index-free adjacency'. That's why graph beats SQL the moment you cross 3-4 hops or have deeply connected data. In a relational DB, a Razorpay fraud-ring query touching 6 hops can take minutes; in Neo4j, milliseconds.
Concretely, Neo4j stores nodes and relationships as fixed-size records on disk, a node record is 15 bytes, a relationship record is 34 bytes, with pointers to neighbouring records. This is what makes 'index-free adjacency' possible: once you've located a starting node (via an index), every subsequent hop is a constant-time pointer dereference, not another index lookup. Practical implication: query latency stays flat as the graph grows.
Adding a million more users to your social graph doesn't slow down a 3-hop friend lookup, because the work scales with hops × average degree, not total graph size. This single property is why companies like NASA (lessons-learned graph across 50 years of missions), eBay (product knowledge graph), and UBS (institutional knowledge + fraud) standardised on Neo4j.
Key Points
- Property graph: nodes + typed relationships + labels + properties
- Index-free adjacency = O(1) per hop traversal
- Relationships are first-class and have their own properties
- Wins over SQL at 3+ hops or deeply connected data
Q2What is Cypher and how do you write your first MATCH query?
BasicCypher
Answer
Cypher is Neo4j's declarative query language, designed to look like ASCII art for graphs. A node is `(variable:Label {prop: value})`, a relationship is `-[variable:TYPE {prop: value}]->`, and a path strings them together. MATCH finds patterns; RETURN projects results.
The mental model is to draw what you want, then ask the engine to find it. Cypher has been standardised as GQL (ISO/IEC 39075:2024), so what you learn here transfers to Memgraph, AuraDB, Neptune (with openCypher), and any other GQL-compliant store. Three things trip people up on day one.
First, direction matters in the pattern even when it does not matter in your head: `-[:PLACED]->` matches only outgoing relationships, while `-[:PLACED]-` matches either direction and costs more to expand, so an undirected pattern on a dense node is a silent performance bug. Second, anonymous nodes and relationships are legal and cheaper when you never reference them again, so `MATCH (u:User)-[:PLACED]->() RETURN count(*)` avoids binding the order rows at all. Third, labels, relationship types and property keys are case sensitive while clause keywords are not: `:user` and `:User` are two different labels, and a typo returns zero rows instead of an error, which is why a query that works in Browser can quietly return nothing after a rename. Interviewers usually follow up with two questions: what does the query return when the pattern matches nothing (an empty result set, not null), and how would you prove the query used an index rather than scanning every `:User` (prefix it with PROFILE and check that the leaf operator is NodeIndexSeek).
// Find all users named 'Asha' and the orders they placed
MATCH (u:User {name: 'Asha'})-[:PLACED]->(o:Order)
RETURN u.name, o.id, o.amount
ORDER BY o.amount DESC
LIMIT 10;
Key Points
- Nodes are (var:Label), relationships are -[var:TYPE]->
- Pattern reads left-to-right; MATCH finds it, RETURN projects
- Now standardised as GQL (ISO 39075:2024)
Q3What is the difference between CREATE and MERGE?
BasicCypher
Answer
CREATE always inserts a new node or relationship, it never checks for duplicates, so running it twice gives you two identical nodes. MERGE is 'match or create': it tries to MATCH the pattern first, and if nothing is found, it CREATEs it. MERGE is the right primitive for idempotent imports (ETL jobs that may rerun), upserts, and any time you want exactly-one-of-something.
The gotcha: MERGE locks the entire matched pattern for the duration of the transaction to prevent two concurrent writers from both deciding to create the same node. That makes MERGE significantly slower than CREATE under high write concurrency, for bulk loads where you know the data is fresh, prefer CREATE and let a UNIQUE constraint catch dupes, or pre-deduplicate the input. Another subtlety: MERGE on a relationship `MERGE (a)-[:KNOWS]->(b)` requires both endpoints to already exist (or be matched in the same query).
And MERGE on a pattern with non-existent properties will CREATE the pattern with exactly those properties, so `MERGE (u:User {email: $email, name: $name})` is different from `MERGE (u:User {email: $email}) SET u.name = $name`: the first finds only users matching BOTH email and name (and creates a new node if either differs), while the second finds the unique user by email and updates the name. The second form is almost always what you want in practice.
// CREATE: always inserts. Run twice → two 'Asha' nodes.
CREATE (u:User {email: 'asha@example.com', name: 'Asha'});
// MERGE: match or create. Idempotent.
MERGE (u:User {email: 'asha@example.com'})
ON CREATE SET u.name = 'Asha', u.createdAt = datetime()
ON MATCH SET u.lastSeen = datetime();
Q4What is WHERE used for in Cypher and how is it different from filters inside the MATCH pattern?
BasicCypher
Answer
WHERE filters bindings produced by MATCH (or WITH, UNWIND, etc.). The planner sometimes pushes WHERE conditions down to the index lookup, so they can be effectively the same as inline filters. Inline filters like `(:User {email: $email})` are syntactic sugar, they work for exact-equality on a single property.
Use WHERE when you need anything more: range comparisons, multiple predicates, OR, NOT, regex (`=~`), list IN, property existence (`IS NOT NULL`), or label predicates (`n:User OR n:Admin`). The planner is smart enough that a WHERE clause on an indexed property still triggers an index seek, you don't pay extra by using WHERE. What does change behaviour is where the predicate sits relative to OPTIONAL MATCH: a WHERE attached to the OPTIONAL MATCH is part of the pattern and still yields a row full of nulls, while a WHERE in the following clause deletes those null rows and quietly turns the outer join into an inner join.
Two more things worth knowing: `WHERE NOT (a)-[:FRIEND]->(b)` is a pattern predicate, so it expands relationships and gets expensive on dense nodes, and the modern form `WHERE NOT EXISTS { MATCH (a)-[:FRIEND]->(b) WHERE b.active }` lets you add predicates inside the subquery instead of chaining more patterns. And regex matching with `=~` never uses an index, whereas `STARTS WITH` and `CONTAINS` can use a RANGE or TEXT index, so rewriting `=~ 'raz.*'` as `STARTS WITH 'raz'` on a hot path is often a 100x difference. A senior interviewer will ask which of your predicates are index-backed and which run as post-filters; PROFILE answers that directly, since index-backed predicates appear inside the NodeIndexSeek operator while post-filters show up as a separate Filter row above it.
// Inline + WHERE combined
MATCH (u:User {country: 'IN'})-[:PLACED]->(o:Order)
WHERE o.amount > 1000
AND o.createdAt > datetime('2026-01-01')
AND u.email =~ '.*@razorpay\\.com'
RETURN u.email, sum(o.amount) AS spend
ORDER BY spend DESC;
Q5What is RETURN and how do you use aggregations?
BasicCypher
Answer
RETURN projects the final result, it's analogous to SELECT in SQL. Cypher's aggregation model is implicit: if you mix grouping keys (raw values) with aggregating functions (count, sum, avg, min, max, collect) in the same RETURN, everything that isn't aggregated becomes a GROUP BY key automatically. No explicit GROUP BY clause needed. `collect()` is especially powerful, it gathers all values from a group into a list, which is how you build nested JSON-shaped results without a second query. `count(*)` counts rows; `count(DISTINCT x)` counts unique values.
Three behaviours matter once this hits production. Aggregations skip nulls, so `count(o.amount)` counts only the rows where that property exists while `count(*)` counts every row; comparing the two is the fastest way to detect a property that is missing on part of your data. `collect()` materialises the whole group in transaction memory, so an unbounded `collect(o)` over ten million orders is a textbook `MemoryPoolOutOfMemoryError`, and the fix is to slice it (`collect(o.id)[..50]`) or aggregate to a count instead. DISTINCT applies to the entire returned row rather than one column, so `RETURN DISTINCT u.email, o.id` deduplicates pairs, which is almost never what the author meant. Also worth knowing: RETURN supports map projections, `RETURN u { .email, .country, orders: collect(o.id) }`, which is the idiomatic way to hand an API layer a JSON-shaped object without reshaping it in application code, and it keeps the payload small because you name exactly the properties you want instead of returning whole nodes.
// Per-user order count + total spend + a list of order IDs
MATCH (u:User)-[:PLACED]->(o:Order)
RETURN u.email,
count(o) AS orderCount,
sum(o.amount) AS totalSpend,
collect(o.id)[..5] AS recentOrderIds
ORDER BY totalSpend DESC
LIMIT 20;
Q6What is WITH in Cypher and why is it important?
BasicCypher
Answer
WITH is Cypher's pipeline operator, it passes results from one part of a query to the next, like a pipe in shell. It serves three jobs: (1) Re-scope variables, only the names listed in WITH stay visible after it. (2) Aggregate then continue, you can aggregate, then MATCH again using the aggregated values. (3) Order/limit in the middle of a query, e.g. find top 100 users, then expand their orders. Without WITH, every MATCH would be evaluated as one giant pattern, which makes large queries unreadable and harder for the planner.
The mental model: WITH = checkpoint + re-shape. Two rules save hours of debugging. First, an aggregation inside WITH turns every non-aggregated variable you listed into a grouping key, so carrying one extra variable through the WITH silently changes your grouping and inflates your counts.
When a count looks too large, read the WITH line before you read the MATCH. Second, WITH is the boundary that makes DISTINCT, ORDER BY, SKIP and LIMIT meaningful mid-query, and it is how you force the planner into stages: a `WITH ... ORDER BY ...
LIMIT 10` means the following MATCH only executes for ten rows, which is the difference between expanding orders for ten users and expanding them for every user in the database. WITH is also the import mechanism for CALL subqueries, since a `CALL { ... }` block can only see variables you explicitly pass in with a leading WITH. A frequent interview follow-up is why adding an apparently harmless `WITH u, o` before the RETURN changed the result: because it either introduced or removed a grouping boundary, which changes what the aggregation is computed over.
// Find top-10 spenders, then their most recent order
MATCH (u:User)-[:PLACED]->(o:Order)
WITH u, sum(o.amount) AS spend
ORDER BY spend DESC LIMIT 10
MATCH (u)-[:PLACED]->(latest:Order)
WITH u, latest, spend ORDER BY latest.createdAt DESC
RETURN u.email, spend, head(collect(latest)) AS latestOrder;
Q7What is a label and how is it different from a property?
BasicSchema
Answer
A label is a category tag attached to a node (a node can have multiple). Labels are first-class, the engine maintains a label scan that lets you cheaply enumerate `(:User)` or count nodes with a given label. Properties are just key-value data on the node.
Use labels for categories that participate in indexes and constraints (User, Order, Product), and use properties for everything else. A common rookie mistake is encoding a category as a property (`{type: 'user'}`), the engine can't use a property the way it uses a label for planning, and you lose the ability to attach a label-specific index. A node CAN have multiple labels (e.g., `(:User:Customer:Indian)`) which is great for facet-style filtering.
The practical limits are worth knowing. Labels are stored as tokens in a registry shared by the whole database, so generating labels from user data (for example `apoc.create.addLabels(n, [row.category])` over a million distinct categories) bloats that registry and makes planning, `SHOW INDEXES` and schema introspection slow, sometimes permanently. Keep labels a small closed set that a human could list, and push open-ended values into properties or into their own nodes with a relationship.
The second real difference is scoping: every index and constraint is defined against exactly one label, so a value you want unique across several categories needs a shared label to hang the constraint on. A standard follow-up is how you would model a status that changes over time. The answer is a property with an index, or a relationship to a `(:Status)` node when you need history, not a label you add and remove, because label writes touch the label scan store and churn there is more expensive than a property update.
Q8How do you create an index in Neo4j and why does it matter?
BasicIndexes
Answer
Without an index, Neo4j has to do a label scan + property comparison for every node with that label, fine for a few thousand, fatal at a million. Create indexes for any property you filter on. The main types in 5.x: (1) RANGE (the default since 5.0, replaces BTREE), for equality and range. (2) TEXT, optimized for string prefix and CONTAINS searches. (3) POINT, for geospatial queries. (4) FULLTEXT, for natural-language search powered by Lucene. (5) VECTOR, for cosine/euclidean similarity on embeddings, added in 5.11 for RAG. (6) LOOKUP, automatic per-label and per-rel-type, never drop these.
Always verify with `SHOW INDEXES` and check usage with `PROFILE`. The operational details are what interviews dig into. Index creation is asynchronous: `CREATE INDEX` returns immediately and the index sits in state POPULATING while it backfills, so a query running in that window silently falls back to a label scan.
Check with `SHOW INDEXES YIELD name, state, populationPercent`, or block in a migration script with `CALL db.awaitIndexes(300)`. Indexes are not free either: every write to an indexed property also writes the index, so a dozen indexes on a hot ingest path measurably slows it, and unused indexes still occupy page cache that your traversals need. Find them with `SHOW INDEXES YIELD name, readCount` and drop what nothing reads.
Composite indexes only serve queries filtering on a prefix of the key order, so `ON (o.status, o.createdAt)` helps a status-only query while an index on createdAt alone does nothing for a status-only filter. And because RANGE replaced BTREE in 5.0, any 4.x backup you restore needs RANGE or TEXT replacements created before the database will start.
// Equality / range lookups
CREATE INDEX user_email_idx IF NOT EXISTS FOR (u:User) ON (u.email);
// Composite index, accelerates queries that filter on both
CREATE INDEX order_status_created_idx IF NOT EXISTS
FOR (o:Order) ON (o.status, o.createdAt);
// Vector index for RAG / embeddings (Neo4j 5.11+)
CREATE VECTOR INDEX product_embedding IF NOT EXISTS
FOR (p:Product) ON p.embedding
OPTIONS { indexConfig: { `vector.dimensions`: 1536, `vector.similarity_function`: 'cosine' } };
Q9What is a constraint in Neo4j?
BasicSchema
Answer
A constraint enforces a schema rule at write time. The four types in 5.x: (1) UNIQUE, only one node with a given label can have a given property value, like a primary key. (2) NODE KEY, UNIQUE + the property must exist; this is the closest equivalent to a SQL primary key. (3) NODE/RELATIONSHIP PROPERTY EXISTENCE, the property cannot be null. (4) NODE/REL PROPERTY TYPE, the property must be a specific type (introduced in 5.9). UNIQUE constraints automatically create a backing index, you don't need a separate CREATE INDEX for the same property.
In production you ALWAYS want a UNIQUE constraint on the natural key (email, external_id) before doing any MERGE; otherwise MERGE can deadlock under concurrency and you can end up with duplicate nodes. What breaks in practice: creating a constraint fails outright if existing data violates it, with an error saying it cannot create the constraint because nodes with duplicate property values exist, so deduplicate before the migration runs. Constraints are checked at commit time, so a transaction may transiently violate uniqueness and still succeed as long as it is consistent by the end.
Edition matters: Community Edition supports only UNIQUE constraints, while NODE KEY, property existence and property type constraints are Enterprise features, which surprises teams moving a schema migration from a laptop to a Community server. There is no cross-label uniqueness, and per-tenant uniqueness is one constraint over a property pair: `REQUIRE (u.tenantId, u.email) IS UNIQUE`. Finally, dropping a UNIQUE constraint also drops the index that backed it, so if reads still depend on that lookup you must create a replacement index explicitly in the same migration.
CREATE CONSTRAINT user_email_unique IF NOT EXISTS
FOR (u:User) REQUIRE u.email IS UNIQUE;
CREATE CONSTRAINT order_id_key IF NOT EXISTS
FOR (o:Order) REQUIRE o.id IS NODE KEY;
CREATE CONSTRAINT user_email_required IF NOT EXISTS
FOR (u:User) REQUIRE u.email IS NOT NULL;
Q10How do you delete nodes and relationships safely?
BasicCypher
Answer
Neo4j refuses to delete a node that still has relationships, you'd be leaving dangling pointers. So you either delete the relationships first, or use DETACH DELETE which removes the node and all attached relationships in one go. For large deletes, never run a single `MATCH (n) DETACH DELETE n` over millions of nodes, it builds a giant transaction, blows out heap, and may roll back at the end.
Instead, batch via `CALL { ... } IN TRANSACTIONS OF 10000 ROWS` (5.x) so each batch commits independently. Details that matter at scale: `DETACH DELETE` on a super-node with ten million relationships is still one large unit of work for that node even inside a batched outer loop, so it can breach `db.memory.transaction.max`; delete its relationships in batches first, then the node itself. Removing a property is `REMOVE n.prop` or `SET n.prop = null` (the same operation internally), while DELETE only accepts nodes, relationships and paths.
Neo4j also does not shrink store files after deletes: the space is reused by later writes, but the filesystem does not get it back until you run `neo4j-admin database copy` or a dump and load, which surprises teams who purge data expecting disk to free up. One more trap: `CALL { ... } IN TRANSACTIONS` only runs in an implicit transaction, so invoking it from a driver managed write transaction fails with a message saying it can only be executed in an implicit transaction; use an auto-commit session run. And batched deletes are not atomic overall, so if batch 40 fails the first 39 remain committed, which means the delete query must be safe to re-run.
// Single safe delete
MATCH (u:User {email: 'asha@example.com'}) DETACH DELETE u;
// Bulk delete in batches of 10,000
MATCH (o:Order) WHERE o.status = 'CANCELLED' AND o.createdAt < datetime('2024-01-01')
CALL { WITH o DETACH DELETE o } IN TRANSACTIONS OF 10000 ROWS;
Q11How do you import data from a CSV file into Neo4j?
BasicImport
Answer
For one-time bulk loads on an empty database, use the `neo4j-admin database import full` command (or the legacy `neo4j-admin import`), it bypasses transactions entirely and can load a billion nodes per hour. For ongoing/streaming imports into a live database, use `LOAD CSV` from Cypher, ideally combined with `CALL ... IN TRANSACTIONS OF N ROWS` so each batch commits independently.
Always create constraints and indexes BEFORE the import, MERGE without an index does a full label scan per row, which turns a 5-minute load into 5 hours. Version and gotcha detail: `USING PERIODIC COMMIT` was removed in Neo4j 5.0, so 4.x loader scripts fail with a syntax error and have to be rewritten as `CALL { ... } IN TRANSACTIONS OF N ROWS`. `LOAD CSV` reads only from the directory named by `server.directories.import` unless you enable file URLs explicitly, and every column arrives as a string, so numbers need `toInteger()` or `toFloat()` and timestamps need `date()` or `datetime()`. An empty cell is an empty string rather than null, which is why real loaders are full of `WHERE row.phone <> ''` guards.
Watch for the `Eager` operator in `PROFILE`: when one query both reads and writes the same labels, the planner inserts Eager, which buffers the entire CSV in memory and defeats the batching you just added. Splitting the job into a nodes pass and a relationships pass normally removes it. For the admin importer the CSV headers must declare `:ID`, `:LABEL`, `:START_ID` and `:END_ID`, and the target database must not already exist or the command refuses to run.
// First, the constraint (so MERGE uses an index)
CREATE CONSTRAINT user_email_unique IF NOT EXISTS
FOR (u:User) REQUIRE u.email IS UNIQUE;
// Then batched import
LOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row
CALL {
WITH row
MERGE (u:User {email: row.email})
SET u.name = row.name, u.country = row.country
} IN TRANSACTIONS OF 10000 ROWS;
Q12Where is Neo4j used in industry?
BasicUse Cases
Answer
The classic graph-database use cases are exactly where Neo4j dominates: (1) Fraud detection, Razorpay and Cred use it to spot transaction rings and shared-device fraud on UPI in real time. (2) Recommendation engines, Flipkart's 'customers who bought this also bought' and Myntra's fashion-similarity graphs are graph traversals. (3) Knowledge graphs, eBay's product knowledge graph, NASA's lessons-learned database, and most modern LLM RAG stacks store entities and relationships in Neo4j with vector indexes on the property values. (4) Network / IT topology, UBS, Walmart use it to model server, microservice, and supply-chain dependencies. (5) Identity & access, graph-shaped permission models (Airbnb's listing access). (6) Social, friend-of-friend, common-connection queries. Anywhere a query touches 3+ joins or you naturally say 'who is connected to whom', Neo4j is a strong fit. What all of these deployments share is a query shape rather than an industry: an entry point found by index, then two to six hops of expansion, where the answer depends on the connections and not on the rows.
The commercial pattern in India is almost always Neo4j alongside an existing system of record rather than replacing it. Postgres or MySQL keeps the transactional truth, a Kafka topic or a change-data-capture stream projects the connected slice into Neo4j, and the graph serves one traversal query behind an API with a latency budget in the tens of milliseconds. Interviewers use this question to check whether you can also say no to a graph: invoice line items, order history and event analytics belong in a relational or columnar store, and a candidate who claims every domain is a graph is signalling inexperience. Be ready to name the single query that justified running a second database.
Key Points
- Fraud detection, Razorpay, Cred, UBS
- Recommendations, Flipkart, Myntra, eBay
- Knowledge graphs + RAG, NASA, AI startups
- Network/dependency graphs, Walmart, IT ops
Q13What does OPTIONAL MATCH do, and how do nulls behave in Cypher?
BasicCypher
Answer
OPTIONAL MATCH is Cypher's LEFT OUTER JOIN. A plain MATCH that finds nothing removes the row entirely, so `MATCH (u:User)-[:PLACED]->(o:Order)` silently drops every user who has never ordered. OPTIONAL MATCH keeps the row and binds every variable introduced by that pattern to null instead.
This single behaviour is behind most 'my count is wrong' bugs, because a report of users and their order totals quietly excludes the users you most wanted to see. Null semantics then follow SQL more closely than most people expect. Any comparison with null yields null rather than false, so `WHERE o.amount > 100` drops the optional rows again and turns your outer join back into an inner join.
Use `WHERE o IS NULL OR o.amount > 100`, or attach the predicate to the OPTIONAL MATCH itself so it becomes part of the pattern rather than a post-filter. Property access on a null node returns null instead of throwing, which is why typos survive to production. Aggregations skip nulls, so `count(o)` returns 0 for a user with no orders while `count(*)` returns 1, and `sum()` over an empty group returns 0 while `avg()` and `max()` return null. `coalesce(o.amount, 0)` is the standard cleanup. Two more rules: variables from an OPTIONAL MATCH cannot be reused as a required binding later without re-checking for null, and OPTIONAL MATCH followed by a write is a common source of accidental null property writes, since `SET u.tier = t.name` on a null `t` clears the property.
// WRONG: users with zero orders vanish from the report
MATCH (u:User)-[:PLACED]->(o:Order)
RETURN u.email, count(o) AS orders;
// RIGHT: keep every user, count only real orders
MATCH (u:User)
OPTIONAL MATCH (u)-[:PLACED]->(o:Order)
WHERE o.status = 'PAID'
RETURN u.email,
count(o) AS paidOrders,
coalesce(sum(o.amount), 0) AS spend
ORDER BY spend DESC;
Q14How do UNWIND and query parameters let you write a whole batch in one round trip?
BasicCypher
Answer
UNWIND turns a list into rows, which is the inverse of `collect()`. Its main production use is batching: instead of the application looping and firing one Cypher statement per record, you pass a list of maps as a single parameter and let one statement iterate it server-side. A thousand single-row writes means a thousand network round trips plus a thousand transactions; one `UNWIND $rows AS row` statement is one round trip and one transaction, and on a typical Bolt connection that is often the difference between a 20-second job and a 200-millisecond one.
Parameters (`$rows`, `$userId`) matter for a second reason beyond convenience. Neo4j caches compiled query plans keyed by the query string, so string-concatenating a different user id into the Cypher on every call produces a new cache entry every time, evicting good plans and burning CPU on re-planning; you can watch this happen in the metric for query cache hit ratio. Parameters also make injection impossible, since a parameter is never parsed as Cypher.
Practical notes: keep the batch in the low thousands, because a 100,000-element list arrives as one transaction and one heap allocation and will hit `MemoryPoolOutOfMemoryError`. `UNWIND []` produces zero rows and kills the rest of the query, while `UNWIND null` produces zero rows too, so guard with `coalesce($rows, [])`. Labels and relationship types can never be parameterised, so dynamic labels need `apoc.merge.node` or the 5.26 dynamic-label syntax.
// One statement, one round trip, 1000 upserts
// $rows = [{email:'a@x.com', name:'A'}, {email:'b@x.com', name:'B'}, ...]
UNWIND $rows AS row
MERGE (u:User {email: row.email})
ON CREATE SET u.createdAt = datetime()
SET u.name = row.name
RETURN count(*) AS upserted;
// UNWIND also expands a collected list back into rows
MATCH (u:User {email: $email})-[:PLACED]->(o:Order)
WITH u, collect(o)[..5] AS recent
UNWIND recent AS o
RETURN u.email, o.id, o.amount;
Key Points
- UNWIND list -> rows; the inverse of collect()
- One UNWIND $rows statement replaces N round trips and N transactions
- Parameters keep the plan cache warm and block injection
- Labels and relationship types cannot be parameterised
Q15How do you explain a Cypher query plan with EXPLAIN and PROFILE?
IntermediatePerformance
Answer
EXPLAIN shows the query plan WITHOUT executing the query, useful in CI to catch full label scans on any new query. PROFILE actually runs the query and adds real db hits, rows, and memory per operator, that's the one you use when something is slow. The metric that matters is 'db hits' (records the engine touched), if your query says 'find one user by email' and PROFILE shows 12 million db hits, you're missing an index.
Read the plan bottom-up; the leaf operator (`NodeIndexSeek`, `NodeByLabelScan`, `AllNodesScan`) tells you how the engine entered the graph. `NodeByLabelScan` over 10M nodes is the most common slow-query symptom. `AllNodesScan` is almost always a bug, it means even the label is missing. Two more operators explain most surprises. `Eager` means the planner had to fully materialise an intermediate result to keep read-write semantics correct, and on a LOAD CSV import it is the reason your batching stopped working. A `Filter` sitting directly above a `NodeIndexSeek` means the index returned a rough candidate set and the real predicate ran in memory, which is the signal to build a composite index instead.
Compare the estimated rows printed on each operator against the actual rows: a plan that estimates 10 and executes 4 million is running on stale statistics, and a bulk load with no subsequent re-plan is the classic cause. PROFILE also reports memory per operator, which is how you find the one that trips `MemoryPoolOutOfMemoryError`. For a query already running in production you cannot attach PROFILE after the fact, so use `SHOW TRANSACTIONS` to see the live Cypher, elapsed time and allocated bytes, and `TERMINATE TRANSACTION` to kill it.
PROFILE
MATCH (u:User {email: 'asha@example.com'})-[:PLACED]->(o:Order)
WHERE o.amount > 1000
RETURN u.email, count(o);
// Good leaf: NodeIndexSeek on :User(email)
// Bad leaf: NodeByLabelScan(:User) → you forgot the index on email
// Worst: AllNodesScan → you forgot the :User label
Key Points
- EXPLAIN = plan only; PROFILE = plan + db hits + memory
- Read bottom-up: the leaf operator shows how the engine entered
- db hits is the universal slowness metric
- AllNodesScan / NodeByLabelScan on a hot path = missing index or label
Q16What is a cartesian product and how do you avoid it?
IntermediatePerformance
Answer
A cartesian product happens when a Cypher query MATCHes two patterns that aren't connected to each other. The planner has no choice but to combine every binding from one with every binding from the other, N × M rows. With 100k users and 100k orders disjoint, that's 10 billion intermediate rows and a query that never finishes.
The fix: connect the patterns with a relationship, or split into two MATCHes joined by a WITH that constrains them. The planner emits a warning `CartesianProduct` in EXPLAIN; never deploy a query with that warning unless you know what you're doing. Set `dbms.cypher.forbid_exhaustive_shortestpath=true` and consider `cypher.forbid_cartesian_products=true` in production configs.
A subtlety candidates miss: a cartesian product is not automatically a bug. When one side is provably tiny (a single config node, a five-row lookup table) the `CartesianProduct` operator is cheap, and the notification does not tell you the sizes, so judge it by the row counts in PROFILE rather than by the warning alone. The more dangerous version is the accidental product inside a single connected MATCH: `MATCH (u:User)-[:PLACED]->(o:Order), (u)-[:VIEWED]->(p:Product)` shares the variable `u`, so no warning fires, but it still multiplies orders by product views per user.
A user with 200 orders and 300 views produces 60,000 rows before aggregation, and `count(o)` then returns 60,000 instead of 200, which is how silent double counting reaches a dashboard. The fix is to split the two expansions into separate blocks joined by a WITH that aggregates each side, or to use `COUNT { }` subqueries so each count is evaluated independently. In CI, the cheapest guard is to run EXPLAIN over every registered query and fail the build on the CartesianProductWarning notification.
// BAD, cartesian product, two unrelated MATCHes
MATCH (u:User), (o:Order)
WHERE u.country = 'IN' AND o.status = 'OPEN'
RETURN u, o;
// GOOD, connected pattern
MATCH (u:User {country: 'IN'})-[:PLACED]->(o:Order {status: 'OPEN'})
RETURN u, o;
Q17What is the difference between label scan and index scan?
IntermediatePerformance
Answer
A label scan iterates every node with a given label, `NodeByLabelScan(:User)` is O(N) over all users. An index scan uses a btree/range index to seek directly to matching rows, `NodeIndexSeek(:User(email))` is O(log N). The planner picks based on selectivity: if you're filtering on a property and there's an index, it does a seek; if not, it falls back to a label scan and post-filters in memory.
The way you fix a label-scan plan is either: (1) add an index on the filter property, (2) make the filter use a property that already has an index, or (3) restrict by a more selective label (`(:User:Premium)` instead of just `(:User)`). For composite predicates, create a composite index in the same order as the WHERE clauses or the planner will fall back to picking one index and filtering on the other in memory. Two further operators complete the picture. `NodeIndexScan` walks the whole index with no seek key, which beats a label scan because the index file is smaller than the node store, but it is still linear. `NodeUniqueIndexSeek`, backed by a UNIQUE constraint, is the cheapest lookup available.
When the planner chooses wrong, usually because statistics are stale or a parameter has unusual selectivity, you can force it with `MATCH (u:User) USING INDEX u:User(email) WHERE u.email = $e`, but treat hints as a last resort: they stop the planner adapting as data changes, and an unfulfillable hint raises an error rather than being ignored. Remember too that plans are cached per query shape, not per parameter value, so a plan compiled while looking up an ordinary user gets reused for a super-node and can behave very differently on the second call.
// Without index: NodeByLabelScan + Filter, slow on 10M users
MATCH (u:User) WHERE u.email = 'asha@example.com' RETURN u;
// After: CREATE INDEX FOR (u:User) ON (u.email)
// → NodeIndexSeek, milliseconds even on 10M users
// Composite, also enables single-seek lookups
CREATE INDEX user_country_signup IF NOT EXISTS FOR (u:User) ON (u.country, u.signupDate);
Q18What is the MERGE locking gotcha and how do you avoid it?
IntermediateConcurrency
Answer
MERGE acquires a label/property lock to prevent two concurrent writers from creating duplicate matching nodes. The lock is on the combination of label + property in the MERGE clause. If two transactions MERGE different things but in a different order, they can deadlock, classic two-resource deadlock.
Mitigations: (1) ALWAYS have a UNIQUE constraint backing the property you MERGE on, without it, the lock is broader and slower. (2) Order MERGE statements consistently across your codebase so locks are always acquired in the same order. (3) For bulk imports, use APOC's `apoc.periodic.iterate` with `parallel:false` for MERGE-heavy work, or partition the input by the MERGE key so each batch touches disjoint locks. (4) Retry on `TransientError`, Neo4j explicitly tells you when a deadlock victim was killed and the transaction is safe to retry. Razorpay's user-ingest pipeline uses APOC + retry with exponential backoff to handle deadlocks gracefully. What the failure actually looks like is worth memorising: `Neo.TransientError.Transaction.DeadlockDetected`, with a message naming the two ForsetiClient threads and the node record they are fighting over.
The official drivers already retry TransientError inside managed transaction functions (`execute_write` in Python, `executeWrite` in JavaScript), so the common production bug is code that issues MERGE through an auto-commit `session.run()` and therefore gets no retry at all. Lock waits that never escalate into a true deadlock surface differently, as a timeout governed by `db.lock.acquisition.timeout`. Also: MERGE on a relationship takes locks on both endpoints, so a shared `(:Device)` or `(:Merchant)` node becomes the serialisation point for the entire ingest pipeline. The fixes there are to shard the hot node, or to MERGE only the leaf node and CREATE the relationship, deduplicating relationships in a later batch job.
Key Points
- MERGE locks on (label, property), UNIQUE constraint makes it cheaper
- Two MERGEs in different order across two txns → deadlock
- Order MERGEs consistently across the codebase
- Catch TransientError and retry with backoff
Q19How do you write a variable-length path query and what are the dangers?
IntermediateCypher
Answer
Variable-length patterns use `*min..max` between two nodes: `(:User)-[:FRIEND*1..3]->(:User)` finds users 1 to 3 hops away. The danger is that path-length expansion is exponential, in a graph with average degree 50, a `*1..5` traversal touches 50^5 = 312M paths even before filtering. Always set an explicit upper bound (never `*` alone), filter early (push predicates inside the pattern or in WHERE), and use `shortestPath()` / `allShortestPaths()` when you only care about reachability.
For deep traversals over big graphs, switch to GDS path-finding algorithms, they're orders of magnitude faster than Cypher for 5+ hops because they use specialized in-memory representations. Version detail worth naming in an interview: Neo4j 5.9 added quantified path patterns, the GQL-standard form, so `(:User)-[:FRIEND]->{1,3}(:User)` expresses the same traversal and, unlike `*1..3`, lets you attach predicates to each repetition of a multi-relationship pattern. Later 5.x releases added GQL path selectors such as `ANY SHORTEST` and `SHORTEST 3`, which the planner handles better than wrapping `shortestPath()` around a broad pattern.
Also know the relationship-uniqueness rule: within one MATCH, Cypher never traverses the same relationship twice, which is what stops variable-length patterns looping forever in a cyclic graph, but nodes can repeat, so a `*1..6` traversal around a cycle still returns paths that revisit the same node. And `shortestPath` with predicates the planner cannot push into the expansion degrades into exhaustive search, which is exactly what `dbms.cypher.forbid_exhaustive_shortestpath` blocks so the query fails loudly instead of running for an hour. Expect to be asked for a cost estimate on the spot: average degree raised to the hop count, minus whatever your predicates prune early.
// Find a path of length 1-4 between two users, shortest only
MATCH path = shortestPath((a:User {id: 'A'})-[:FRIEND*1..4]-(b:User {id: 'B'}))
RETURN [n IN nodes(path) | n.email] AS hops, length(path) AS hopCount;
// Friend-of-friend (exclude direct friends)
MATCH (me:User {id: 'A'})-[:FRIEND]->()-[:FRIEND]->(fof:User)
WHERE NOT (me)-[:FRIEND]->(fof) AND fof <> me
RETURN fof.email, count(*) AS mutualFriends
ORDER BY mutualFriends DESC LIMIT 20;
Q20What is APOC and which procedures should every Neo4j developer know?
IntermediateAPOC
Answer
APOC (Awesome Procedures On Cypher) is the standard utility-procedure library for Neo4j, installed everywhere in production, ships with Neo4j Aura. It plugs gaps in core Cypher: date math, JSON parsing, periodic iteration for bulk writes, dynamic Cypher generation, integration with external systems (HTTP, JDBC, Kafka, S3), graph refactoring (rename labels, merge nodes), and metadata introspection. Must-know procedures: (1) `apoc.periodic.iterate`, bulk write in batches, the bread and butter of large imports. (2) `apoc.merge.node` / `.relationship`, dynamic MERGE with computed labels. (3) `apoc.create.uuid`, generate UUIDs server-side. (4) `apoc.coll.*`, list manipulation. (5) `apoc.load.json` / `.csv` / `.jdbc`, pull from external sources. (6) `apoc.export.csv.query`, dump query results. (7) `apoc.meta.schema`, introspect the schema.
The operational reality is that APOC is not one artifact. APOC Core ships with the distribution and with Aura, while APOC Extended (JDBC, MongoDB, Elasticsearch and several export procedures) is a separate jar that Aura does not permit, so a query that works on your laptop can fail in the cloud with 'There is no procedure with the name'. Anything that touches the filesystem or the network is disabled by default: you enable it through `apoc.conf` with keys such as `apoc.import.file.enabled=true` and `apoc.export.file.enabled=true`, and procedures that need to bypass the sandbox must be listed in `dbms.security.procedures.unrestricted`.
Version pinning matters too, since the APOC jar must match the server version or the plugin silently fails to load and the database starts without it. A strong answer also names the reverse check: before reaching for APOC, confirm plain Cypher does not already cover it, because 5.x absorbed a lot of former APOC territory including batched transactions, EXISTS and COUNT subqueries, the SHOW commands and `elementId()`.
// Bulk-set a derived property on 10M nodes, 10k at a time
CALL apoc.periodic.iterate(
'MATCH (u:User) WHERE u.totalSpend IS NULL RETURN u',
'MATCH (u)-[:PLACED]->(o:Order) WITH u, sum(o.amount) AS s SET u.totalSpend = s',
{ batchSize: 10000, parallel: false, retries: 3 }
) YIELD batches, total, failedOperations
RETURN batches, total, failedOperations;
Q21What is GDS (Graph Data Science) and when do you use it?
IntermediateGDS
Answer
GDS is Neo4j's graph algorithms library, 70+ production-grade algorithms (PageRank, Louvain community detection, Node2Vec embeddings, Dijkstra, A*, betweenness centrality, link prediction, k-means on node embeddings). It works by projecting your graph (or a subgraph) into an in-memory representation optimized for the algorithm, running the algorithm in parallel across all cores, then streaming results back or writing them as node properties. You use GDS instead of pure Cypher when (a) the algorithm is well-known and Cypher would be 100+ lines, (b) the graph is big enough that you need parallel execution, (c) you need scientific-quality numerics (centrality, modularity).
Common Indian use cases: Razorpay scoring transaction networks for fraud (Louvain + PageRank), Flipkart learning product embeddings (Node2Vec → recommendation), Cred building user similarity for credit scoring. The details that separate people who have run GDS from people who have read about it: a projection is a snapshot held in heap, it does not see writes made after the projection was created, and it is lost on restart, so production pipelines re-project on a schedule and call `gds.graph.drop` when finished. Size it before you run it, because every algorithm has an estimate variant (`gds.pageRank.write.estimate`) that reports the memory required, and exceeding available heap fails the job rather than degrading gracefully.
Each algorithm exposes four execution modes: `stream` returns rows, `stats` returns a summary only, `mutate` writes the result into the in-memory graph so the next algorithm can chain off it, and `write` persists it back to the database. Chaining with `mutate` (for example Louvain, then PageRank restricted to a community) avoids two round-trips through disk and is the standard pipeline shape. GDS also has its own licence tier for Enterprise capabilities such as multi-threaded write-back, which is a budgeting question fintech interviewers genuinely ask.
// Project subgraph in-memory, run PageRank, write score back to nodes
CALL gds.graph.project(
'fraudGraph',
['User', 'Device'],
{ TRANSACTS_WITH: { orientation: 'UNDIRECTED' } }
);
CALL gds.pageRank.write('fraudGraph', {
writeProperty: 'pageRank',
maxIterations: 20,
dampingFactor: 0.85
}) YIELD nodePropertiesWritten, ranIterations;
// High-PageRank users in a fraud graph = hubs, candidates for review
MATCH (u:User) RETURN u.id, u.pageRank ORDER BY u.pageRank DESC LIMIT 20;
Q22How do you model a recommendation engine in Neo4j?
IntermediatePatterns
Answer
The simplest recommendation pattern is collaborative filtering as a two-hop traversal: 'find products bought by users who bought what I bought, that I haven't bought yet'. In Cypher, that's a single MATCH; in SQL, it's 4 joins and a NOT EXISTS subquery. For richer recommendations, blend multiple signals, co-purchase, co-view, category similarity, demographic similarity, and score them.
Step up: use GDS Node2Vec or FastRP to learn embeddings, then store them on the product node and use a vector index for similarity search. Flipkart's recommendation graph blends collaborative + content-based + recency signals; Swiggy's restaurant recommendations use location + order history + cuisine similarity in a single graph. The hard parts in production are popularity bias and latency, not the Cypher.
Everyone bought the bestseller, so the raw co-purchase query recommends the same three items to every user; the standard corrections are to weight co-occurrence by inverse popularity (divide by the log of the product's total buyers), to cap the middle of the traversal with `WITH other LIMIT 500`, and to exclude hub products above a degree threshold. Latency comes from those same hubs, because a product bought by two million users makes the second hop enormous. Real deployments therefore precompute: run `gds.nodeSimilarity.write` nightly to materialise `(:Product)-[:SIMILAR {score}]->(:Product)`, and let the serving query be a single indexed hop with an ORDER BY on the stored score, which keeps p99 in single-digit milliseconds. Expect follow-ups on cold start (fall back to category and trending), on how you would A/B test the change, and on where ranking lives, since the graph supplies candidates while a separate model usually orders them.
// Collaborative filtering: 'users who bought X also bought Y'
MATCH (me:User {id: $userId})-[:BOUGHT]->(p:Product)<-[:BOUGHT]-(other:User)
MATCH (other)-[:BOUGHT]->(rec:Product)
WHERE NOT (me)-[:BOUGHT]->(rec) AND rec <> p
WITH rec, count(DISTINCT other) AS strength
ORDER BY strength DESC
LIMIT 10
RETURN rec.name, rec.price, strength;
Q23How do you model fraud detection patterns in Neo4j?
IntermediatePatterns
Answer
Fraud rings are graph shapes, multiple accounts sharing a device, IP, phone number, or beneficiary account. In SQL this is a self-join nightmare; in Cypher it's a pattern match. Three common shapes: (1) Shared-device fraud, `(u1:User)-[:USED]->(d:Device)<-[:USED]-(u2:User)` and look for clusters of 3+ users. (2) Money mule networks, chains of transactions `(:Account)-[:TRANSFERRED*2..6]->(:Account)` that form a cycle back to the source. (3) First-party fraud, applicants sharing 2+ PII attributes (phone, email domain, address).
Razorpay's pattern: every UPI transaction creates a `(:Transaction)` node connected to `(:Payer)`, `(:Payee)`, `(:Device)`, `(:IP)`; a real-time service runs Cypher pattern matches on every new transaction and flags any that complete a known suspicious shape. Combine with GDS Louvain to find dense subcommunities and PageRank to score 'hubness'. What makes this hard in production is precision, not pattern syntax.
Shared devices are normal in India (family phones, shared handsets in small businesses, carrier-grade NAT putting thousands of users behind one IP), so a naive shared-device rule floods the review queue and the team stops trusting it. Real rules add a time window (`WHERE t.at > datetime() - duration('P1D')`), require two or more shared attributes, and exclude known-benign hubs by degree. Second, the rule runs on the write path, so it must be bounded: match outward from the new transaction, never start from `(:Device)` and scan the whole graph, and cap the hop count.
Third, the feedback loop matters more than the rule: every analyst verdict should be written back as a relationship so the next scoring run can learn from it. Interviewers follow up on how you evaluate a rule (precision at a fixed review capacity), how you avoid discriminatory proxies, and how you would explain a block to a regulator, which is where a printable path beats an opaque score.
// Detect device fraud: 3+ users sharing one device
MATCH (d:Device)<-[:USED]-(u:User)
WITH d, collect(DISTINCT u) AS users
WHERE size(users) >= 3
RETURN d.fingerprint,
[u IN users | u.email] AS suspiciousUsers,
size(users) AS userCount
ORDER BY userCount DESC;
Q24How do you handle large transaction batching for bulk imports?
IntermediateImport
Answer
A single Neo4j transaction holds all changes in memory until commit. Try to load 50M nodes in one transaction and you'll either OOM the JVM or hit a 30-minute lock timeout. The fix is batched commits, every N rows, commit and start a new transaction.
Three ways: (1) Native Cypher: `CALL { ... } IN TRANSACTIONS OF 10000 ROWS` (5.x preferred). (2) APOC: `apoc.periodic.iterate(outerQuery, innerQuery, {batchSize: 10000, parallel: true, retries: 3})`, has parallel mode for read-heavy work. (3) `neo4j-admin database import full`, bypasses transactions entirely for the initial empty-DB load, the only way to get a billion-node initial import in under an hour. Always ensure indexes/constraints are in place before the import; the cost of a label scan per MERGE explodes geometrically with size. Two more things a senior interviewer expects.
Neo4j 5.21 added concurrency to native batching, `CALL (row) { ... } IN 4 CONCURRENT TRANSACTIONS OF 1000 ROWS`, which is the modern replacement for APOC parallel mode and deadlocks in exactly the same way if two batches MERGE overlapping keys, so partition the input by the MERGE key before turning concurrency on. And batched transactions are not atomic as a whole: the default `ON ERROR FAIL` stops at the first failing batch and leaves earlier batches committed, while `ON ERROR CONTINUE` combined with `REPORT STATUS AS s` lets you collect the failures and re-run only those rows, which is what you want for a nightly feed with dirty data. Size batches by transaction memory rather than instinct: raise the batch until you approach `db.memory.transaction.max`, then back off, because tiny batches spend all their time on commit overhead and oversized ones die with `MemoryPoolOutOfMemoryError`. Also remember `IN TRANSACTIONS` requires an implicit transaction, so run it through an auto-commit session.
// Pattern 1: native Cypher
LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row
CALL {
WITH row
MATCH (p:Account {id: row.payerId}), (q:Account {id: row.payeeId})
CREATE (p)-[:TRANSFERRED {amount: toFloat(row.amount), at: datetime(row.ts)}]->(q)
} IN TRANSACTIONS OF 10000 ROWS;
// Pattern 2: APOC with parallel = true (read query) but parallel = false for writes touching shared nodes
CALL apoc.periodic.iterate(
'LOAD CSV WITH HEADERS FROM "file:///big.csv" AS row RETURN row',
'MERGE (u:User {email: row.email}) SET u.country = row.country',
{ batchSize: 5000, parallel: false, retries: 3 }
);
Q25How do you query Neo4j from Python or Node.js applications?
IntermediateDrivers
Answer
Official drivers exist for Python, Java, JavaScript/TypeScript, .NET, Go, and JVM. The pattern is the same in every language: instantiate a driver once at app startup (it's a thread-safe pool), then per request open a session, run one or more transactions, and close the session. Use `read` vs `write` transactions explicitly, in a clustered deployment, read transactions can be routed to follower nodes for load balancing.
Always parametrize Cypher (`$userId`, never string concatenation), both for SQL-injection-style safety and so the planner caches the compiled plan. Use connection pooling defaults (~100 connections per app instance is plenty), and reuse a single driver instance for the lifetime of the process. Interviewers push on what only shows up under load.
Managed transaction functions (`execute_read` / `execute_write` in Python, `executeRead` / `executeWrite` in JavaScript) retry automatically on `Neo.TransientError.*`, which covers deadlock victims and the leader switch during a cluster failover; a bare auto-commit `session.run()` gets no retry at all, so the classic production bug is an ingest job that dies every time the leader moves. Because the function body can be re-executed, it must be idempotent and free of side effects outside the transaction. Results are streamed cursors, not lists: touching one after its session closes raises `ResultConsumedError`, so materialise with `.data()` inside the session block.
Sessions are cheap and single-threaded while connections are not, so never share a session across threads or async tasks. Know the URI schemes, because they decide behaviour more than any config flag: `bolt://` pins you to one instance, `neo4j://` turns on cluster routing, `+s` requires a certificate that chains to a trusted CA and `+ssc` accepts self-signed. Tune `max_connection_pool_size` and `connection_acquisition_timeout`, and read `SessionExpired` as a routing-table refresh rather than an outage.
// Python, neo4j-python-driver 5.x
from neo4j import GraphDatabase
driver = GraphDatabase.driver('neo4j+s://prod-cluster:7687', auth=('app', os.environ['NEO_PWD']))
def top_orders(user_id: str):
with driver.session(database='neo4j') as s:
return s.execute_read(
lambda tx: tx.run(
'MATCH (u:User {id: $uid})-[:PLACED]->(o:Order) '
'RETURN o.id, o.amount ORDER BY o.amount DESC LIMIT 10',
uid=user_id,
).data()
)
Key Points
- One driver per app, many sessions per request
- Explicit read/write transactions for cluster routing
- Always parametrize ($var), never string-concatenate
Q26When does Neo4j beat a relational database, and when does it lose?
IntermediateComparison
Answer
Neo4j wins when (1) the queries are deeply connected, 3+ hops, recursive, variable-length. Friend-of-friend, ancestor lookup, fraud rings, supply-chain dependencies, knowledge-graph traversal. (2) The schema is evolving and you don't want to ALTER TABLE every week. (3) The relationships themselves have rich semantics (timestamps, weights, types), modelling 'PLACED at 2026-05-01 with confidence 0.92' is awkward in SQL with a join table. (4) Pattern matching is the primary query shape. Neo4j loses against Postgres when (1) the workload is mostly tabular aggregation, 'sum revenue by region by month for the last 3 years'.
Columnar systems or Postgres demolish Neo4j here. (2) You need joins with no graph shape, 'list all orders with status=OPEN'. A single Postgres index lookup is faster than a label scan. (3) Single-row lookups by primary key at very high QPS, Postgres + PgBouncer is faster. (4) Mature BI tooling, Looker/Tableau have nothing graph-native. Practical rule: use Postgres for OLTP and transactional data, Neo4j alongside for the highly connected slice (fraud, recommendations, identity graph).
Two moves sharpen this answer in an interview. First, quantify the crossover instead of saying 'three joins': the break point is where the intermediate result set grows per join, because each self-join level re-probes an index and multiplies rows, while Neo4j pays average-degree cost per hop and stays flat as the table grows. Second, be honest that Postgres fakes a lot of this well.
A `WITH RECURSIVE` CTE handles a three-level org tree, and the `ltree` extension handles fixed hierarchies, so those are not reasons to add a database. The real cost of Neo4j is operational: dual writes or a CDC pipeline, a second backup and DR runbook, and engineers who can read Cypher during an incident. Adopt it when you can name the one query whose latency budget the recursive CTE misses by an order of magnitude.
Key Points
- Graph wins: 3+ hops, recursion, rich relationships, fraud, recommendations
- SQL wins: large aggregations, OLTP single-row writes, BI
- Common pattern: Postgres OLTP + Neo4j as a graph-shaped projection
Q27How does Neo4j compare to RDF / SPARQL stores like GraphDB?
IntermediateComparison
Answer
RDF stores (Ontotext GraphDB, Apache Jena, Stardog, Blazegraph) and Neo4j both store graphs, but the model and the tribe differ. RDF is built around triples, subject-predicate-object, and the SPARQL query language. It's heavily standardised (W3C), excels at federation across multiple datasets via SPARQL endpoints, and natively supports ontologies and reasoning (RDFS, OWL).
It's the format of choice for academia, life sciences, government open data. Neo4j's property graph is more developer-friendly, relationships have IDs and properties as first-class citizens (in RDF you have to reify, which is awkward), Cypher reads more naturally than SPARQL, and tooling/ecosystem (drivers, GDS, APOC) is much richer. For most application workloads (fraud, recommendations, knowledge graphs feeding LLMs), Neo4j ships faster.
For deeply formal ontologies, multi-organization data sharing, and inferencing-heavy queries, RDF stores remain the right pick. Neo4j 5.x has limited RDF import via the n10s plugin to bridge the two worlds. Get concrete on the three practical differences.
Reification: attaching a timestamp or a confidence score to a fact costs one relationship property in Cypher, while in RDF you either mint a blank node for the statement or use RDF-star (`<< :a :knows :b >> :since 2024`), and support for RDF-star still varies by store. Identity: RDF nodes are globally unique IRIs, which is exactly why federation works, whereas Neo4j identifiers are local to the database, so cross-organisation joins need a shared key you agree on yourself. Inference: an RDF store can answer 'is a Cardiologist a Doctor' from an OWL subclass axiom with no query change, while in Neo4j you either write the extra label at ingest or add `(:Cardiologist)-[:SUBCLASS_OF*]->(:Doctor)` to the pattern. If you do need both, neosemantics (`CALL n10s.graphconfig.init()` then `n10s.rdf.import.fetch`) maps IRIs onto labels and relationship types, and `n10s.rdf.export.cypher` goes the other way, though the round trip is lossy on ontology axioms.
Q28When do you use a CALL subquery versus EXISTS { } and COUNT { } subqueries?
IntermediateCypher
Answer
Neo4j 5 gave Cypher three subquery forms and they solve different problems. A `CALL { ... }` block runs a whole query per incoming row and returns rows back into the outer query, which is how you do per-row LIMIT (top three orders per user, impossible with a single outer LIMIT), UNION branches inside one query, and post-union aggregation. Its one rule is scoping: the block sees nothing from outside unless you import it with a leading `WITH u`, and the imported WITH cannot contain expressions, so you sometimes need a `WITH u.id AS uid` before the CALL.
From 5.23 you can write `CALL (u) { ... }` to import variables directly, and `CALL { ... } IN TRANSACTIONS` is the same construct used for batched writes. `EXISTS { MATCH ... }` and `COUNT { MATCH ... }` are expressions, not clauses, so they live inside WHERE or RETURN. They replace two older idioms that interviewers still test: the pattern predicate `WHERE (u)-[:PLACED]->(:Order)`, and `size((u)-[:PLACED]->())`, which was deprecated in 5.x in favour of `COUNT { }`. The functional difference is short-circuiting.
EXISTS stops at the first match, so it is cheap even on a node with a million relationships, while COUNT must expand all of them; using `COUNT { } > 0` where EXISTS would do is a classic super-node performance bug. Both accept a full MATCH with WHERE, ORDER BY and even nested subqueries, which is why they beat the old pattern predicates for anything conditional.
// Per-row LIMIT: top 3 orders per user (impossible with one outer LIMIT)
MATCH (u:User {country: 'IN'})
CALL {
WITH u
MATCH (u)-[:PLACED]->(o:Order)
RETURN o ORDER BY o.amount DESC LIMIT 3
}
RETURN u.email, collect(o.id) AS topOrders;
// EXISTS short-circuits at the first match; COUNT expands everything
MATCH (u:User)
WHERE EXISTS { (u)-[:PLACED]->(:Order {status: 'FAILED'}) }
AND COUNT { (u)-[:PLACED]->(:Order) } > 5
RETURN u.email;
Key Points
- CALL { } = clause, runs per row, enables per-row LIMIT and UNION
- Imported variables need a leading WITH, or CALL (u) { } in 5.23+
- EXISTS { } short-circuits; COUNT { } expands every relationship
- COUNT { } replaced the deprecated size((a)-[:R]->()) idiom
Q29How do you decide whether something should be a node, a relationship, or a property?
IntermediateModelling
Answer
The decision rule is query-driven, not ontology-driven: model whatever your traversals need to start from or filter on. Make it a property when it is a leaf value you only ever read after reaching the node (a name, an amount, a status). Make it a relationship when it connects two things and carries at most a few scalar attributes you never search across (a `:RATED {stars, at}` edge).
Promote it to a node the moment one of three signals appears. First, you want to find things by it: a country stored as `u.country` cannot be traversed, so once you ask 'which users share a country with Asha' a `(:Country)` node with an index turns a label scan into two hops. Second, it needs its own relationships: an order that connects a user, a payment, a courier and five line items is an entity, so `(:User)-[:PLACED]->(:Order)` beats a `:BOUGHT` relationship carrying a JSON blob.
Third, the fact itself has history: a job title that changes over time becomes `(:Person)-[:HELD {from, to}]->(:Role)` because a property can only hold the current value. The counter-pressure is degree. Promoting a low-cardinality value like `status` or `gender` to a node creates a super-node that every query funnels through, which is the single most common modelling mistake in graph interviews. The working heuristic: promote high-cardinality values you traverse from, keep low-cardinality values as indexed properties, and remember that relationship type is itself free filtering, so `:PAID_WITH_UPI` can beat `:PAID {method}` on a hot path.
// Property: fine while you only read it back
CREATE (u:User {email: 'asha@x.com', country: 'IN', tier: 'GOLD'});
// Node: needed once you traverse BY the value
MERGE (c:Country {code: 'IN'})
WITH c MATCH (u:User {email: 'asha@x.com'})
MERGE (u)-[:LIVES_IN]->(c);
// Reified relationship: the fact has its own history and edges
MATCH (p:Person {id: $pid}), (r:Role {title: 'SDE-3'})
CREATE (p)-[:HELD {from: date('2024-04-01'), to: null}]->(r);
Q30How do you test application code that talks to Neo4j?
IntermediateTesting
Answer
Mocking the driver is the wrong default, because almost every Neo4j bug is in the Cypher itself: a missing DETACH, an OPTIONAL MATCH that inner-joins, a MERGE on the wrong key. Mocks assert that you sent a string, which is exactly the thing you are trying to verify. The standard answer in 2026 is Testcontainers, which starts a real `neo4j:5.26-enterprise` container per test suite, gives you a bolt URI, and tears it down afterwards.
Enterprise images need `NEO4J_ACCEPT_LICENSE_AGREEMENT=yes` and are worth it when your code uses NODE KEY or property-type constraints, since Community rejects those and your migration tests would pass locally and fail in staging. Add APOC with `withPlugins` or by mounting the jar if your queries call it, because a missing plugin surfaces as 'There is no procedure with the name' at runtime rather than at startup. Isolation between tests is the real design decision. `MATCH (n) DETACH DELETE n` is simple but leaves indexes and constraints behind and gets slow; `CREATE OR REPLACE DATABASE test` is clean and fast on Enterprise; the fastest pattern is one container for the whole suite, schema applied once, and each test wrapped in a transaction that is rolled back.
Also test the things unit tests normally skip: run EXPLAIN over every registered query in CI and fail on `AllNodesScan`, `NodeByLabelScan` or a CartesianProductWarning, and assert that migrations are idempotent by applying them twice. For pure Cypher logic with no application code, `cypher-shell --file` against a seeded container works fine.
# Python, testcontainers + pytest
import pytest
from testcontainers.neo4j import Neo4jContainer
from neo4j import GraphDatabase
@pytest.fixture(scope='session')
def driver():
with Neo4jContainer('neo4j:5.26') as neo:
d = GraphDatabase.driver(neo.get_connection_url(), auth=('neo4j', 'password'))
with d.session() as s:
s.run('CREATE CONSTRAINT u_email IF NOT EXISTS '
'FOR (u:User) REQUIRE u.email IS UNIQUE')
yield d
d.close()
@pytest.fixture(autouse=True)
def clean(driver):
yield
with driver.session() as s:
s.run('MATCH (n) DETACH DELETE n')
Q31Why was id() deprecated in favour of elementId(), and what else changed between Neo4j 4.x and the 2025 releases?
IntermediateVersions
Answer
`id()` returned a long that was really an offset into the store file, so it was reused after deletion and was only unique within one database. In a 5.x cluster with multiple databases and composite databases federating across them, that guarantee breaks, so 5.0 introduced `elementId()`, a string that encodes the database and entity, and deprecated `id()`. The practical rule is the one interviewers want: never persist either of them outside the database and never use them as a foreign key in another system, because a delete plus reinsert can hand the same `id()` to a different node.
Store your own `uuid` or external id with a UNIQUE constraint and look up by that. The other breaking changes worth naming: RANGE and TEXT indexes replaced BTREE in 5.0, so a 4.x dump will not start until replacements exist; `USING PERIODIC COMMIT` was removed in favour of `CALL { } IN TRANSACTIONS`; `exists(n.prop)` on a property became `n.prop IS NOT NULL`; `size((a)-[:R]->())` gave way to `COUNT { }`; configuration keys were renamed wholesale to the `server.*` and `db.*` namespaces, so `dbms.memory.pagecache.size` is now `server.memory.pagecache.size`; and cluster core and read-replica roles became primaries and secondaries declared per database. Since 5.26, which is the LTS release most Indian teams standardise on, Neo4j moved to calendar versioning, so the releases after it are named by year and month rather than 5.27. When asked 'which version', the safe production answer is the current LTS unless you need a feature that only landed later.
// Deprecated: reused after delete, unique only per database
MATCH (u:User) RETURN id(u);
// 5.x: opaque string, safe within the DBMS, still not a business key
MATCH (u:User) RETURN elementId(u);
// What you should actually key on
CREATE CONSTRAINT user_uuid IF NOT EXISTS
FOR (u:User) REQUIRE u.uuid IS UNIQUE;
MATCH (u:User {email: $email}) SET u.uuid = coalesce(u.uuid, randomUUID());
Key Points
- id() is a reusable store offset; elementId() is DBMS-scoped and stable
- Never persist either outside Neo4j, use your own UUID with a constraint
- 5.0 removed BTREE and USING PERIODIC COMMIT, renamed config to server.*/db.*
- 5.26 is the LTS; later releases use calendar versioning
Q32How do you architect Neo4j for high availability and what is the role of clustering in 5.x?
AdvancedArchitecture
Answer
Neo4j Enterprise 5.x uses an autonomous clustering model based on Raft consensus. The recommended minimum is a 3-instance cluster, one elected leader handles writes, two followers replicate the transaction log and serve reads. With three instances you tolerate one node failure; with five, two.
Reads can be load-balanced across followers with the `neo4j+s://` routing protocol, drivers see the cluster topology and route automatically. For multi-region or sharded deployments, Neo4j 5 introduced 'composite databases', a single virtual database that federates queries across multiple physical databases, useful when a single graph exceeds 10TB or you need data residency in multiple regions. Backups: continuous archive of transaction logs to S3 + nightly `neo4j-admin database backup`.
For RPO < 1 minute, run synchronous replication to a standby cluster in another region. Razorpay-class fintech setups in India: 3-node primary cluster + a 3-node DR cluster in another GCP region with async replication, with leader-failover automation via Aura or self-managed via Kubernetes operators. Monitor via Prometheus metrics endpoint, alert on `dbms.cluster.raft.is_leader` flapping and on replication lag.
The detail that separates operators from readers is what 'autonomous' changed in 5.x. Core and read-replica roles from 4.x are gone: you now declare a topology per database with `CREATE DATABASE orders TOPOLOGY 3 PRIMARIES 2 SECONDARIES`, and the cluster itself decides which servers host which database, so one five-server cluster can run twenty databases with different replication factors. Writes are still single-leader per database, so no amount of hardware raises the write ceiling; the only real scaling levers are batching, splitting into separate databases, or composite databases.
Followers are asynchronous by default, which means a client can write on the leader and then read a stale value from a follower. The fix is bookmarks, which every official driver passes automatically inside one session and which you must forward yourself when a write in one service is followed by a read in another. Reads also do not have to be routed away: `session(default_access_mode=READ)` plus `neo4j://` is what actually sends them to a secondary. Community Edition has no clustering at all, so single-node plus a fast restore is the honest HA story there.
Key Points
- 3-node Raft cluster: 1 leader + 2 followers tolerates 1 failure
- Drivers auto-route reads to followers via neo4j+s://
- Composite databases for multi-region / very large graphs (5.x)
- WAL archive to S3 + nightly backup = standard DR setup
Q33How do you tune query performance on a graph with billions of nodes?
AdvancedPerformance
Answer
Order of impact: (1) Always profile, run `PROFILE` on the actual query, identify the leaf operator and high-db-hits operators, then pick the right index. (2) Use composite indexes that match the WHERE clause order. (3) Cap variable-length paths with explicit upper bounds and use `shortestPath()` when you only need one. (4) Use server-side cursors (`session.read_transaction`) for large result sets so you stream rather than buffer all rows. (5) Pre-aggregate hot queries, materialize a `:User.totalSpend` property updated nightly instead of recomputing from a billion `:PLACED` relationships on each query. (6) For dense super-nodes (a celebrity user with 10M followers), use relationship indexes (Neo4j 5.x) and/or split the super-node into shards. (7) Tune page cache, set it to ~50% of RAM, ensure the entire hot relationship store fits. (8) Use parallel runtime (`CYPHER runtime=parallel`) for analytical reads, single query can use all cores. (9) Push aggregations into GDS where Cypher would do 100 lines. (10) Switch to read replicas for analytical workloads so OLTP latency stays clean. The biggest win on every cluster I've tuned is index strategy, adding the right composite index drops p99 from 8s to 50ms on a 10B-edge graph.
Key Points
- PROFILE → fix the leaf op (NodeIndexSeek > LabelScan > AllNodesScan)
- Composite indexes match WHERE-clause order
- Server-side cursors for streaming large result sets
- Page cache ~= half of RAM; hot stores must fit in RAM
- Materialize hot aggregations; split super-nodes
Q34How would you architect a real-time fraud detection system on Neo4j for a fintech like Razorpay?
AdvancedArchitecture
Answer
The pipeline has four stages. (1) Ingest, every transaction event (UPI, card, NEFT) is published to Kafka by the payment service. A Kafka consumer (Flink / Kafka Streams / a Go service) MERGEs the transaction, payer, payee, device, and IP into Neo4j inside a single transaction, p99 < 50ms. Use UNIQUE constraints + MERGE on all natural keys so ingest is idempotent. (2) Rule engine, for every new transaction, run a set of pre-defined Cypher pattern queries: shared device with 3+ users, payment chain returning to source in 5 hops, beneficiary appearing in 5+ flagged transactions in last 24h.
Each rule is parameterised by the new transaction's ID; total Cypher work < 100ms per transaction. (3) Score model, a periodically refreshed GDS pipeline computes Louvain communities, PageRank, and similarity embeddings on the rolling 90-day graph; scores are written back as properties on `(:Account)`. The synchronous rule engine reads these scores in (2). (4) Action, high-risk transactions are flagged in the same response (block or step-up auth); medium-risk go to a manual review queue. Operational: separate read-only follower cluster for analyst queries so production traffic isn't impacted, point-in-time backup every 5 minutes, audit log of every MERGE/SET for compliance with RBI / PCI-DSS. Razorpay's RX-style fraud stack is essentially this shape.
Key Points
- Kafka → idempotent MERGE into Neo4j, p99 < 50ms
- Pattern-match rules (Cypher) for synchronous block/allow
- GDS batch jobs for community / centrality scores written as properties
- Read replica cluster for analyst queries
- Audit trail + PITR for regulatory compliance
Q35How do vector indexes in Neo4j 5.x change RAG / LLM architectures?
AdvancedVector Search
Answer
Neo4j 5.11 added vector indexes, you can store embedding vectors on nodes and search by cosine / euclidean similarity using `db.index.vector.queryNodes(indexName, k, queryVector)`. The big idea for RAG: instead of running a vector store (Pinecone, Weaviate, Qdrant) alongside a knowledge graph, store both in Neo4j. A document chunk becomes `(:Chunk {text, embedding})`, and you connect chunks to the entities they describe, `(:Chunk)-[:MENTIONS]->(:Person|:Product|:Concept)`.
At query time: (1) embed the user's question. (2) Vector-search top-k similar chunks. (3) Graph-expand from those chunks to related entities and adjacent chunks. (4) Feed the expanded context to the LLM. This 'GraphRAG' pattern gives much better answers than pure vector search because the graph adds explicit relationships the embedding can't capture. Neo4j's vector index is HNSW under the hood, performance is competitive with dedicated stores at moderate scale (10M-100M vectors); at billions, dedicated vector stores still win.
The huge advantage is unified ACID semantics, your document updates and graph updates commit together, no eventual consistency between stores. Three operational facts decide whether this works for you. The index is approximate, so `db.index.vector.queryNodes` returns the k nearest by HNSW traversal and can miss a true neighbour; you cannot filter inside the call either, so a tenant-scoped or date-scoped search has to over-fetch (ask for 200, filter down to 10) or you silently return fewer rows than the caller expects.
Dimensions and the similarity function are fixed at creation time by the `vector.dimensions` and `vector.similarity_function` keys inside the index `OPTIONS` map, so changing embedding model means dropping and rebuilding the index, and a vector of the wrong length is rejected at write time rather than being coerced. Memory is the real cost: an HNSW graph is held largely in page cache, roughly four bytes per dimension per vector plus graph overhead, so ten million 1536-dimension vectors is tens of gigabytes before your actual graph gets any cache at all. Also know `db.index.vector.queryRelationships` for embeddings on relationships (5.18+) and the LangChain and LlamaIndex `Neo4jVector` integrations, which is how most teams actually wire retrieval.
// Create vector index on chunk embeddings
CREATE VECTOR INDEX chunkEmbeddings IF NOT EXISTS
FOR (c:Chunk) ON c.embedding
OPTIONS { indexConfig: { `vector.dimensions`: 1536, `vector.similarity_function`: 'cosine' } };
// GraphRAG retrieval, vector search + 1-hop graph expansion
CALL db.index.vector.queryNodes('chunkEmbeddings', 5, $questionEmbedding)
YIELD node AS chunk, score
MATCH (chunk)-[:MENTIONS]->(e)<-[:MENTIONS]-(neighbour:Chunk)
WITH chunk, score, collect(DISTINCT neighbour.text)[..3] AS contextChunks
RETURN chunk.text AS primary, contextChunks, score
ORDER BY score DESC;
Key Points
- Vector index = HNSW, store embeddings on nodes (5.11+)
- GraphRAG = vector search + graph expansion in one query
- Unified ACID with the rest of the graph, no dual-write problem
- Competitive at 10M-100M vectors; dedicated stores win at 1B+
Q36How do you handle schema migration and zero-downtime deployments on a production Neo4j cluster?
AdvancedOperations
Answer
Neo4j is schema-flexible, you don't need to ALTER TABLE to add a property, but indexes and constraints are real schema and need migrations. Use a tool like Liquibase Neo4j or `neo4j-migrations` (open source, widely used) to version-control changesets and apply them deterministically across environments. Migration rules: (1) Online operations only, `CREATE INDEX` and `CREATE CONSTRAINT` populate in the background; don't run blocking versions in business hours. (2) Backfills run as batched Cypher (`CALL ...
IN TRANSACTIONS`) or APOC `apoc.periodic.iterate`, never as a single transaction. (3) Forward-compatible code first, deploy app code that tolerates the new property being absent, then run the backfill, then deploy code that requires it. Classic strangler pattern. (4) Renaming labels or relationship types: use APOC's `apoc.refactor.rename.label` / `apoc.refactor.rename.type`, also batched. (5) Test every migration on a snapshot of production before running. (6) Always have a rollback plan, most schema changes are reversible (drop the constraint, drop the index); data backfills are not, so make backups. In production at scale, run migrations against a follower first (read-only verify), then promote, or use `neo4j-admin database copy` to clone the DB for safe test runs. Razorpay-grade compliance also requires every migration to be approved and auditable, so the migration tool's changelog gets checked into version control with sign-off.
Key Points
- Use neo4j-migrations or Liquibase to version-control schema
- Online indexes/constraints; batched backfills with APOC or IN TRANSACTIONS
- Forward-compatible code → backfill → require-new-schema (strangler)
- Test on a copy of production before running
- Audit trail of every migration for regulatory compliance
Q37How do you handle super-nodes, and what breaks when one node has ten million relationships?
AdvancedPerformance
Answer
A super-node is a node whose degree is orders of magnitude above the average: a celebrity account, a shared payment gateway, a `(:Country {code:'IN'})` node every user points at. Index-free adjacency means each hop costs average degree, so one super-node in the middle of a pattern turns a millisecond traversal into a full scan of its relationship chain. Symptoms in PROFILE are unmistakable: an `Expand(All)` operator with tens of millions of db hits while every other operator is in the hundreds, and a plan whose estimated rows are wildly below actual because statistics assume an even degree distribution.
Concurrency degrades too, since any write touching that node takes a lock every other writer then queues behind, which shows up as rising `db.lock.acquisition.timeout` errors rather than slow reads. Five mitigations, roughly in order of preference. Traverse from the selective side, because `(u:User {email:$e})-[:LIVES_IN]->(c:Country)` is cheap while the reverse is not, and the planner will pick correctly only if the selective end is indexed.
Split by relationship type or time, so `:PLACED_2026_Q1` or a `(:User)-[:HAS_ORDERS]->(:OrderBucket {month})` fan-out layer caps any single chain. Denormalise the answer onto the node as a counter or a precomputed property refreshed nightly rather than counting on read. Use a relationship property index (5.x) so the filter happens in the index instead of during expansion. And question the model: a low-cardinality value promoted to a node is usually a super-node you created for no traversal benefit, and belongs back as an indexed property.
// Diagnose: find your worst offenders before they find you
MATCH (n)
WITH n, COUNT { (n)--() } AS degree
WHERE degree > 100000
RETURN labels(n) AS labels, elementId(n) AS id, degree
ORDER BY degree DESC LIMIT 20;
// Mitigate: bucket a hot fan-out by month so no chain is unbounded
MATCH (u:User {id: $uid})
MERGE (u)-[:HAS_BUCKET]->(b:OrderBucket {month: date().year * 100 + date().month})
CREATE (b)-[:CONTAINS]->(o:Order {id: $orderId, amount: $amount});
Key Points
- Degree, not graph size, sets traversal cost per hop
- PROFILE symptom: one Expand(All) with millions of db hits
- Locks on the hot node serialise every writer
- Fixes: traverse from the selective side, bucket by time, denormalise counters
Q38How do you size heap and page cache for a Neo4j server, and what drives the bill?
AdvancedOperations
Answer
Neo4j has two memory pools and confusing them is the most common self-inflicted outage. Page cache (`server.memory.pagecache.size`) holds the node, relationship, property and index store files; it should be large enough for the hot working set, ideally the whole node and relationship store, because a page cache miss is a disk read on every hop. Heap (`server.memory.heap.initial_size` and `server.memory.heap.max_size`, always set equal to avoid resize pauses) holds transaction state, query execution, and the plan cache.
A rough starting split on a dedicated box is roughly half of RAM to page cache, a quarter to heap capped near 31GB so the JVM keeps compressed object pointers, and the rest to the OS. `neo4j-admin server memory-recommendation` reads your store sizes and prints a config block, which is the answer to give rather than a memorised ratio. Guard rails matter as much as sizing: `db.memory.transaction.total.max` caps all concurrent transactions and `db.memory.transaction.max` caps a single one, and without them one runaway `collect()` takes the whole instance down instead of failing its own query. Verify with the page cache hit ratio metric, where sustained values below roughly 98 percent on an OLTP workload mean the working set does not fit.
On cost: Aura is billed per instance-hour by memory tier with storage scaling alongside, so memory is the price lever and dropping unused indexes genuinely reduces the bill. Self-managed Enterprise is licensed separately from the hardware, and GDS Enterprise is another line item, which is why teams prototype on Community and budget the tier before the pilot ends.
# Ask the server what it wants, then paste the output into neo4j.conf
neo4j-admin server memory-recommendation --memory=64g --docker
# Typical neo4j.conf for a 64GB dedicated box
server.memory.heap.initial_size=16g
server.memory.heap.max_size=16g
server.memory.pagecache.size=32g
db.memory.transaction.total.max=8g
db.memory.transaction.max=2g
# Verify at runtime
# SHOW SETTINGS YIELD name, value WHERE name STARTS WITH 'server.memory'
Q39How do you lock down a production Neo4j deployment with RBAC and fine-grained privileges?
AdvancedSecurity
Answer
Enterprise Edition gives you role-based access control down to the label and property level, which is what makes a shared graph safe for analysts, services and support staff. The model is roles built from grants: `GRANT ACCESS ON DATABASE`, then `GRANT TRAVERSE ON GRAPH ... NODES :Label`, `GRANT READ {prop} ...`, and `GRANT MATCH` as the shorthand for traverse plus read.
Write privileges are separate (`CREATE`, `DELETE`, `SET PROPERTY`, `MERGE`), and administration is its own set (`GRANT CREATE INDEX`, `GRANT EXECUTE PROCEDURE`). DENY always beats GRANT, which is how you expose an analyst role to the whole graph while hiding a column: grant MATCH on everything, then `DENY READ {pan, aadhaar} ON GRAPH neo4j NODES User`. The subtlety worth stating is that a denied property reads as null rather than raising an error, and a node you cannot TRAVERSE is invisible rather than forbidden, so queries silently return fewer rows, which is good for security and confusing during support calls.
Audit with `SHOW PRIVILEGES` and `SHOW ROLE analyst PRIVILEGES AS COMMANDS`, which prints replayable statements you can check into version control. Beyond RBAC: change the default `neo4j/neo4j` credentials at first boot, keep `dbms.security.auth_enabled=true`, terminate Bolt over TLS with `neo4j+s://` clients, restrict procedure escapes by keeping `dbms.security.procedures.unrestricted` to the minimum APOC list, and use `GRANT IMPERSONATE` plus the query log rather than shared service accounts so every statement traces to a human. Community Edition has none of this, only a single all-powerful user.
// Read-only analyst who cannot see PII
CREATE ROLE analyst IF NOT EXISTS;
GRANT ACCESS ON DATABASE neo4j TO analyst;
GRANT MATCH {*} ON GRAPH neo4j NODES * TO analyst;
GRANT MATCH {*} ON GRAPH neo4j RELATIONSHIPS * TO analyst;
DENY READ {pan, aadhaar, phone} ON GRAPH neo4j NODES User TO analyst;
DENY TRAVERSE ON GRAPH neo4j NODES InternalAudit TO analyst;
CREATE USER priya SET PASSWORD $pwd CHANGE REQUIRED;
GRANT ROLE analyst TO priya;
// Audit what a role can actually do
SHOW ROLE analyst PRIVILEGES AS COMMANDS;
Q40A Cypher query that was fast yesterday is timing out today. How do you debug it in production?
AdvancedDebugging
Answer
Work outside-in, because the query text usually did not change. First, see what is actually running: `SHOW TRANSACTIONS` gives you the live Cypher, elapsed time, allocated bytes, status and the client that submitted it, and `TERMINATE TRANSACTIONS $id` stops a runaway without restarting the server. If many transactions sit in status 'Blocked', you have lock contention rather than a slow plan, and the culprit is usually a MERGE on a shared node.
Second, read the query log. With `db.logs.query.enabled=INFO` and `db.logs.query.threshold=1s`, Neo4j writes slow statements with parameters, planning time and page-cache hits, which tells you whether the regression is one parameter value or all of them. Third, suspect the plan, not the query.
Plans are cached per query shape, so a plan compiled while the parameter was a low-degree node gets reused for a super-node; a bulk load also leaves statistics stale, and the tell is a plan whose estimated rows are orders of magnitude below actual. `CALL db.clearQueryCaches()` forces a re-plan and is a safe, reversible experiment. Fourth, check the machine before blaming Cypher: a page cache hit ratio that fell after a data load means the working set no longer fits, and long GC pauses look identical to slow queries from the client side. Fifth, confirm the data changed shape, since the usual root cause is a node whose degree exploded overnight.
Only then re-run with PROFILE on a copy. Set `db.transaction.timeout` so a pathological query dies on its own instead of holding locks for an hour.
// 1. What is running right now, and who sent it
SHOW TRANSACTIONS
YIELD transactionId, currentQuery, elapsedTime, status, allocatedBytes, clientAddress
WHERE elapsedTime > duration('PT5S')
RETURN transactionId, status, elapsedTime, allocatedBytes, currentQuery
ORDER BY elapsedTime DESC;
// 2. Kill the runaway
TERMINATE TRANSACTIONS 'neo4j-transaction-4821';
// 3. Suspect a cached plan compiled for a different parameter value
CALL db.clearQueryCaches();
// 4. Did a node's degree explode overnight?
MATCH (n:Account) WITH n, COUNT { (n)--() } AS d
RETURN max(d) AS worstDegree, avg(d) AS avgDegree;
Key Points
- SHOW TRANSACTIONS / TERMINATE TRANSACTIONS before anything else
- Status 'Blocked' means lock contention, not a bad plan
- Cached plans plus stale statistics explain most overnight regressions
- Check page cache hit ratio and GC before re-profiling the Cypher
Frequently Asked Questions
Is Neo4j free to use in production?
Neo4j Community Edition is free and open-source (GPLv3), runs on a single node, and is enough for many production use cases. Enterprise Edition adds clustering, role-based access, online backups, and is what you need for HA, it's commercial (subscription) but also available as managed Neo4j Aura. Most Indian startups start on Community, move to Aura or self-managed Enterprise as scale demands.
How much does a Neo4j developer earn in India?
₹8-25 LPA in 2026 for mid-to-senior backend / data engineers who pair Neo4j with strong SQL or distributed-systems experience. Companies hiring: Razorpay, Cred, Flipkart, Myntra, Swiggy, Tata 1mg, UBS, eBay India, and most fraud/recommendation teams at fintechs. Knowing Cypher AND GDS AND a vector index workflow (RAG) puts you at the upper end of the band.
Is Cypher hard to learn coming from SQL?
Easier than most people expect. The mental shift is from joins to pattern-matching, once you can read `(a:User)-[:FRIEND]->(b:User)` as 'user a is a friend of user b', the rest follows. A SQL developer who builds something real (a friend-of-friend query, a recommendation engine) can be productive in a week, and fluent in a month. The Cypher → GQL standardisation in 2024 makes the investment more durable than ever.
When should I NOT use Neo4j?
Skip Neo4j if your queries are mostly tabular aggregations (use Postgres / ClickHouse), if you only need single-key lookups at huge QPS (Redis / DynamoDB), or if your data isn't actually connected (no graph shape = no graph win). A common anti-pattern is forcing a graph model on simple OLTP data just because graphs are interesting, you'll lose on operational simplicity. The right call is usually Postgres for OLTP plus Neo4j alongside for the connected slice (fraud, recommendations, identity).
Does Neo4j support ACID transactions like SQL databases?
Yes, full ACID, including across multiple nodes and relationships in a single transaction. Write-ahead log on disk for durability, MVCC isolation, deferred constraint checking. This is what separates Neo4j from non-ACID graph systems like older Titan/JanusGraph, financial-grade fraud detection at Razorpay or UBS requires that a fraud flag and a transaction record commit together or not at all.
What's the role of Neo4j in LLM and RAG architectures in 2026?
Neo4j has become a serious contender for knowledge-graph-backed RAG (GraphRAG) since vector indexes landed in 5.11. The pattern: store document chunks and their embeddings AS nodes, connect them to extracted entities (people, products, concepts), then at query time do vector search + graph expansion in one query. Anthropic, Microsoft, and many India AI startups (Sarvam, Krutrim partners) have published blog posts on GraphRAG over Neo4j; it consistently produces better answers than pure vector search on connected domains like medical records, legal contracts, and product catalogues.
Which Neo4j version should I learn and put on my CV in 2026?
Learn 5.26, the LTS that most production teams standardise on, and know what changed from 4.x because interviewers use it as a seniority filter: RANGE and TEXT indexes replaced BTREE, `USING PERIODIC COMMIT` became `CALL { } IN TRANSACTIONS`, `exists(n.prop)` became `n.prop IS NOT NULL`, `id()` gave way to `elementId()`, and config keys moved into the `server.*` and `db.*` namespaces. Releases after 5.26 use calendar versioning, so do not expect a 5.27. Saying 'Neo4j 5' with no specifics is a weaker answer than naming the LTS and one migration you have actually done.
Do I need APOC and GDS licences, and what do they cost to run?
APOC Core ships with the database and with Aura at no extra cost; APOC Extended (JDBC, MongoDB, Elasticsearch procedures) is a separate jar that Aura does not allow, which is why a laptop query can fail in the cloud with 'There is no procedure with the name'. GDS has a free tier that covers most algorithms on a single machine, while GDS Enterprise adds multi-threaded write-back, model catalogue persistence and larger projections, and is licensed separately from the database. On Aura, cost tracks the memory tier rather than query volume, so the practical lever is keeping the working set small: drop unused indexes (`SHOW INDEXES YIELD name, readCount`) and avoid promoting low-cardinality values to nodes.
Introduction
Neo4j is the dominant graph database in 2026, and the property-graph model it pioneered has become the default mental model for graph data, even competing systems like Memgraph, AuraDB, and Amazon Neptune (when configured for openCypher) speak essentially the same query language. Neo4j 5.x (current LTS is 5.26) introduced server-side cursors, vector indexes for embeddings, parallel runtime, composite databases for sharding, and tighter integration with the Graph Data Science (GDS) library for production graph algorithms.
In India, Neo4j is the system of record at fintechs that need real-time fraud detection, Razorpay's risk team uses graph traversals to spot transaction rings in milliseconds, and Cred runs similar pipelines on UPI and credit-card flows. Flipkart, Swiggy, and Myntra use it under the recommendation engine where 'users who bought X also bought Y' becomes a one-hop Cypher query instead of a multi-table join. UBS, Walmart, and eBay use it for fraud, supply chain, and master-data management at much larger scale.
Interviews in 2026 go well beyond 'what is a node and an edge'. Hiring managers probe Cypher fluency (MATCH, MERGE, WITH, pattern comprehension), index strategy, query planning via EXPLAIN/PROFILE, when to use APOC procedures versus pure Cypher, GDS algorithms for shortest path / Louvain / PageRank, the gotchas around cartesian products and MERGE locking, transaction batching for large imports, and where graph beats relational SQL. Expect to defend schema decisions and explain why your traversal is doing 50 million db hits.
This guide covers the 40 most-asked Neo4j interview questions in 2026, grouped by difficulty. Each answer includes the underlying mechanism, common production gotchas, and a Cypher example where it adds clarity.
Ready to practice Neo4j interviews?
Don't just read, practice these Neo4j questions live with an AI interviewer that asks follow-ups and scores your answers.