LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Graph Databases with Neo4j

neo4jgraph-databasescypherdatabasesdata-modelingbackend

Most data has always been a graph. Customers know each other. Accounts share devices. Products are purchased together. Infrastructure components depend on each other in chains three layers deep. Relational databases have modeled this for decades — foreign keys, junction tables, recursive CTEs — and for shallow, well-bounded relationships the relational model is fine. Then the depth increases. You need to find every account within four hops of a flagged entity. You need to know which packages transitively depend on a vulnerable library. You need to recommend items based on a customer’s behavioral neighborhood, not just their own history. At that point the relational model starts to strain, and the question becomes whether the strain is worth the cost of adding another datastore.

The answer is often no — and the honest version of this post has to say that up front. But when the answer is yes, the tool is a property graph database, and the canonical implementation in production use is Neo4j. This post covers the model, the query language, why it performs the way it does on traversal workloads, the real use cases, how the cluster holds together, and where the whole category is overkill.


The property-graph model

A property graph has three primitives. Nodes are entities — a Person, a Product, an Account. Relationships are first-class directed connections between nodes — (:Person)-[:PURCHASED]->(:Product), (:Account)-[:SHARES_DEVICE_WITH]->(:Account). Properties are key-value pairs that live on both nodes and relationships — a Person has a name and a signupDate; a PURCHASED relationship has an amount and a timestamp.

(:Person {name: "Alice", signupDate: "2023-01-15"})
    -[:PURCHASED {amount: 49.99, timestamp: "2024-11-02"}]->
(:Product {sku: "B7", category: "electronics"})

(:Person {name: "Alice"})
    -[:KNOWS {since: "2020-06"}]->
(:Person {name: "Bob"})
    -[:KNOWS {since: "2021-03"}]->
(:Person {name: "Carol"})

The critical insight is that relationships are not secondary citizens derived by joining tables at query time — they are stored objects with their own identity, direction, type, and properties, persisted alongside the nodes they connect. When you traverse a relationship, the database follows a pointer. There is no join computation; there is a pointer dereference. This is called index-free adjacency, and it is what makes deep traversal fast in a way that SQL joins are not.

Compare that to the relational representation of the same data:

   Relational (join at query time)          Property graph (follow pointers)
   ─────────────────────────────            ──────────────────────────────
   persons table                            (Alice)──PURCHASED──>(B7)
   purchases table (FK → persons, products)         │
   products table                           (Alice)──KNOWS──────>(Bob)
                                                              │
   SELECT ... FROM persons p                         └──KNOWS──>(Carol)
   JOIN purchases pu ON p.id = pu.person_id
   JOIN products pr ON pu.product_id = pr.id    MATCH (a)-[:KNOWS*1..3]->(c)
   WHERE p.name = 'Alice'                       WHERE a.name = 'Alice'
                                                RETURN c.name

For a two-hop query the difference is negligible. At five hops with variable depth across millions of nodes, it is not.


Index-free adjacency vs. recursive SQL CTEs

The standard relational answer to variable-depth graph traversal is the recursive CTE:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- "find everyone reachable from Alice within 4 hops"
WITH RECURSIVE reachable(person_id, depth) AS (
  SELECT id, 0 FROM persons WHERE name = 'Alice'
  UNION ALL
  SELECT k.to_person_id, r.depth + 1
  FROM reachable r
  JOIN knows k ON k.from_person_id = r.person_id
  WHERE r.depth < 4
)
SELECT DISTINCT p.name FROM reachable r JOIN persons p ON p.id = r.person_id;

This works, and Postgres’s planner executes it reasonably well at shallow depths. The problem is what the planner must do at each level: construct a join between the current frontier and the full knows table, filtered by from_person_id. As depth grows, the frontier grows, and each level is a fresh join scan. With a dense graph and depth 5 or 6, you are looking at multiple full-index scans per level against a large table, and the execution time compounds. Postgres also does not have native shortest-path, PageRank, or community detection — you simulate them in SQL, which is painful and slow.

