A labeled property graph database for Go, stored in SQLite. It uses the pure-Go modernc.org/sqlite driver, so it needs no CGo.
Nodes and edges have fixed columns plus a JSON properties document. A query builder compiles traversals to SQL, using recursive CTEs for multi-hop paths.
g, _ := graph.Open("file:my.db", nil)
defer g.Close()
// Find people Alice knows within 3 hops.
results, _ := g.Match("Person").
Where("name", "=", "Alice").
Related("KNOWS", 1, 3).
Run(ctx)
for _, n := range results.Nodes() {
fmt.Println(n.Name, n.Properties)
}go get github.com/justintout/go-sqlite-graph
Requires Go 1.23 or later. The graph package depends only on zombiezen.com/go/sqlite. The viz subpackage renders nodes and edges as interactive HTML and adds go-echarts.
Open creates and migrates three tables:
| Table | Columns |
|---|---|
nodes |
id, name, created_at, updated_at, properties (JSON) |
node_labels |
node_id, label; a node can have many labels |
edges |
id, source_id, target_id, type, name, created_at, updated_at, properties (JSON) |
sqlitemigration versions the schema. Deleting a node deletes its labels and edges through foreign key cascades. Timestamps are UTC strings in 2006-01-02T15:04:05.000Z format.
// Migrate the schema on first use (default).
g, err := graph.Open("file:my.db", nil)
// Migrate explicitly.
f := false
g, err := graph.Open("file:my.db", &graph.Options{AutoMigrate: &f, PoolSize: 4})
err = g.Migrate(ctx)Open takes a SQLite URI. Every connection opens in WAL mode with foreign keys on. PoolSize defaults to 10.
alice := &graph.Node{
Name: "Alice",
Labels: []string{"Person", "Engineer"},
Properties: map[string]any{"age": 30, "city": "NYC"},
}
g.CreateNode(ctx, alice) // sets alice.ID, CreatedAt, and UpdatedAt
node, err := g.GetNode(ctx, alice.ID)
alice.Name = "Alice Smith"
g.UpdateNode(ctx, alice)
g.DeleteNode(ctx, alice.ID)
g.AddLabels(ctx, alice.ID, "Manager")
g.RemoveLabels(ctx, alice.ID, "Engineer")UpdateNode replaces the name, labels, and properties with the values on the struct. A Node with nil Labels loses all its labels. Read the node with GetNode before changing one field.
edge := &graph.Edge{
SourceID: alice.ID,
TargetID: bob.ID,
Type: "KNOWS",
Properties: map[string]any{"since": 2020},
}
g.CreateEdge(ctx, edge)
e, err := g.GetEdge(ctx, edge.ID)
g.UpdateEdge(ctx, edge) // replaces type, name, and properties
g.DeleteEdge(ctx, edge.ID)Edges are directed. UpdateEdge does not change the endpoints.
EdgesBetween returns the edges whose endpoints are both in a set of node IDs, such as the nodes of a query result. Pass edge types to return only those.
edges, err := g.EdgesBetween(ctx, []int64{alice.ID, bob.ID}, "KNOWS")Get, update, and delete return an error when the ID does not exist.
tx, err := g.BeginTx(ctx)
defer tx.Rollback() // does nothing after Commit
tx.CreateNode(ctx, node)
tx.CreateEdge(ctx, edge)
tx.Match("Person").Count(ctx)
err = tx.Commit()*Tx has every CRUD method on *Graph, and Match. BeginTx takes the write lock immediately, so only one transaction writes at a time. Outside a transaction, each write is atomic. Inside one, a method that returns an error can leave part of its writes behind, such as a node without its labels. Roll back after any error.
Load large batches in one transaction. On an in-memory database, 1,000 nodes and 999 edges take about 19ms as separate writes and 6.5ms in one transaction. The gap is larger on disk, where each write outside a transaction is its own commit.
Match selects nodes by label. Each Related step replaces the current node set with the nodes it reaches. The result holds the distinct nodes of the last step, in no guaranteed order.
// Match by label.
g.Match("Person").Run(ctx)
// Filter on node columns.
g.Match("Person").Where("name", "=", "Alice").Run(ctx)
// Filter on JSON properties. Nested paths use dots.
g.Match("Person").WhereJSON("age", ">", 30).Run(ctx)
g.Match("Person").WhereJSON("address.city", "=", "NYC").Run(ctx)
// One hop.
g.Match("Person").
Where("name", "=", "Alice").
Related("KNOWS", 1, 1).
Run(ctx)
// One to three hops.
g.Match("Person").
Where("name", "=", "Alice").
Related("KNOWS", 1, 3).
Run(ctx)
// Chained steps. WhereRel filters the nodes the last step reached.
g.Match("Person").
Where("name", "=", "Alice").
Related("KNOWS", 1, 2).
Related("WORKS_AT", 1, 1).
WhereRel("name", "=", "Acme").
Run(ctx)
// Direction: Outgoing (the Related default), Incoming, or Both.
g.Match("Person").
Where("name", "=", "Bob").
RelatedDir("KNOWS", graph.Incoming, 1, 1). // who knows Bob?
Run(ctx)
// Expand each reached node once. See "Breadth-first steps" below.
g.Match("Person").
Where("name", "=", "Alice").
Related("KNOWS", 1, 6).
BreadthFirst().
Run(ctx)
// Return only some columns and property paths.
g.Match("Person").
Related("KNOWS", 1, 3).
Return("name", "labels", "age").
Run(ctx)
// Count and paginate.
count, _ := g.Match("Person").Count(ctx)
results, _ := g.Match("Person").Limit(10).Offset(20).Run(ctx)Where and WhereRel accept the columns id, name, created_at, updated_at, and properties. WhereJSON and WhereRelJSON accept any property path. When the value, or the element type of an IN slice, is a Go integer or float, the property is cast to INTEGER or REAL before comparison.
The operators are =, !=, <>, <, <=, >, >=, LIKE, NOT LIKE, IN, NOT IN, IS, and IS NOT. IN and NOT IN take a slice, such as []string{"Alice", "Bob"}.
A step takes between 1 and graph.MaxHops (10) hops. A start node is in the result only if the step reaches it, for example through a cycle. Cycles do not loop forever: a walk expands each node at most once per depth.
By default, query results populate every Node field except Labels, because reading labels makes queries about 40% slower. WithLabels adds them to the returned fields, and Return can name labels. Query results do not include edges; read them with EdgesBetween. Return sets ID plus the named fields: name, labels, created_at, updated_at, properties, or a property path. A property whose name matches one of those columns can only be filtered, not returned. It sets a property path as a key in Properties and leaves every other field zero. Returning fewer fields skips JSON decoding, which is a large share of the cost of big traversals.
Count ignores Limit and Offset.
Builder errors, such as an unknown column or a hop count out of range, are returned by Run or Count.
By default, a multi-hop step expands a node once for every depth at which it is reachable. On a dense graph with cycles, most nodes are reachable at many depths. BreadthFirst makes the last step expand each node once. The results are the same.
BreadthFirst adds a fixed cost per hop level, so it is slower when the walk rarely revisits nodes. Measure your graph before using it. It requires a minimum of 1 hop.
res, _ := g.Match("Person").WhereJSON("age", ">", 25).Run(ctx)
for res.Next() {
row := res.Row()
fmt.Println(row.Node.Name, row.Node.Properties)
}
nodes := res.Nodes()
count := res.Len()Run reads every row before it returns.
- Multi-hop steps compile to recursive CTEs.
UNIONdeduplicates on (node, depth), which stops cycles and bounds the work at each depth. - Each step yields a set of node IDs. The query reads node rows only for the final set.
- Edge indexes cover
(source_id, type, target_id)and(target_id, type, source_id), so a hop reads only the index. - Nodes can have many labels, stored in
node_labels. Each edge has one type. id,name, and the timestamps are columns. Other properties are a JSON column, queried with SQLite's->>operator.- Connections come from a
sqlitex.Poolorsqlitemigration.Pool.
Traversal cost follows the number of nodes reached, not the graph size. See BENCHMARKS.md for results and a comparison with GraphQLite.
BSD-3-Clause