Use the Python client
Open stores from Python, bind typed values, group writes in transactions, read result metadata, branch on error classes, and use a connection from threads.
python
Before you start: Embed relate in a Python app
This guide shows you how to use the relate Python client A language package that runs the relate engine inside a program's process: the Python, Node.js, Rust, Java, Go and Swift clients. Read more in the glossary, the relate-db package, in an app: which store to open and how, which Python types a value arrives as, how to group writes, what a result tells you about itself, how to branch on an error, and how to use a connection from a threaded server. The client runs the engine inside your Python process. Every program on this page uses the same team graph as the Python tutorial, which covers the first program step by step.
Prerequisites
- Python 3.9 or later, and the steps of Embed relate in a Python app or the equivalent experience.
Add the client
The package is relate-db; the import is import relate.
pip install relate-dbcursor.to_df() needs pandas and cursor.to_arrow() needs pyarrow. Each is an extra: pip install 'relate-db[pandas]' or pip install 'relate-db[arrow]'.
Open a store
relate.connect(database, namespace="neo4j", read_only=False) opens a store and returns a connection. The path decides the layout:
| Call | Opens | Notes |
|---|---|---|
relate.connect(":memory:") | An in-memory store | The default; gone when the connection closes |
relate.connect("./team") | A directory store | Created when missing; another process may read it with read_only=True while you write |
relate.connect("./team.rl") | A single-file store | Created when missing; opens only while no other process writes it |
relate.connect(path, read_only=True) | An existing store, read-only | Loads once and refuses writes; a missing store is an error, never created |
relate.connect(path, namespace="system") | The same store, another namespace | neo4j (default), system or system_ontologies |
A new directory store writes its snapshot with the codec in RELATE_SNAPSHOT_FORMAT (parquet by default, json or markdown); an existing store keeps its own. The client has no codec argument. This program creates a directory store with the json codec, then reads it back through a read-only connection:
import os
import relate
TEAM = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
os.environ["RELATE_SNAPSHOT_FORMAT"] = "json"
with relate.connect("./team") as conn:
conn.execute(TEAM)
print("graph.json written:", os.path.exists("team/graph.json"))
reader = relate.connect("./team", read_only=True)
print("people:", reader.execute("MATCH (p:Person) RETURN count(p) AS n").single()["n"])
try:
reader.execute("CREATE (:Person {name: 'Lin'})")
except relate.OperationalError as e:
print(type(e).__name__, e.gql_status)
reader.close()graph.json written: True people: 3 OperationalError 42N18
The with block closes the connection, which checkpoints a file-backed store. A read-only connection sees the store as it was when it opened. To change namespaces later, call conn.set_namespace(name) between transactions, or name the target in the statement with USE system.
Bind parameters and read typed values
execute(statement, params) takes a dict with string keys, and each value converts to a Cypher value. Results convert back to the most exact Python type available:
| Cypher value | As a parameter | In a result |
|---|---|---|
INTEGER, FLOAT, STRING, BOOLEAN, NULL | int (64-bit), float, str, bool, None | The same types |
DATE, LOCAL DATETIME, ZONED DATETIME, LOCAL TIME, ZONED TIME | datetime.date; a naive or an aware datetime.datetime or datetime.time | The same types, or relate.Temporal when the value has nanoseconds or a named zone |
DURATION | relate.Duration, or datetime.timedelta | relate.Duration, always |
POINT | relate.Point(srid, x, y) | relate.Point |
VECTOR | A relate.Vector from an earlier result | relate.Vector |
LIST, MAP | list or tuple, dict | list, dict |
A plain dict stays a map, even with srid, x and y keys; build a point with relate.Point or point($coords). relate.Vector has no constructor: create a vector in Cypher with vector($values, <dimensions>, FLOAT32). This program stores a start date, a release cycle and an office location on the relate project, then reads them back:
import datetime
import relate
TEAM = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
conn = relate.connect(":memory:")
conn.execute(TEAM)
conn.execute(
"MATCH (p:Project {name: 'relate'}) "
"SET p.started = $started, p.cycle = $cycle, p.office = $office",
{
"started": datetime.date(2023, 3, 1),
"cycle": relate.Duration(days=42),
"office": relate.Point(4326, 13.4, 52.5),
},
)
row = conn.execute(
"MATCH (p:Project {name: 'relate'}) "
"RETURN p.started AS started, p.cycle AS cycle, p.office AS office, "
"p.started + p.cycle AS next_release, "
"datetime('2026-11-08T09:30:00.123456789Z') AS tagged_at, "
"vector([1.0, 0.5, 0.25], 3, FLOAT32) AS focus"
).single()
for name, value in row.items():
print(f"{name}: {type(value).__name__} {value}")
conn.close()started: date 2023-03-01 cycle: Duration P42D office: Point Point(srid=4326, x=13.4, y=52.5) next_release: date 2023-04-12 tagged_at: Temporal 2026-11-08T09:30:00.123456789Z focus: Vector vector([1.0, 0.5, 0.25], 3, FLOAT32 NOT NULL)
tagged_at has nanoseconds, which datetime.datetime cannot hold, so it arrives as relate.Temporal; its .text keeps every digit and .to_native() gives the microsecond datetime. A relate.Duration keeps months, days, seconds and nanoseconds apart, so PT36H and P1DT12H stay different values.
Group writes in a transaction
A connection commits each statement as its own transaction A unit of work that commits as a whole or not at all; each statement is its own transaction unless an explicit begin … commit groups several. Read more in the glossary. Set conn.autocommit = False and the first statement opens a transaction; conn.commit() keeps it and returns its bookmark The marker a commit returns, relate:<n>, naming its place in the store's commit sequence; returned by the clients and over Bolt. Read more in the glossary, relate:<n>, and conn.rollback() discards it. An auto-commit write's bookmark is on cursor.consume().bookmark.
import relate
TEAM = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
conn = relate.connect(":memory:")
print("seed bookmark:", conn.execute(TEAM).consume().bookmark)
conn.autocommit = False
conn.execute(
"MATCH (tck:Project {name: 'opencypher-tck'}) "
"CREATE (:Person {name: 'Lin', role: 'engineer'})-[:WORKS_ON {since: 2026}]->(tck)"
)
conn.execute("MATCH (noor:Person {name: 'Noor'}) SET noor.role = 'lead'")
print("commit bookmark:", conn.commit())
conn.execute("MATCH (p:Project) DETACH DELETE p")
conn.rollback()
print("projects:", conn.execute("MATCH (p:Project) RETURN count(p) AS n").single()["n"])
conn.close()seed bookmark: relate:1 commit bookmark: relate:2 projects: 2
Setting autocommit back to True commits an open transaction first. Closing a connection with a transaction open rolls it back.
Read result metadata
A cursor holds every row at once. Beyond the rows, cur.description gives a type code per column, cur.consume() returns a ResultSummary (counters, query_type, namespace, bookmark, timings, notifications, plan), and cur.graph() collects the distinct nodes and relationships the rows carry. Each node and relationship has an element id The string elementId() returns for a node or relationship, such as 4:relate-neo4j:n0; it stays the same when the store is closed and opened again. Read more in the glossary, the string elementId() returns, which finds it again with WHERE elementId(n) = $id.
import relate
TEAM = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
conn = relate.connect(":memory:")
conn.execute(TEAM)
graph = conn.execute("MATCH (p:Person)-[w:WORKS_ON]->(proj:Project) RETURN p, w, proj").graph()
print("nodes:", sorted(n["name"] for n in graph.nodes))
print("relationships:", len(graph.relationships))
ada = graph.nodes[0]
print(ada["name"], ada.element_id, ada.labels)
found = conn.execute(
"MATCH (n) WHERE elementId(n) = $id RETURN n.name AS name", {"id": ada.element_id}
).single()
print("found by element id:", found.name)
summary = conn.execute("EXPLAIN MATCH (p:Person), (q:Project) RETURN p, q").consume()
print("query type:", summary.query_type)
for n in summary.notifications:
print(n["gql_status"], n["title"])
conn.close()nodes: ['Ada', 'Grace', 'opencypher-tck', 'relate'] relationships: 3 Ada 4:relate-neo4j:n0 ['Person'] found by element id: Ada query type: r 03N90 Cartesian product
A plain run leaves notifications empty and plan set to None; EXPLAIN and PROFILE fill both. Rows are relate.Record dicts, so row["name"] and row.name read the same value.
Handle errors
Every error the engine reports raises a subclass of relate.Error, following Python's database API: ProgrammingError for a statement mistake, DataError for a bad value, IntegrityError for a constraint violation, OperationalError for opening, locking, namespaces and read-only refusals. Each carries the engine's own classification as attributes:
import relate
TEAM = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
conn = relate.connect(":memory:")
conn.execute(TEAM)
for statement in [
"MATCH (p:Person) RETURN p.name +",
"MATCH (:Person {name: 'Ada'})-[w:WORKS_ON]->() RETURN w.since / 0",
"MATCH (p:Person {name: $name}) RETURN p.role AS role",
]:
try:
conn.execute(statement)
except relate.Error as e:
print(type(e).__name__, e.gql_status, e.stage, e.retryable)
try:
conn.execute("RETURN $n AS n", {"n": 2**63})
except relate.DataError as e:
print(type(e).__name__, e)
conn.close()ProgrammingError 42N06 compile False DataError 22G03 runtime False ProgrammingError 42N02 compile False DataError integer parameter is out of range for a 64-bit Relate integer
gql_status and neo4j_code are the codes to branch on; the errors reference lists every class and code. retryable is True only for a failure that a retry of the whole transaction can fix, such as a write-write conflict. An error the Python layer raises itself, like the out-of-range integer, has None in all four engine attributes.
Use a connection from one thread
A connection belongs to the thread that opened it (relate.threadsafety is 1). A statement from any other thread raises relate.ProgrammingError with kind WrongThread, and the connection stays usable on its own thread; close() from another thread raises the same error but leaves the connection closed for every thread, so close it on its owner. In a threaded server, give the connection one worker thread and send every call to it:
import concurrent.futures
import threading
import relate
TEAM = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
owner = concurrent.futures.ThreadPoolExecutor(max_workers=1)
conn = owner.submit(relate.connect, ":memory:").result()
def run(statement, params=None):
return owner.submit(lambda: conn.execute(statement, params).fetchall()).result()
run(TEAM)
PROJECTS = "MATCH (:Person {name: $name})-[:WORKS_ON]->(p:Project) RETURN count(p) AS n"
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as callers:
counts = callers.map(lambda name: run(PROJECTS, {"name": name})[0]["n"], ["Ada", "Grace", "Noor"])
print("projects per person:", list(counts))
def direct():
try:
conn.execute("RETURN 1 AS one")
except relate.ProgrammingError as e:
print("direct call:", type(e).__name__, e.kind)
thread = threading.Thread(target=direct)
thread.start()
thread.join()
owner.submit(conn.close).result()
owner.shutdown()projects per person: [1, 2, 0] direct call: ProgrammingError WrongThread
The engine releases the GIL while a statement runs, so other threads keep working. One connection per thread also works for :memory: stores, each its own graph. A file-backed store takes one writing connection at a time, in this process or any other; other connections open it with read_only=True, which a directory store allows beside its writer.
Verify
Write the graph to a .rl file, close it, and read it back read-only:
import relate
TEAM = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
with relate.connect("./team.rl") as conn:
conn.execute(TEAM)
with relate.connect("./team.rl", read_only=True) as conn:
row = conn.execute(
"MATCH (n) OPTIONAL MATCH (n)-[r]->() RETURN count(DISTINCT n) AS nodes, count(r) AS relationships"
).single()
print("nodes:", row.nodes, "relationships:", row.relationships)nodes: 5 relationships: 5
Five nodes and five relationships after a reopen mean the store was written and read back through the client.
If it goes wrong
| Error | Cause | Remedy |
|---|---|---|
OperationalError: storage locked: <path> (for a directory store, <path> is <store>/.cypherlite.writer) | Another connection or process holds the store for writing | Close the other writer, or open with read_only=True (a directory store allows a reader beside its writer; a .rl store does not) |
ProgrammingError with kind WrongThread | The connection was used from a thread other than the one that opened it | Route calls to the owning thread, as in Use a connection from one thread |
OperationalError: storage error: read-only open requires an existing database at <path> | read_only=True on a directory path with no store (a .rl path says storage error: database not found: <path>) | Create the store with a writable open first, or fix the path |
OperationalError: semantic error: WriteOnReadOnlyAccessDatabase: … (gql_status 42N18) | A write on a read-only connection | Open without read_only=True for writes |
ProgrammingError: query parameters must be a dict mapping string keys to values, got 'list' | params was not a dict | Pass {"name": value} and refer to it as $name |
ImportError: to_df() needs pandas: pip install 'relate-db[pandas]' | pandas is not installed | Install the extra named in the message |
Related
- Python API: every class, method, value mapping, and exception.
- Embed relate in a Python app: the first program, step by step.
- Errors: the exception classes and every engine error code.
- Transactions and concurrency: what a transaction isolates, and why a store has one writer.
- Share one store between two processes: a writer and its readers on one store.
- Embedded graph in an app: the problem this client solves.