rgoussu@goussu: ~/library/go/storage
~/library/go/storage cat graph-databases.md

Graph databases from Go

# Neo4j's official driver and Cypher transaction functions, Gremlin via gremlin-go, Dgraph's dgo client, and when graph beats recursive SQL.

Conceptsaved 2026-08-09 #go#storage#graph#neo4j

Overview

Graph access from Go is query-strings-over-drivers: Cypher through the official Neo4j driver, Gremlin traversals through gremlin-go, DQL/GraphQL through Dgraph's dgo. There is no mapping layer of consequence — results come back as records and maps you unpack by hand — so the Go code stays thin and the modeling effort lives in the query language. Dgraph is the odd one out: a graph database written in Go itself, with a client that feels native rather than ported.

Key points

  • Neo4j — neo4j-go-driver (github.com/neo4j/neo4j-go-driver/v5/neo4j): one long-lived DriverWithContext per application (it owns the connection pool), short-lived sessions per work unit. Bolt protocol, neo4j:// routing URIs for clusters.
  • Transaction functions are the idiom: session.ExecuteRead/ExecuteWrite take a closure and retry it on transient cluster errors (leader switch, deadlock) — write the closure idempotently. ExecuteQuery is the one-shot convenience for single statements.
  • Results are records, not entities: record.Get("n") returns neo4j.Node with a Props map[string]any — you write the struct mapping; neo4j.GetProperty[T] helps with typed extraction. Parameters are always $param maps — never fmt.Sprintf Cypher.
  • Gremlin — gremlin-go (Apache TinkerPop's official Go GLV): build traversals in Go (g.V().HasLabel("person").Out("knows")) against any TinkerPop-enabled store — JanusGraph, Neptune, Cosmos DB Gremlin API. The traversal source connects over a websocket DriverRemoteConnection.
  • Dgraph — dgo: gRPC client for a Go-native distributed graph DB; queries in DQL (GraphQL-like), mutations as JSON or RDF N-Quads, txn.Commit/Discard for its transactional model. Dgraph also serves a spec-compliant GraphQL endpoint directly — sometimes no Go client code is needed at all.
  • When graph beats recursive SQL: multi-hop traversals of unpredictable depth (fraud rings, dependency chains, org charts, recommendations) where SQL needs recursive CTEs and each hop is another self-join. For one or two fixed hops on mostly tabular data, Postgres with a recursive CTE is simpler operationally — one store fewer.
  • Testing: testcontainers-go has a dedicated neo4j module (modules/neo4j) — spin the real store per test package; Gremlin code tests against a TinkerPop/Gremlin-server container; dgo against a Dgraph standalone image.

Details

Client landscape

Store Client Query language Transport
Neo4j, Aura neo4j-go-driver v5 Cypher Bolt
JanusGraph, Neptune, Cosmos gremlin-go Gremlin traversals WebSocket
Dgraph dgo (or plain GraphQL over HTTP) DQL / GraphQL gRPC

Neo4j session discipline

  • Sessions are cheap and not goroutine-safe: create per request/work unit, defer session.Close(ctx). The driver instance is the expensive, share-me object.
  • Causal consistency across sessions: pass bookmarks (session.LastBookmarks()) into the next session's config when a read must observe a prior write on a cluster.
  • Everything takes ctx — traversal timeouts are your context deadlines, same as every other Go client.

Examples

driver, _ := neo4j.NewDriverWithContext(uri, neo4j.BasicAuth(user, pass, ""))
defer driver.Close(ctx)

session := driver.NewSession(ctx, neo4j.SessionConfig{})
defer session.Close(ctx)

// Retryable write transaction: create a follows edge
_, err := session.ExecuteWrite(ctx,
    func(tx neo4j.ManagedTransaction) (any, error) {
        _, err := tx.Run(ctx,
            `MATCH (a:User {id: $from}), (b:User {id: $to})
             MERGE (a)-[:FOLLOWS {since: date()}]->(b)`,
            map[string]any{"from": fromID, "to": toID})
        return nil, err
    })

// Multi-hop read: friends-of-friends, the query recursive SQL hates
records, err := session.ExecuteRead(ctx,
    func(tx neo4j.ManagedTransaction) (any, error) {
        res, err := tx.Run(ctx,
            `MATCH (u:User {id: $id})-[:FOLLOWS*2..3]->(fof)
             RETURN DISTINCT fof.id AS id LIMIT 50`,
            map[string]any{"id": userID})
        if err != nil {
            return nil, err
        }
        return res.Collect(ctx)
    })

Related