skills/neo4j-contrib/neo4j-skills/neo4j-driver-go-skill

neo4j-driver-go-skill

Installation
SKILL.md

Neo4j Go Driver

Import path: github.com/neo4j/neo4j-go-driver/v6/neo4j
Current stable: v6
Docs: https://neo4j.com/docs/go-manual/current/
API ref: https://pkg.go.dev/github.com/neo4j/neo4j-go-driver/v6/neo4j

When to Use

  • Writing Go code that connects to Neo4j
  • Setting up neo4j.NewDriver(), ExecuteQuery(), or session/transaction patterns in Go
  • Questions about managed vs explicit transactions, error handling, or data type mapping in Go
  • Debugging connection, result handling, or causal consistency issues

When NOT to Use

  • Writing or optimizing Cypher queries → use neo4j-cypher-skill
  • Upgrading from an older driver version → use neo4j-migration-skill

Installation

go get github.com/neo4j/neo4j-go-driver/v6

Migrating from v5?

In v6 the WithContext suffix was dropped — the whole API is now context-aware by default:

v5 v6
neo4j.NewDriverWithContext(...) neo4j.NewDriver(...)
neo4j.DriverWithContext neo4j.Driver

The old names still exist as deprecated aliases (removed in v7), so v5 code compiles unchanged — but new code should use the v6 names.


1. Driver Lifecycle

Driver is immutable, goroutine-safe, and expensive to create — create exactly one instance per application and share it everywhere.

import (
    "context"
    "github.com/neo4j/neo4j-go-driver/v6/neo4j"
)
 
func NewNeo4jDriver(uri, user, password string) (neo4j.Driver, error) {
    driver, err := neo4j.NewDriver(
        uri, // e.g. "neo4j+s://xxx.databases.neo4j.io" for Aura
        neo4j.BasicAuth(user, password, ""),
    )
    if err != nil {
        return nil, fmt.Errorf("create driver: %w", err)
    }
 
    ctx := context.Background()
    if err := driver.VerifyConnectivity(ctx); err != nil {
        driver.Close(ctx)
        return nil, fmt.Errorf("verify connectivity: %w", err)
    }
    return driver, nil
}
 
// In main / app teardown:
defer driver.Close(ctx)

URI Schemes

Scheme When to use
neo4j:// Unencrypted, cluster-routing
neo4j+s:// Encrypted (TLS), cluster-routing — use for Aura
bolt:// Unencrypted, single instance
bolt+s:// Encrypted, single instance

Auth Options

neo4j.BasicAuth(user, password, "")           // username + password
neo4j.BearerAuth(token)                        // SSO / JWT
neo4j.KerberosAuth(base64EncodedTicket)        // Kerberos
neo4j.NoAuth()                                 // unauthenticated (dev only)

2. Choosing the Right API

The driver offers three levels of transaction control. Pick the lowest complexity that meets your needs:

API When to use Auto-retry? Lazy results?
ExecuteQuery() Most queries — simple, safe default ❌ (eager)
session.ExecuteRead/Write() Need lazy streaming, or complex callback logic
session.BeginTransaction() Spanning multiple functions, external API coordination
session.Run() Self-managing queries only (see below)

Self-managing transactionsCALL { … } IN TRANSACTIONS and USING PERIODIC COMMIT manage their own transactions internally and fail if run inside a managed transaction. Use session.Run() (auto-commit) for these queries; neither ExecuteQuery nor ExecuteRead/Write will work.


3. ExecuteQuery (Recommended Default)

The simplest, highest-level API. Manages sessions, transactions, retries, and bookmarks automatically.

result, err := neo4j.ExecuteQuery(ctx, driver,
    `MATCH (p:Person {name: $name})-[:KNOWS]->(friend)
     RETURN friend.name AS name`,
    map[string]any{"name": "Alice"},
    neo4j.EagerResultTransformer,
    neo4j.ExecuteQueryWithDatabase("neo4j"),        // ← always specify
    neo4j.ExecuteQueryWithReadersRouting(),          // ← for read queries
)
if err != nil {
    return fmt.Errorf("query people: %w", err)
}
 
for _, record := range result.Records {
    name, _ := record.Get("name")
    fmt.Println(name)
}
 
// Summary / counters
fmt.Println(result.Summary.Counters().NodesCreated())

Key options (variadic callbacks):

neo4j.ExecuteQueryWithDatabase("mydb")         // required for performance
neo4j.ExecuteQueryWithReadersRouting()          // route reads to replicas
neo4j.ExecuteQueryWithAuthToken(token)          // per-query auth / impersonation
neo4j.ExecuteQueryWithImpersonatedUser("jane")  // impersonate without password
neo4j.ExecuteQueryWithoutBookmarkManager()       // opt out of causal consistency

⚠ Never concatenate user input into query strings. Always use map[string]any parameters.