Neo4j’s index-free adjacency sidesteps this entirely. Each node stores direct pointers to its adjacent relationships. Traversal at depth n is n pointer-follow operations per path — the work is proportional to the neighborhood visited, not to the total size of the dataset. A five-hop traversal on a ten-million-node graph visits only the nodes and relationships actually reachable, not a join scan of the full edge table at each level.

Query type Postgres (recursive CTE + indexes) Neo4j (index-free adjacency)
Single-hop lookup, small result Excellent Excellent
Two-hop join, bounded Good Good
Variable-length traversal (3–6 hops) Degrades polynomially Roughly linear in nodes visited
Shortest path (Dijkstra, A*) Manual, slow Built-in, fast
PageRank, community detection Not feasible in SQL GDS library, purpose-built
Full-table analytical queries Strong (planner, parallelism) Weaker than RDBMS or columnar

The honest caveat: Neo4j’s traversal advantage only materializes when your queries actually do deep, variable-length traversal. If your “graph” queries are all single-hop lookups — show me all orders for this customer — you are paying a steep operational tax for something a well-indexed Postgres table does equally well.


Cypher: the query language

Cypher is Neo4j’s declarative query language, and its defining characteristic is that graph patterns look like the graphs they describe. Nodes are parentheses, relationships are arrows, and a query reads like a diagram:

-- Find products purchased by people Alice knows, within 2 degrees
MATCH (alice:Person {name: "Alice"})-[:KNOWS*1..2]->(friend:Person)
      -[:PURCHASED]->(product:Product)
WHERE product.category = "electronics"
  AND NOT (alice)-[:PURCHASED]->(product)
RETURN product.sku, product.name, COUNT(DISTINCT friend) AS recommenders
ORDER BY recommenders DESC
LIMIT 10;

The [:KNOWS*1..2] syntax is variable-length traversal — one or two hops of the KNOWS relationship type. No recursive CTE, no self-join, no subquery. The pattern is the query.

More real examples. Fraud ring detection — find accounts that share a device with a known-flagged account:

MATCH (flagged:Account {fraud: true})-[:SHARES_DEVICE]->(device:Device)
      <-[:SHARES_DEVICE]-(suspect:Account)
WHERE suspect.fraud IS NULL
  AND suspect <> flagged
RETURN suspect.id, suspect.email, COUNT(DISTINCT device) AS shared_devices
ORDER BY shared_devices DESC;

Shortest path between two nodes, respecting relationship type:

MATCH path = shortestPath(
  (a:Person {name: "Alice"})-[:KNOWS*]-(b:Person {name: "Dave"})
)
RETURN [node IN nodes(path) | node.name] AS chain,
       length(path) AS hops;

Creating nodes and relationships — Cypher uses MERGE (create-or-match) and CREATE:

MERGE (alice:Person {id: "u-001"})
ON CREATE SET alice.name = "Alice", alice.signupDate = date()
MERGE (bob:Person {id: "u-002"})
ON CREATE SET bob.name = "Bob"
MERGE (alice)-[r:KNOWS]->(bob)
ON CREATE SET r.since = date();

As of Neo4j 2025+, Cypher is versioned independently of the server (Cypher 5 and the newer Cypher 25 track), and the language is now a major influence on ISO/IEC 39075:2024 — the GQL standard, the first new ISO database query language since SQL debuted four decades ago. Cypher and GQL are not identical, but GQL borrowed Cypher’s core MATCH/RETURN semantics, and Neo4j is actively implementing GQL conformance. This matters operationally: Cypher-fluent engineers have a very short ramp to GQL, and skills will transfer across graph databases as the standard matures.


Indexes in Neo4j

Index-free adjacency handles traversal, but you still need indexes to find your starting nodes. Without them, every query that begins with a MATCH (n:Person {name: "Alice"}) does a label scan — reading every Person node. Create indexes for the properties you filter on at the start of queries:

-- Create a range index on Person.name (good for equality and prefix)
CREATE INDEX person_name FOR (p:Person) ON (p.name);

