Skip to main content

Using LoraDB in Go

Overview

lora-go is a thin cgo wrapper over the shared lora-ffi C ABI. The engine runs in-process — no separate server, no socket hop. Values follow the same tagged model as the Node, Python, WASM, and Ruby bindings (primitives pass through; nodes, relationships, paths, temporals, and points come back as map[string]any with a "kind" discriminator).

Installation / Setup

Requirements

  • Go 1.21+
  • A C toolchain with cgo enabled (clang / gcc)
  • The liblora_ffi static library on disk (built locally with cargo build --release -p lora-ffi, or downloaded from a tagged GitHub Release as lora-ffi-vX.Y.Z-<triple>.tar.gz)

Install

go get github.com/lora-db/lora/crates/bindings/lora-go

Because the binding links against the Rust engine, go build needs liblora_ffi.a on disk before it runs. The simplest path is to clone the workspace and build the FFI in-tree:

git clone https://github.com/lora-db/lora
cd lora
cargo build --release -p lora-ffi # produces target/release/liblora_ffi.a
cd crates/bindings/lora-go
go test -race ./...

The default #cgo LDFLAGS in lora.go resolves to ${SRCDIR}/../../target/release/liblora_ffi.a — the right path in the workspace layout.

For consumer projects outside the repo, build lora-ffi once and override the cgo flags in the environment:

export CGO_CFLAGS="-I$PWD/lora/crates/bindings/lora-go/include"
export CGO_LDFLAGS="-L$PWD/lora/target/release -llora_ffi -lm -ldl -lpthread"
go build ./...

See crates/bindings/lora-go/README.md for the full build-from-release-archive flow.

Creating a Client / Connection

import lora "github.com/lora-db/lora/crates/bindings/lora-go"

db, err := lora.New()
if err != nil { log.Fatal(err) }
defer db.Close()

lora.New() and lora.NewDatabase() are the same constructor — both return a ready-to-use handle over an empty in-memory graph.

Running Your First Query

package main

import (
"fmt"
"log"

lora "github.com/lora-db/lora/crates/bindings/lora-go"
)

func main() {
db, err := lora.New()
if err != nil { log.Fatal(err) }
defer db.Close()

if _, err := db.Execute(
"CREATE (:Person {name: 'Ada', born: 1815})",
nil,
); err != nil { log.Fatal(err) }

r, err := db.Execute(
"MATCH (p:Person) RETURN p.name AS name, p.born AS born",
nil,
)
if err != nil { log.Fatal(err) }

fmt.Println(r.Columns, r.Rows)
// [name born] [map[name:Ada born:1815]]
}

Examples

Parameterised query

r, err := db.Execute(
"MATCH (p:Person) WHERE p.name = $name RETURN p.name AS name",
lora.Params{"name": "Ada"},
)

Go values map automatically: int/int64Integer, float64Float, stringString, boolBoolean, nilNull, []anyList, map[string]anyMap. Use the tagged helpers for dates, durations, and points — see typed helpers below.

Explain and profile

Explain and Profile are binding methods, not Cypher keywords in the query string. db.Explain(...) compiles the query and returns the physical plan without running the executor:

plan, err := db.Explain(
"MATCH (p:Person) WHERE p.name = $name RETURN p",
lora.Params{"name": "Ada"},
)
if err != nil { log.Fatal(err) }

fmt.Println(plan.Shape) // "readOnly" or "mutating"
fmt.Println(plan.ResultColumns) // []string{"p"}
fmt.Println(plan.Tree.Operator)

The plan tree is made of PlanNode values with ID, Operator, Details, EstimatedRows, and Children. Details values are human-readable and opaque; avoid parsing them programmatically.

db.Profile(...) runs the query and returns the plan plus runtime metrics:

prof, err := db.Profile(
"MATCH (p:Person) WHERE p.name = $name RETURN p",
lora.Params{"name": "Ada"},
)
if err != nil { log.Fatal(err) }

fmt.Println(prof.Metrics.TotalElapsedNs)
fmt.Println(prof.Metrics.TotalRows)
fmt.Println(prof.Metrics.Mutated)
fmt.Println(prof.Metrics.PerOperator)
Profile executes the query

Mutating queries passed to Profile produce the same side effects as Execute. Use Explain to inspect a mutating CREATE, MERGE, SET, DELETE, or REMOVE plan without changing the graph.

ExplainContext and ProfileContext are available when you want the same Go-side cancellation behavior as ExecuteContext; cancellation does not currently interrupt native work already running inside Rust.

Both methods accept the same parameter values as Execute, including tagged helper maps for temporal, spatial, vector, and binary values. Graph structs such as returned nodes are result values; for input, pass property values or typed helper values:

params := lora.Params{
"since": lora.Date("1800-01-01"),
"near": lora.WGS84(4.89, 52.37),
"radius": 5000.0,
}

plan, err := db.Explain(
`MATCH (c:City)
WHERE c.founded >= $since
AND geo.distance(c.location, $near) < $radius
RETURN c.name AS name`,
params,
)
if err != nil { log.Fatal(err) }

prof, err := db.Profile(
`MATCH (c:City)
WHERE c.founded >= $since
AND geo.distance(c.location, $near) < $radius
RETURN c.name AS name`,
params,
)
if err != nil { log.Fatal(err) }

Structured result handling

r, err := db.Execute("MATCH (n:Person) RETURN n", nil)
if err != nil { log.Fatal(err) }

for _, row := range r.Rows {
if lora.IsNode(row["n"]) {
n := row["n"].(map[string]any)
fmt.Println(n["id"], n["labels"], n["properties"])
}
}

