neo4j is the database data engineers reach for the moment a query stops being "scan a table and aggregate" and starts being "follow the relationships" — the fraud ring three hops from a flagged account, the shortest supply-chain path between a raw material and a finished SKU, the recommendation that lives in "people who bought what you bought also bought." Those queries are the ones that turn a tidy relational schema into a tangle of self-joins whose cost curve bends upward with every extra hop, and they are exactly the queries a native graph database answers in flat, predictable time because the relationships are stored as first-class pointers rather than reconstructed by a join at read time. The engineering decision is not "graphs are cool" — it is a sober trade-off between a traversal-shaped workload and a scan-shaped one, and getting it right is what separates an engineer who reaches for a graph database when it earns its place from one who bolts a graph onto a problem a warehouse would have solved for free.
This guide is the data-engineering walkthrough you wished existed the first time an interviewer asked "when would you pick a graph database over Postgres, and what does the query cost look like at four hops?", or "translate this ER diagram into a property graph — what becomes a node and what becomes a relationship?", or "walk me through loading a billion-edge graph without falling over." It covers the five things every data engineer needs to hold in their head: why relationship-first modeling and index-free adjacency change the cost curve, the property graph model of nodes, relationships, labels, and properties and how to derive it from a relational schema, the Cypher query language end to end (pattern matching, MERGE, variable-length paths, aggregation), production graph ETL from LOAD CSV through batched idempotent loads to neo4j-admin bulk import and streaming connectors, and graph analytics with the Graph Data Science library — PageRank, community detection, shortest path — plus the anti-patterns where a graph database is the wrong tool. Each section pairs a teaching block with a Solution-Tail interview answer: runnable Cypher, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.
When you want hands-on reps immediately after reading, drill the graph practice library →, rehearse on the ETL practice library →, and sharpen the modeling axis with the data-transformation practice library →.
On this page
- Why data engineers reach for a graph database
- The property graph model — nodes, relationships, properties
- Cypher — MATCH, MERGE, paths, and aggregation
- Graph ETL — LOAD CSV, batched MERGE, and bulk import
- Graph analytics and interview signals
- Cheat sheet — Neo4j and Cypher recipes
- Frequently asked questions
- Practice on PipeCode
1. Why data engineers reach for a graph database
Index-free adjacency turns relationship traversal from a join into a pointer-chase — that is the whole pitch
The one-sentence invariant: a graph database stores relationships as physical, dereferenceable pointers between records so that traversing from one entity to its neighbours is a constant-time pointer-chase instead of an index lookup plus a join, which means multi-hop queries — the fraud ring three hops out, the reachability question five hops out — run in time proportional to the result you touch rather than to the tables you scan. In a relational database, "who are the friends of the friends of Alice" is two self-joins on a friendships table, and each join re-consults an index over the whole table; at four or five hops the join fan-out and the index re-lookups make the query cost explode. In Neo4j the same question walks the KNOWS pointers hanging directly off each node, so the cost is the number of relationships you actually traverse — not the size of any table.
The four axes data engineers actually weigh.
-
Query shape — traversal vs scan. Graph databases win when the workload is traversal ("follow these relationships from this starting point"). They lose when the workload is scan-and-aggregate ("sum revenue by region over 400M rows"). If your dominant query is a
GROUP BYover a fact table, you want a columnar warehouse, not a graph. - Relationship depth. The advantage grows with the number of hops. A single-hop join is cheap everywhere; the graph advantage compounds at three, four, five hops where the relational self-join fan-out goes non-linear. Ask "how deep does the deepest important query go?" first.
- Schema volatility. Relationships in a graph are schema-optional — adding a new relationship type is a write, not a migration. In relational, a new many-to-many relationship is a new join table plus foreign keys plus indexes. Highly connected, evolving domains (identity graphs, knowledge graphs, permission graphs) favour the graph.
- Write vs read skew. Graphs shine on read-heavy traversal workloads. Extremely high-throughput, single-row OLTP writes (a payments ledger appending millions of rows/sec) are not where a graph database earns its place; a purpose-built OLTP store does.
What "index-free adjacency" actually buys you.
- Constant-cost hops. Each node holds direct references to its relationship records; hopping to a neighbour does not consult a global index. The per-hop cost is independent of total graph size.
- Cost proportional to the answer. A traversal that visits 500 nodes costs ~500 node-visits whether the graph holds a thousand nodes or a billion. Relational join cost, by contrast, scales with the tables being joined.
-
Relationships as first-class citizens. A relationship carries a type, a direction, and its own properties (
SINCE,WEIGHT,AMOUNT). You query, filter, and weight relationships directly — you do not reconstruct them from foreign-key columns.
What interviewers listen for.
- Do you name index-free adjacency as the reason multi-hop traversal is cheap? — senior signal.
- Do you frame the choice as traversal-shaped vs scan-shaped workload rather than "graphs are faster"? — required answer.
- Do you name a concrete anti-pattern (aggregate scans, columnar analytics) unprompted? — senior signal.
- Do you describe relationships as typed, directed, and property-bearing, not just "edges"? — required answer.
Worked example — the friend-of-friend join explosion
Detailed explanation. The canonical demonstration of why data engineers reach for a graph database is the multi-hop friendship query. Model it both ways — a relational friendships(user_a, user_b) table and a Neo4j (:User)-[:KNOWS]->(:User) graph — and watch what happens to the query as the hop count climbs. The point is not that one hop is faster; it is that the shape of the cost curve diverges.
-
Relational cost. Each additional hop is another self-join; with an average degree of
d, ak-hop query fans out to roughlyd^kintermediate rows, each requiring an index re-lookup onfriendships. -
Graph cost. Each hop dereferences the
KNOWSpointers on the current frontier of nodes; the cost is the number of relationships actually traversed, capped by the reachable neighbourhood, not by table size. - Where it bites. At one or two hops the difference is invisible. At four hops on a social graph with average degree 50, the relational query is joining tens of millions of intermediate rows while the graph query walks the same reachable set once.
Question. Write the "friends of friends of friends" (three-hop) query both ways for user Alice, and explain why the relational plan degrades.
Input.
| Model | Storage | Three-hop query mechanism |
|---|---|---|
| Relational |
friendships(user_a, user_b) + index |
3 self-joins, index re-lookup per join |
| Graph (Neo4j) | (:User)-[:KNOWS]->(:User) |
walk KNOWS pointers on the frontier |
Code.
-- Relational: three-hop friends-of-friends-of-friends for Alice (id = 1)
SELECT DISTINCT f3.user_b AS reachable_user
FROM friendships f1
JOIN friendships f2 ON f2.user_a = f1.user_b
JOIN friendships f3 ON f3.user_a = f2.user_b
WHERE f1.user_a = 1
AND f3.user_b <> 1; -- exclude Alice herself
-- Each JOIN re-consults the index on friendships(user_a);
-- intermediate rows fan out as degree^hops.
// Neo4j: same three-hop reachability in Cypher
MATCH (alice:User {id: 1})-[:KNOWS*3]-(reachable:User)
WHERE reachable <> alice
RETURN DISTINCT reachable.id AS reachable_user;
// [:KNOWS*3] walks exactly three relationships out from the anchor node;
// the anchor is found once via the index on :User(id), then it is pointers.
Step-by-step explanation.
- The relational query anchors on
f1.user_a = 1using the index, then joinsf2ontof1.user_b— for every friend of Alice, it index-scansfriendshipsagain to find their friends, and once more for the third hop. Three index consultations, and the intermediate result set fans out multiplicatively. - With an average degree of 50, hop one yields ~50 rows, hop two ~2,500, hop three ~125,000 — and each of those rows triggered an index probe. The optimiser may hash-join instead, but the intermediate cardinality is the same order of magnitude.
- The Cypher query resolves the anchor
(:User {id: 1})once through the:User(id)index, then[:KNOWS*3]follows the physical relationship pointers stored on each node. There is no repeated global index lookup — each hop is a local pointer dereference. -
RETURN DISTINCTin both cases deduplicates the reachable set; the difference is entirely in how the intermediate frontier was produced. The graph engine's traversal cost tracks the reachable neighbourhood; the relational cost tracks the join fan-out. - The practical lesson: benchmark at the hop depth your real queries use. A one-hop benchmark will show the two systems as roughly equal and hide the entire reason you would reach for a graph.
Output.
| Hop depth | Relational intermediate rows (deg≈50) | Graph node-visits | Practical latency gap |
|---|---|---|---|
| 1 | ~50 | ~50 | negligible |
| 2 | ~2,500 | ~2,500 (reachable) | small |
| 3 | ~125,000 | reachable set only | graph clearly ahead |
| 4 | ~6,250,000 | reachable set only | graph wins by orders of magnitude |
Rule of thumb. Pick the model based on the deepest important query, not the shallowest. If your traversals stay at one hop, relational is fine; if any load-bearing query is three-plus hops on a densely connected domain, index-free adjacency is the reason to reach for neo4j.
Worked example — the "pick graph" decision signals
Detailed explanation. Given a new domain, a data engineer runs a short checklist to decide whether a graph database earns its place. Codifying the checklist makes the interview answer reproducible — an interviewer can hand you any domain and you can walk the signals out loud instead of hand-waving "it's very connected."
- Signal 1 — variable-length paths. Does an important query ask "how are A and B connected?" or "everything within N hops of X" where N is not fixed? Variable-length reachability is the graph's home turf.
-
Signal 2 — relationships carry data you filter on. Do you weight, timestamp, or type the connections themselves (
TRANSFERRED {amount, at})? First-class relationships favour a graph. - Signal 3 — the join count is embarrassing. Does the equivalent SQL need five-plus self-joins or a recursive CTE that the DBA fears? That is the relational model straining against a traversal workload.
-
Anti-signal — the query is an aggregate scan. If the dominant workload is
SUM/COUNT/GROUP BYover the whole dataset, a graph database is the wrong tool; the answer is a warehouse.
Question. Score three domains — a payments fraud-detection service, a monthly revenue-by-region report, and an IAM permission graph — against the signals and pick the store for each.
Input.
| Domain | Variable-length paths? | Relationships carry data? | Join-count pain? | Dominant aggregate scan? |
|---|---|---|---|---|
| Fraud detection | yes (rings, N hops) | yes (amount, time) | yes | no |
| Revenue-by-region report | no | no | no | yes |
| IAM permission graph | yes (inherited access) | yes (grant type) | yes | no |
Code.
# Decision helper — score a domain against the graph signals (illustrative)
def pick_store(variable_length_paths: bool,
relationships_carry_data: bool,
join_count_pain: bool,
dominant_aggregate_scan: bool) -> str:
if dominant_aggregate_scan and not variable_length_paths:
return "columnar warehouse (Snowflake/BigQuery/Delta)"
graph_score = sum([variable_length_paths,
relationships_carry_data,
join_count_pain])
if graph_score >= 2:
return "graph database (Neo4j)"
return "relational (Postgres) — traversal not deep enough"
print(pick_store(True, True, True, False)) # fraud detection
# -> 'graph database (Neo4j)'
print(pick_store(False, False, False, True)) # revenue report
# -> 'columnar warehouse (Snowflake/BigQuery/Delta)'
print(pick_store(True, True, True, False)) # IAM permissions
# -> 'graph database (Neo4j)'
Step-by-step explanation.
- Fraud detection scores 3/3 on the graph signals: ring detection is variable-length ("any cycle within 6 hops"), transfers carry
amountandtimestamp, and the SQL equivalent is a recursive CTE that most teams are scared to run in production. Neo4j earns its place. - The revenue-by-region report trips the anti-signal: it is a
GROUP BY regionaggregate over a fact table with no traversal at all. Forcing it into a graph would be slower and harder; a columnar warehouse is the correct home. Naming this unprompted is the senior signal. - The IAM permission graph scores 3/3: inherited access is a variable-length traversal ("does user U reach resource R through any group/role chain?"), grants carry a type, and the relational version is a join nightmare across users, roles, groups, and resources. Graph again.
- The helper short-circuits on the anti-signal first — a domain dominated by aggregate scans goes to the warehouse even if it has some connectivity, because the dominant workload decides the store. This ordering matters: connectivity alone does not justify a graph.
- In real architectures the answer is often "both" — a graph for the traversal queries and a warehouse for the aggregate reporting, fed from the same upstream events. The skill is knowing which query goes where.
Output.
| Domain | Decisive signal | Chosen store |
|---|---|---|
| Fraud detection | variable-length rings + weighted edges | Neo4j |
| Revenue-by-region | dominant aggregate scan | columnar warehouse |
| IAM permissions | inherited-access traversal | Neo4j |
Rule of thumb. Reach for a graph database when two or more of {variable-length paths, data-bearing relationships, embarrassing join counts} hold and the dominant workload is not an aggregate scan. Otherwise keep it relational or columnar — a graph is a specialised tool, not a default.
Data-engineering interview question on graph vs relational
A senior interviewer often opens with: "You are handed a fraud-detection requirement: given a flagged account, find every account connected to it within four hops through shared devices, shared cards, or money transfers, and flag rings — cycles where money returns to its origin. The team's instinct is a Postgres recursive CTE. Walk me through why you'd model this as a graph, sketch the model, and write the ring-detection query."
Solution Using a Neo4j property graph with typed relationships and variable-length traversal
// 1. The model — one Account node label, three typed relationships.
// Constraints make lookups O(1) and MERGE idempotent (covered in section 4).
CREATE CONSTRAINT account_id IF NOT EXISTS
FOR (a:Account) REQUIRE a.id IS UNIQUE;
// 2. Reachability: every account within 4 hops of the flagged account
// through ANY of the three relationship types.
MATCH (flagged:Account {id: $flaggedId})
MATCH path = (flagged)-[:SHARES_DEVICE|SHARES_CARD|TRANSFERRED*1..4]-(connected:Account)
WHERE connected <> flagged
RETURN DISTINCT connected.id AS account,
min(length(path)) AS nearest_hop
ORDER BY nearest_hop;
// 3. Ring detection: money that leaves the flagged account and returns to it,
// following TRANSFERRED direction, within 6 hops.
MATCH ring = (flagged:Account {id: $flaggedId})-[:TRANSFERRED*2..6]->(flagged)
RETURN [n IN nodes(ring) | n.id] AS ring_accounts,
reduce(total = 0, r IN relationships(ring) | total + r.amount) AS cycle_amount
ORDER BY cycle_amount DESC
LIMIT 20;
Step-by-step trace.
| Step | Input / anchor | What the engine does |
|---|---|---|
| Anchor | Account {id: $flaggedId} |
one index lookup on account_id constraint |
| Reachability | `[:SHARES_DEVICE\ | SHARES_CARD\ |
| Dedup | {% raw %}RETURN DISTINCT connected
|
collapse multiple paths to same account |
| Nearest hop | min(length(path)) |
shortest connection distance per account |
| Ring pattern | (flagged)-[:TRANSFERRED*2..6]->(flagged) |
directed cycle back to the anchor |
| Cycle amount | reduce(... r.amount ...) |
sum the transfer amounts around the ring |
Walking the ring query: the engine resolves the flagged account once, then expands only TRANSFERRED relationships in the outgoing direction, pruning any path that cannot return to the anchor. Because expansion is a pointer-chase over each node's outgoing TRANSFERRED list — not a re-scan of a transfers table — the cost tracks the size of the money-flow neighbourhood, not the size of the bank.
Output:
| ring_accounts | cycle_amount |
|---|---|
| [A-1001, A-2087, A-3345, A-1001] | 48,500 |
| [A-1001, A-9921, A-1001] | 12,000 |
| [A-1001, A-4410, A-7782, A-5560, A-1001] | 9,300 |
Why this works — concept by concept:
-
Typed relationships — modelling device-sharing, card-sharing, and transfers as three distinct relationship types lets one traversal consider all connection kinds (
:A|:B|:C) while still letting you filter to just money flows for ring detection. In relational this is three tables and aUNIONof self-joins. -
Variable-length paths —
*1..4and*2..6express "any depth in this range" directly. The relational equivalent is a recursive CTE with a depth guard, which most optimisers handle poorly and most DBAs distrust in production. -
Directed cycle detection — anchoring both ends of the pattern on the same node (
(flagged)-[...]->(flagged)) is how you express "money returns to origin." Direction (->) enforces flow semantics; an undirected pattern would find laundering and legitimate mutual transfers. -
reduce() over relationships — because relationships carry an
amountproperty, you sum them along the matched path withreduce()— the connection data lives on the edge, not in a separate join. - Cost — one index lookup to anchor, then traversal cost O(reachable relationships within the hop bound), independent of total account count. The recursive-CTE relational plan is O(degree^hops) intermediate rows — the exact cost curve index-free adjacency flattens.
Graph
Topic — graph
Graph traversal and reachability problems
2. The property graph model — nodes, relationships, properties
Nodes carry labels, relationships carry types, both carry properties — the labelled property graph in one picture
The mental model in one line: the property graph (Neo4j's labelled property graph, or LPG) is made of four primitives — nodes that represent entities, relationships that connect exactly two nodes with a type and a direction, labels that group nodes into classes, and properties that are key/value pairs attached to either — and graph data modeling is the discipline of deciding which things in your domain become nodes, which become relationships, and which become properties on them. Where a relational modeler thinks in tables, columns, and foreign keys, a graph modeler thinks in whiteboard circles and arrows: the circles are nodes, the arrows are relationships, and the annotations on both are properties. The translation from one to the other is mechanical enough to teach and subtle enough to get wrong.
The four primitives.
-
Nodes. The entities — a customer, an order, a product, a device. A node can have zero, one, or several labels (
:Customer,:Person:Employee). Labels are how you scope queries and attach indexes and constraints. -
Relationships. Always connect exactly two nodes, always have a single type (
:PLACED,:CONTAINS), and always have a direction ((a)-[:PLACED]->(b)). A relationship can carry properties of its own. -
Labels. Group nodes into sets.
MATCH (c:Customer)scans only customers. A node with multiple labels belongs to multiple sets — useful for role-like modeling. -
Properties. Key/value pairs on nodes and relationships:
Customer {id, name, since},PLACED {at, channel}. Values are scalars, or lists of scalars — never nested maps (that is a modeling smell that usually means "this should be a node").
The relational-to-graph mapping (the part interviewers test).
-
A table becomes a label. Each row in the
customerstable becomes a(:Customer)node; the columns become properties. -
A foreign key becomes a relationship.
orders.customer_id -> customers.idbecomes(:Customer)-[:PLACED]->(:Order). The direction usually follows the verb ("a customer placed an order"). -
A join (junction) table becomes a relationship — or a node. A pure many-to-many bridge with no extra columns (
order_products(order_id, product_id)) becomes a plain relationship(:Order)-[:CONTAINS]->(:Product). If the bridge carries data (order_products(order_id, product_id, quantity, unit_price)), you either put that data on the relationship ([:CONTAINS {quantity, unit_price}]) or, if the bridge is itself an entity you query about, reify it into its own node. -
A lookup/dimension table becomes a shared node. A
categoriestable becomes(:Category)nodes that many products point to — turning a repeated foreign key into a hub node you can traverse through.
When to reify a relationship into a node.
-
The connection has its own identity. A "rating" is conceptually an edge from user to movie, but if it has an id, a timestamp, a review text, and other things point at it (helpful-votes), model it as a
(:Rating)node:(:User)-[:GAVE]->(:Rating)-[:OF]->(:Movie). -
You need N-ary relationships. Relationships connect exactly two nodes. A "prescription" linking a doctor, a patient, and a drug cannot be one edge — reify it:
(:Prescription)with[:PRESCRIBED_BY],[:FOR_PATIENT],[:OF_DRUG]. - You query the connection itself. If "find all ratings created last week across all users" is a real query, ratings need to be nodes so you can index and scan them.
Common beginner mistakes
-
Encoding a type as a property instead of a label or relationship type.
(:Node {type: 'Customer'})throws away the label index; use(:Customer). -
Nesting maps in properties.
Customer {address: {city, zip}}is not an LPG property; make(:Address)a node or flatten toaddress_city,address_zip. -
Bidirectional duplicate relationships. Storing both
(a)-[:KNOWS]->(b)and(b)-[:KNOWS]->(a)doubles writes; store one and query undirected(a)-[:KNOWS]-(b). - Over-labelling. Slapping five labels on every node bloats the label index and confuses queries; use labels for genuine classes, properties for attributes.
Worked example — translate an orders ER schema into a property graph
Detailed explanation. Take a small, familiar relational schema — customers, orders, order-lines, products, categories — and translate it mechanically into a property graph, showing the decisions at each junction table. This is the exact exercise an interviewer sets when they hand you an ER diagram and say "model this."
-
Tables → labels.
customers,orders,products,categorieseach become a node label. -
Foreign keys → relationships.
orders.customer_id,products.category_id. -
The order-line bridge carries data.
order_lines(order_id, product_id, quantity, unit_price)→ relationship with properties, because the line is not an entity we query independently. -
The category lookup → hub node.
categoriesbecomes shared(:Category)nodes products traverse through.
Question. Produce the property-graph model (labels, relationships, key properties, constraints) for the five-table schema and justify the order-line decision.
Input.
| Relational object | Type | Graph translation |
|---|---|---|
customers(id, name, email, since) |
table | (:Customer {id, name, email, since}) |
orders(id, customer_id, placed_at, status) |
table + FK |
(:Order {id, placed_at, status}), (:Customer)-[:PLACED]->(:Order)
|
order_lines(order_id, product_id, qty, unit_price) |
bridge + data | (:Order)-[:CONTAINS {qty, unit_price}]->(:Product) |
products(id, name, category_id) |
table + FK |
(:Product {id, name}), (:Product)-[:IN_CATEGORY]->(:Category)
|
categories(id, name) |
lookup |
(:Category {id, name}) shared hub |
Code.
// Constraints first — one per node key (also creates a backing index)
CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.id IS UNIQUE;
CREATE CONSTRAINT order_id IF NOT EXISTS FOR (o:Order) REQUIRE o.id IS UNIQUE;
CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.id IS UNIQUE;
CREATE CONSTRAINT category_id IF NOT EXISTS FOR (c:Category) REQUIRE c.id IS UNIQUE;
// Illustrative instance: one customer, one order, two lines, two products
MERGE (c:Customer {id: 1})
ON CREATE SET c.name = 'Ada Lovelace', c.email = 'ada@example.com', c.since = date('2021-03-01');
MERGE (o:Order {id: 5001})
ON CREATE SET o.placed_at = datetime('2026-08-01T10:15:00'), o.status = 'PAID';
MERGE (c)-[:PLACED]->(o);
MERGE (p1:Product {id: 'SKU-1'}) ON CREATE SET p1.name = 'USB-C Cable';
MERGE (p2:Product {id: 'SKU-2'}) ON CREATE SET p2.name = 'Laptop Stand';
MERGE (cat:Category {id: 'accessories'}) ON CREATE SET cat.name = 'Accessories';
MERGE (p1)-[:IN_CATEGORY]->(cat);
MERGE (p2)-[:IN_CATEGORY]->(cat);
// Order-line data lives ON the CONTAINS relationship
MERGE (o)-[l1:CONTAINS]->(p1) ON CREATE SET l1.qty = 2, l1.unit_price = 9.99;
MERGE (o)-[l2:CONTAINS]->(p2) ON CREATE SET l2.qty = 1, l2.unit_price = 39.00;
Step-by-step explanation.
- Constraints come first.
CREATE CONSTRAINT ... REQUIRE ... IS UNIQUEguarantees one node per business key and creates the backing index that makes every subsequentMERGEan indexed lookup instead of a full label scan. Skipping this is the number-one cause of catastrophically slow loads. - Each row becomes a
MERGEon its business key withON CREATE SETfor the attributes — idempotent, so re-running the script never duplicates nodes (covered fully in section 4). - The foreign key
orders.customer_idbecomes(:Customer)-[:PLACED]->(:Order). Direction follows the domain verb; you will still query it undirected when direction is irrelevant. - The order-line bridge carries
qtyandunit_price, so that data lands on the relationship:[:CONTAINS {qty, unit_price}]. We do not reify it into a node because we never ask "find all order-lines created on Tuesday" — the line has no independent identity. Contrast this with a rating, which we would reify. -
categoriesbecomes a shared(:Category)hub. Two products pointing at oneaccessoriesnode means "products in the same category" is a two-hop traversal(:Product)-[:IN_CATEGORY]->(:Category)<-[:IN_CATEGORY]-(:Product)— noGROUP BY category_idneeded.
Output.
| Graph element | Kind | Carries |
|---|---|---|
(:Customer) |
node | id, name, email, since |
(:Order) |
node | id, placed_at, status |
(:Product) |
node | id, name |
(:Category) |
node (hub) | id, name |
[:PLACED] |
relationship | (structure only) |
[:CONTAINS] |
relationship | qty, unit_price |
[:IN_CATEGORY] |
relationship | (structure only) |
Rule of thumb. Table → label, foreign key → relationship, data-carrying bridge → relationship-with-properties, entity-like bridge → reified node, lookup table → shared hub node. Constraints on every node key, always, before any load.
Worked example — reifying a rating from edge to node
Detailed explanation. A ratings(user_id, movie_id, stars, created_at) table looks like a data-carrying edge, and a naive model makes it (:User)-[:RATED {stars, created_at}]->(:Movie). That is correct until a new requirement arrives — "let users mark a review as helpful" — at which point the rating needs to be pointed at, and relationships cannot be endpoints of other relationships. Reify.
-
Before.
(:User)-[:RATED {stars}]->(:Movie)— fine for "average stars per movie." - The trigger. "Add helpful-votes on reviews" — something must point at the rating.
-
After.
(:User)-[:GAVE]->(:Rating {stars, created_at})-[:OF]->(:Movie), and(:User)-[:FOUND_HELPFUL]->(:Rating).
Question. Show the before/after model and the query that "average stars per movie" becomes in each, so the cost of reification is explicit.
Input.
| Concern | Edge model | Reified model |
|---|---|---|
| Storage | property on RATED
|
(:Rating) node + 2 rels |
| "Avg stars per movie" | 1 hop | 2 hops |
| "Helpful votes on a review" | impossible | 1 hop into (:Rating)
|
| Indexable rating scan | no | yes (:Rating label) |
Code.
// BEFORE — rating as a data-carrying relationship
MATCH (u:User)-[r:RATED]->(m:Movie {id: $movieId})
RETURN avg(r.stars) AS avg_stars, count(*) AS n;
// AFTER — rating reified as a node; helpful votes now expressible
CREATE CONSTRAINT rating_id IF NOT EXISTS FOR (r:Rating) REQUIRE r.id IS UNIQUE;
MATCH (u:User)-[:GAVE]->(rt:Rating)-[:OF]->(m:Movie {id: $movieId})
RETURN avg(rt.stars) AS avg_stars, count(*) AS n;
// The new requirement — impossible in the edge model — is trivial here
MATCH (rt:Rating {id: $ratingId})<-[:FOUND_HELPFUL]-(voter:User)
RETURN rt.id AS rating, count(voter) AS helpful_votes;
Step-by-step explanation.
- In the edge model, "average stars per movie" is a clean one-hop aggregation over the
RATEDrelationship property. This is the model you should keep if the rating is never an endpoint of anything. - The helpful-votes requirement breaks the edge model: a relationship cannot be the target of another relationship, so there is nowhere to attach
FOUND_HELPFUL. This is the defining trigger for reification. - Reifying inserts a
(:Rating)node between user and movie:(:User)-[:GAVE]->(:Rating)-[:OF]->(:Movie). Thestarsandcreated_atmove from the edge onto the node, and the node gets its own unique id and constraint. - The aggregation query grows by one hop — a real, if small, cost. This is the trade-off: reification adds a hop to existing queries in exchange for making the connection a first-class, referenceable, indexable entity.
- The payoff query — helpful votes per review — is now a simple one-hop pattern into the
(:Rating)node. And because:Ratingis a label, "all ratings created last week" is an indexable scan, impossible when ratings were edges.
Output.
| Query | Edge model | Reified model |
|---|---|---|
| avg stars per movie | 1 hop ✓ | 2 hops ✓ |
| helpful votes per review | impossible ✗ | 1 hop ✓ |
| scan ratings by date | not indexable ✗ | indexable ✓ |
| write cost per rating | 1 rel | 1 node + 2 rels |
Rule of thumb. Keep a connection as a relationship while it is only a connection; reify it into a node the moment it needs an identity, needs to be pointed at, connects more than two things, or needs to be scanned as a set. Reification costs one hop and buys first-class citizenship.
Data-engineering interview question on graph data modeling
A senior interviewer might ask: "Model a music-streaming recommendation domain as a property graph: users, tracks, artists, playlists, and listen events. Users follow other users and add tracks to playlists; you need to answer 'recommend tracks played by people I follow that I haven't heard.' Give me the labels, relationships, where the listen-count lives, and one modeling decision you'd defend."
Solution Using a property graph with a reified nothing — listens on the edge, follows as first-class relationships
// Constraints (node keys) — always first
CREATE CONSTRAINT user_id IF NOT EXISTS FOR (u:User) REQUIRE u.id IS UNIQUE;
CREATE CONSTRAINT track_id IF NOT EXISTS FOR (t:Track) REQUIRE t.id IS UNIQUE;
CREATE CONSTRAINT artist_id IF NOT EXISTS FOR (a:Artist) REQUIRE a.id IS UNIQUE;
CREATE CONSTRAINT playlist_id IF NOT EXISTS FOR (p:Playlist) REQUIRE p.id IS UNIQUE;
// The model, by example
MERGE (u:User {id: 'u1'}) ON CREATE SET u.name = 'Ada';
MERGE (t:Track {id: 't9'}) ON CREATE SET t.title = 'Nightcall';
MERGE (a:Artist {id: 'a3'}) ON CREATE SET a.name = 'Kavinsky';
MERGE (t)-[:BY_ARTIST]->(a);
// A play is a data-carrying edge (count + last time) — NOT reified: we never
// query "the listen event" as an entity, only aggregate over it.
MERGE (u)-[l:LISTENED]->(t)
ON CREATE SET l.count = 1, l.last_at = datetime()
ON MATCH SET l.count = l.count + 1, l.last_at = datetime();
// FOLLOWS is first-class (directed) — the traversal backbone of the rec query
MERGE (u)-[:FOLLOWS]->(:User {id: 'u2'});
// The recommendation: tracks played by people I follow that I haven't heard
MATCH (me:User {id: 'u1'})-[:FOLLOWS]->(f:User)-[fl:LISTENED]->(rec:Track)
WHERE NOT (me)-[:LISTENED]->(rec)
RETURN rec.id AS track, sum(fl.count) AS score
ORDER BY score DESC
LIMIT 10;
Step-by-step trace.
| Step | Pattern element | Meaning |
|---|---|---|
| Anchor | User {id: 'u1'} |
indexed lookup on user_id
|
| Social hop | -[:FOLLOWS]->(f:User) |
the people I follow |
| Play hop | (f)-[fl:LISTENED]->(rec:Track) |
tracks they played |
| Exclusion | WHERE NOT (me)-[:LISTENED]->(rec) |
drop tracks I already heard |
| Scoring | sum(fl.count) |
weight by how much they played it |
| Rank | ORDER BY score DESC LIMIT 10 |
top recommendations |
Walking it: the engine finds u1 once, expands FOLLOWS to the followed set, expands each of their LISTENED edges to candidate tracks, then the anti-pattern NOT (me)-[:LISTENED]->(rec) prunes tracks I have already played by a direct pointer check (no join). Listen counts sitting on the LISTENED edge are summed to rank the survivors.
Output:
| track | score |
|---|---|
| t9 | 87 |
| t14 | 63 |
| t2 | 40 |
Why this works — concept by concept:
-
Labels as classes —
:User,:Track,:Artist,:Playlistare the entity types; each gets a unique-key constraint so anchoring andMERGEare O(1) index lookups. -
Data on the edge, not reified —
LISTENED {count, last_at}stays a relationship because we only ever aggregate over plays; we never point at "a listen event" or scan listens as a set. Correctly not reifying is as much a modeling skill as reifying. -
FOLLOWS as a directed first-class relationship — the social backbone of the recommendation is a typed, directed edge; the whole rec query is a two-hop traversal along
FOLLOWSthenLISTENED, exactly the traversal shape graphs are built for. -
Negative pattern for exclusion —
WHERE NOT (me)-[:LISTENED]->(rec)is a pointer existence check, not aLEFT JOIN ... WHERE ... IS NULL; it filters already-heard tracks in constant time per candidate. -
Cost — one anchor lookup, then traversal cost O(followed × their-tracks), independent of the total user or track count. The relational version is a three-way join (users→follows→listens) plus a
NOT EXISTSanti-join — the join fan-out graphs are designed to avoid.
Graph
Topic — graph
Property-graph modeling problems
3. Cypher — MATCH, MERGE, paths, and aggregation
Cypher is ASCII-art pattern matching — you draw the shape you want, the engine finds every instance of it
The one-sentence invariant: the cypher query language is a declarative pattern-matching language where you draw the graph shape you are looking for as ASCII-art — (node)-[:REL]->(node) — and the engine returns every subgraph that matches, with MATCH for reads, CREATE/MERGE for writes, WHERE for predicates, variable-length patterns for reachability, and SQL-like aggregation with implicit grouping. If you can draw the pattern on a whiteboard, you can write it in Cypher; the language is deliberately visual so the query looks like the thing it matches. The clauses compose like a pipeline: each clause takes the rows from the previous one and transforms them.
The read clauses.
-
MATCH. Find a pattern.MATCH (u:User)-[:PLACED]->(o:Order)bindsuandoto every matching pair. Anchor on an indexed property ({id: $x}) so the match starts from a point, not a full label scan. -
WHERE. Filter the matched rows.WHERE o.status = 'PAID' AND o.total > 100. Also does existence checks with patterns:WHERE (u)-[:FOLLOWS]->(:User {vip: true}). -
RETURN. Project the output — nodes, properties, expressions, aggregates.RETURN u.name, count(o). -
OPTIONAL MATCH. The graphLEFT JOIN— the pattern is matched if it exists, otherwise the bound variables arenull.
The write clauses.
-
CREATE. Unconditionally create nodes/relationships. Fast, but duplicates if you run it twice — use only when you know the data is new. -
MERGE. Match-or-create — the idempotency workhorse.MERGE (u:User {id: 1})finds the user if present, creates it if not. Pair withON CREATE SET(run only on insert) andON MATCH SET(run only on update). -
SET/REMOVE. Add/update properties and labels; remove them. -
DELETE/DETACH DELETE. Delete nodes/relationships. A node with relationships cannot be plain-DELETEd;DETACH DELETEremoves the node and its relationships in one go.
Variable-length paths and shortest path.
-
*min..max.(a)-[:KNOWS*1..3]-(b)matches paths of 1 to 3KNOWShops.*alone is unbounded (dangerous on dense graphs — always bound it in production). -
shortestPath/allShortestPaths.MATCH p = shortestPath((a)-[:KNOWS*]-(b))returns the shortest connecting path — the reachability primitive. -
Path functions.
length(p),nodes(p),relationships(p)let you inspect a matched path;reduce()folds over it.
Aggregation — implicit grouping is the gotcha.
-
No
GROUP BY. Cypher groups implicitly: the non-aggregated columns inRETURNare the grouping keys.RETURN u.country, count(o)groups bycountryautomatically. -
collect(). The signature graph aggregate — rolls matched values into a list.collect(o.id)gives every order id per group. -
count,sum,avg,min,max. As in SQL.count(*)counts rows;count(DISTINCT x)deduplicates. -
Pattern comprehensions.
[(u)-[:PLACED]->(o) | o.total]builds a list inline from a pattern without a separateMATCH.
Common beginner mistakes
-
Forgetting to anchor.
MATCH (u:User)-[:PLACED]->(o:Order) WHERE u.id = 1scans all users then filters; writeMATCH (u:User {id: 1})-...to start from the index. -
CREATEwhereMERGEwas meant. Re-running aCREATEload duplicates every node; useMERGEon a constrained key for idempotency. -
Unbounded
*.[:KNOWS*]on a dense graph can traverse the whole component; bound it (*1..4) or useshortestPath. -
Accidental implicit grouping. Adding a stray property to
RETURNalongside an aggregate silently changes the grouping key and the numbers.
Worked example — MERGE-based idempotent upsert
Detailed explanation. The single most important Cypher pattern for a data engineer is the idempotent upsert: run the same load twice, get the same graph. MERGE on a uniqueness-constrained key, with ON CREATE SET/ON MATCH SET to split insert-time from update-time logic, is how you get it. Get the MERGE granularity wrong and you either duplicate nodes or accidentally match too broadly.
-
Constraint first. The uniqueness constraint on
User(id)is what makesMERGE (u:User {id: 1})an indexed match, not a scan. -
Split logic.
ON CREATE SETruns only when the node is new (setcreated_at);ON MATCH SETruns only when it already existed (bumpupdated_at). -
MERGE the relationship separately.
MERGEthe two endpoint nodes on their keys first, thenMERGEthe relationship — merging a whole path at once can create surprising extra nodes.
Question. Write an idempotent upsert that creates-or-updates a user and links them to a (created-or-found) company, tracking created/updated timestamps correctly.
Input.
| Element | Key | On create | On match |
|---|---|---|---|
(:User) |
id |
set name, created_at | set updated_at |
(:Company) |
id |
set name | (nothing) |
[:WORKS_AT] |
endpoints | set since | (nothing) |
Code.
CREATE CONSTRAINT user_id IF NOT EXISTS FOR (u:User) REQUIRE u.id IS UNIQUE;
CREATE CONSTRAINT company_id IF NOT EXISTS FOR (c:Company) REQUIRE c.id IS UNIQUE;
// Parameters would come from the driver; inline here for illustration
MERGE (u:User {id: 42})
ON CREATE SET u.name = 'Grace Hopper',
u.created_at = datetime(),
u.updated_at = datetime()
ON MATCH SET u.updated_at = datetime();
MERGE (c:Company {id: 'acme'})
ON CREATE SET c.name = 'Acme Corp';
// Merge endpoints FIRST, then the relationship between the bound variables
MERGE (u)-[w:WORKS_AT]->(c)
ON CREATE SET w.since = date('2026-01-15');
Step-by-step explanation.
- The two constraints guarantee at most one
:Userperidand one:Companyperid, and back each with an index. Without them,MERGE (u:User {id: 42})would scan every:Userand could still race two concurrent loads into duplicates. -
MERGE (u:User {id: 42})matches the existing user or creates it.ON CREATE SETfires only on the create branch, stamping both timestamps;ON MATCH SETfires only when the user already existed, bumping justupdated_at. This is how you keep an accurate created-vs-updated distinction in an idempotent load. - The company is merged on its own key in a separate statement, binding
c. Merging it separately (rather than as part of a single big path pattern) avoids the classic footgun whereMERGE (u)-[:WORKS_AT]->(c:Company {id:'acme'})creates a new company if the exact whole pattern is not found. - With
uandcalready bound,MERGE (u)-[w:WORKS_AT]->(c)merges only the relationship between those two specific nodes — it will not invent new endpoints.ON CREATE SET w.sincerecords the tenure start once. - Re-running the entire block is a no-op on structure: the user matches, the company matches, the relationship matches, and only
updated_atadvances. That is the idempotency guarantee a re-runnable ETL load depends on.
Output.
| Run | :User(42) | :Company(acme) | :WORKS_AT | updated_at moves? |
|---|---|---|---|---|
| 1 (cold) | created | created | created | set |
| 2 (warm) | matched | matched | matched | yes |
| 3 (warm) | matched | matched | matched | yes |
Rule of thumb. MERGE node endpoints on their constrained keys first, bind them, then MERGE the relationship between the bound variables. Use ON CREATE SET for insert-only fields and ON MATCH SET for update-only fields. Never MERGE a whole multi-node path in one clause unless you truly want "create the entire path if any part is missing."
Worked example — variable-length traversal with a path predicate
Detailed explanation. The query that has no clean SQL equivalent is bounded reachability with a filter on the path: "everyone within 1–3 management hops of this VP who is an active employee." Variable-length patterns plus a WHERE on the intermediate nodes express it directly; the shortest-path variant answers "how far apart are these two people in the org."
-
Variable length.
[:REPORTS_TO*1..3]walks one to three management edges. -
Path filter. A
WHERE all(...)predicate overnodes(path)keeps only paths where every intermediate node satisfies a condition. -
Shortest path.
shortestPathcollapses "the distance" to a single number.
Question. Find every active employee within three REPORTS_TO hops below a given VP, excluding any chain that passes through an inactive manager, and separately compute the management distance between two named people.
Input.
| Query | Pattern | Predicate |
|---|---|---|
| reports (bounded) | (vp)<-[:REPORTS_TO*1..3]-(e:Employee) |
every node on path active = true
|
| distance | shortestPath((a)-[:REPORTS_TO*]-(b)) |
none (undirected) |
Code.
// Everyone within 3 reporting hops below the VP, no inactive manager in the chain
MATCH path = (vp:Employee {id: $vpId})<-[:REPORTS_TO*1..3]-(e:Employee)
WHERE all(n IN nodes(path) WHERE n.active = true)
RETURN e.id AS employee,
length(path) AS depth
ORDER BY depth, employee;
// Management distance between two people (undirected shortest path)
MATCH (a:Employee {id: $aId}), (b:Employee {id: $bId})
MATCH p = shortestPath((a)-[:REPORTS_TO*..10]-(b))
RETURN length(p) AS management_distance,
[n IN nodes(p) | n.id] AS chain;
Step-by-step explanation.
- The reports query anchors on the VP via the
idindex, then expandsREPORTS_TOinbound (<-) one to three hops — inbound because reports point up to their manager, so the people below the VP are those whose edges arrive at the VP's subtree. -
all(n IN nodes(path) WHERE n.active = true)is a path-level predicate: it inspects every node the path passes through and keeps the path only if all of them are active. This prunes any chain routed through a disabled manager account — a filter with no tidy SQL analogue. -
length(path)gives the reporting depth per employee, so the result is naturally ordered by how far down the tree each person sits. - The distance query binds both endpoints, then
shortestPath((a)-[:REPORTS_TO*..10]-(b))finds the fewest-hop connection between them, undirected (-) because "how closely related in the org" ignores who reports to whom. The*..10bound caps the search so a disconnected pair fails fast instead of scanning forever. -
[n IN nodes(p) | n.id]is a list comprehension that projects the id of each node on the path — turning the matched path object into a readable chain of people.
Output.
| Result set | Row |
|---|---|
| reports | employee=E-88, depth=1 |
| reports | employee=E-90, depth=2 |
| reports | employee=E-97, depth=3 |
| distance | management_distance=4, chain=[E-88, E-40, E-1, E-12, E-77] |
Rule of thumb. Bound every variable-length pattern (*1..3, *..10) — an unbounded * on a connected graph can walk the entire component. Use all()/any()/none() over nodes(path) or relationships(path) for path-level predicates, and shortestPath when you want the distance, not every path.
SQL-vs-Cypher interview question on aggregation and recommendation
A senior interviewer might ask: "On a retail co-purchase graph — (:Customer)-[:BOUGHT]->(:Product) — write the Cypher that, for a given product, recommends the top five products most frequently bought by customers who also bought that product, excluding the product itself. Then explain how Cypher's implicit grouping produces the counts."
Solution Using a two-hop co-purchase traversal with implicit-grouping aggregation
// Products co-bought with a given anchor product, ranked by co-purchase count
MATCH (anchor:Product {id: $productId})<-[:BOUGHT]-(c:Customer)-[:BOUGHT]->(rec:Product)
WHERE rec <> anchor
RETURN rec.id AS recommended,
rec.name AS name,
count(DISTINCT c) AS co_buyers
ORDER BY co_buyers DESC
LIMIT 5;
Step-by-step trace.
| Step | Pattern element | Rows produced |
|---|---|---|
| Anchor | Product {id: $productId} |
1 (indexed) |
| Buyers | <-[:BOUGHT]-(c:Customer) |
everyone who bought the anchor |
| Their carts | (c)-[:BOUGHT]->(rec:Product) |
every other product those buyers bought |
| Exclude self | WHERE rec <> anchor |
drop the anchor product |
| Group + count |
count(DISTINCT c) grouped by rec
|
co-buyer count per product |
| Rank | ORDER BY co_buyers DESC LIMIT 5 |
top 5 |
Walking it: the anchor product is found once via its index. Expanding <-[:BOUGHT]- inbound gives every customer who bought it; expanding -[:BOUGHT]-> outbound from each of those customers gives every other product in their history. Each (rec, c) pair is one row; the RETURN names rec as a non-aggregated key, so Cypher groups by rec implicitly and count(DISTINCT c) counts distinct co-buyers per recommended product.
Output:
| recommended | name | co_buyers |
|---|---|---|
| SKU-77 | Phone Case | 412 |
| SKU-12 | Screen Protector | 388 |
| SKU-40 | Charging Cable | 301 |
| SKU-9 | Earbuds | 254 |
| SKU-31 | Pop Socket | 190 |
Why this works — concept by concept:
-
Two-hop co-purchase pattern —
product <- customer -> productis the collaborative-filtering shape drawn directly: shared customers are the bridge between the anchor and its recommendations. The middle node is the join, expressed as a traversal. - Directed BOUGHT relationships — inbound to find buyers, outbound to find their other purchases; direction encodes "customer bought product" cleanly and lets one relationship type serve both legs.
-
Implicit grouping — Cypher has no
GROUP BY; the non-aggregatedRETURNcolumns (rec.id,rec.name) are the grouping key, socount(DISTINCT c)aggregates per recommended product automatically. This is the aggregation gotcha to name in an interview. -
count(DISTINCT c) — a customer who bought the recommended product twice must not double-count;
DISTINCTcollapses them, soco_buyersis genuine distinct-customer support, the metric collaborative filtering ranks on. -
Cost — one anchor lookup, then traversal cost O(buyers × their-basket-size); no self-join over an
ordersfact table. The relational equivalent is a self-join ofboughtto itself oncustomer_idwith aGROUP BY product— the join graphs replace with a pointer walk.
Graph
Topic — graph
Cypher pattern-matching and traversal problems
4. Graph ETL — LOAD CSV, batched MERGE, and bulk import
The graph ETL rule is constraints-first, MERGE-idempotent, and batch your transactions — bulk-import only for the cold start
The one-sentence invariant: graph etl into Neo4j has three loading tiers — LOAD CSV with MERGE for incremental and mid-sized loads (constraints created first, transactions batched with CALL { ... } IN TRANSACTIONS or apoc.periodic.iterate), neo4j-admin database import for the one-time cold-start bulk load of billions of rows (offline, no transactions, an order of magnitude faster), and streaming connectors (Kafka Connect, the Neo4j Spark connector) for continuous ingestion — and the single most common failure is running a huge MERGE load without a uniqueness constraint, which turns every merge into a full label scan. The tier you pick is a function of size and whether the database is already live.
Tier 1 — LOAD CSV with MERGE (incremental / mid-sized).
-
Constraints first, always. Create the uniqueness constraint on every node key before loading.
MERGE (n:Node {id: row.id})without the constraint scans the whole label per row — O(N²) over the load. - Nodes before relationships. Load all node types first (so both endpoints exist), then load relationships by matching the endpoints and merging the edge.
-
Batch the transactions. A single 50M-row
LOAD CSVin one transaction runs the machine out of memory. Wrap the per-row work inCALL { ... } IN TRANSACTIONS OF 10000 ROWS(Neo4j 5) so each 10k-row chunk commits independently.
Tier 2 — neo4j-admin database import (cold-start bulk).
- Offline, one-time, fastest. Runs against a stopped database and builds the store files directly, bypassing the transaction log. This is the only sane way to load billions of nodes/edges initially.
-
Header-driven CSVs. Separate node CSVs (with an
:IDcolumn and a label) and relationship CSVs (with:START_ID,:END_ID,:TYPE). IDs are matched by an id-space, not by a query. - Not for live data. You cannot use it to add to a running database; it is the initial seed only. After it, you switch to Tier 1 for deltas.
Tier 3 — streaming connectors (continuous).
-
Kafka. The Neo4j Connector for Kafka (a Kafka Connect sink) consumes topics and applies templated Cypher (usually
MERGE) per message — the graph equivalent of a CDC sink. -
Spark. The Neo4j Connector for Apache Spark reads/writes graph data as DataFrames, so a Spark job can
MERGEnodes and relationships from a batch or structured-streaming pipeline. -
APOC for polyglot pulls.
apoc.load.jdbcpulls straight from a relational source inside a Cypher statement;apoc.periodic.iteratebatches the resulting merges.
Idempotency and batching primitives.
-
MERGE+ constraint = idempotent. Re-running a load never duplicates; the merge finds the existing node. This is the whole reason ETL usesMERGEoverCREATE. -
CALL { } IN TRANSACTIONS OF N ROWS. The native Neo4j 5 batching construct (replacing the oldUSING PERIODIC COMMIT). Each batch is its own transaction. -
apoc.periodic.iterate. A two-query batching+parallelism primitive: an outer query yields the batch, an inner query does the per-item work, withbatchSizeandparalleloptions.
Common beginner mistakes
- Loading without constraints. The single biggest graph-ETL performance bug — merges degrade to full scans. Create constraints first.
-
One giant transaction. A multi-million-row
LOAD CSVin a single transaction exhausts heap; batch it. -
Relationships before nodes.
MATCHing endpoints that were not loaded yet silently drops relationships; load nodes first. -
Using neo4j-admin import on a live DB. It only seeds a stopped database; for deltas you need
LOAD CSV/connectors.
Worked example — constraints-first batched LOAD CSV
Detailed explanation. The production incremental load: a CSV of customer→order→product rows, loaded idempotently with constraints created first, nodes before relationships, and every write batched. This is the exact script a data engineer ships for a nightly or hourly graph refresh.
- Step 0. Create all uniqueness constraints.
-
Steps 1–2. Load node types (customers, products) with batched
MERGE. - Step 3. Load relationships by matching endpoints and merging the edge, also batched.
Question. Write the batched, constraints-first LOAD CSV load for a orders.csv with columns customer_id, customer_name, order_id, product_id, product_name, qty.
Input.
| Stage | Source columns | Graph write |
|---|---|---|
| constraints | — | unique on Customer.id, Order.id, Product.id |
| customers | customer_id, customer_name | MERGE (:Customer) |
| products | product_id, product_name | MERGE (:Product) |
| orders + lines | order_id, customer_id, product_id, qty |
PLACED + CONTAINS {qty}
|
Code.
// Step 0 — constraints first (each creates a backing index)
CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.id IS UNIQUE;
CREATE CONSTRAINT order_id IF NOT EXISTS FOR (o:Order) REQUIRE o.id IS UNIQUE;
CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.id IS UNIQUE;
// Step 1 — customers, batched 10k rows per transaction
LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
CALL {
WITH row
MERGE (c:Customer {id: row.customer_id})
ON CREATE SET c.name = row.customer_name
} IN TRANSACTIONS OF 10000 ROWS;
// Step 2 — products, batched
LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
CALL {
WITH row
MERGE (p:Product {id: row.product_id})
ON CREATE SET p.name = row.product_name
} IN TRANSACTIONS OF 10000 ROWS;
// Step 3 — orders + relationships; endpoints already exist so MATCH is O(1)
LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
CALL {
WITH row
MATCH (c:Customer {id: row.customer_id})
MATCH (p:Product {id: row.product_id})
MERGE (o:Order {id: row.order_id})
MERGE (c)-[:PLACED]->(o)
MERGE (o)-[l:CONTAINS]->(p)
ON CREATE SET l.qty = toInteger(row.qty)
} IN TRANSACTIONS OF 10000 ROWS;
Step-by-step explanation.
- Step 0 creates the three uniqueness constraints. This is not optional: without
customer_idunique+indexed, everyMERGE (c:Customer {id: ...})in step 1 scans all existing customers, and a load that should take minutes takes hours.IF NOT EXISTSmakes the constraint creation itself idempotent. - Step 1 loads customers in their own pass, wrapping the per-row
MERGEinCALL { ... } IN TRANSACTIONS OF 10000 ROWS. Each 10k-row chunk commits separately, so peak memory is bounded by the batch, not the file.ON CREATE SETfills the name only on insert. - Step 2 loads products the same way, in a separate pass. Splitting node types into their own passes (rather than one mega-statement) keeps each transaction simple and lets you re-run any single pass independently.
- Step 3 loads the structure. Because every customer and product already exists (steps 1–2), the two
MATCHclauses are O(1) index lookups.MERGE (o:Order {id})upserts the order; the two relationshipMERGEs are idempotent, andtoInteger(row.qty)casts the CSV string to a number (all CSV values arrive as strings). - Re-running the whole load is safe: every
MERGEmatches the existing element, so a re-run adds nothing and only re-stampsON CREATEfields on genuinely new rows. That is the property that lets you retry a failed nightly load without cleanup.
Output.
| Pass | Rows | Writes | Transactions (10k batch) |
|---|---|---|---|
| customers | 1,000,000 | up to 1M :Customer merges |
100 |
| products | 1,000,000 | up to 200k :Product merges |
100 |
| orders+lines | 1,000,000 | orders + PLACED + CONTAINS | 100 |
| re-run | 1,000,000 | 0 new (idempotent) | 100 |
Rule of thumb. Constraints first, nodes before relationships, one pass per element type, and every pass wrapped in IN TRANSACTIONS OF N ROWS. If a load is slow, the first thing to check is whether the merge keys are constrained and indexed.
Worked example — apoc.periodic.iterate and the neo4j-admin cold start
Detailed explanation. Two heavier tools. apoc.periodic.iterate batches (and optionally parallelises) a large transformation that is driven by a Cypher query rather than a CSV — for example, back-filling a relationship across the existing graph. neo4j-admin database import is the offline bulk loader for the initial multi-billion-row seed. Knowing which to use when is a common interview probe.
-
apoc.periodic.iterate. Outer query streams items; inner query does the work;
batchSizecontrols commit granularity;parallel: trueuses multiple threads when batches do not touch the same nodes. -
neo4j-admin import. Header-typed CSVs, an
:IDspace for nodes,:START_ID/:END_ID/:TYPEfor relationships; runs against a stopped database and writes store files directly.
Question. (a) Back-fill a :SIMILAR_TO relationship between products in the same category using apoc.periodic.iterate; (b) show the neo4j-admin command and CSV headers for a cold-start seed.
Input.
| Task | Tool | Key knobs |
|---|---|---|
| back-fill similar-to | apoc.periodic.iterate |
batchSize 5000, parallel false (writes overlap) |
| cold-start seed | neo4j-admin database import full |
typed CSV headers, offline |
Code.
// (a) Back-fill SIMILAR_TO between products sharing a category.
// Outer query yields product pairs; inner query merges the edge in batches.
CALL apoc.periodic.iterate(
'MATCH (p1:Product)-[:IN_CATEGORY]->(cat)<-[:IN_CATEGORY]-(p2:Product)
WHERE id(p1) < id(p2)
RETURN p1, p2',
'MERGE (p1)-[:SIMILAR_TO]-(p2)',
{batchSize: 5000, parallel: false}
)
YIELD batches, total, errorMessages
RETURN batches, total, errorMessages;
# (b) Cold-start bulk load — runs against a STOPPED database, builds store files directly.
# nodes.csv header: id:ID,name,:LABEL
# rels.csv header: :START_ID,:END_ID,:TYPE
neo4j-admin database import full \
--nodes=import/products_nodes.csv \
--nodes=import/customers_nodes.csv \
--relationships=import/bought_rels.csv \
--id-type=string \
--skip-bad-relationships=true \
--high-parallel-io=on \
neo4j
# products_nodes.csv
id:ID,name,:LABEL
SKU-1,USB-C Cable,Product
SKU-2,Laptop Stand,Product
# bought_rels.csv
:START_ID,:END_ID,:TYPE
C-100,SKU-1,BOUGHT
C-100,SKU-2,BOUGHT
Step-by-step explanation.
-
apoc.periodic.iteratetakes two Cypher strings: the outer query (MATCH ... RETURN p1, p2) streams the driving rows, and the inner query (MERGE (p1)-[:SIMILAR_TO]-(p2)) runs once per row, committed in batches ofbatchSize. Theid(p1) < id(p2)guard emits each unordered pair once, avoiding duplicate reciprocal edges. -
parallel: falseis deliberate here: neighbouring product pairs can touch the same product node, so parallel batches would deadlock or lock-contend. Setparallel: trueonly when batches are guaranteed disjoint (e.g. keyed node property updates). Knowing when not to parallelise is the senior detail. -
YIELD batches, total, errorMessagessurfaces how many batches ran, how many items were processed, and any per-batch errors — the observability you need for a long-running back-fill. -
neo4j-admin database import fullis a different world: it runs against a stopped database, reads typed CSVs, and writes the store files directly with no transactions — which is why it loads billions of records in the timeLOAD CSVwould take for millions.--id-type=stringmatches relationship endpoints by the string ids in the:IDspace. - The node CSV declares
id:ID(the join key), plain columns as properties, and:LABEL; the relationship CSV declares:START_ID,:END_ID,:TYPE.--skip-bad-relationships=truetolerates edges whose endpoints are missing instead of aborting the whole import. After this seed you start the database and switch to Tier 1 for all deltas.
Output.
| Tool | When | Throughput class | Live DB? |
|---|---|---|---|
apoc.periodic.iterate |
back-fills / transforms over existing graph | millions/hour | yes |
LOAD CSV + IN TRANSACTIONS
|
incremental / mid-sized | millions/hour | yes |
neo4j-admin import |
one-time cold-start seed | billions/hour | no (stopped) |
Rule of thumb. Use neo4j-admin database import exactly once — the cold-start seed on a stopped database. Use LOAD CSV/apoc.periodic.iterate for everything after that, always constraints-first and batched. Parallelise a batch job only when its batches cannot touch the same nodes.
Graph-ETL interview question on idempotent incremental loads
A senior interviewer might ask: "You are fed a CDC stream of order events (insert/update/delete) from Kafka. Design the graph-ETL so that replaying the same Kafka partition is a no-op, deletes are handled, and a 50M-row backfill does not OOM the box. Walk me through constraints, the MERGE strategy, batching, and delete handling."
Solution Using constraint-backed MERGE, IN TRANSACTIONS batching, and DETACH DELETE for tombstones
// 1. Constraints — the backbone of idempotency and O(1) merges
CREATE CONSTRAINT order_id IF NOT EXISTS FOR (o:Order) REQUIRE o.id IS UNIQUE;
CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.id IS UNIQUE;
// 2. Backfill from a staging CSV (50M rows) — batched so it cannot OOM.
// op = 'c'|'u' upsert; op = 'd' tombstone -> DETACH DELETE.
LOAD CSV WITH HEADERS FROM 'file:///orders_cdc.csv' AS row
CALL {
WITH row
MERGE (c:Customer {id: row.customer_id})
WITH row, c
CALL {
WITH row, c
WITH row, c WHERE row.op IN ['c', 'u']
MERGE (o:Order {id: row.order_id})
ON CREATE SET o.status = row.status, o.total = toFloat(row.total)
ON MATCH SET o.status = row.status, o.total = toFloat(row.total)
MERGE (c)-[:PLACED]->(o)
}
WITH row
CALL {
WITH row
WITH row WHERE row.op = 'd'
MATCH (o:Order {id: row.order_id})
DETACH DELETE o
}
} IN TRANSACTIONS OF 10000 ROWS;
// 3. Continuous stream: the Neo4j Kafka Connect sink runs this templated
// Cypher per message (idempotent by the same MERGE keys).
// (connector config: neo4j.topic.cypher.orders = the statement below)
MERGE (c:Customer {id: event.customer_id})
MERGE (o:Order {id: event.order_id})
ON CREATE SET o.status = event.status, o.total = event.total
ON MATCH SET o.status = event.status, o.total = event.total
MERGE (c)-[:PLACED]->(o);
Step-by-step trace.
| Step | Mechanism | Guarantee |
|---|---|---|
| constraints | unique on Order.id, Customer.id | dedupe + O(1) merge |
| upsert (c/u) | MERGE ... ON CREATE/ON MATCH SET |
replay = same state |
| delete (d) | MATCH ... DETACH DELETE |
tombstone removes node + edges |
| batching | IN TRANSACTIONS OF 10000 ROWS |
50M rows without OOM |
| stream | Kafka sink runs the same MERGE | continuous idempotent apply |
Walking a replay: Kafka redelivers a partition; each MERGE on order_id/customer_id finds the already-present node and re-applies the same SET, so the graph state is byte-identical — replay is a no-op. A d event MATCHes the order and DETACH DELETEs it, removing the node and its PLACED/CONTAINS edges in one operation (a plain DELETE would fail on a node that still has relationships).
Output:
| Event stream | Graph effect | Re-apply effect |
|---|---|---|
| c order 5001 | create Order 5001 + PLACED | no-op |
| u order 5001 status=SHIPPED | update status | no-op |
| d order 5001 | DETACH DELETE Order 5001 | already gone → no-op |
| replay whole partition | identical final state | idempotent |
Why this works — concept by concept:
-
Constraint-backed MERGE — uniqueness constraints on the business keys make
MERGEboth correct (no duplicates under concurrency) and fast (indexed lookup); they are the foundation every other guarantee rests on. - ON CREATE / ON MATCH symmetry — setting the mutable fields in both branches means an update event and an out-of-order create-then-update converge to the same state, so ordering and replay do not corrupt the graph.
-
DETACH DELETE for tombstones — CDC deletes must remove the node and every relationship attached to it;
DETACH DELETEdoes both atomically, where a bareDELETEerrors on a still-connected node. - IN TRANSACTIONS batching — committing every 10k rows caps memory at one batch, so a 50M-row backfill runs in bounded heap and a mid-batch failure only loses the current chunk, which the idempotent re-run replaces.
- Cost — O(1) indexed merge per event, O(batch) memory, O(events) total — linear in the change stream, not in the graph size. The same Cypher template drives both the CSV backfill and the Kafka sink, so batch and streaming share one idempotent code path.
ETL
Topic — etl
Graph-ETL and idempotent-load problems
5. Graph analytics and interview signals
Graph Data Science runs PageRank, communities, and shortest paths over a projected in-memory graph — and knowing when NOT to is the senior signal
The one-sentence invariant: graph analytics in Neo4j runs through the Graph Data Science (GDS) library, which projects a named in-memory graph from your stored data and then runs algorithms over it — centrality (PageRank), community detection (Louvain, weakly connected components), and pathfinding (Dijkstra shortest path) — in one of three execution modes (stream results back, write scores onto nodes, mutate the in-memory graph) — and the mark of a senior engineer is naming the anti-patterns where a graph database is the wrong tool as fluently as the algorithms it is right for. Analytics does not run on the raw store; you project first, run the algorithm on the projection, then stream or write back the results.
The GDS workflow — project, run, consume.
-
Project.
gds.graph.project('myGraph', nodeSpec, relSpec)builds a compressed in-memory copy of the nodes and relationships an algorithm needs. You project only the labels and relationship types relevant to the analysis. -
Run. Call the algorithm on the named graph:
gds.pageRank.stream('myGraph', config). -
Consume via a mode.
streamreturns rows (nodeId, score) for ad-hoc queries;writepersists a score property back onto the stored nodes;mutatewrites into the in-memory graph for chaining a second algorithm;statsreturns only summary statistics.
The three algorithm families interviewers probe.
-
Centrality — PageRank. Scores each node by the weighted importance of what points at it.
gds.pageRankfinds influential users, authoritative pages, key accounts in a transaction network. Degree centrality and betweenness are the other common members. -
Community detection — Louvain / WCC. Louvain finds densely connected clusters (market segments, fraud rings, topic communities) by modularity optimisation; weakly connected components (
gds.wcc) find the disconnected islands — often the first sanity check on a new graph. -
Pathfinding — Dijkstra / A*. Weighted shortest path between two nodes (cheapest route, lowest-latency path, fewest-risk connection).
gds.shortestPath.dijkstrauses a relationship weight property; unweighted BFS shortest path is expressible in plain Cypher withshortestPath.
When NOT to use a graph database (the senior signal).
-
Aggregate scans over the whole dataset.
SUM(revenue) GROUP BY monthover a billion-row fact table is a columnar-warehouse job; a graph database will be slower and costlier. If there is no traversal, there is no graph advantage. - Simple single-hop lookups at extreme write throughput. A key/value or relational store beats a graph for "get row by primary key" at millions of writes/sec with no multi-hop reads.
- Purely tabular data with no meaningful relationships. Forcing flat, unconnected records into a graph adds modeling overhead for zero traversal benefit.
-
When the graph does not fit the memory budget for GDS. GDS projects into memory; a projection larger than RAM needs partitioning, sampling, or a different tool. Estimate first with
gds.graph.project.estimate.
Common beginner mistakes
-
Running an algorithm on the raw store. GDS needs a projected named graph; you cannot
gds.pageRank.streama label directly without projecting. -
writemode in an ad-hoc query.writemutates every node's properties; usestreamfor exploration and reservewritefor a deliberate pipeline step. -
Ignoring relationship weights. PageRank and Dijkstra respect a weight property only if you tell them (
relationshipWeightProperty); omit it and every edge counts equally. -
Forgetting to drop the projection. In-memory graphs hold RAM until
gds.graph.drop('myGraph'); leaking them starves the next projection.
Worked example — PageRank influence scoring with write-back
Detailed explanation. The archetypal centrality job: score users in a FOLLOWS graph by influence with PageRank, then write the score back so downstream Cypher can rank by it. The pattern is project → run in write mode → query the written property.
-
Project. A named graph of
:Usernodes andFOLLOWSrelationships. -
Run.
gds.pageRank.writewith a damping factor and iteration cap, writing aninfluenceproperty. -
Consume. A plain Cypher query ranks users by the written
influence.
Question. Compute PageRank influence over the follower graph and return the top five most influential users.
Input.
| Stage | Call | Config |
|---|---|---|
| project | gds.graph.project |
nodes :User, rels FOLLOWS
|
| run | gds.pageRank.write |
dampingFactor 0.85, writeProperty influence
|
| consume | MATCH (u:User) RETURN ... ORDER BY u.influence |
top 5 |
Code.
// 1. Project the follower graph into memory (named 'social')
CALL gds.graph.project(
'social',
'User',
{FOLLOWS: {orientation: 'NATURAL'}}
);
// 2. Run PageRank in WRITE mode — persists u.influence on every :User
CALL gds.pageRank.write('social', {
writeProperty: 'influence',
dampingFactor: 0.85,
maxIterations: 20
})
YIELD nodePropertiesWritten, ranIterations
RETURN nodePropertiesWritten, ranIterations;
// 3. Consume the written score with ordinary Cypher
MATCH (u:User)
RETURN u.id AS user, u.name AS name, round(u.influence, 4) AS influence
ORDER BY influence DESC
LIMIT 5;
// 4. Housekeeping — free the in-memory projection
CALL gds.graph.drop('social');
Step-by-step explanation.
-
gds.graph.project('social', 'User', {FOLLOWS: {orientation: 'NATURAL'}})builds a compressed in-memory graph containing only:Usernodes andFOLLOWSedges in their stored direction. Projecting a focused subgraph — not the whole database — keeps the analysis memory-bounded and fast. -
gds.pageRank.writeruns the algorithm and persists the result as aninfluenceproperty on each stored:User.dampingFactor: 0.85is the standard PageRank random-jump parameter;maxIterations: 20caps the power-iteration so a non-converging graph still terminates. -
YIELD nodePropertiesWritten, ranIterationsconfirms how many scores were written and whether the algorithm converged before the iteration cap — the observability a pipeline step needs. - Consumption is ordinary Cypher: because the score is now a node property, ranking by influence is a plain
MATCH ... ORDER BY u.influence. Downstream queries do not need GDS at all; the analytics result has become regular graph data. -
gds.graph.drop('social')releases the in-memory projection. Skipping this leaks RAM — the projection persists until dropped or the database restarts, and the next projection may fail to allocate.
Output.
| user | name | influence |
|---|---|---|
| u-17 | Frida | 8.4213 |
| u-3 | Ada | 6.9902 |
| u-88 | Linus | 5.1120 |
| u-42 | Grace | 4.8877 |
| u-9 | Alan | 3.9051 |
Rule of thumb. Project a focused subgraph, run in write mode when the score feeds downstream queries (or stream for exploration), then gds.graph.drop the projection. PageRank influence becomes ordinary node data you rank with plain Cypher.
Worked example — Louvain communities and Dijkstra shortest path
Detailed explanation. Two more GDS staples. Louvain community detection partitions the graph into densely connected clusters — segments, rings, topic groups. Dijkstra finds the weighted shortest path between two specific nodes using a relationship weight. Both project first; Louvain streams a community id per node, Dijkstra streams the path.
-
Louvain.
gds.louvain.streamreturns(nodeId, communityId); group by community to size the clusters. -
Dijkstra.
gds.shortestPath.dijkstra.streamreturns the path and total cost usingrelationshipWeightProperty.
Question. (a) Detect communities in the follower graph and report the five largest; (b) find the cheapest route between two cities in a ROUTE {cost} graph.
Input.
| Task | Call | Weight |
|---|---|---|
| communities | gds.louvain.stream('social') |
none (structural) |
| cheapest route | gds.shortestPath.dijkstra.stream |
relationshipWeightProperty: 'cost' |
Code.
// (a) Louvain community detection over the projected 'social' graph
CALL gds.louvain.stream('social')
YIELD nodeId, communityId
WITH communityId, count(*) AS members
RETURN communityId, members
ORDER BY members DESC
LIMIT 5;
// (b) Dijkstra cheapest route between two cities.
// Project a weighted graph first.
CALL gds.graph.project(
'routes',
'City',
{ROUTE: {orientation: 'UNDIRECTED', properties: 'cost'}}
);
MATCH (src:City {name: 'London'}), (dst:City {name: 'Rome'})
CALL gds.shortestPath.dijkstra.stream('routes', {
sourceNode: src,
targetNode: dst,
relationshipWeightProperty: 'cost'
})
YIELD totalCost, nodeIds, costs
RETURN totalCost,
[nid IN nodeIds | gds.util.asNode(nid).name] AS route,
costs;
Step-by-step explanation.
- Louvain runs on the already-projected
socialgraph and streams acommunityIdfor every node. Because it isstreammode, nothing is written back — you aggregate the stream on the fly.WITH communityId, count(*) AS membersgroups nodes into their communities and counts each. - Ordering by
members DESC LIMIT 5surfaces the five largest clusters — the market segments or fraud rings a downstream analyst investigates. Louvain optimises modularity, so these communities are internally dense and externally sparse. - For Dijkstra we project a weighted graph:
{ROUTE: {orientation: 'UNDIRECTED', properties: 'cost'}}pulls thecostproperty onto the projected relationships so the algorithm can minimise total cost. Undirected because a route is traversable both ways. -
gds.shortestPath.dijkstra.streamtakes the source and target nodes plusrelationshipWeightProperty: 'cost'and returnstotalCost, the orderednodeIdson the path, and the per-hopcosts. WithoutrelationshipWeightProperty, Dijkstra would treat every edge as weight 1 and degenerate into BFS. -
[nid IN nodeIds | gds.util.asNode(nid).name]maps the internal node ids back to city names viagds.util.asNode, turning the raw id path into a human-readable route.
Output.
| Result | Row |
|---|---|
| communities | communityId=4, members=1_204 |
| communities | communityId=1, members=980 |
| communities | communityId=7, members=612 |
| route | totalCost=18.5, route=[London, Paris, Milan, Rome] |
Rule of thumb. Louvain in stream mode for exploratory clustering (aggregate the stream); Dijkstra with an explicit relationshipWeightProperty for weighted routes (omit the weight and you get unweighted BFS). Project a weighted graph when the algorithm needs edge costs.
Graph-analytics interview question on influence and community pipelines
A senior interviewer might ask: "You have a transaction graph — (:Account)-[:TRANSFERRED {amount}]->(:Account). Design a GDS pipeline that (1) scores each account's influence weighted by transfer amount, (2) partitions accounts into communities, and (3) writes both back so an analyst can query 'high-influence accounts in the largest community.' Then tell me when this whole approach is the wrong tool."
Solution Using a weighted-PageRank + Louvain write-back pipeline over one projection
// 1. Project the transaction graph ONCE, carrying the transfer amount as weight
CALL gds.graph.project(
'txn',
'Account',
{TRANSFERRED: {orientation: 'NATURAL', properties: 'amount'}}
);
// 2. Weighted PageRank -> write influence back onto :Account
CALL gds.pageRank.write('txn', {
writeProperty: 'influence',
relationshipWeightProperty: 'amount',
dampingFactor: 0.85,
maxIterations: 20
})
YIELD nodePropertiesWritten;
// 3. Louvain communities -> write communityId back onto :Account
CALL gds.louvain.write('txn', {
writeProperty: 'communityId'
})
YIELD communityCount, modularity;
// 4. Analyst query: high-influence accounts in the largest community
MATCH (a:Account)
WITH a.communityId AS community, count(*) AS size
ORDER BY size DESC LIMIT 1
WITH community
MATCH (a:Account {communityId: community})
RETURN a.id AS account, round(a.influence, 4) AS influence
ORDER BY influence DESC
LIMIT 10;
// 5. Free the projection
CALL gds.graph.drop('txn');
Step-by-step trace.
| Step | Call | Written property |
|---|---|---|
| project | gds.graph.project('txn', ...) |
in-memory only |
| influence |
gds.pageRank.write weighted by amount
|
a.influence |
| community | gds.louvain.write |
a.communityId |
| largest community | aggregate + ORDER BY size DESC LIMIT 1
|
(query) |
| top accounts | filter by community, order by influence | (query) |
| cleanup | gds.graph.drop('txn') |
frees RAM |
Walking it: one projection carries the amount weight so both algorithms share it. Weighted PageRank writes influence (an account that receives large transfers from other influential accounts scores high); Louvain writes communityId (dense transfer clusters). Because both scores are now stored node properties, the analyst question is plain Cypher — find the biggest community, then rank its accounts by influence — with no further GDS calls.
Output:
| account | influence |
|---|---|
| A-3391 | 27.8841 |
| A-1002 | 19.4410 |
| A-7785 | 15.2093 |
Why this works — concept by concept:
-
One projection, two algorithms — projecting the weighted graph once and running both PageRank and Louvain against it avoids rebuilding the in-memory graph twice; the shared
amountweight is available to whichever algorithm asks for it. -
Weighted PageRank —
relationshipWeightProperty: 'amount'makes large money flows count more than small ones, so influence reflects financial weight, not just transfer count — the difference between a spammer and a hub. -
Louvain write-back — persisting
communityIdas a node property turns the clustering result into ordinary graph data, so the "largest community" question is a Cypher aggregation, not a second analytics run. -
Analytics becomes graph data — once
influenceandcommunityIdare written, the analyst never touches GDS; the whole pipeline output is queryable with the same Cypher any other property uses. - Cost — GDS runtime is roughly O(iterations × relationships) for PageRank and near-linear for Louvain, bounded by the projection fitting in RAM. When it does not fit, or when the real question is an aggregate scan with no traversal, this whole approach is the wrong tool — the senior signal is saying so unprompted and routing that workload to a warehouse.
Graph
Topic — graph
Graph-analytics and pathfinding problems
Design
Topic — design
Design problems on analytics and datastore fit
Cheat sheet — Neo4j and Cypher recipes
-
When to reach for a graph. Pick
neo4jwhen two or more hold: variable-length paths matter (reachability, rings, inherited access), relationships carry data you filter on (amount, weight, timestamp, type), and the equivalent SQL needs five-plus self-joins or a recursive CTE. The decisive anti-signal is a dominant aggregate scan (SUM/COUNT/GROUP BYover a fact table) with no traversal — that stays in a columnar warehouse. Decide on the deepest important query, not the shallowest. - Relational → property graph mapping. Table → node label; row → node; column → property; foreign key → relationship (direction follows the verb); pure many-to-many bridge → plain relationship; data-carrying bridge → relationship-with-properties; entity-like bridge (own id, pointed at, or scanned as a set) → reified node; lookup/dimension table → shared hub node many nodes traverse through.
- Reify a relationship into a node when it has its own identity, must be pointed at by other relationships, connects more than two entities (N-ary), or must be scanned as a set. Reification costs one hop on existing queries and buys first-class, indexable citizenship. Keep it an edge while it is only a connection.
-
Cypher clause map.
MATCH(find pattern) ·WHERE(filter / existence check) ·RETURN(project, implicit grouping) ·OPTIONAL MATCH(graph LEFT JOIN) ·CREATE(unconditional insert) ·MERGE(match-or-create) ·ON CREATE SET/ON MATCH SET·SET/REMOVE·DELETE/DETACH DELETE. Anchor every read on an indexed property ({id: $x}) so the match starts from a point, not a label scan. -
MERGE idempotency pattern. Constraint on the key first, then
MERGEnode endpoints on their keys separately, bind them, thenMERGEthe relationship between the bound variables.ON CREATE SETfor insert-only fields,ON MATCH SETfor update-only. NeverMERGEa whole multi-node path in one clause unless you truly mean "create the entire path if any part is missing." -
Variable-length + shortest path.
(a)-[:REL*1..4]-(b)for bounded reachability; always bound the range (unbounded*can walk a whole component).shortestPath((a)-[:REL*..10]-(b))for distance;allShortestPathsfor every shortest. Path-level predicates viaall(n IN nodes(p) WHERE ...); fold withreduce(acc=0, r IN relationships(p) | acc + r.weight). -
Constraints + indexes.
CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE n.id IS UNIQUE(uniqueness + backing index) on every node key. AddCREATE INDEX FOR (n:Label) ON (n.prop)for non-unique properties you filter on frequently. Constraints are the single biggest lever on load performance — no constraint means everyMERGEis a full label scan. -
LOAD CSV recipe. Constraints first → nodes before relationships → one pass per element type → wrap per-row work in
CALL { WITH row ... } IN TRANSACTIONS OF 10000 ROWS(Neo4j 5; replacesUSING PERIODIC COMMIT). Cast CSV strings withtoInteger()/toFloat()/date()/datetime(). Re-running is a no-op because every write is aMERGE. -
Bulk import tiers.
neo4j-admin database import fullonce, offline, against a stopped database for the multi-billion-row cold-start seed (typed CSVs:id:ID,:LABEL,:START_ID,:END_ID,:TYPE;--id-type=string).LOAD CSV+IN TRANSACTIONSandapoc.periodic.iteratefor everything after, on the live database. -
apoc.periodic.iterate.
CALL apoc.periodic.iterate('outer MATCH ... RETURN x', 'inner MERGE ...', {batchSize: 5000, parallel: false}). Outer query streams the driving rows; inner query does the work per batch. Setparallel: trueonly when batches cannot touch the same nodes; leave itfalsewhen writes overlap (e.g. reciprocal relationships). -
Streaming ingestion. Neo4j Connector for Kafka (Kafka Connect sink) runs a templated
MERGEper message — the graph CDC sink, idempotent by the merge keys. Neo4j Connector for Apache Spark reads/writes graph as DataFrames.apoc.load.jdbcpulls straight from a relational source inside Cypher. Handle CDC deletes withMATCH ... DETACH DELETE. -
GDS analytics workflow.
gds.graph.project('g', nodeSpec, relSpec)→ run algorithm → consume via mode (stream/write/mutate/stats) →gds.graph.drop('g'). Centrality:gds.pageRank(addrelationshipWeightPropertyfor weighted). Community:gds.louvain,gds.wcc. Pathfinding:gds.shortestPath.dijkstra(needsrelationshipWeightProperty). Estimate memory withgds.graph.project.estimatebefore projecting a huge graph; always drop the projection to free RAM. - Anti-patterns (say these unprompted). Do NOT use a graph for aggregate scans over a whole fact table, for extreme-throughput single-row key/value writes, for flat tabular data with no meaningful relationships, or for a projection that does not fit the GDS memory budget. Every technology has a workload envelope; naming the graph's is the senior signal.
Frequently asked questions
What is Neo4j in one sentence?
neo4j is a native graph database that stores data as a labelled property graph — nodes (entities) connected by typed, directed relationships, both carrying key/value properties — and uses index-free adjacency so that traversing from one entity to its neighbours is a constant-time pointer-chase rather than a join, which makes multi-hop queries (fraud rings, reachability, recommendations) run in time proportional to the part of the graph you touch instead of the size of the tables. You query it with the Cypher query language, load it with LOAD CSV / neo4j-admin import / streaming connectors, and analyse it with the Graph Data Science library (PageRank, community detection, shortest path). It is the tool data engineers reach for when the dominant query is "follow the relationships," and explicitly not the tool for aggregate scans over flat fact tables.
Graph database vs relational — when do I pick each?
Pick a graph database when the workload is traversal-shaped: variable-length reachability ("everything within N hops"), path-finding, ring/cycle detection, or recommendations, especially when relationships carry data you filter on and the SQL equivalent needs many self-joins or a recursive CTE. The advantage comes from index-free adjacency — each hop is a pointer dereference, so a four-hop query costs the reachable neighbourhood, not degree^4 join rows. Pick relational (or columnar) when the workload is scan-and-aggregate: GROUP BY over a fact table, single-table filters, or high-throughput single-row OLTP with shallow joins. The honest answer in most real architectures is "both" — a graph for the traversal queries and a warehouse for the aggregate reporting, fed from the same upstream events. Decide by the deepest important query and the dominant workload, never by "it feels connected."
What is Cypher and how is it different from SQL?
Cypher is Neo4j's declarative cypher query language, built around ASCII-art pattern matching: you draw the graph shape you want — (node)-[:REL]->(node) — and the engine returns every subgraph that matches. Versus SQL, the biggest differences are that relationships are first-class (you traverse -[:BOUGHT]-> instead of joining on a foreign key), reachability is a native construct ([:KNOWS*1..3] and shortestPath) rather than a recursive CTE, and there is no GROUP BY — aggregation groups implicitly by the non-aggregated columns in RETURN. Reads use MATCH/WHERE/RETURN; writes use CREATE (unconditional) and MERGE (match-or-create, the idempotency workhorse) with ON CREATE SET/ON MATCH SET; deletes use DETACH DELETE to remove a node and its relationships together. If you can draw the pattern on a whiteboard, you can write the Cypher.
How do I model an ER diagram as a property graph?
Translate mechanically: each table becomes a node label and each row a node with the columns as properties; each foreign key becomes a relationship whose direction follows the domain verb (orders.customer_id → (:Customer)-[:PLACED]->(:Order)); a pure many-to-many junction table becomes a plain relationship, while a junction table that carries data either puts that data on the relationship ([:CONTAINS {qty, unit_price}]) or, if the junction is itself an entity you query about, gets reified into its own node; and a lookup/dimension table becomes a shared hub node many nodes traverse through (turning a repeated category_id into one (:Category) node). Add a uniqueness constraint on every node's business key before loading. Reify a connection into a node when it has its own identity, must be pointed at, connects more than two things, or must be scanned as a set — otherwise keep it an edge.
How do I load a large graph into Neo4j efficiently?
Match the tier to the size and whether the database is live. For the one-time cold-start seed of billions of nodes/edges, use neo4j-admin database import full against a stopped database with typed CSVs (id:ID, :LABEL, :START_ID, :END_ID, :TYPE) — it writes store files directly, skipping transactions, and is an order of magnitude faster than any online load. For incremental and mid-sized loads on a live database, use LOAD CSV WITH HEADERS with constraints created first (so every MERGE is an indexed lookup, not a full label scan), nodes before relationships, and every per-row write wrapped in CALL { WITH row ... } IN TRANSACTIONS OF 10000 ROWS so the load commits in bounded-memory batches. For transformations over the existing graph, use apoc.periodic.iterate. For continuous ingestion, use the Neo4j Kafka Connect sink or the Spark connector running templated MERGEs. The number-one performance bug is loading without a uniqueness constraint on the merge key.
When should I NOT use a graph database?
Do NOT reach for a graph database when the dominant workload is an aggregate scan — SUM/COUNT/GROUP BY over a whole fact table — because there is no traversal for index-free adjacency to accelerate, and a columnar warehouse (Snowflake, BigQuery, Delta) will be faster and cheaper. Avoid it for extreme-throughput single-row OLTP (millions of key-lookup writes per second with no multi-hop reads), where a key/value or relational store wins; for flat, tabular data with no meaningful relationships, where the graph adds modeling overhead for zero benefit; and for analytics whose projection does not fit the GDS memory budget without partitioning or sampling. The mark of a senior engineer is naming these anti-patterns unprompted — every technology has a workload envelope, and knowing the graph's is what makes a datastore-choice defensible in an interview.
Practice on PipeCode
- Drill the graph practice library → for the traversal, reachability, ring-detection, and shortest-path problems that Neo4j and Cypher live on.
- Rehearse on the ETL practice library → for the constraints-first
LOAD CSV, batchedMERGE, idempotent-replay, and CDC-into-graph loading patterns senior interviewers probe. - Sharpen the modeling axis with the data-transformation practice library → for the aggregation, pattern-matching, and relational-to-graph translation drills that turn a schema into a property graph.
- Cement datastore-fit judgment on the design practice library → for the "graph vs relational vs warehouse" decisions and the anti-patterns that separate a defensible choice from a trendy one.
- Stack the prerequisites against PipeCode's broader 450+ data-engineering catalogue to anchor the property-graph model, Cypher fluency, and GDS workflow against real graded inputs.
Lock in graph-thinking muscle memory
Docs explain Cypher syntax. PipeCode drills explain the decision — when index-free adjacency beats a five-way self-join, when a junction table should be reified into a node, when a load needs a constraint before a single `MERGE`, when PageRank is the answer and when the real question belongs in a warehouse. Pipecode.ai is Leetcode for Data Engineering — pattern-first practice tuned for the production trade-offs data engineers actually face across modeling, Cypher, graph ETL, and analytics.





Top comments (0)