-- Composite index for common filter combinations
CREATE INDEX account_status_created FOR (a:Account) ON (a.status, a.createdAt);

-- Full-text index for search
CREATE FULLTEXT INDEX product_search FOR (p:Product) ON EACH [p.name, p.description];

Neo4j 5.x introduced RANGE and POINT index types alongside the existing B-tree (now deprecated in 5.x in favor of RANGE), plus native full-text via Lucene. The rule is the same as in any database: the first MATCH clause of a query must hit an index; once you are traversing relationships, you are in index-free-adjacency territory and indexes no longer help. Use EXPLAIN or PROFILE to see whether the planner found an index start:

PROFILE MATCH (a:Account {status: "flagged"})-[:SHARES_DEVICE*1..3]->(b:Account)
RETURN b.id, b.email;

Real use cases: when graphs earn their keep

Fraud detection and financial crime. This is the canonical graph database use case because fraud is structurally a graph problem. A single fraudulent actor rarely operates in isolation — they share devices, IP addresses, bank accounts, and phone numbers across a ring of synthetic or mule accounts. Detecting that requires multi-hop queries: find all accounts within three hops of a flagged entity that share at least two attributes. In a relational schema this is multiple joins of large tables, repeated per fraud rule, slow enough to run offline. In Neo4j it runs in milliseconds, fast enough for real-time transaction scoring.

Recommendation engines. Collaborative filtering and content-based recommendation both map naturally to graph traversal. “Users who bought what you bought also bought X” is a two-hop traversal (User-[:PURCHASED]->Product<-[:PURCHASED]-User-[:PURCHASED]->Product). “Find products similar to what you’ve viewed based on shared category, brand, and buyer demographics” is a neighborhood walk. Graph recommendations capture behavioral similarity through structure rather than matrix decomposition, and they stay interpretable — you can explain why a recommendation was made by showing the path.

Dependency and impact analysis. Package managers, CI/CD pipelines, microservice call graphs, data lineage pipelines — all are graphs where the important question is “what does this node transitively depend on, and what would break if it changed?” Answering that with a recursive SQL CTE is painful; with a graph database it is a single variable-length MATCH and a few milliseconds.

Knowledge graphs and GraphRAG. The 2025-2026 wave in graph databases is the integration of knowledge graphs with large language models — GraphRAG, where a language model’s retrieval step queries a structured knowledge graph rather than or in addition to a vector index. Neo4j’s GDS (Graph Data Science) library and its Aura cloud service have leaned heavily into this pattern: entities and their relationships in the graph provide structured context that pure vector similarity retrieval cannot. This is an area of active development and real production adoption, not just demos. See the vector-databases-in-production post for the vector side of that retrieval story.

Access control and permissions. RBAC hierarchies, organizational reporting chains, and attribute-based access control are deeply recursive. “Does this user, through their role memberships and group inheritances, have permission X on resource Y?” is a path-existence query. Graph databases handle this with trivial Cypher; relational databases handle it with recursive CTEs that become unmaintainable at organizational scale.


Clustering and causal consistency

A production Neo4j deployment is a cluster, not a single node. The architecture uses a Primary–Secondary model backed by the Raft consensus protocol. Primary servers form the consensus group: writes must be acknowledged by a majority before committing. Secondary servers replicate asynchronously from primaries via transaction log shipping and serve read queries at scale. One primary per database is the elected writer at any moment; primaries automatically re-elect if the writer fails.

   ┌─────────────────────────────────────────────────┐
   │  Neo4j Cluster                                  │
   │                                                 │
   │  ┌──────────┐   Raft   ┌──────────┐            │
   │  │ Primary  │◀────────▶│ Primary  │            │
   │  │ (writer) │          │          │            │
   │  └────┬─────┘          └──────────┘            │
   │       │ txlog shipping                          │
   │       ▼                                        │
   │  ┌──────────┐  ┌──────────┐  ┌──────────┐     │
   │  │Secondary │  │Secondary │  │Secondary │     │
   │  │(read)    │  │(read)    │  │(read)    │     │
   │  └──────────┘  └──────────┘  └──────────┘     │
   └─────────────────────────────────────────────────┘

