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 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

// 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:

// 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":

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:

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 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.

-- 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:

-- 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?):

-- 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.

-- 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:

-- 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 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.