This tutorial demonstrates how to build a simple social network application using the Pathway graph database. We will cover:
- Setting up the database
- Defining a schema-less data model
- Seeding data (Users, Posts, interactions)
- Running graph queries using the fluent traversal API
First, import the necessary packages:
package main
import (
"context"
"fmt"
"log"
"github.com/google/uuid"
"github.com/npclaudiu/pathway"
)
func main() {
// Indexes are optional. Configure only properties used by FindNodes.
db, err := pathway.OpenWithOptions(":memory:", pathway.Options{
// DurabilitySync is the default and is shown explicitly here.
Durability: pathway.DurabilitySync,
Indexes: []pathway.IndexDefinition{
{Label: "User", Property: "username"},
},
})
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
// ...
}Index definitions are persisted. On a later open, pathway.Open preserves the
stored definitions. Passing a non-nil Options.Indexes slice instead makes it
the desired set: newly added indexes are rebuilt from existing nodes and
removed indexes are dropped atomically. FindNodes returns no matches for an
unindexed label/property pair.
OpenWithOptions clones its Pebble configuration and index slice before
opening. The same options value can be reused for concurrent databases, and
the :memory: filesystem override never changes the caller's
PebbleOptions.
DurabilitySync waits for each successful Update to be synchronized to
stable storage. For replayable bulk imports, DurabilityNoSync can reduce
commit latency, but a process or machine crash may lose recent updates that
already returned successfully. It does not change transaction atomicity, and
it does not relax schema-marker or index-definition writes.
Pathway is schema-less, but conceptually we will model:
- Nodes:
User,Post,Comment - Edges:
FOLLOWS(User -> User)POSTED(User -> Post)LIKED(User -> Post)COMMENTED(User -> Comment)ON(Comment -> Post)
We use db.BulkUpdate to seed the graph in one atomic commit. Its writer caches
edge endpoint validation, which is especially useful when many imported edges
share nodes. Endpoint validation checks node-key existence without copying or
decoding labels; each distinct endpoint is probed at most once per callback.
func seedData(ctx context.Context, db *pathway.Database) (map[string]uuid.UUID, map[string]uuid.UUID, error) {
users := make(map[string]uuid.UUID)
posts := make(map[string]uuid.UUID)
err := db.BulkUpdate(ctx, func(writer *pathway.BulkWriter) error {
// 1. Create Users
names := []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
for _, name := range names {
id := uuid.New()
users[name] = id
if err := writer.PutNode(id, "User"); err != nil {
return err
}
if err := writer.SetProperties(id, map[string]any{
"username": name,
"age": 25,
}); err != nil {
return err
}
}
// 2. Create Posts
post1 := uuid.New()
posts["AliceIntro"] = post1
if err := writer.PutNode(post1, "Post"); err != nil {
return err
}
if err := writer.SetProperties(post1, map[string]any{"content": "Hello World"}); err != nil {
return err
}
post2 := uuid.New()
posts["BobUpdate"] = post2
if err := writer.PutNode(post2, "Post"); err != nil {
return err
}
if err := writer.SetProperties(post2, map[string]any{"content": "Bob is here"}); err != nil {
return err
}
// 3. Create Edges
edges := []struct {
from, to uuid.UUID
label string
}{
{users["Alice"], users["Bob"], "FOLLOWS"},
{users["Alice"], users["Charlie"], "FOLLOWS"},
{users["Bob"], users["Charlie"], "FOLLOWS"},
{users["Alice"], posts["AliceIntro"], "POSTED"},
{users["Bob"], posts["AliceIntro"], "LIKED"},
}
for _, edge := range edges {
if _, err := writer.PutEdge(edge.from, edge.to, edge.label); err != nil {
return err
}
}
return nil
})
return users, posts, err
}BulkUpdate uses the configured durability mode and commits exactly once. Any
callback or writer-operation error aborts the complete batch. BulkWriter
remembers its first operation error, so accidentally ignoring one cannot commit
the operations staged before it.
Pathway provides a fluent traversal interface inspired by Gremlin. Normal node
results are collected as []pathway.Node with ToNodes; ID and path
projections use ToIDs and ToPaths. Property projections remain dynamic and
use ToList because one traversal can return mixed property types.
If imported nodes already have stable identities, preserve them explicitly instead of deriving UUIDs. For example, decode a Git checksum to raw bytes and include the hash algorithm in the namespace:
commitID, err := pathway.NewScopedExternalID(
"git-object/sha1",
[]byte(canonicalRepositoryURL),
rawCommitDigest,
)
if err != nil {
log.Fatal(err)
}
err = db.Update(ctx, func(tx *pathway.Tx) error {
_, err := tx.PutNodeByExternalID(commitID, "Commit")
return err
})The same import is idempotent and returns the same internal UUID. Use
git-object/sha256 for SHA-256 repositories. Scope and value are separately
length-delimited opaque bytes, so repository-local identity does not rely on
ambiguous string concatenation. Traverse from the source identity with
g.VExternal(commitID); after resolution, graph traversal continues with the
internal UUID.
func findFriends(db *pathway.Database, aliceID uuid.UUID) {
g := pathway.NewTraversalSource(db)
// Query: Start at Alice -> Outgoing "FOLLOWS" edges -> Neighbor Nodes
results, err := g.V(aliceID).Out("FOLLOWS").ToNodes()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Alice follows %d people:\n", len(results))
for _, friend := range results {
fmt.Printf("- %v (Label: %s)\n", friend.ID, friend.Label)
}
}Passing one or more edge labels to Out or In restricts Pebble to the exact
adjacency range for each unique label. Multiple labels are returned in
deterministic adjacency-key order, not caller argument order. Omitting labels
scans every incident edge for that direction.
V takes typed uuid.UUID values. If an import or request provides textual
UUIDs, parse them explicitly with VStrings:
query, err := g.VStrings(aliceIDText)
if err != nil {
log.Fatal(err) // errors.Is(err, pathway.ErrInvalidNodeID)
}
results, err := query.Out("FOLLOWS").ToNodes()VStrings rejects the entire request when any ID is malformed; it never drops
bad values and continues with a partial traversal.
For a result set that should not be collected in memory, stream it with the terminal matching its result shape:
err := g.V().HasLabel("Person").EachNode(ctx, func(person pathway.Node) error {
fmt.Printf("- %v\n", person.ID)
return nil
})
if err != nil {
log.Fatal(err)
}Returning an error stops the traversal early, and cancelling ctx returns
context.Canceled. Pathway closes the iterator stack and read transaction on
every exit path. EachNode, EachEdge, EachPath, and EachID return
ErrTraversalResult if the final pipeline step emits a different shape. When
collection is convenient but cancellation is required, use the corresponding
context terminal, such as ToNodesContext(ctx).
Find people Alice follows, and then who they follow.
results, err := g.V(aliceID).
Out("FOLLOWS"). // 1st Hop (Friends)
Out("FOLLOWS"). // 2nd Hop (Friends of Friends)
ToNodes()
if err != nil {
log.Fatal(err)
}The same fixed-depth traversal can use Repeat:
results, err := g.V(aliceID).
Repeat(func(p *pathway.TraversalPipeline) *pathway.TraversalPipeline {
return p.Out("FOLLOWS")
}).
Times(2).
ToNodes()A repeat must declare a positive Times, a non-nil Until, or explicitly call
AllowUnboundedRepeat. Emit can be combined with either termination policy
to include intermediate nodes, but does not terminate the repeat itself.
Repeat modifiers must remain adjacent to Repeat; invalid configurations
return ErrInvalidRepeat when the traversal executes.
Repeat runs breadth-first and deduplicates node UUIDs by default. To keep
distinct simple paths that converge on the same node, add
WithRepeatVisitMode(pathway.RepeatPathSensitive). Node-deduplicated mode uses
one repeat-wide visited set; path-sensitive mode suppresses only nodes already
in the current ancestry. Both choices stop cyclic expansion, including when
AllowUnboundedRepeat removes the explicit depth or predicate limit. Use a
cancellable context for large traversals.
Traverse backwards using In().
// Start at Post -> Incoming "LIKED" edge -> User
results, err := g.V(postID).
In("LIKED").
ToNodes()
if err != nil {
log.Fatal(err)
}Complex traversal combining multiple edge types.
// Alice -> (Follows) -> Friends -> (Posted) -> Posts
results, err := g.V(aliceID).
Out("FOLLOWS").
Out("POSTED").
ToNodes()
if err != nil {
log.Fatal(err)
}Use IDs when the caller needs only UUIDs. Neighbor labels are loaded lazily,
so this avoids one node-label point read per traversed edge and is especially
useful for high-degree nodes and multi-hop traversals.
friendIDs, err := g.V(aliceID).
Out("FOLLOWS").
IDs().
ToIDs()
if err != nil {
log.Fatal(err)
}Each result is a uuid.UUID. Use ToNodes when labels are needed;
HasLabel and Path also materialize labels by design.
Values emits one typed scalar for every requested property that exists. The
requested key order is preserved and missing properties are skipped.
names, err := g.V(aliceID).
Out("FOLLOWS").
Values("username").
ToList()
if err != nil {
log.Fatal(err)
}Path returns a pathway.Path. Each entry has a Kind, ID, and Label;
edge entries also set Other to the endpoint reached by that step.
paths, err := g.V(aliceID).Out("FOLLOWS").Path().ToPaths()
if err != nil {
log.Fatal(err)
}
for _, path := range paths {
for _, element := range path {
fmt.Printf("%s %s %s\n", element.Kind, element.Label, element.ID)
}
}Run the complete example or the integration test suite:
go run ./examples/social_network
go test ./tests -v