beginner~2h

Neo4j — The Graph Model & Cypher Basics

Every relational database can store a foreign key. Ask it to walk three hops of them at scale and watch the query degrade — that concrete failure is the whole reason graph databases exist, and this chapter makes it real with numbers before writing a line of Cypher.

Learning objectives

  • Explain, with real numbers, why multi-hop relationship queries get progressively slower in a relational database but stay cheap in a graph database.
  • Model data as nodes (with labels and properties) and typed, directed relationships that can carry their own properties.
  • Write basic Cypher — CREATE, MATCH ... WHERE ... RETURN, and MERGE — and explain why using CREATE where you meant MERGE is a common source of duplicate data.

◆ The problem

Picture a social network with 1 million users, each following roughly 200 others — a realistic, unremarkable density. Someone asks a completely ordinary product question: "show me everyone within 3 hops of Alice" (her friends, their friends, and their friends — a very standard "people you may know" feature).

In a relational database, "who does Alice follow" lives in a follows(user_id, followee_id) edge table with roughly 200 million rows (1,000,000 users × 200 edges each). Finding 3-hop reach means joining that table to itself three times:

SELECT DISTINCT f3.followee_id FROM follows f1 JOIN follows f2 ON f1.followee_id = f2.user_id JOIN follows f3 ON f2.followee_id = f3.user_id WHERE f1.user_id = :aliceId;

Even with a perfect index on user_id for every join step, each JOIN produces an intermediate result set that fans out by Alice's average degree — roughly 200 rows after hop 1, up to 200×200 = 40,000 after hop 2, up to 200×200×200 = 8,000,000 after hop 3 — before the final DISTINCT collapses duplicates back down. The query planner has to actually generate and process that fan-out at each step, and a 4th hop means writing, and paying for, a 4th JOIN. Standard SQL has no clean way to say "however many hops it takes" without switching to a recursive CTE (SQL Mastery, Chapter 10) — which brings its own, often worse, performance profile for deep graph-shaped traversals.

In Cypher, the same question is one pattern:

MATCH (alice:Person {name: 'Alice'})-[:FOLLOWS]->()-[:FOLLOWS]->()-[:FOLLOWS]->(fof) RETURN DISTINCT fof.name;

Neo4j doesn't touch the other 999,999 users or their ~200 million relationships at all. It starts at exactly one node — Alice — and walks her relationships directly, then walks the relationships of whatever it finds, one hop at a time. The cost of this query is determined by how many relationships actually get walked starting from Alice, not by how large the total dataset is. That's the concrete, practical reason a graph database earns a place in your stack: not as a fancier way to store the same rows, but because "traverse a variable number of relationships" is a fixed-cost operation in a graph and a multiplicatively-more-expensive-per-hop one in a relational join.

◆ Under the hood

The reason each hop stays cheap regardless of total graph size: Neo4j stores a relationship as a direct, physical reference between two node records — sometimes called index-free adjacency. Following a relationship from a node you already have is a pointer dereference, not a lookup into a separate structure the way a foreign-key JOIN requires. You still need an index to find your starting node quickly (Chapter 03) — but once you're standing on a node, walking its relationships doesn't get slower as the rest of the graph grows around it.

💻 Code example

MATCH (alice:Person {name: 'Alice'})-[:FOLLOWS]->()-[:FOLLOWS]->()-[:FOLLOWS]->(fof) RETURN DISTINCT fof.name;
LevelDefinition
BeginnerA graph database stores your data as things (nodes) and the connections between them (relationships) — instead of rows in separate tables linked by IDs.
TechnicalA property graph stores data as nodes (each with zero or more labels and a set of key-value properties) and relationships (each with exactly one type, a direction, and its own optional key-value properties) connecting exactly two nodes.
Interview-gradeNeo4j implements the labeled property graph model: nodes and relationships are both first-class, independently-typed and independently-propertied entities, and a relationship is a direct, traversable reference between two specific node records rather than a value that must be re-joined against a second table to resolve.

Four building blocks make up every Neo4j graph:

  • Nodes — the "things": a person, a product, an account. A node can carry one or more labels (:Person, :Employee) that categorize it, similar in spirit to a table name, except a single node can hold multiple labels at once.
  • Properties — key-value pairs on either a node or a relationship (name: 'Alice', since: 2019).
  • Relationships — the connections: always typed (exactly one type, like :FOLLOWS or :WORKS_AT) and always directed (they point from one node to another) — but they're queryable in either direction regardless of which way they were created.
  • Relationship properties — a relationship isn't just a link; it can carry its own data. A :FOLLOWS relationship might have a since property; a :WORKS_AT relationship might have a role and startDate.
CREATE (alice:Person {name: 'Alice', age: 29}) CREATE (bob:Person {name: 'Bob', age: 31}) CREATE (acme:Company {name: 'Acme Corp'}) CREATE (alice)-[:FOLLOWS {since: 2021}]->(bob) CREATE (alice)-[:WORKS_AT {role: 'Engineer', startDate: date('2022-03-01')}]->(acme);

Notice the relationship carries data (since, role, startDate) exactly the way a node's properties do — a genuine modeling capability a plain foreign key column can't express without an extra join table of its own.

💻 Code example

CREATE (alice:Person {name: 'Alice', age: 29}) CREATE (bob:Person {name: 'Bob', age: 31}) CREATE (acme:Company {name: 'Acme Corp'}) CREATE (alice)-[:FOLLOWS {since: 2021}]->(bob) CREATE (alice)-[:WORKS_AT {role: 'Engineer', startDate: date('2022-03-01')}]->(acme);

