<!-- Canonical: https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison
Language: en
Content-SHA256: 19d62cfe70c404b44410ed6ef1cf07ecfcf08dda075ee9fdae05f0b582903b00
-->

Databases

# Graph Databases: Neo4j vs SurrealDB vs Apache AGE, Cypher, GQL, and Real Social Network Query Optimization

2025-11-247 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/graph-databases-neo4j-surrealdb-age-comparison>)

## The Data Model

A social network feature requires: users, follows, posts, likes, comments, and "interests" (topics users tag themselves with). The graph model:

```
(:User)-[:FOLLOWS]->(:User)
(:User)-[:POSTED]->(:Post)
(:User)-[:LIKED]->(:Post)
(:User)-[:COMMENTED {text: "..."}]->(:Post)
(:User)-[:INTERESTED_IN]->(:Topic)
(:Post)-[:TAGGED_WITH]->(:Topic)
```

About 500K users, 2M follow relationships, 1.5M posts, 8M likes. Not massive by any means, but enough to expose performance differences.

## Neo4j: The Native Graph

[Neo4j](<https://neo4j.com/>) stores data as nodes and relationships in a native graph format, index-free adjacency means each node physically points to its neighbors. This makes traversal operations constant-time per hop, regardless of total graph size.

### Setup and Ingestion

```cypher
// Create constraints and indexes
CREATE CONSTRAINT user_email IF NOT EXISTS
FOR (u:User) REQUIRE u.email IS UNIQUE;

CREATE INDEX user_name IF NOT EXISTS
FOR (u:User) ON (u.name);

CREATE INDEX post_created IF NOT EXISTS
FOR (p:Post) ON (p.createdAt);

// Batch import with LOAD CSV
LOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row
CREATE (u:User {
 id: row.id,
 name: row.name,
 email: row.email,
 joinedAt: datetime(row.joinedAt)
});
```

Ingestion of 500K users + relationships took about 90 seconds using ` neo4j-admin import ` (offline bulk load). The LOAD CSV approach took 12 minutes. For production migrations, always use the admin tool.

### Query Examples

The bread and butter, "who should this user follow?" based on mutual connections and shared interests:

```cypher
// Friend-of-friend recommendation excluding existing follows
MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)
WHERE NOT (me)-[:FOLLOWS]->(suggestion)
 AND suggestion <> me
WITH suggestion, count(friend) AS mutualFriends
ORDER BY mutualFriends DESC
LIMIT 20
RETURN suggestion.name, suggestion.id, mutualFriends
```

This ran in 15ms on our dataset. The query planner uses the index on ` User.id `, then traverses two hops through the FOLLOWS relationship. The ` count(friend) ` aggregation is where the ranking happens.

A more complex one, "find the shortest path between two users through their follow graph":

```cypher
MATCH path = shortestPath(
 (a:User {id: $userA})-[:FOLLOWS*..6]-(b:User {id: $userB})
)
RETURN [node IN nodes(path) | node.name] AS names,
 length(path) AS degrees
```

That ` *..6 ` is critical. Without a depth limit, this query will explore the entire connected component, which on a social graph means potentially millions of nodes. Setting it to 6 follows the six degrees of separation theory, if there is no path within 6 hops, the users are not meaningfully connected for typical UX purposes.

### Performance Optimization

The single most impactful optimization in Neo4j is **profile your queries**:

```cypher
PROFILE MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)
WHERE NOT (me)-[:FOLLOWS]->(suggestion)
RETURN suggestion.name, count(friend) AS mutual
ORDER BY mutual DESC LIMIT 20
```

The ` PROFILE ` prefix shows you the query execution plan with actual row counts. Analysis revealed that a "trending posts in your network" query was doing an ` AllNodesScan ` on Post instead of using the date index, adding a ` WHERE p.createdAt > datetime() - duration('P7D') ` filter before the relationship traversal cut the query from 2.3s to 80ms.

## SurrealDB: The Multi-Model Approach

[SurrealDB](<https://surrealdb.com/>) handles graphs through its record link system, any field can reference another record using ` record_id ` syntax, and you can traverse these links with arrow notation in SurrealQL.

```sql
-- Define schema
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD name ON user TYPE string;
DEFINE FIELD email ON user TYPE string;
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;

DEFINE TABLE follows SCHEMAFULL TYPE RELATION
 FROM user TO user;
DEFINE FIELD since ON follows TYPE datetime DEFAULT time::now();

DEFINE TABLE post SCHEMAFULL;
DEFINE FIELD content ON post TYPE string;
DEFINE FIELD author ON post TYPE record<user>;
DEFINE FIELD createdAt ON post TYPE datetime DEFAULT time::now();
```

### Graph Traversal in SurrealQL

The arrow syntax is surprisingly clean:

```sql
-- Direct follows
SELECT name FROM user:alice->follows->user;

-- Friend-of-friend (2 hops)
SELECT name, count() AS mutual FROM
 user:$userId->follows->user->follows->user
WHERE id NOT IN (SELECT VALUE out FROM follows WHERE in = user:$userId)
 AND id != user:$userId
GROUP BY id
ORDER BY mutual DESC
LIMIT 20;
```

The same recommendation query that took 15ms in Neo4j ran in 45ms in SurrealDB on equivalent hardware. Not bad for a database that also handles document and relational queries, but the gap widens on deeper traversals.

Where SurrealDB shines is mixed workloads. Consider a feed query that needs both graph traversal (who do I follow?) and document-style aggregation (what are their recent posts, sorted by engagement?):

```sql
-- Feed: posts from followed users, ranked by engagement
SELECT
 content,
 author.name AS authorName,
 createdAt,
 count(->liked_by) AS likes,
 count(->comments) AS commentCount
FROM post
WHERE author IN (SELECT VALUE out FROM follows WHERE in = user:$userId)
 AND createdAt > time::now() - 7d
ORDER BY likes DESC, createdAt DESC
LIMIT 50;
```

In Neo4j, this would require combining Cypher traversal with full-text search and aggregation, doable but less natural.

## Apache AGE: Graph on PostgreSQL

Apache AGE (A Graph Extension) adds openCypher support to PostgreSQL. Your graph data lives in PostgreSQL tables with a specific internal format, but you query it with Cypher syntax wrapped in SQL.

```sql
-- Enable the extension
CREATE EXTENSION IF NOT EXISTS age;
LOAD 'age';
SET search_path = ag_catalog, "$user", public;

-- Create a graph
SELECT create_graph('social');

-- Create nodes
SELECT * FROM cypher('social', $$
 CREATE (u:User {id: 'alice', name: 'Alice', email: 'alice@example.com'})
 RETURN u
$$) AS (u agtype);

-- Create relationships
SELECT * FROM cypher('social', $$
 MATCH (a:User {id: 'alice'}), (b:User {id: 'bob'})
 CREATE (a)-[:FOLLOWS {since: '2025-01-15'}]->(b)
$$) AS (result agtype);
```

### The SQL-Cypher Bridge

The killer feature is mixing SQL and Cypher in the same query:

```sql
-- Use regular PostgreSQL tables alongside graph queries
SELECT
 u.display_name,
 u.avatar_url,
 graph_data.mutual_count
FROM users u
JOIN (
 SELECT * FROM cypher('social', $$
 MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)
 WHERE NOT (me)-[:FOLLOWS]->(suggestion) AND suggestion <> me
 RETURN suggestion.id AS user_id, count(friend) AS mutual_count
 ORDER BY mutual_count DESC
 LIMIT 20
 $$, $1) AS (user_id agtype, mutual_count agtype)
) AS graph_data ON u.id = graph_data.user_id::text;
```

This is AGE's superpower, you can add graph queries to an existing PostgreSQL application without migrating data to a separate database. The ` users ` table with profile data stays in regular PostgreSQL; the social graph lives in AGE.

### Performance Reality

The same friend-of-friend recommendation: 85ms on AGE vs 15ms on Neo4j. The overhead comes from PostgreSQL's executor planning the Cypher query as a subquery, each hop goes through the regular planner instead of native graph traversal.

For queries up to 3 hops, AGE is absolutely competitive. Beyond that, the overhead compounds:

```
1 hop: AGE 8ms vs Neo4j 3ms (2.7x)
2 hops: AGE 35ms vs Neo4j 15ms (2.3x)
3 hops: AGE 85ms vs Neo4j 25ms (3.4x)
4 hops: AGE 340ms vs Neo4j 55ms (6.2x)
5 hops: AGE 1.8s vs Neo4j 120ms (15x)
```

The ratio gets worse because Neo4j's index-free adjacency is O(1) per hop while AGE does index lookups through PostgreSQL's B-tree for each level.

## GQL: The Future Standard

ISO published GQL (ISO/IEC 39075) in 2024 as the standard graph query language. It draws heavily from Cypher but adds features like graph types, path patterns, and a more rigorous type system.

```gql
-- GQL syntax (evolving, this is approximate)
GRAPH social
MATCH (me:User WHERE me.id = $userId)
 -/:FOLLOWS/-> (friend)
 -/:FOLLOWS/-> (suggestion)
WHERE NOT EXISTS (me -/:FOLLOWS/-> suggestion)
 AND suggestion <> me
RETURN suggestion.name, COUNT(friend) AS mutual
ORDER BY mutual DESC
LIMIT 20;
```

The key difference from Cypher: GQL uses path pattern syntax (` -/:FOLLOWS/-> `) and explicit graph selection (` GRAPH social `). Neo4j has committed to GQL support, and newer databases like Memgraph and Kuzu are implementing it.

For now, Cypher remains the practical choice. GQL adoption is where SQL was in the early 1990s, the standard exists, but implementations are catching up.

## Recommendations by Use Case

For a greenfield social graph feature: **Neo4j** if graph queries are the primary workload and the operational cost of a separate database is justified. The query performance and tooling (Neo4j Browser, APOC library) are unmatched.

For adding graph queries to an existing PostgreSQL app: **Apache AGE** without hesitation. The ability to JOIN graph results with regular tables is a massive advantage, and the performance is good enough for 3-hop queries which covers most real use cases.

For a new project with mixed requirements (graph + document + real-time): **SurrealDB** is worth evaluating. The multi-model approach reduces operational complexity. Just go in with eyes open about query optimizer maturity.

The graph model isn't the hard part. The hard part is knowing when your data is actually a graph, and when you're just forcing a relational problem into a graph shape because the query language looks nicer.

## Frequently asked questions

### When should I use a graph database instead of a relational database?

When your queries are relationship-centric, 'find all paths between A and B', 'show me N degrees of connection', 'detect cycles in a dependency graph'. If you're mostly doing key-value lookups or aggregations, a relational database with JOIN is fine. The break-even point is usually around 3+ JOINs on self-referential tables, where graph traversal becomes both faster and more readable.

### Is SurrealDB production-ready for graph workloads?

As of early 2026, SurrealDB is usable for moderate graph workloads but lacks the query optimizer maturity of Neo4j. Its strength is the multi-model approach, if you need document, graph, and relational queries in the same system, it's compelling. For pure graph workloads at scale, Neo4j is still the safer bet.

### What is GQL and how does it relate to Cypher?

GQL (Graph Query Language) is the ISO standard (ISO/IEC 39075) for graph databases, published in 2024. It's heavily influenced by Cypher (Neo4j's query language) and openCypher. Think of GQL as what SQL is for relational databases, a vendor-neutral standard. Neo4j is moving toward GQL compatibility, and new databases are implementing it natively.

## Tips

1. **Tip:** Never traverse more than 4 hops in a social graph query without a depth limit. A common lesson: a 'friends of friends of friends' query on 500K users took 47 seconds in Neo4j, adding ` *1..3 ` brought it to 200ms.
2. **Tip:** If your graph queries always start from a known node, index that node's lookup property. An unindexed ` MATCH (u:User {email: $email}) ` does a full node scan, Neo4j clusters can hit 100% CPU because of a missing index on a field used in every single query.
3. **Tip:** Apache AGE is brilliant for adding graph queries to an existing PostgreSQL app, but don't try to migrate a full graph workload to it. The overhead of the Postgres executor on deep traversals is real, benchmark before committing.

- graph-databases
- neo4j
- surrealdb
- apache-age
- cypher
- gql
- property-graphs
- query-optimization
- social-network
- databases

[Back to Blog](<https://lucioduran.com/blog>)
