{"schemaVersion":1,"id":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison","title":"Graph Databases: Neo4j vs SurrealDB vs Apache AGE, Cypher, GQL, and Real Social Network Query Optimization","description":"A hands-on comparison of three graph database approaches, Neo4j (native graph), SurrealDB (multi-model), and Apache AGE (PostgreSQL extension). Covers Cypher vs emerging GQL standard, property graph models, and real query optimization from a social network project.","language":"en","datePublished":"2025-11-24","dateModified":null,"contentFormat":"text/markdown","content":"Databases\n\n# Graph Databases: Neo4j vs SurrealDB vs Apache AGE, Cypher, GQL, and Real Social Network Query Optimization\n\n2025-11-247 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/graph-databases-neo4j-surrealdb-age-comparison>)\n\n## The Data Model\n\nA social network feature requires: users, follows, posts, likes, comments, and \"interests\" (topics users tag themselves with). The graph model:\n\n```\n(:User)-[:FOLLOWS]->(:User)\n(:User)-[:POSTED]->(:Post)\n(:User)-[:LIKED]->(:Post)\n(:User)-[:COMMENTED {text: \"...\"}]->(:Post)\n(:User)-[:INTERESTED_IN]->(:Topic)\n(:Post)-[:TAGGED_WITH]->(:Topic)\n```\n\nAbout 500K users, 2M follow relationships, 1.5M posts, 8M likes. Not massive by any means, but enough to expose performance differences.\n\n## Neo4j: The Native Graph\n\n[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.\n\n### Setup and Ingestion\n\n```cypher\n// Create constraints and indexes\nCREATE CONSTRAINT user_email IF NOT EXISTS\nFOR (u:User) REQUIRE u.email IS UNIQUE;\n\nCREATE INDEX user_name IF NOT EXISTS\nFOR (u:User) ON (u.name);\n\nCREATE INDEX post_created IF NOT EXISTS\nFOR (p:Post) ON (p.createdAt);\n\n// Batch import with LOAD CSV\nLOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row\nCREATE (u:User {\n id: row.id,\n name: row.name,\n email: row.email,\n joinedAt: datetime(row.joinedAt)\n});\n```\n\nIngestion 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.\n\n### Query Examples\n\nThe bread and butter, \"who should this user follow?\" based on mutual connections and shared interests:\n\n```cypher\n// Friend-of-friend recommendation excluding existing follows\nMATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)\nWHERE NOT (me)-[:FOLLOWS]->(suggestion)\n AND suggestion <> me\nWITH suggestion, count(friend) AS mutualFriends\nORDER BY mutualFriends DESC\nLIMIT 20\nRETURN suggestion.name, suggestion.id, mutualFriends\n```\n\nThis 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.\n\nA more complex one, \"find the shortest path between two users through their follow graph\":\n\n```cypher\nMATCH path = shortestPath(\n (a:User {id: $userA})-[:FOLLOWS*..6]-(b:User {id: $userB})\n)\nRETURN [node IN nodes(path) | node.name] AS names,\n length(path) AS degrees\n```\n\nThat ` *..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.\n\n### Performance Optimization\n\nThe single most impactful optimization in Neo4j is **profile your queries**:\n\n```cypher\nPROFILE MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)\nWHERE NOT (me)-[:FOLLOWS]->(suggestion)\nRETURN suggestion.name, count(friend) AS mutual\nORDER BY mutual DESC LIMIT 20\n```\n\nThe ` 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.\n\n## SurrealDB: The Multi-Model Approach\n\n[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.\n\n```sql\n-- Define schema\nDEFINE TABLE user SCHEMAFULL;\nDEFINE FIELD name ON user TYPE string;\nDEFINE FIELD email ON user TYPE string;\nDEFINE INDEX email_idx ON user FIELDS email UNIQUE;\n\nDEFINE TABLE follows SCHEMAFULL TYPE RELATION\n FROM user TO user;\nDEFINE FIELD since ON follows TYPE datetime DEFAULT time::now();\n\nDEFINE TABLE post SCHEMAFULL;\nDEFINE FIELD content ON post TYPE string;\nDEFINE FIELD author ON post TYPE record<user>;\nDEFINE FIELD createdAt ON post TYPE datetime DEFAULT time::now();\n```\n\n### Graph Traversal in SurrealQL\n\nThe arrow syntax is surprisingly clean:\n\n```sql\n-- Direct follows\nSELECT name FROM user:alice->follows->user;\n\n-- Friend-of-friend (2 hops)\nSELECT name, count() AS mutual FROM\n user:$userId->follows->user->follows->user\nWHERE id NOT IN (SELECT VALUE out FROM follows WHERE in = user:$userId)\n AND id != user:$userId\nGROUP BY id\nORDER BY mutual DESC\nLIMIT 20;\n```\n\nThe 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.\n\nWhere 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?):\n\n```sql\n-- Feed: posts from followed users, ranked by engagement\nSELECT\n content,\n author.name AS authorName,\n createdAt,\n count(->liked_by) AS likes,\n count(->comments) AS commentCount\nFROM post\nWHERE author IN (SELECT VALUE out FROM follows WHERE in = user:$userId)\n AND createdAt > time::now() - 7d\nORDER BY likes DESC, createdAt DESC\nLIMIT 50;\n```\n\nIn Neo4j, this would require combining Cypher traversal with full-text search and aggregation, doable but less natural.\n\n## Apache AGE: Graph on PostgreSQL\n\nApache 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.\n\n```sql\n-- Enable the extension\nCREATE EXTENSION IF NOT EXISTS age;\nLOAD 'age';\nSET search_path = ag_catalog, \"$user\", public;\n\n-- Create a graph\nSELECT create_graph('social');\n\n-- Create nodes\nSELECT * FROM cypher('social', $$\n CREATE (u:User {id: 'alice', name: 'Alice', email: 'alice@example.com'})\n RETURN u\n$$) AS (u agtype);\n\n-- Create relationships\nSELECT * FROM cypher('social', $$\n MATCH (a:User {id: 'alice'}), (b:User {id: 'bob'})\n CREATE (a)-[:FOLLOWS {since: '2025-01-15'}]->(b)\n$$) AS (result agtype);\n```\n\n### The SQL-Cypher Bridge\n\nThe killer feature is mixing SQL and Cypher in the same query:\n\n```sql\n-- Use regular PostgreSQL tables alongside graph queries\nSELECT\n u.display_name,\n u.avatar_url,\n graph_data.mutual_count\nFROM users u\nJOIN (\n SELECT * FROM cypher('social', $$\n MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)\n WHERE NOT (me)-[:FOLLOWS]->(suggestion) AND suggestion <> me\n RETURN suggestion.id AS user_id, count(friend) AS mutual_count\n ORDER BY mutual_count DESC\n LIMIT 20\n $$, $1) AS (user_id agtype, mutual_count agtype)\n) AS graph_data ON u.id = graph_data.user_id::text;\n```\n\nThis 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.\n\n### Performance Reality\n\nThe 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.\n\nFor queries up to 3 hops, AGE is absolutely competitive. Beyond that, the overhead compounds:\n\n```\n1 hop: AGE 8ms vs Neo4j 3ms (2.7x)\n2 hops: AGE 35ms vs Neo4j 15ms (2.3x)\n3 hops: AGE 85ms vs Neo4j 25ms (3.4x)\n4 hops: AGE 340ms vs Neo4j 55ms (6.2x)\n5 hops: AGE 1.8s vs Neo4j 120ms (15x)\n```\n\nThe 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.\n\n## GQL: The Future Standard\n\nISO 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.\n\n```gql\n-- GQL syntax (evolving, this is approximate)\nGRAPH social\nMATCH (me:User WHERE me.id = $userId)\n -/:FOLLOWS/-> (friend)\n -/:FOLLOWS/-> (suggestion)\nWHERE NOT EXISTS (me -/:FOLLOWS/-> suggestion)\n AND suggestion <> me\nRETURN suggestion.name, COUNT(friend) AS mutual\nORDER BY mutual DESC\nLIMIT 20;\n```\n\nThe 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.\n\nFor now, Cypher remains the practical choice. GQL adoption is where SQL was in the early 1990s, the standard exists, but implementations are catching up.\n\n## Recommendations by Use Case\n\nFor 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.\n\nFor 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.\n\nFor 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.\n\nThe 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.\n\n## Frequently asked questions\n\n### When should I use a graph database instead of a relational database?\n\nWhen 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.\n\n### Is SurrealDB production-ready for graph workloads?\n\nAs 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.\n\n### What is GQL and how does it relate to Cypher?\n\nGQL (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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n\n- graph-databases\n- neo4j\n- surrealdb\n- apache-age\n- cypher\n- gql\n- property-graphs\n- query-optimization\n- social-network\n- databases\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Graph Databases: Neo4j vs SurrealDB vs Apache AGE, Cypher, GQL, and Real Social Network Query Optimization","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#title"},{"level":2,"text":"The Data Model","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#the-data-model"},{"level":2,"text":"Neo4j: The Native Graph","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#neo4j-the-native-graph"},{"level":3,"text":"Setup and Ingestion","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#setup-and-ingestion"},{"level":3,"text":"Query Examples","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#query-examples"},{"level":3,"text":"Performance Optimization","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#performance-optimization"},{"level":2,"text":"SurrealDB: The Multi-Model Approach","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#surrealdb-the-multi-model-approach"},{"level":3,"text":"Graph Traversal in SurrealQL","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#graph-traversal-in-surrealql"},{"level":2,"text":"Apache AGE: Graph on PostgreSQL","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#apache-age-graph-on-postgresql"},{"level":3,"text":"The SQL-Cypher Bridge","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#the-sql-cypher-bridge"},{"level":3,"text":"Performance Reality","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#performance-reality"},{"level":2,"text":"GQL: The Future Standard","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#gql-the-future-standard"},{"level":2,"text":"Recommendations by Use Case","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#recommendations-by-use-case"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#questions"},{"level":3,"text":"When should I use a graph database instead of a relational database?","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#question-1"},{"level":3,"text":"Is SurrealDB production-ready for graph workloads?","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#question-2"},{"level":3,"text":"What is GQL and how does it relate to Cypher?","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#article-tips"}],"sections":[{"heading":"Graph Databases: Neo4j vs SurrealDB vs Apache AGE, Cypher, GQL, and Real Social Network Query Optimization","level":1,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison","content":"Databases"},{"heading":"Graph Databases: Neo4j vs SurrealDB vs Apache AGE, Cypher, GQL, and Real Social Network Query Optimization","level":1,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#title","content":"2025-11-247 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/graph-databases-neo4j-surrealdb-age-comparison>)"},{"heading":"The Data Model","level":2,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#the-data-model","content":"A social network feature requires: users, follows, posts, likes, comments, and \"interests\" (topics users tag themselves with). The graph model:\n\n```\n(:User)-[:FOLLOWS]->(:User)\n(:User)-[:POSTED]->(:Post)\n(:User)-[:LIKED]->(:Post)\n(:User)-[:COMMENTED {text: \"...\"}]->(:Post)\n(:User)-[:INTERESTED_IN]->(:Topic)\n(:Post)-[:TAGGED_WITH]->(:Topic)\n```\n\nAbout 500K users, 2M follow relationships, 1.5M posts, 8M likes. Not massive by any means, but enough to expose performance differences."},{"heading":"Neo4j: The Native Graph","level":2,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#neo4j-the-native-graph","content":"[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."},{"heading":"Setup and Ingestion","level":3,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#setup-and-ingestion","content":"```cypher\n// Create constraints and indexes\nCREATE CONSTRAINT user_email IF NOT EXISTS\nFOR (u:User) REQUIRE u.email IS UNIQUE;\n\nCREATE INDEX user_name IF NOT EXISTS\nFOR (u:User) ON (u.name);\n\nCREATE INDEX post_created IF NOT EXISTS\nFOR (p:Post) ON (p.createdAt);\n\n// Batch import with LOAD CSV\nLOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row\nCREATE (u:User {\n id: row.id,\n name: row.name,\n email: row.email,\n joinedAt: datetime(row.joinedAt)\n});\n```\n\nIngestion 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."},{"heading":"Query Examples","level":3,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#query-examples","content":"The bread and butter, \"who should this user follow?\" based on mutual connections and shared interests:\n\n```cypher\n// Friend-of-friend recommendation excluding existing follows\nMATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)\nWHERE NOT (me)-[:FOLLOWS]->(suggestion)\n AND suggestion <> me\nWITH suggestion, count(friend) AS mutualFriends\nORDER BY mutualFriends DESC\nLIMIT 20\nRETURN suggestion.name, suggestion.id, mutualFriends\n```\n\nThis 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.\n\nA more complex one, \"find the shortest path between two users through their follow graph\":\n\n```cypher\nMATCH path = shortestPath(\n (a:User {id: $userA})-[:FOLLOWS*..6]-(b:User {id: $userB})\n)\nRETURN [node IN nodes(path) | node.name] AS names,\n length(path) AS degrees\n```\n\nThat ` *..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."},{"heading":"Performance Optimization","level":3,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#performance-optimization","content":"The single most impactful optimization in Neo4j is **profile your queries**:\n\n```cypher\nPROFILE MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)\nWHERE NOT (me)-[:FOLLOWS]->(suggestion)\nRETURN suggestion.name, count(friend) AS mutual\nORDER BY mutual DESC LIMIT 20\n```\n\nThe ` 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."},{"heading":"SurrealDB: The Multi-Model Approach","level":2,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#surrealdb-the-multi-model-approach","content":"[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.\n\n```sql\n-- Define schema\nDEFINE TABLE user SCHEMAFULL;\nDEFINE FIELD name ON user TYPE string;\nDEFINE FIELD email ON user TYPE string;\nDEFINE INDEX email_idx ON user FIELDS email UNIQUE;\n\nDEFINE TABLE follows SCHEMAFULL TYPE RELATION\n FROM user TO user;\nDEFINE FIELD since ON follows TYPE datetime DEFAULT time::now();\n\nDEFINE TABLE post SCHEMAFULL;\nDEFINE FIELD content ON post TYPE string;\nDEFINE FIELD author ON post TYPE record<user>;\nDEFINE FIELD createdAt ON post TYPE datetime DEFAULT time::now();\n```"},{"heading":"Graph Traversal in SurrealQL","level":3,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#graph-traversal-in-surrealql","content":"The arrow syntax is surprisingly clean:\n\n```sql\n-- Direct follows\nSELECT name FROM user:alice->follows->user;\n\n-- Friend-of-friend (2 hops)\nSELECT name, count() AS mutual FROM\n user:$userId->follows->user->follows->user\nWHERE id NOT IN (SELECT VALUE out FROM follows WHERE in = user:$userId)\n AND id != user:$userId\nGROUP BY id\nORDER BY mutual DESC\nLIMIT 20;\n```\n\nThe 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.\n\nWhere 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?):\n\n```sql\n-- Feed: posts from followed users, ranked by engagement\nSELECT\n content,\n author.name AS authorName,\n createdAt,\n count(->liked_by) AS likes,\n count(->comments) AS commentCount\nFROM post\nWHERE author IN (SELECT VALUE out FROM follows WHERE in = user:$userId)\n AND createdAt > time::now() - 7d\nORDER BY likes DESC, createdAt DESC\nLIMIT 50;\n```\n\nIn Neo4j, this would require combining Cypher traversal with full-text search and aggregation, doable but less natural."},{"heading":"Apache AGE: Graph on PostgreSQL","level":2,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#apache-age-graph-on-postgresql","content":"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.\n\n```sql\n-- Enable the extension\nCREATE EXTENSION IF NOT EXISTS age;\nLOAD 'age';\nSET search_path = ag_catalog, \"$user\", public;\n\n-- Create a graph\nSELECT create_graph('social');\n\n-- Create nodes\nSELECT * FROM cypher('social', $$\n CREATE (u:User {id: 'alice', name: 'Alice', email: 'alice@example.com'})\n RETURN u\n$$) AS (u agtype);\n\n-- Create relationships\nSELECT * FROM cypher('social', $$\n MATCH (a:User {id: 'alice'}), (b:User {id: 'bob'})\n CREATE (a)-[:FOLLOWS {since: '2025-01-15'}]->(b)\n$$) AS (result agtype);\n```"},{"heading":"The SQL-Cypher Bridge","level":3,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#the-sql-cypher-bridge","content":"The killer feature is mixing SQL and Cypher in the same query:\n\n```sql\n-- Use regular PostgreSQL tables alongside graph queries\nSELECT\n u.display_name,\n u.avatar_url,\n graph_data.mutual_count\nFROM users u\nJOIN (\n SELECT * FROM cypher('social', $$\n MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)\n WHERE NOT (me)-[:FOLLOWS]->(suggestion) AND suggestion <> me\n RETURN suggestion.id AS user_id, count(friend) AS mutual_count\n ORDER BY mutual_count DESC\n LIMIT 20\n $$, $1) AS (user_id agtype, mutual_count agtype)\n) AS graph_data ON u.id = graph_data.user_id::text;\n```\n\nThis 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."},{"heading":"Performance Reality","level":3,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#performance-reality","content":"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.\n\nFor queries up to 3 hops, AGE is absolutely competitive. Beyond that, the overhead compounds:\n\n```\n1 hop: AGE 8ms vs Neo4j 3ms (2.7x)\n2 hops: AGE 35ms vs Neo4j 15ms (2.3x)\n3 hops: AGE 85ms vs Neo4j 25ms (3.4x)\n4 hops: AGE 340ms vs Neo4j 55ms (6.2x)\n5 hops: AGE 1.8s vs Neo4j 120ms (15x)\n```\n\nThe 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."},{"heading":"GQL: The Future Standard","level":2,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#gql-the-future-standard","content":"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.\n\n```gql\n-- GQL syntax (evolving, this is approximate)\nGRAPH social\nMATCH (me:User WHERE me.id = $userId)\n -/:FOLLOWS/-> (friend)\n -/:FOLLOWS/-> (suggestion)\nWHERE NOT EXISTS (me -/:FOLLOWS/-> suggestion)\n AND suggestion <> me\nRETURN suggestion.name, COUNT(friend) AS mutual\nORDER BY mutual DESC\nLIMIT 20;\n```\n\nThe 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.\n\nFor now, Cypher remains the practical choice. GQL adoption is where SQL was in the early 1990s, the standard exists, but implementations are catching up."},{"heading":"Recommendations by Use Case","level":2,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#recommendations-by-use-case","content":"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.\n\nFor 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.\n\nFor 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.\n\nThe 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."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#questions","content":""},{"heading":"When should I use a graph database instead of a relational database?","level":3,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#question-1","content":"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."},{"heading":"Is SurrealDB production-ready for graph workloads?","level":3,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#question-2","content":"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."},{"heading":"What is GQL and how does it relate to Cypher?","level":3,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#article-tips","content":"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.\n2. **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.\n3. **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.\n\n- graph-databases\n- neo4j\n- surrealdb\n- apache-age\n- cypher\n- gql\n- property-graphs\n- query-optimization\n- social-network\n- databases\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/graph-databases-neo4j-surrealdb-age-comparison"},{"text":"Neo4j","url":"https://neo4j.com/"},{"text":"SurrealDB","url":"https://surrealdb.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison"},"headline":"Graph Databases: Neo4j vs SurrealDB vs Apache AGE, Cypher, GQL, and Real Social Network Query Optimization","description":"A hands-on comparison of three graph database approaches, Neo4j (native graph), SurrealDB (multi-model), and Apache AGE (PostgreSQL extension). Covers Cypher vs emerging GQL standard, property graph models, and real query optimization from a social network project.","datePublished":"2025-11-24","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Databases","keywords":"graph-databases, neo4j, surrealdb, apache-age, cypher, gql, property-graphs, query-optimization, social-network, databases","wordCount":1357,"timeRequired":"PT7M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/graph-databases-neo4j-surrealdb-age-comparison"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Graph Databases: Neo4j vs SurrealDB vs Apache AGE, Cypher, GQL, and Real Social Network Query Optimization","item":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"When should I use a graph database instead of a relational database?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Is SurrealDB production-ready for graph workloads?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"What is GQL and how does it relate to Cypher?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"19d62cfe70c404b44410ed6ef1cf07ecfcf08dda075ee9fdae05f0b582903b00","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/graph-databases-neo4j-surrealdb-age-comparison","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":65962,"contentBytes":12193}}