Cypher's pattern syntax is meant to look like the graph it describes: parentheses () are nodes, square brackets [] are relationships, and -> / <- show direction. Once you can read a pattern as a little ASCII drawing, most of Cypher follows naturally.

// CREATE — always creates new nodes/relationships, never checks if they already exist CREATE (p:Person {name: 'Priya', city: 'Bengaluru'}); // MATCH ... WHERE ... RETURN — the core read pattern, directly analogous to SQL's FROM ... WHERE ... SELECT MATCH (p:Person) WHERE p.city = 'Bengaluru' RETURN p.name, p.age; // Traversal is just a longer pattern in the same MATCH MATCH (p:Person {name: 'Priya'})-[:FOLLOWS]->(followed:Person) RETURN followed.name; // Filtering on a relationship's own property MATCH (p:Person)-[f:FOLLOWS]->(other:Person) WHERE f.since < 2022 RETURN p.name, other.name, f.since;

A few habits carry over directly from SQL: WHERE filters, and you can filter on node properties, relationship properties, or both in the same clause. What doesn't carry over: there's no JOIN keyword, because a relationship in the pattern is the join — (p)-[:FOLLOWS]->(other) already tells Neo4j exactly which two node sets to connect and how, with no separate ON condition to write.

RETURN supports the aggregate functions you'd expect — count(), collect(), avg() — and clauses like ORDER BY and LIMIT work exactly as they read:

MATCH (p:Person)-[:FOLLOWS]->(other:Person) RETURN p.name, count(other) AS followingCount ORDER BY followingCount DESC LIMIT 5;

💻 Code example

CREATE (p:Person {name: 'Priya', city: 'Bengaluru'}); MATCH (p:Person) WHERE p.city = 'Bengaluru' RETURN p.name, p.age; MATCH (p:Person {name: 'Priya'})-[:FOLLOWS]->(followed:Person) RETURN followed.name; MATCH (p:Person)-[f:FOLLOWS]->(other:Person) WHERE f.since < 2022 RETURN p.name, other.name, f.since;

▲ Common mistake

CREATE never checks whether a matching node already exists — it unconditionally creates a new one, every single time it runs. Re-run an import script, a signup endpoint, or a data-sync job that uses CREATE (p:Person {email: $email}) twice for the same email, and you now have two separate Person nodes with the same email, no relationship between them, and every future query that assumes "one node per person" is quietly wrong.

MERGE is Cypher's create-if-not-exists: it first tries to MATCH the given pattern, and only falls through to CREATE if nothing matched.

// Safe to run any number of times — only ever one Person node with this email MERGE (p:Person {email: 'alice@example.com'}) RETURN p;

MERGE matches on the entire pattern you give it, properties included — MERGE (p:Person {email: $email}) will happily create a second node if you later MERGE (p:Person {email: $email, city: 'Pune'}), because as a pattern it's not identical to the first one. Keep the property or properties you're merging on minimal and stable (usually whatever your actual uniqueness key is), and set everything else afterward with ON CREATE SET / ON MATCH SET:

MERGE (p:Person {email: $email}) ON CREATE SET p.name = $name, p.createdAt = timestamp() ON MATCH SET p.lastSeenAt = timestamp();

This runs the createdAt assignment only the first time this person is ever merged, and lastSeenAt on every subsequent run — exactly the shape of logic an idempotent import or upsert endpoint needs. MERGE also works on whole patterns, not just single nodes — MERGE (a)-[:FOLLOWS]->(b) creates the relationship only if that exact a-to-b :FOLLOWS edge doesn't already exist, which is precisely how you avoid accidentally doubling up relationships too. A uniqueness constraint (Chapter 03) is the belt-and-suspenders way to make duplicate nodes impossible even if a stray CREATE slips through.

💻 Code example

MERGE (p:Person {email: $email}) ON CREATE SET p.name = $name, p.createdAt = timestamp() ON MATCH SET p.lastSeenAt = timestamp();

NoSQL Foundations, Chapter 1 places graph databases as the fourth NoSQL family, alongside key-value, document, and column-family stores — each earning its place by being the best fit for one specific access pattern (see that chapter's family table). Graph's access pattern is traversing relationships multiple hops deep.

SignalLean toward a graph databaseLean toward relational/other NoSQL
Query shape"Find things connected to this thing, N hops away" — friends-of-friends, fraud rings, recommendation pathsAggregations, reporting, single-entity lookups by key
Relationship depth known in advanceNo — 2, 3, or unbounded hops, decided at query timeYes — a fixed, small number of predictable JOINs
What's expensive todayMulti-hop JOIN queries getting slower as the dataset growsNothing relationship-shaped is currently slow
Data modelThe relationships themselves carry meaningful data (since, weight, role)Relationships are simple, low-cardinality foreign keys

▲ Common mistake

Reaching for Neo4j because your data is technically "related" is a common overcorrection — almost all data is related, and a relational database with a couple of well-indexed foreign keys handles fixed, shallow, 1-2-hop relationships (a blog post and its author, an order and its line items) perfectly well, often faster, without the operational cost of running a second database system. Graph databases earn their keep specifically when the number of hops is variable, deep, or decided at query time — not merely when data references other data.

Want a visual for this concept?

Generate a diagram tailored to “Neo4j — The Graph Model & Cypher Basics” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Neo4j — Traversals, Patterns & Common Query Shapes← Back to all Neo4j chapters