DEV Community

Cover image for Neo4j & Graph Data Modeling for Data Engineers: Cypher, ETL & Graph Analytics
Gowtham Potureddi
Gowtham Potureddi

Posted on

Neo4j & Graph Data Modeling for Data Engineers: Cypher, ETL & Graph Analytics

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.

PipeCode blog header for Neo4j and graph data modeling — bold white headline 'Neo4j & Graph Data Modeling' over a hero composition of glowing purple nodes joined by labelled relationship arrows, orbited by small glyph medallions for Cypher, ETL, and analytics, on a dark gradient.

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


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 BY over 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, a k-hop query fans out to roughly d^k intermediate rows, each requiring an index re-lookup on friendships.
  • Graph cost. Each hop dereferences the KNOWS pointers 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.
Enter fullscreen mode Exit fullscreen mode
// 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.
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. The relational query anchors on f1.user_a = 1 using the index, then joins f2 onto f1.user_b — for every friend of Alice, it index-scans friendships again to find their friends, and once more for the third hop. Three index consultations, and the intermediate result set fans out multiplicatively.
  2. 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.
  3. 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.
  4. RETURN DISTINCT in 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.
  5. 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 BY over 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)'
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Fraud detection scores 3/3 on the graph signals: ring detection is variable-length ("any cycle within 6 hops"), transfers carry amount and timestamp, and the SQL equivalent is a recursive CTE that most teams are scared to run in production. Neo4j earns its place.
  2. The revenue-by-region report trips the anti-signal: it is a GROUP BY region aggregate 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.
  3. 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.
  4. 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.
  5. 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;
Enter fullscreen mode Exit fullscreen mode

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 a UNION of self-joins.
  • Variable-length paths*1..4 and *2..6 express "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 amount property, you sum them along the matched path with reduce() — 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

Practice →

Design Topic — design Design problems on choosing the right datastore

Practice →


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.