4. Session-Based Transactions

Use when you need lazy streaming (large result sets) or more control within the callback.

session := driver.NewSession(ctx, neo4j.SessionConfig{
    DatabaseName: "neo4j", // always specify
    AccessMode:   neo4j.AccessModeRead,
})
defer session.Close(ctx)
 
result, err := session.ExecuteRead(ctx,
    func(tx neo4j.ManagedTransaction) (any, error) {
        result, err := tx.Run(ctx,
            `MATCH (p:Person) RETURN p.name AS name LIMIT $limit`,
            map[string]any{"limit": 100},
        )
        if err != nil {
            return nil, err
        }
 
        var names []string
        for result.Next(ctx) { // lazy iteration — don't call Collect() on large sets
            name, _ := result.Record().Get("name")
            names = append(names, name.(string))
        }
        return names, result.Err()
    },
)
  • The callback is automatically retried on transient failures (leader election, lock timeouts, etc.)
  • Do not perform side effects in the callback that you don't want repeated on retry
  • ExecuteRead routes to read replicas; ExecuteWrite routes to the cluster leader

5. Explicit Transactions

Use when transaction work spans multiple functions or requires coordination with external systems.

session := driver.NewSession(ctx, neo4j.SessionConfig{DatabaseName: "neo4j"})
defer session.Close(ctx)
 
tx, err := session.BeginTransaction(ctx)
if err != nil {
    return err
}
 
// Pass tx to subordinate functions
if err := doPartA(ctx, tx); err != nil {
    tx.Rollback(ctx) // always rollback on error
    return err
}
if err := doPartB(ctx, tx); err != nil {
    tx.Rollback(ctx)
    return err
}
 
return tx.Commit(ctx)

Explicit transactions are NOT automatically retried. Your caller is responsible for retry logic. Prefer managed transactions unless you specifically need this control.


6. Error Handling

import (
    "errors"
    "github.com/neo4j/neo4j-go-driver/v6/neo4j"
)
 
result, err := neo4j.ExecuteQuery(...)
if err != nil {
    var neo4jErr *neo4j.Neo4jError
    if errors.As(err, &neo4jErr) {
        // neo4jErr.Code is the GQLSTATUS/Neo4j error code
        // neo4jErr.Msg is the server message
        slog.Error("database error", "code", neo4jErr.Code, "msg", neo4jErr.Msg)
    }
 
    var connErr *neo4j.ConnectivityError
    if errors.As(err, &connErr) {
        slog.Error("connectivity error", "err", connErr)
    }
    return fmt.Errorf("execute query: %w", err)
}

Error classification helpers (useful for custom retry logic):

neo4j.IsNeo4jError(err)            // server-side Cypher/database error
neo4j.IsTransactionExecutionLimit(err) // retries exhausted
// IsRetryable is internal; rely on managed transactions for automatic retry

Within a managed transaction callback, return the error to trigger retry:

session.ExecuteWrite(ctx, func(tx neo4j.ManagedTransaction) (any, error) {
    _, err := tx.Run(ctx, query, params)
    if err != nil {
        return nil, err // driver retries if transient
    }
    // ...
})

7. Data Types

Go ↔ Cypher type mapping:

Cypher type Go type
Integer int64
Float float64
String string
Boolean bool
List []any
Map map[string]any
Node neo4j.Node
Relationship neo4j.Relationship
Path neo4j.Path
Date neo4j.Date
DateTime neo4j.Time
Duration neo4j.Duration
null nil

Extracting typed values safely:

record.Get("name")          // returns (any, bool) — bool is whether key exists
record.AsMap()              // returns map[string]any for the whole record
neo4j.GetRecordValue[string](record, "name")   // typed extraction — no manual type assert needed (v6+)
 
// Type assert after extraction:
rawAge, ok := record.Get("age")
if !ok {
    return errors.New("missing 'age' field")
}
age, ok := rawAge.(int64) // Neo4j integers come back as int64
if !ok {
    return errors.New("'age' is not an integer")
}
 
// Node access:
rawNode, _ := record.Get("p")
node := rawNode.(neo4j.Node)
name := node.Props["name"].(string)
labels := node.Labels // []string

8. Best practices

Always Specify the Database

// With ExecuteQuery:
neo4j.ExecuteQueryWithDatabase("neo4j")
 
// With sessions:
neo4j.SessionConfig{DatabaseName: "neo4j"}

Omitting this costs a network round-trip on every call to resolve the home database.

Context

Always pass a context.Context for cancellation and timeout. context.WithTimeoutis recommended for production queries. context.Background() has no deadline — a slow query will block indefinitely.

Lazy vs Eager Loading

// Eager (default with ExecuteQuery) — fine for small/medium result sets
result, _ := neo4j.ExecuteQuery(ctx, driver, query, nil, neo4j.EagerResultTransformer, ...)
 
