This tutorial mirrors the CLI session tutorial, but uses the
grm_rs.Session Python API for the same workflow.
Python methods are adapter conveniences over GRM's shared typed runtime
operations. In particular, structured node_find(...) traversal and
explain_node_find(...) / profile_node_find(...) calls are represented inside
the runtime as typed request objects rather than CLI command strings.
You will:
- define a tiny graph schema
- create nodes and a relationship
- query and traverse the graph
- inspect the logical plan with
explain_node_find - run a first-phase profile with
profile_node_find - save and export the session
From the repository root:
python -m venv .venv
source .venv/bin/activate
pip install maturin
cd grm-python
mkdir -p test-dbs
maturin developmaturin develop compiles the Rust extension and installs it into the active
virtualenv, so import grm_rs works immediately in that environment.
Create a file named tutorial_session.py:
from grm_rs import Session
session = Session()The session starts empty.
Use ServiceSession when Python should operate on a workspace owned by the
local gRPC service rather than an embedded in-process session:
from grm_rs import ServiceSession
session = ServiceSession(
endpoint="http://127.0.0.1:50051",
workspace_ref="tutorial-python",
mode="create",
)The schema, CRUD, traversal, batch, explain, and profile methods follow the
same Python surface where supported. Binary service persistence is the default.
Use mode="open" to resume the workspace on a later run. The service owns
persistence, so local Session save/load/import/export methods do not apply to
ServiceSession.
For TLS or mutual TLS, pass tls_ca_cert, tls_domain_name,
tls_client_cert, and tls_client_key, or use the shared service environment
variables. See the gRPC Docker quick start for service
startup and certificate setup.
Create two node models and one relationship model:
session.model_create(
"User",
"userId",
[
{"name": "name", "type": "string", "required": True},
],
)
session.model_create(
"Post",
"postId",
[
{"name": "title", "type": "string", "required": True},
],
)
session.link_create(
"AUTHORED",
"User",
"Post",
"authoredId",
[
{"name": "year", "type": "int", "required": True},
],
)This says:
Usernodes have a requirednamePostnodes have a requiredtitleAUTHOREDrelationships connectUsertoPostand carry ayear
Create one user, one post, and one relationship:
alice = session.node_create("User", {"name": "Alice"})
notes = session.node_create("Post", {"title": "Graph Notes"})
authored = session.edge_create(
"AUTHORED",
alice["id"],
notes["id"],
{"year": 2026},
)IDs are backend-assigned. The returned dictionaries include the assigned id,
so Python code usually keeps the returned node objects instead of assuming
specific numeric IDs.
Find the user:
users = session.node_find("User", {"name": "Alice"})
print(users)Find relationships from Alice:
edges = session.edge_find("AUTHORED", {"from": alice["id"]})
print(edges)Find Alice's authored posts by traversing the graph:
posts = session.node_find(
"User",
{"name": "Alice"},
via=[
{"dir": "out", "link": "AUTHORED", "model": "Post"},
],
)
print(posts)The traversal means:
(User {name: "Alice"}) -[:AUTHORED]-> (Post)
Python traversal mirrors CLI node.find ... via=... semantics, but uses
structured inputs. The via list contains one dictionary per traversal step.
Traversal queries return end nodes by default. To return the traversed
relationship instead, pass return_="edge":
authored_edges = session.node_find(
"User",
{"name": "Alice"},
via=[
{"dir": "out", "link": "AUTHORED", "model": "Post"},
],
end_filters={"title": "Graph Notes"},
edge_filters={"year": 2026},
return_="edge",
)
print(authored_edges)Use end_filters for the node reached by the traversal and edge_filters for
properties on the relationship.
The current in-memory backend creates and maintains a small set of default indexes for the graph data it stores:
- node labels
- node properties
- relationship types
- outgoing and incoming adjacency
These are backend-maintained indexes. You do not define them from Python today.
They are what make common local lookups and traversals practical, and they are
also why explain_node_find can talk about logical steps such as
NodePropertySeek, RelationshipTypeScan, and ExpandOut.
You can inspect the current index catalog:
print(session.indexes())User-defined indexes are a future direction. For now, model definitions describe schema and validation; they do not declare custom index policy.
explain_node_find shows the current logical plan without running the query:
plan = session.explain_node_find(
"User",
{"name": "Alice"},
via=[
{"dir": "out", "link": "AUTHORED", "model": "Post"},
],
)
print(plan["plan"]["text"])The exact rendered text is allowed to evolve, but the plan shape will look like these steps:
1. NodePropertySeek v0 User.name
2. ExpandOut v0 -[v1:AUTHORED]-> v2
3. NodeCheck v2 Post
4. Return Node v2
The returned value is a Python dictionary, so you can also inspect
plan["plan"]["steps"] or plan["plan"]["details"].
profile_node_find runs the query and reports the plan, result count, and total
elapsed time:
profile = session.profile_node_find(
"User",
{"name": "Alice"},
via=[
{"dir": "out", "link": "AUTHORED", "model": "Post"},
],
)
print(profile["result_rows"], profile["elapsed"]["display"])
print(profile["plan"]["text"])Use explain_node_find when you want to inspect without changing or running the
query. Use profile_node_find when you want to execute and measure the current
query path.
Save the session:
session.save_json("test-dbs/tutorial-session.json")Reload it later:
reloaded = Session()
reloaded.load_json("test-dbs/tutorial-session.json")
print(reloaded.model_list())
print(
reloaded.node_find(
"User",
{"name": "Alice"},
via=[
{"dir": "out", "link": "AUTHORED", "model": "Post"},
],
)
)save_json / load_json are workspace persistence methods. They restore the
local session shape, including runtime schema and graph data. Binary persistence
is also available with save_binary and load_binary.
If you want successful edits to persist as you work, enable autocommit when you create the session:
session = Session(
autocommit=True,
autocommit_path="test-dbs/tutorial-session.json",
)When autocommit is enabled, successful schema changes and data mutations are persisted through the shared append-log/checkpoint path. Autocommit is a runtime choice, not a setting stored inside the session file.
Export a machine-friendly graph document:
session.export_json("test-dbs/tutorial-export.json")You can also get the same document as a Python dictionary:
portable = session.export_dict()
print(portable["format"], portable["version"])export_json and export_dict are for interchange with other tools and future
bulk workflows. They are separate from workspace persistence even though the
data may look similar for small examples.
Import reads the same interchange document into a fresh session:
fresh = Session()
fresh.import_json("test-dbs/tutorial-export.json")
print(fresh.node_find("User", {"name": "Alice"}))Import currently requires an empty session. If the session already has schema or
graph data, GRM raises grm_rs.GrmError instead of merging or replacing
contents.
You can apply the same setup as one structured batch:
session = Session()
result = session.batch(
[
{
"op": "schema_define_node",
"args": {
"name": "User",
"id_field": "userId",
"fields": [
{"name": "name", "type": "string", "required": True},
],
},
},
{
"op": "schema_define_node",
"args": {
"name": "Post",
"id_field": "postId",
"fields": [
{"name": "title", "type": "string", "required": True},
],
},
},
{
"op": "schema_define_edge",
"args": {
"name": "AUTHORED",
"from_model": "User",
"to_model": "Post",
"id_field": "authoredId",
"fields": [
{"name": "year", "type": "int", "required": True},
],
},
},
{
"op": "node_create",
"args": {
"model": "User",
"props": {"name": "Alice"},
"ref": "alice",
},
},
{
"op": "node_create",
"args": {
"model": "Post",
"props": {"title": "Graph Notes"},
"ref": "notes",
},
},
{
"op": "edge_create",
"args": {
"model": "AUTHORED",
"from": "alice",
"to": "notes",
"props": {"year": 2026},
},
},
],
atomic=True,
response="detailed",
)
print(result["applied"])
print(result["counts"])The batch-local ref values capture node IDs from earlier operations in the
same batch. Later operations can use those names anywhere a node ID is expected,
so the AUTHORED link can connect alice to notes without knowing their
numeric IDs ahead of time.
Here is the whole tutorial as one script:
from grm_rs import Session
session = Session()
session.model_create(
"User",
"userId",
[
{"name": "name", "type": "string", "required": True},
],
)
session.model_create(
"Post",
"postId",
[
{"name": "title", "type": "string", "required": True},
],
)
session.link_create(
"AUTHORED",
"User",
"Post",
"authoredId",
[
{"name": "year", "type": "int", "required": True},
],
)
alice = session.node_create("User", {"name": "Alice"})
notes = session.node_create("Post", {"title": "Graph Notes"})
session.edge_create("AUTHORED", alice["id"], notes["id"], {"year": 2026})
print(session.node_find("User", {"name": "Alice"}))
print(session.edge_find("AUTHORED", {"from": alice["id"]}))
print(
session.node_find(
"User",
{"name": "Alice"},
via=[
{"dir": "out", "link": "AUTHORED", "model": "Post"},
],
)
)
plan = session.explain_node_find(
"User",
{"name": "Alice"},
via=[
{"dir": "out", "link": "AUTHORED", "model": "Post"},
],
)
print(plan["plan"]["text"])
profile = session.profile_node_find(
"User",
{"name": "Alice"},
via=[
{"dir": "out", "link": "AUTHORED", "model": "Post"},
],
)
print(profile["result_rows"], profile["elapsed"]["display"])
session.save_json("test-dbs/tutorial-session.json")
session.export_json("test-dbs/tutorial-export.json")- Use Python quickstart for a compact Python API overview.
- Use import/export for interchange format details.
- Use Python Neo4j API expansion for the live Neo4j backend direction.