---
title: "Semurg as a graph database: load edges, walk them to any depth"
slug: semurg-as-a-graph-database
date: 2026-10-23
author: Semurg
section: how-to
tags: [graph, database, how-to, python]
summary: Load a graph from Python with semurg.container, walk it from a set of starting nodes to any depth, and read a node's neighbours. What each answer contains, what the graph door does not do yet, and how to run the graph benchmark yourself.
status: published
covers: [route:POST /v1/ingest, route:POST /v1/query, op:traverse, op:neighbour_ids]
constants: [100000]
---
> Last checked on r11.126.20261025.70c5311 (25 Oct 2026): every command below ran on a fresh node of that release. The current release is r11.128.20261026.4964735 (26 Oct 2026), and the commands have not been re-checked on it yet.

A graph in Semurg is a set of edges. An edge's id is computed from its two ends and its relation, so writing the same edge twice
still leaves one edge. A walk starts from the nodes you name and follows outgoing edges level by level, as deep as you ask, and
answers with how many nodes it reached, how many edges it followed, and how many nodes it first reached at each level.

> Diagram graph-walk: Your edges, one record each; a walk starts from the seeds you name and follows outgoing edges level by level, answering with the nodes reached, the edges followed and how many nodes were first reached at each level.

## Load edges and walk them, from Python

`pip install semurg`, then build each edge with `semurg.container.edge(source, target)` (or `edge(source, target, relation)`) and
send them with `ingest`. The program below writes a small graph, a short chain with one branch, and walks it out from the first node
of the chain. It reads your node's API token from `SEMURG_API_TOKEN`: the installer prints it, and `semurg.open()` reads it for you.

```python runnable kind=sdk:python needs=token
import os
from semurg import Client, container

c = Client("http://localhost:4000", token=os.environ["SEMURG_API_TOKEN"])

# 1 -> 2 -> 3 -> 4, and a branch 2 -> 5
edges = [container.edge(1, 2), container.edge(2, 3), container.edge(3, 4), container.edge(2, 5)]
print("written", c.ingest(edges)["containers"])

walk = c.query("traverse", {"seeds": [1], "hops": 3})["result"]
print("levels", walk["witness"]["levels"])
print("nodes", walk["nodes_visited"], "edges", walk["edge_hops"])
```

```expect
expect: four edges written; levels 1, 1, 2, 1 (node 1, then 2, then 3 and 5, then 4); five nodes reached (node 1 included) over four edges
assert: out~^written 4$;out~^levels \[1, 1, 2, 1\]$;out~^nodes 5 edges 4$
```

`semurg.open()` starts a local node with Docker and returns the same client, so on an x86_64 Linux machine with Docker the first line
can be `c = semurg.open()`. A large graph goes in batches: each `ingest` call takes a list of edges, and the edges are checked before
anything is sent (an edge built wrong is refused by its position in the list, never stored wrong).

## What a walk answers

- `nodes_visited`: the distinct nodes reached, the starting nodes included.
- `edge_hops`: the edges followed.
- `witness.levels`: how many nodes were first reached at each level, starting with the seeds, so you can see where the walk grew and
  where it stopped. `levels_complete` is `true` when every node of the cluster reported its counts; `false` means the per-level
  counts are partial, and `"elided"` means the walk went too deep to list every level.

A walk takes up to 100000 starting nodes in one call (`"seeds": [...]`) and any depth (`"hops"`). Over HTTP it is one call:

```bash
curl -sS http://localhost:4000/v1/query \
  -H "Authorization: Bearer $SEMURG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"op":"traverse","seeds":[1],"hops":3}'
```

## Read a node's neighbours

`neighbour_ids` answers each node's outgoing neighbours, or only how many there are with `"count_only": true`:

```bash
curl -sS http://localhost:4000/v1/query \
  -H "Authorization: Bearer $SEMURG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"op":"neighbour_ids","ids":[2],"direction":"out"}'
```

Each id in the answer says its state: its edges (an empty list for a node with none), a page of them when there are more than you
asked for, a count with `count_only`, or `absent` when nothing was ever written under that id.

## When something is refused

- `422`: a malformed request, with the field named.
- `413`: more than 100000 starting nodes in one call.
- `501`: starting nodes that exist but cannot be walked on this release.
- `503`: the node is under its memory floor, or busy with other walks; retry.

## What it does not do yet

- A walk answers counts and a witness per level, not the list of nodes it reached.
- No shortest-path door, and walks follow outgoing edges only.
- No graph query language (Cypher or GQL).
- Edges load as records built by the clients (`semurg.container` in Python), not as JSON.

## Where it stands, and run it yourself

Graph is on the [benchmark page](/benchmark) against Neo4j, ArangoDB, FalkorDB, ArcadeDB and SurrealDB, each on all of the server's
cores, in memory and on a graph far bigger than memory, with the release and the machine beside each figure.
[Graph on one server](/blog/graph-on-one-server) and [a graph bigger than memory](/blog/a-graph-bigger-than-memory) walk through
those results. To run the same comparison on your machine:

```bash
curl -fsSL https://one.semurg.io/bench | sudo bash -s -- --graph
```

The runner loads the same graph into every engine, checks that each answers every question the same way, and only then times it.

## Next

- [Walking a graph to extreme depths](/blog/graph-to-131072-hops): the deepest walks, every engine.
- [Talk to a Semurg node over plain HTTP](/blog/connect-over-rest): the token and every route.
