Skip to content

Python

The namidb Python package is a PyO3 / maturin wrapper over the Rust engine. Heavy lifting happens in Rust; the Python surface is thin and explicit.

See Install for the full install matrix and the pyarrow >= 14 requirement.

Client(uri)

import namidb
client = namidb.Client("memory://demo")

The URI selects the backend (see Storage backends for the complete grammar). The first call per process bootstraps a tokio runtime owned by the client.

URI parsing is delegated to the same Rust implementation the CLI and server use. Malformed URIs raise PyValueError; backend-init failures raise PyRuntimeError.

Two write paths

Cypher (auto-commit)

client.cypher("CREATE (a:Person {name: 'Alice', age: 30})")
client.cypher("CREATE (a:Person {name: 'Bob', age: 25})")
result = client.cypher(
"MATCH (p:Person) WHERE p.age > $min RETURN p.name AS name, p.age AS age",
params={"min": 26},
)
print(result.columns) # ['name', 'age']
print(len(result)) # 1
print(result.first()) # {'name': 'Alice', 'age': 30}
for row in result.rows():
print(row)

Cypher writes (CREATE, SET, DELETE, MERGE, REMOVE) are durably committed before cypher() returns — the executor calls commit_batch() at the end of every write plan. You still want to call client.flush() periodically to push the memtable into L0 SSTs, but durability is taken care of.

Typed staging API

import uuid
import namidb
client = namidb.Client("memory://demo")
alice = str(uuid.uuid7())
bob = str(uuid.uuid7())
client.upsert_node("Person", alice, {"name": "Alice", "age": 30})
client.upsert_node("Person", bob, {"name": "Bob"})
client.upsert_edge("KNOWS", alice, bob, {"since": 2020})
client.commit() # WAL + manifest CAS
client.flush() # memtable -> L0 SSTs

The staging API stages mutations in the current batch and waits for an explicit client.commit(). Pair with client.flush() when you want the L0 SSTs on disk / in the bucket right away.

Tombstones use the same shape: client.tombstone_node(label, id), client.tombstone_edge(edge_type, src, dst).

Bulk inserts

For thousands of nodes / edges in one round trip, prefer merge_nodes / merge_edges — they batch many writes behind a single tokio-runtime + mutex round trip:

client.merge_nodes(
"Person",
[{"id": str(uuid.uuid4()), "name": f"p{i}", "age": 20 + i}
for i in range(10_000)],
)
client.merge_edges(
"KNOWS",
[
{"src": "uuid-a", "dst": "uuid-b", "since": 2020},
{"src": "uuid-b", "dst": "uuid-c", "since": 2021},
],
)
client.commit()
client.flush()

Each node row needs an id (UUID string) plus arbitrary properties. Each edge row needs src + dst (UUID strings) plus arbitrary properties. merge_nodes / merge_edges stage into the current batch — call commit() to make the mutations durable.

Async (acypher)

import asyncio
import namidb
async def main() -> None:
client = namidb.Client("memory://demo")
await client.acypher("CREATE (p:Person {name: 'Alice'})")
result = await client.acypher(
"MATCH (p:Person {name: $name}) RETURN p.name AS name",
params={"name": "Alice"},
)
print(result.rows())
asyncio.run(main())

acypher rides the pyo3-async-runtimes tokio bridge. Every call runs on the same multi-threaded tokio runtime that backs the synchronous API, so mixing the two from one Client is fine.

Result formats

QueryResult exposes the same rows in several shapes. pyarrow >= 14 is a hard dependency, so to_arrow() is always available:

result = client.cypher(
"MATCH (p:Person) RETURN p.name AS name, p.age AS age ORDER BY p.age DESC"
)
result.rows() # list[dict] — easy for small results
table = result.to_arrow() # pyarrow.Table
df = result.to_pandas() # pandas.DataFrame (needs pandas extra)
pl_df = result.to_polars() # polars.DataFrame (needs polars extra)

Calling to_polars() without the polars extra raises a clear ImportError pointing at the install command.

Column order follows the RETURN projection from the parsed plan, so RETURN p.name AS name, p.age AS age always gives you columns ["name", "age"] — even when nothing matches.

For label-wide scans you can skip the Cypher round-trip:

table = client.scan_label_arrow("Person")
# Columns: id, label, lsn, schema_version, then the union of property
# keys across the scanned views (missing keys filled with None).

Direct API

For lookups that don’t justify a Cypher round-trip:

client.lookup_node("Person", alice)
# {'id': '...', 'label': 'Person', 'lsn': 1, 'schema_version': 0,
# 'properties': {'name': 'Alice', 'age': 30}}
client.scan_label("Person")
client.out_edges("KNOWS", alice)
client.cache_stats() # {hit, miss, evict, ...} for the shared SstCache

Type mapping (Cypher ↔ Python)

Cypher RuntimeValuePython type
NullNone
Boolbool
Integerint
Floatfloat
Stringstr
Bytesbytes
Vector(Vec<f32>)list[float]
Listlist
Mapdict[str, ...]
Datedatetime.date
DateTime (UTC, μs)datetime.datetime (tz=UTC)
Node{"_kind": "node", "id", "label", "properties"}
Rel{"_kind": "rel", "edge_type", "src", "dst", "properties"}
Pathlist[Node|Rel] alternating

bool is checked before int so True / False do not silently round-trip as Integer(1) / Integer(0).

What’s next