An in-memory directed graph with an interactive REPL, a Cypher-inspired query language (GQL), JSON persistence, and an optional web UI.
You need Zig 0.16. Build the binary:
zig build # produces ./zig-out/bin/graph
zig build test # run the unit and integration test suiteThe graph binary has three modes:
./zig-out/bin/graphStarts immediately with a graph> prompt — no arguments or config needed. The
graph starts empty and lives in memory for the session. Type help to list all
commands, exit or Ctrl-D to quit. You can type both imperative commands and
GQL queries at the prompt:
graph> node add name=Alice age=30
Added node 0
graph> node add name=Bob age=25
Added node 1
graph> edge add 0 1 type=friends
Added edge 0
graph> MATCH (n) WHERE n.age >= 28 RETURN n.name
name: "Alice"
graph> show
0(Alice) --[friends]--> 1(Bob)
1(Bob)
graph> exit
Pass -f <file.gql> to execute a file of ;-separated GQL statements and
print the results:
cat > demo.gql <<'EOF'
CREATE (a:Person {name: "Alice", age: 30});
CREATE (b:Person {name: "Bob", age: 25});
MATCH (n:Person) RETURN n.name, n.age
EOF
./zig-out/bin/graph -f demo.gqlStart the backend HTTP server (loopback only, default port 7878):
./zig-out/bin/graph --serve # or: --serve 9000 for a custom portIt exposes GET /graph (the graph as JSON) and POST /command (run a
command/GQL line, returns {"output": "..."}). You can hit it directly:
curl -X POST --data 'node add name=Alice' http://127.0.0.1:7878/command
curl http://127.0.0.1:7878/graphFor the interactive visualization, run the React front end in a second terminal.
It proxies /api/* to the server on port 7878, so start the backend first:
cd web
npm install
npm run dev # open the printed http://localhost:5173 URLzig build test # unit + integration tests (see the Architecture section)The code is organized into small, single-responsibility modules under src/.
Every file opens with a //! doc comment describing its role; the summary:
| Module | Responsibility |
|---|---|
types.zig |
Core, dependency-free data types (NodeId, PropertyValue, Node, Edge). |
graph.zig |
The in-memory directed property graph: nodes, edges, adjacency lists, secondary indexes. |
traverse.zig |
Graph algorithms (BFS shortest path). |
format.zig |
Shared rendering/parsing of property values for human-readable output. |
json_io.zig |
JSON serialization/deserialization of the whole graph (shared by persist and server). |
persist.zig |
File-backed save/load (thin filesystem wrapper over json_io). |
commands.zig |
Handlers for the imperative REPL commands. |
repl.zig |
Interactive loop and command/GQL dispatch. |
gql.zig |
Public facade tying the GQL pipeline together. |
gql_lexer.zig / gql_parser.zig / gql_ast.zig / gql_executor.zig |
The GQL lexer → parser → AST → executor pipeline. |
server.zig |
Minimal HTTP server backing the web/ UI. |
main.zig |
Executable entry point and CLI dispatch (-f, --serve, REPL). |
Dependency direction flows downward: everything depends on types.zig; the
REPL and server depend on commands/gql; nothing depends back up on main.
Tests live next to the code they cover (e.g. graph.zig, traverse.zig,
format.zig, json_io.zig, persist.zig, commands.zig, repl.zig), with
two cross-cutting suites: gql_test.zig (lexer/parser/executor) and
integration_test.zig (end-to-end flows driven through repl.dispatch —
imperative + GQL + persistence + JSON). zig build test runs them all.
| Command | Description |
|---|---|
node add [k=v ...] |
Add a node with optional properties |
node rm <id> |
Remove a node (cascades its edges) |
node get <id> |
Show a node's properties |
node list |
List all nodes |
edge add <from> <to> [k=v ...] |
Add a directed edge |
edge rm <id> |
Remove an edge |
edge get <id> |
Show an edge's properties |
edge list |
List all edges |
neighbors <id> |
Show out- and in-neighbors of a node |
path <from> <to> |
BFS shortest path between two nodes |
show |
Print adjacency-list view of the graph |
save <file> |
Serialize graph to a JSON file |
load <file> |
Load graph from a JSON file (replaces current graph) |
help |
Print command reference |
exit / quit |
Exit the REPL |
Property values are auto-typed: age=30 becomes a number, name=Alice becomes a string.
graph> node add name=Alice age=30
Added node 0
graph> node add name=Bob age=25
Added node 1
graph> node add name=Carol age=28
Added node 2
graph> edge add 0 1 type=friends since=2020
Added edge 0
graph> edge add 1 2 type=colleagues since=2019
Added edge 1
graph> edge add 0 2 type=follows since=2022
Added edge 2
graph> show
0(Alice) --[friends]--> 1(Bob) --[follows]--> 2(Carol)
1(Bob) --[colleagues]--> 2(Carol)
2(Carol)
graph> node get 0
0: {name: "Alice", age: 30}
graph> edge list
0: 0 -> 1 {type: "friends", since: 2020}
1: 1 -> 2 {type: "colleagues", since: 2019}
2: 0 -> 2 {type: "follows", since: 2022}
graph> neighbors 1
Out: 2(Carol)
In: 0(Alice)
graph> path 0 2
0(Alice) -> 1(Bob) -> 2(Carol)
graph> node rm 1
Removed node 1
graph> show
0(Alice) --[follows]--> 2(Carol)
2(Carol)
graph> path 0 2
0(Alice) -> 2(Carol)
graph> save social.json
Saved to social.json
graph> node rm 0
Removed node 0
graph> show
2(Carol)
graph> load social.json
Loaded from social.json (2 nodes, 1 edges)
graph> show
0(Alice) --[follows]--> 2(Carol)
2(Carol)
graph> exit
Ctrl-D also exits cleanly.
The REPL also accepts a graph query language inspired by Cypher. Lines that start with MATCH or CREATE (case-insensitive) are dispatched to the GQL interpreter instead of the command parser.
(variable) any node
(variable:Type) node of a specific type
(variable {prop: value}) node matching inline properties
(variable:Type {prop: value}) type and inline properties combined
-[variable]-> outgoing edge (any type)
-[variable:Type]-> outgoing edge of a specific type
<-[variable]- incoming edge
<-[variable:Type]- incoming edge of a specific type
CREATE (n)
CREATE (n:Type)
CREATE (n:Type {key: "value", num: 42, flag: true})
CREATE (a:Type)-[:EdgeType]->(b:Type)
CREATE (a:Type)<-[:EdgeType]-(b:Type)
MATCH (n) RETURN n
MATCH (n:Person) RETURN n
MATCH (n:Person {name: "Alice"}) RETURN n
MATCH (n) RETURN n.name, n.age
MATCH (n) RETURN n.name AS name, n.age AS age
MATCH (a)-[e:KNOWS]->(b) RETURN a, e, b
MATCH (a)<-[e:FOLLOWS]-(b) RETURN b
WHERE accepts =, <>, <, >, <=, >= comparisons on property access expressions, combined with AND, OR, and NOT.
MATCH (n) WHERE n.age > 25 RETURN n
MATCH (n) WHERE n.name = "Alice" AND n.age >= 30 RETURN n.name
MATCH (n) WHERE n.role = "admin" OR n.role = "mod" RETURN n
MATCH (n) WHERE NOT n.active = false RETURN n
Deletes all matched nodes (cascades incident edges) or edges.
MATCH (n) DELETE n
MATCH (n) WHERE n.age < 18 DELETE n
MATCH (a)-[e:SPAM]->(b) DELETE e
Updates properties on matched nodes.
MATCH (n) SET n.active = true
MATCH (n) WHERE n.name = "Alice" SET n.age = 31, n.verified = true
graph> CREATE (a:Person {name: "Alice", age: 30})
Created node 0
graph> CREATE (b:Person {name: "Bob", age: 25})
Created node 1
graph> CREATE (c:Person {name: "Carol", age: 28})
Created node 2
graph> CREATE (a:Person {name: "Alice"})-[:FRIENDS]->(b:Person {name: "Bob"})
Created nodes 3, 4 and edge 0
graph> MATCH (n:Person) RETURN n.name, n.age
name: "Alice", age: 30
name: "Bob", age: 25
name: "Carol", age: 28
graph> MATCH (n) WHERE n.age >= 28 RETURN n.name AS name
name: "Alice"
name: "Carol"
graph> MATCH (a)-[e:FRIENDS]->(b) RETURN a, e, b
a: (0:Person {name: "Alice", age: 30}), e: [0:FRIENDS 0->1], b: (1:Person {name: "Bob", age: 25})
graph> MATCH (n) WHERE n.name = "Bob" SET n.age = 26
graph> MATCH (n) WHERE n.name = "Bob" RETURN n.age
age: 26
graph> MATCH (n) WHERE n.age < 27 DELETE n
graph> MATCH (n:Person) RETURN n.name
name: "Alice"
name: "Carol"
- Nodes have an auto-assigned integer ID and an optional property map (
stringornumbervalues). - Edges are directed, have an auto-assigned integer ID, a
fromnode, atonode, and an optional property map. - Removing a node cascades — all of its incident edges are removed automatically.
- The graph is in-memory only; use
save/loadfor persistence.