The key feature is causal consistency via bookmarks. When a client completes a write transaction, the server returns a bookmark — a marker encoding the transaction’s position in the log. The client passes this bookmark to subsequent read requests (even if those reads go to a secondary), and the database guarantees the read will see at least that write. This is read-your-writes consistency without forcing all reads to the primary. In practice, application drivers handle bookmarks automatically and transparently; you opt into causal chaining by passing the bookmark from one session to the next in the same logical flow.

For multi-region deployments, each remote region runs read secondaries that asynchronously replicate from the primary region. Latency-sensitive reads go local; causal consistency ensures correctness when a user immediately reads back data they just wrote.


Where a graph database is overkill

This section deserves more space than it usually gets in graph database posts, because the failure mode is expensive. Adding Neo4j to your stack means another database process to operate, monitor, back up, and tune; another authentication system to manage; another schema to keep synchronized with your application; and for your on-call engineers, another unknown system to debug at 3 AM.

Most applications do not need it. If your relationships are shallow and well-defined — orders belong to customers, comments belong to posts, products belong to categories — a normalized relational schema with foreign keys and a few joins is the right answer. Postgres handles this elegantly, enforces referential integrity, gives you ACID transactions across any combination of your data, and your entire team already knows SQL. The postgresql-for-developers post covers what modern Postgres can do, and it is a lot.

Postgres’s ltree extension handles hierarchies cleanly. Recursive CTEs handle shallow graph queries. JSONB handles flexible-schema documents. None of these are graph databases, but none of them require you to run a second database. Adding Neo4j to solve a problem Postgres already handles is a tax disguised as an optimization.

The signal that you genuinely need a graph database:

  • Variable-length traversals at depth > 3 are a core query pattern, not an edge case.
  • Graph algorithms — shortest path, centrality, community detection, PageRank — are first-class features of your product.
  • Relationship properties matter — the edges in your data carry their own attributes (weights, timestamps, types) that you query and filter on.
  • The graph is dense and the neighborhood queries are the bottleneck, not analytical aggregations.

If your workload is primarily aggregations, reporting, and analytics over large datasets — even if the data has some relational structure — a columnar OLAP database will serve you far better than Neo4j. Neo4j’s strength is traversal; it is not a replacement for MongoDB for document-shaped data, nor for a data warehouse for analytical workloads.

The cost of adding a graph database is real. The benefit is real too, but only for specific query shapes. Be honest about whether your access patterns actually include deep traversal at scale before committing to the operational weight.


The verdict

Neo4j and the property-graph model solve a specific class of problem better than anything else: connected data at depth, where the relationships between entities are as important as the entities themselves. Index-free adjacency is not marketing — it is a genuine structural advantage for multi-hop traversal that recursive SQL CTEs cannot match past shallow depths. Cypher is a clean, readable query language that maps to graph thinking naturally, and its convergence with the ISO GQL standard gives the skill set a longer shelf life than proprietary query languages usually have.

The production story in 2026 is mature: Raft-based clustering, causal consistency via bookmarks, read replicas that scale horizontally without sacrificing correctness, and a calendar-versioned release cadence that replaced the old 5.x series with cleaner long-term support commitments. The GraphRAG integration pattern — knowledge graphs feeding structured context to LLMs — has moved from experiment to real deployments and is the primary growth vector for the category right now.

But the single most important thing to internalize about graph databases is the qualification: they are the right tool for a narrow but real class of problems. Fraud ring detection, deep recommendation traversal, dependency analysis, access control hierarchies — these benefit enormously. Everything else probably runs fine on Postgres, and adding Neo4j to solve a two-hop join is bringing heavy machinery to a job a good index handles. Know the shape of your queries before you commit to the shape of your data store.


Sources

Comments