Available guards: IsNode, IsRelationship, IsPath, IsPoint, IsTemporal.

Context cancellation (important caveat)

ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()

r, err := db.ExecuteContext(ctx, "MATCH (n) RETURN count(n)", nil)

ExecuteContext honours context.Context deadlines on the Go side — the call returns ctx.Err() as soon as the context fires. But the engine does not yet support mid-query cancellation, so the native call keeps running in a helper goroutine until it reaches a normal completion point. Follow-up writes or explicit read-write transactions may still queue behind that work.

If you rely on a hard deadline, either keep queries small enough that their worst-case latency is acceptable even if they can't be interrupted, or guard the database with a higher-level rate-limiter.

Typed helpers

db.Execute(
"CREATE (:Trip {when: $when, span: $span, origin: $origin})",
lora.Params{
"when": lora.DateTime("2026-05-01T10:15:00Z"),
"span": lora.Duration("PT90M"),
"origin": lora.WGS84(4.89, 52.37),
},
)

Available helpers: Date, Time, LocalTime, DateTime, LocalDateTime, Duration, Cartesian, Cartesian3D, WGS84, WGS84_3D.

Handle errors

if err != nil {
var lerr *lora.LoraError
if errors.As(err, &lerr) {
switch lerr.Code {
case lora.CodeInvalidParams:
// bad params
case lora.CodeLoraError:
// parse / analyze / execute failure
}
}
}

Persisting your graph

LoraDB can save the in-memory graph to a single file and restore it later. Go has three persistence shapes:

  • lora.New() / lora.NewDatabase() => in-memory
  • lora.New("app", lora.Options{DatabaseDir: "./data"}) / lora.NewDatabase("app", lora.Options{DatabaseDir: "./data"}) => container-backed
  • lora.OpenWal(lora.WalOptions{WalDir: "./data/wal", SnapshotDir: "./data/snapshots"}) => explicit WAL with optional managed snapshots
import lora "github.com/lora-db/lora/crates/bindings/lora-go"

db, err := lora.New() // in-memory
// db, err := lora.New("app", lora.Options{DatabaseDir: "./data"}) // archive: ./data/app.loradb
if err != nil { log.Fatal(err) }
defer db.Close()

if _, err := db.Execute("CREATE (:Person {name: 'Ada'})", nil); err != nil {
log.Fatal(err)
}

meta, err := db.SaveSnapshot("graph.bin")
if err != nil { log.Fatal(err) }
fmt.Printf("nodes=%d rels=%d\n", meta.NodeCount, meta.RelationshipCount)

db2, err := lora.New()
if err != nil { log.Fatal(err) }
defer db2.Close()

if _, err := db2.LoadSnapshot("graph.bin"); err != nil {
log.Fatal(err)
}

durable, err := lora.OpenWal(lora.WalOptions{
WalDir: "./data/wal",
SnapshotDir: "./data/snapshots",
SnapshotEveryCommits: 1000,
SnapshotKeepOld: 2,
SnapshotOptions: &lora.SnapshotOptions{
Compression: &lora.SnapshotCompression{Format: "gzip", Level: 1},
},
})
if err != nil { log.Fatal(err) }
defer durable.Close()

SnapshotMeta.WalLsn is a *uint64; it is nil for a pure snapshot and non-nil when you load or save a checkpoint snapshot written by a WAL-enabled deployment. Save and load encode or decode the whole graph, so large snapshots can still affect latency. A crash between saves loses every mutation since the last save.

Passing a database name and directory opens or creates an container-backed persistent database at <databaseDir>/<name>.loradb. Reopening the same path replays committed writes before the handle is returned. OpenWal opens a raw WAL directory; when SnapshotDir and SnapshotEveryCommits are set, the database writes managed checkpoint snapshots after that many committed transactions. Go does not expose WAL status, truncate, or sync-mode controls; use Rust or lora-server for those operator knobs.

If you run lora-server alongside a Go client, you can also drive the admin surface as an ordinary HTTP request — see lora-server → Snapshots, WAL, and restore and POST /admin/snapshot/save.

See the canonical Snapshots guide for the full metadata shape, atomic-rename guarantees, and boundaries, and WAL and checkpoints for the recovery model.

Common Patterns

Bulk insert from a Go slice

rows := make([]any, 0, 100)
for i := 0; i < 100; i++ {
rows = append(rows, map[string]any{"id": i, "name": fmt.Sprintf("user-%d", i)})
}

db.Execute(
"UNWIND $rows AS row CREATE (:User {id: row.id, name: row.name})",
lora.Params{"rows": rows},
)

See UNWIND.

Other methods

db.Clear() // drop all nodes + relationships
db.NodeCount() // int64, error
db.RelationshipCount() // int64, error
db.Version() // module / engine version string

Error Handling

CodeWhen
LORA_ERRORParse / analyze / execute failure
INVALID_PARAMSA parameter value couldn't be mapped
PANICThe engine panicked; the FFI caught it and surfaced the message
UNKNOWNCatch-all for messages without a recognised prefix

Engine-level causes live in Troubleshooting.

Performance / Best Practices

  • Platform support. Linux and macOS (x86_64, arm64). Windows is not yet supported — revisit once a Windows Go target ships.
  • One graph per Database. Auto-commit reads can overlap on snapshots; write commits and explicit read-write transactions serialize. Multiple Database instances have separate graphs/archives.
  • No cancellation. ExecuteContext returns the context error immediately but the native call keeps running. See the caveat above.
  • Parameters, not string concatenation. The only safe way to mix untrusted input into a query.

See also