// Lazy — use with session.ExecuteRead/Write for large result sets
result, _ := tx.Run(ctx, query, params)
for result.Next(ctx) {       // stream records one at a time
    record := result.Record()
    // process...
}
if err := result.Err(); err != nil { ... }

Batching Writes

// Bad: one transaction per record
for _, item := range items {
    neo4j.ExecuteQuery(ctx, driver, writeQuery, item, ...)
}
 
// Good: all in one transaction using UNWIND
neo4j.ExecuteQuery(ctx, driver,
    `UNWIND $items AS item
     MERGE (n:Node {id: item.id})
     SET n += item`,
    map[string]any{"items": items},
    neo4j.EagerResultTransformer,
    neo4j.ExecuteQueryWithDatabase("neo4j"),
)

CREATE vs MERGE

Use CREATE when you know the data is new — MERGE issues two queries internally (match then create).

Connection Pool

import "github.com/neo4j/neo4j-go-driver/v6/neo4j/config"
 
driver, _ := neo4j.NewDriver(uri, auth,
    func(conf *config.Config) {
        conf.MaxConnectionPoolSize = 50              // default: 100
        conf.ConnectionAcquisitionTimeout = 30 * time.Second
        conf.MaxConnectionLifetime = 1 * time.Hour
    },
)

9. Causal Consistency & Bookmarks

Within a single session, queries are automatically causally chained — no action required.

Across sessions (e.g. parallel workers), use ExecuteQuery (auto-managed) or share bookmarks explicitly:

// sessionA and sessionB run concurrently; sessionC waits for both
sessionC := driver.NewSession(ctx, neo4j.SessionConfig{
    DatabaseName: "neo4j",
    Bookmarks:    neo4j.CombineBookmarks(
        sessionA.LastBookmarks(),
        sessionB.LastBookmarks(),
    ),
})

ExecuteQuery manages bookmarks automatically across calls to the same database — this is usually all you need.


10. Advanced Connection Config

import (
    "github.com/neo4j/neo4j-go-driver/v6/neo4j/config"
    "github.com/neo4j/neo4j-go-driver/v6/neo4j/notifications"
)
 
driver, err := neo4j.NewDriver(uri, auth,
    func(conf *config.Config) {
        // Custom address resolver (e.g. for local dev against a cluster)
        conf.AddressResolver = func(addr config.ServerAddress) []config.ServerAddress {
            return []config.ServerAddress{
                neo4j.NewServerAddress("localhost", "7687"),
            }
        }
 
        // Reduce notification noise
        conf.NotificationsMinSeverity = notifications.WarningLevel
        conf.NotificationsDisabledClassifications = notifications.DisableClassifications(
            notifications.Hint, notifications.Generic,
        )
 
        // Bolt-level logging (debug)
        conf.Log = neo4j.ConsoleLogger(neo4j.DEBUG)
    },
)

11. Wrapping the Driver — Recommended Pattern

For testability and clean separation, wrap the driver behind a repository interface:

type PersonRepo struct {
    driver neo4j.Driver
    db     string
}
 
func NewPersonRepo(driver neo4j.Driver, db string) *PersonRepo {
    return &PersonRepo{driver: driver, db: db}
}
 
func (r *PersonRepo) FindByName(ctx context.Context, name string) ([]Person, error) {
    result, err := neo4j.ExecuteQuery(ctx, r.driver,
        `MATCH (p:Person {name: $name}) RETURN p`,
        map[string]any{"name": name},
        neo4j.EagerResultTransformer,
        neo4j.ExecuteQueryWithDatabase(r.db),
        neo4j.ExecuteQueryWithReadersRouting(),
    )
    if err != nil {
        return nil, fmt.Errorf("find person %q: %w", name, err)
    }
 
    people := make([]Person, 0, len(result.Records))
    for _, rec := range result.Records {
        raw, _ := rec.Get("p")
        node := raw.(neo4j.Node)
        people = append(people, Person{
            Name: node.Props["name"].(string),
        })
    }
    return people, nil
}

Quick Reference: Common Mistakes

Mistake Fix
String-interpolating Cypher params Use map[string]any params always
Omitting DatabaseName Always set in SessionConfig or ExecuteQueryWithDatabase
Creating a new driver per request Create once, share across goroutines
Calling Collect() on huge result sets Iterate with result.Next(ctx) instead
Side effects inside managed tx callbacks Move side effects outside; callback may be retried
Using MERGE for guaranteed-new data Use CREATE for new data; saves one round-trip
Not checking result.Err() after lazy iteration Always check after the for result.Next() loop
Using explicit tx where managed tx suffices Prefer ExecuteRead/Write for automatic retry
Weekly Installs
2
GitHub Stars
28
First Seen
2 days ago