Iconographic property-graph diagram — three labelled node circles (Customer, Order, Product) joined by directed relationship arrows PLACED and CONTAINS, each node and arrow annotated with property key-value chips, beside a small ER-table card showing the relational source it was derived from.

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 customers table becomes a (:Customer) node; the columns become properties.
  • A foreign key becomes a relationship. orders.customer_id -> customers.id becomes (: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 categories table 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 to address_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, categories each 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. categories becomes 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;
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Constraints come first. CREATE CONSTRAINT ... REQUIRE ... IS UNIQUE guarantees one node per business key and creates the backing index that makes every subsequent MERGE an indexed lookup instead of a full label scan. Skipping this is the number-one cause of catastrophically slow loads.
  2. Each row becomes a MERGE on its business key with ON CREATE SET for the attributes — idempotent, so re-running the script never duplicates nodes (covered fully in section 4).
  3. The foreign key orders.customer_id becomes (:Customer)-[:PLACED]->(:Order). Direction follows the domain verb; you will still query it undirected when direction is irrelevant.
  4. The order-line bridge carries qty and unit_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.
  5. categories becomes a shared (:Category) hub. Two products pointing at one accessories node means "products in the same category" is a two-hop traversal (:Product)-[:IN_CATEGORY]->(:Category)<-[:IN_CATEGORY]-(:Product) — no GROUP BY category_id needed.

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;
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. In the edge model, "average stars per movie" is a clean one-hop aggregation over the RATED relationship property. This is the model you should keep if the rating is never an endpoint of anything.
  2. 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.
  3. Reifying inserts a (:Rating) node between user and movie: (:User)-[:GAVE]->(:Rating)-[:OF]->(:Movie). The stars and created_at move from the edge onto the node, and the node gets its own unique id and constraint.
  4. 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.
  5. The payoff query — helpful votes per review — is now a simple one-hop pattern into the (:Rating) node. And because :Rating is 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;
Enter fullscreen mode Exit fullscreen mode

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, :Playlist are the entity types; each gets a unique-key constraint so anchoring and MERGE are O(1) index lookups.
  • Data on the edge, not reifiedLISTENED {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 FOLLOWS then LISTENED, exactly the traversal shape graphs are built for.
  • Negative pattern for exclusionWHERE NOT (me)-[:LISTENED]->(rec) is a pointer existence check, not a LEFT 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 EXISTS anti-join — the join fan-out graphs are designed to avoid.

Graph
Topic — graph
Property-graph modeling problems

Practice →

Design Topic — design Design problems on schema and data modeling

Practice →


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.

Iconographic Cypher diagram — a whiteboard-style ASCII-art graph pattern (parenthesis node) dash bracket relationship arrow node, annotated with clause chips MATCH, WHERE, MERGE, and a variable-length asterisk range, over a light editorial card.

The read clauses.

  • MATCH. Find a pattern. MATCH (u:User)-[:PLACED]->(o:Order) binds u and o to 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 graph LEFT JOIN — the pattern is matched if it exists, otherwise the bound variables are null.

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 with ON CREATE SET (run only on insert) and ON 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 DELETE removes 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 3 KNOWS hops. * 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 in RETURN are the grouping keys. RETURN u.country, count(o) groups by country automatically.
  • 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 separate MATCH.

Common beginner mistakes

  • Forgetting to anchor. MATCH (u:User)-[:PLACED]->(o:Order) WHERE u.id = 1 scans all users then filters; write MATCH (u:User {id: 1})-... to start from the index.
  • CREATE where MERGE was meant. Re-running a CREATE load duplicates every node; use MERGE on a constrained key for idempotency.
  • Unbounded *. [:KNOWS*] on a dense graph can traverse the whole component; bound it (*1..4) or use shortestPath.
  • Accidental implicit grouping. Adding a stray property to RETURN alongside 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 makes MERGE (u:User {id: 1}) an indexed match, not a scan.
  • Split logic. ON CREATE SET runs only when the node is new (set created_at); ON MATCH SET runs only when it already existed (bump updated_at).
  • MERGE the relationship separately. MERGE the two endpoint nodes on their keys first, then MERGE the 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');
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. The two constraints guarantee at most one :User per id and one :Company per id, and back each with an index. Without them, MERGE (u:User {id: 42}) would scan every :User and could still race two concurrent loads into duplicates.
  2. MERGE (u:User {id: 42}) matches the existing user or creates it. ON CREATE SET fires only on the create branch, stamping both timestamps; ON MATCH SET fires only when the user already existed, bumping just updated_at. This is how you keep an accurate created-vs-updated distinction in an idempotent load.
  3. 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 where MERGE (u)-[:WORKS_AT]->(c:Company {id:'acme'}) creates a new company if the exact whole pattern is not found.
  4. With u and c already 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.since records the tenure start once.
  5. Re-running the entire block is a no-op on structure: the user matches, the company matches, the relationship matches, and only updated_at advances. 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 over nodes(path) keeps only paths where every intermediate node satisfies a condition.
  • Shortest path. shortestPath collapses "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;
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. The reports query anchors on the VP via the id index, then expands REPORTS_TO inbound (<-) 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.
  2. 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.
  3. length(path) gives the reporting depth per employee, so the result is naturally ordered by how far down the tree each person sits.
  4. 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 *..10 bound caps the search so a disconnected pair fails fast instead of scanning forever.
  5. [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;
Enter fullscreen mode Exit fullscreen mode

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 patternproduct <- customer -> product is 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-aggregated RETURN columns (rec.id, rec.name) are the grouping key, so count(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; DISTINCT collapses them, so co_buyers is 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 orders fact table. The relational equivalent is a self-join of bought to itself on customer_id with a GROUP BY product — the join graphs replace with a pointer walk.

Graph
Topic — graph
Cypher pattern-matching and traversal problems

Practice →

Transform Topic — data-transformation Data-transformation and aggregation problems

Practice →


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.

Iconographic graph-ETL diagram — a CSV file and a Kafka stream feeding an arrow labelled MERGE into a Neo4j graph, with a separate offline neo4j-admin bulk-import lane loading a large node/edge store, plus a batching chip 'IN TRANSACTIONS OF 10000 ROWS'.

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 CSV in one transaction runs the machine out of memory. Wrap the per-row work in CALL { ... } 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 :ID column 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 MERGE nodes and relationships from a batch or structured-streaming pipeline.
  • APOC for polyglot pulls. apoc.load.jdbc pulls straight from a relational source inside a Cypher statement; apoc.periodic.iterate batches 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 uses MERGE over CREATE.
  • CALL { } IN TRANSACTIONS OF N ROWS. The native Neo4j 5 batching construct (replacing the old USING 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, with batchSize and parallel options.

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 CSV in 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;
Enter fullscreen mode Exit fullscreen mode
// 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;
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Step 0 creates the three uniqueness constraints. This is not optional: without customer_id unique+indexed, every MERGE (c:Customer {id: ...}) in step 1 scans all existing customers, and a load that should take minutes takes hours. IF NOT EXISTS makes the constraint creation itself idempotent.
  2. Step 1 loads customers in their own pass, wrapping the per-row MERGE in CALL { ... } IN TRANSACTIONS OF 10000 ROWS. Each 10k-row chunk commits separately, so peak memory is bounded by the batch, not the file. ON CREATE SET fills the name only on insert.
  3. 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.
  4. Step 3 loads the structure. Because every customer and product already exists (steps 1–2), the two MATCH clauses are O(1) index lookups. MERGE (o:Order {id}) upserts the order; the two relationship MERGEs are idempotent, and toInteger(row.qty) casts the CSV string to a number (all CSV values arrive as strings).
  5. Re-running the whole load is safe: every MERGE matches the existing element, so a re-run adds nothing and only re-stamps ON CREATE fields 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; batchSize controls commit granularity; parallel: true uses multiple threads when batches do not touch the same nodes.
  • neo4j-admin import. Header-typed CSVs, an :ID space for nodes, :START_ID/:END_ID/:TYPE for 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;
Enter fullscreen mode Exit fullscreen mode
# (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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. apoc.periodic.iterate takes 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 of batchSize. The id(p1) < id(p2) guard emits each unordered pair once, avoiding duplicate reciprocal edges.
  2. parallel: false is deliberate here: neighbouring product pairs can touch the same product node, so parallel batches would deadlock or lock-contend. Set parallel: true only when batches are guaranteed disjoint (e.g. keyed node property updates). Knowing when not to parallelise is the senior detail.
  3. YIELD batches, total, errorMessages surfaces how many batches ran, how many items were processed, and any per-batch errors — the observability you need for a long-running back-fill.
  4. neo4j-admin database import full is 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 time LOAD CSV would take for millions. --id-type=string matches relationship endpoints by the string ids in the :ID space.
  5. 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=true tolerates 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;
Enter fullscreen mode Exit fullscreen mode
// 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;
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode

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 MERGE both 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 DELETE does both atomically, where a bare DELETE errors 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

Practice →

Transform Topic — data-transformation Data-transformation and batching problems

Practice →


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.

Iconographic graph-analytics diagram — a small network with node sizes scaled by PageRank score, three coloured clusters showing community detection, and a highlighted shortest path between two nodes, beside a chip panel reading GDS PageRank / Louvain / Dijkstra and a red 'not for aggregate scans' warning ribbon.

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. stream returns rows (nodeId, score) for ad-hoc queries; write persists a score property back onto the stored nodes; mutate writes into the in-memory graph for chaining a second algorithm; stats returns only summary statistics.

The three algorithm families interviewers probe.

  • Centrality — PageRank. Scores each node by the weighted importance of what points at it. gds.pageRank finds 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.dijkstra uses a relationship weight property; unweighted BFS shortest path is expressible in plain Cypher with shortestPath.

When NOT to use a graph database (the senior signal).

  • Aggregate scans over the whole dataset. SUM(revenue) GROUP BY month over 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.stream a label directly without projecting.
  • write mode in an ad-hoc query. write mutates every node's properties; use stream for exploration and reserve write for 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 :User nodes and FOLLOWS relationships.
  • Run. gds.pageRank.write with a damping factor and iteration cap, writing an influence property.
  • 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');
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. gds.graph.project('social', 'User', {FOLLOWS: {orientation: 'NATURAL'}}) builds a compressed in-memory graph containing only :User nodes and FOLLOWS edges in their stored direction. Projecting a focused subgraph — not the whole database — keeps the analysis memory-bounded and fast.
  2. gds.pageRank.write runs the algorithm and persists the result as an influence property on each stored :User. dampingFactor: 0.85 is the standard PageRank random-jump parameter; maxIterations: 20 caps the power-iteration so a non-converging graph still terminates.
  3. YIELD nodePropertiesWritten, ranIterations confirms how many scores were written and whether the algorithm converged before the iteration cap — the observability a pipeline step needs.
  4. 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.
  5. 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.stream returns (nodeId, communityId); group by community to size the clusters.
  • Dijkstra. gds.shortestPath.dijkstra.stream returns the path and total cost using relationshipWeightProperty.

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;
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Louvain runs on the already-projected social graph and streams a communityId for every node. Because it is stream mode, nothing is written back — you aggregate the stream on the fly. WITH communityId, count(*) AS members groups nodes into their communities and counts each.
  2. Ordering by members DESC LIMIT 5 surfaces 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.
  3. For Dijkstra we project a weighted graph: {ROUTE: {orientation: 'UNDIRECTED', properties: 'cost'}} pulls the cost property onto the projected relationships so the algorithm can minimise total cost. Undirected because a route is traversable both ways.
  4. gds.shortestPath.dijkstra.stream takes the source and target nodes plus relationshipWeightProperty: 'cost' and returns totalCost, the ordered nodeIds on the path, and the per-hop costs. Without relationshipWeightProperty, Dijkstra would treat every edge as weight 1 and degenerate into BFS.
  5. [nid IN nodeIds | gds.util.asNode(nid).name] maps the internal node ids back to city names via gds.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');
Enter fullscreen mode Exit fullscreen mode

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 amount weight is available to whichever algorithm asks for it.
  • Weighted PageRankrelationshipWeightProperty: '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 communityId as 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 influence and communityId are 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

Practice →

Design
Topic — design
Design problems on analytics and datastore fit

Practice →


Cheat sheet — Neo4j and Cypher recipes

  • When to reach for a graph. Pick neo4j when 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 BY over 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 MERGE node endpoints on their keys separately, bind them, then MERGE the relationship between the bound variables. ON CREATE SET for insert-only fields, ON MATCH SET for update-only. Never MERGE a 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; allShortestPaths for every shortest. Path-level predicates via all(n IN nodes(p) WHERE ...); fold with reduce(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. Add CREATE 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 every MERGE is 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; replaces USING PERIODIC COMMIT). Cast CSV strings with toInteger() / toFloat() / date() / datetime(). Re-running is a no-op because every write is a MERGE.
  • Bulk import tiers. neo4j-admin database import full once, 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 TRANSACTIONS and apoc.periodic.iterate for 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. Set parallel: true only when batches cannot touch the same nodes; leave it false when writes overlap (e.g. reciprocal relationships).
  • Streaming ingestion. Neo4j Connector for Kafka (Kafka Connect sink) runs a templated MERGE per message — the graph CDC sink, idempotent by the merge keys. Neo4j Connector for Apache Spark reads/writes graph as DataFrames. apoc.load.jdbc pulls straight from a relational source inside Cypher. Handle CDC deletes with MATCH ... 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 (add relationshipWeightProperty for weighted). Community: gds.louvain, gds.wcc. Pathfinding: gds.shortestPath.dijkstra (needs relationshipWeightProperty). Estimate memory with gds.graph.project.estimate before 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 scanSUM/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, batched MERGE, 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.

Practice graph problems →
Practice ETL problems →

Top comments (0)