Using LoraDB in Python
Overview
lora-python is a PyO3 binding built with maturin. It ships two
classes with identical surfaces: a synchronous Database and an
asyncio-friendly AsyncDatabase. Switching between them is a
one-line import change.
Installation / Setup
Requirements
- Python 3.8+
- For building from source: Rust toolchain (
rustup) +maturin
Install
pip install lora-python
Creating a Client / Connection
from lora_python import Database
db = Database.create()
Database.create() and Database() do the same thing — the factory
exists for API symmetry with AsyncDatabase.
Asyncio equivalent:
from lora_python import AsyncDatabase
db = await AsyncDatabase.create()
Running Your First Query
from lora_python import Database
db = Database.create()
db.execute("CREATE (:Person {name: 'Ada', born: 1815})")
result = db.execute("MATCH (p:Person) RETURN p.name AS name, p.born AS born")
print(result["rows"])
# [{'name': 'Ada', 'born': 1815}]
Examples
Minimal working example
Already shown above.
Parameterised query
result = db.execute(
"MATCH (p:Person) WHERE p.name = $name RETURN p.name AS name",
{"name": "Ada"},
)
Python values map to engine values automatically:
int/float/bool/str/None and list/dict pass through. For
temporal and spatial values, use the tagged helpers below.
Structured result handling
from lora_python import Database, is_node
db = Database.create()
db.execute("CREATE (:Person {name: 'Ada'})")
result = db.execute("MATCH (n:Person) RETURN n")
for row in result["rows"]:
n = row["n"]
if is_node(n):
print(n["id"], n["labels"], n["properties"])
Available guards: is_node, is_relationship, is_path,
is_point, is_temporal.
FastAPI route handler
from fastapi import FastAPI, HTTPException
from lora_python import AsyncDatabase, LoraQueryError
app = FastAPI()
db: AsyncDatabase # initialised at startup
@app.on_event("startup")
async def _bootstrap():
global db
db = await AsyncDatabase.create()
@app.get("/users/{user_id}")
async def get_user(user_id: int):
try:
res = await db.execute(
"MATCH (u:User {id: $id}) RETURN u {.id, .handle, .tier} AS user",
{"id": user_id},
)
except LoraQueryError as exc:
raise HTTPException(status_code=400, detail=str(exc))
rows = res["rows"]
if not rows:
raise HTTPException(status_code=404)
return rows[0]["user"]
Works unchanged under Flask/Django/Litestar — swap the framework
but keep the AsyncDatabase instance at module scope.
Handle errors
from lora_python import Database, LoraQueryError, InvalidParamsError
db = Database.create()
try:
db.execute("BAD QUERY")
except LoraQueryError as exc:
print("query failed:", exc)
except InvalidParamsError as exc:
print("bad params:", exc)
LoraError is the common base class — catch it if you don't need
to distinguish.
Sync vs async
Sync:
from lora_python import Database
db = Database.create()
db.execute("CREATE (:Node)")
Async:
import asyncio
from lora_python import AsyncDatabase
async def main():
db = await AsyncDatabase.create()
await db.execute("CREATE (:Person {name: 'Ada'})")
result = await db.execute("MATCH (n:Person) RETURN n.name AS name")
return result["rows"]
asyncio.run(main())
AsyncDatabase delegates to asyncio.to_thread so long queries
don't block the event loop. The surface is identical — switching is
a one-line import change.
Persisting your graph
LoraDB can save the in-memory graph to a single file and restore it later. Python now supports the same simple initialization rule as Node:
Database.create()/Database()=> in-memoryDatabase.create("app", {"database_dir": "./data"})/Database("app", {"database_dir": "./data"})=> persistent
Async follows the same rule:
await AsyncDatabase.create()=> in-memoryawait AsyncDatabase.create("app", {"database_dir": "./data"})=> persistent
from lora_python import Database
db = Database.create() # in-memory
# db = Database.create("app", {"database_dir": "./data"}) # persistent: ./data/app.loradb
db.execute("CREATE (:Person {name: 'Ada'})")
# Save everything to disk.
meta = db.save_snapshot("graph.bin")
print(meta["nodeCount"], meta["relationshipCount"])
# Restore into a fresh handle (in a new process, for example).
db = Database.create()
db.load_snapshot("graph.bin")
AsyncDatabase exposes the same two methods as coroutines — the sync
call runs on a worker thread via asyncio.to_thread, so large saves
do not block the event loop:
import asyncio
from lora_python import AsyncDatabase
async def main():
db = await AsyncDatabase.create() # in-memory
# db = await AsyncDatabase.create("app", {"database_dir": "./data"}) # persistent: ./data/app.loradb
await db.execute("CREATE (:Person {name: 'Ada'})")
await db.save_snapshot("graph.bin")
db2 = await AsyncDatabase.create()
await db2.load_snapshot("graph.bin")
asyncio.run(main())
Both save and load serialise against every query on the handle. A crash between saves loses every mutation since the last save.
Passing a database name and directory opens or creates an archive-backed persistent
database at <database_dir>/<name>.loradb. Reopening the same path replays committed
writes before the handle is returned. This first Python persistence
slice intentionally stays small: the binding exposes archive-backed
initialization plus snapshots, but not checkpoint, truncate, status, or
sync-mode controls. Call db.close() / await db.close() before
reopening the same archive inside one process.
See the canonical Snapshots guide for the full metadata shape, atomic-rename guarantees, and boundaries, and WAL and checkpoints for the recovery model.
Common Patterns
Bulk insert from a list
rows = [{"id": i, "name": f"user-{i}"} for i in range(100)]
db.execute(
"UNWIND $rows AS row CREATE (:User {id: row.id, name: row.name})",
{"rows": rows},
)
See UNWIND.
Typed helpers
from lora_python import Database, date, duration, wgs84
db = Database.create()
db.execute(
"CREATE (:Trip {when: $when, span: $span, origin: $origin})",
{
"when": date("2026-05-01"),
"span": duration("PT90M"),
"origin": wgs84(4.89, 52.37),
},
)
Available helpers: date, time, localtime, datetime,
localdatetime, duration, cartesian, cartesian_3d, wgs84,
wgs84_3d.
Repository pattern
from lora_python import Database, LoraQueryError
class UserRepo:
def __init__(self, db: Database):
self._db = db
def upsert(self, user_id: int, handle: str):
self._db.execute(
"""
MERGE (u:User {id: $id})
ON CREATE SET u.created = timestamp()
SET u.handle = $handle, u.updated = timestamp()
""",
{"id": user_id, "handle": handle},
)
def find_by_handle(self, handle: str):
res = self._db.execute(
"MATCH (u:User {handle: $handle}) RETURN u {.*} AS user",
{"handle": handle},
)
rows = res["rows"]
return rows[0]["user"] if rows else None
Other methods
db.clear() # drop all nodes + relationships
db.close() # release the native handle
db.node_count # int — property, not a method
db.relationship_count # int — property
node_count and relationship_count are read-only properties.
AsyncDatabase exposes the same count properties and an async
close() method.
Error Handling
| Class | When |
|---|---|
LoraError | Base — catch if you don't need to distinguish |
LoraQueryError | Parse / semantic / runtime query error |
InvalidParamsError | A parameter couldn't be mapped to a LoraValue |
Engine-level causes live in Troubleshooting.
Performance / Best Practices
- Thread-safety.
Databaseis safe to share across threads — the underlying mutex serialises access. No Python-level locking needed. - GIL.
Database.executereleases the GIL while Rust code runs, so other Python threads / asyncio tasks can progress. This is the real non-blocking mechanism —AsyncDatabaseis a thin wrapper that uses it. - Integer precision. Python integers are arbitrary precision,
so
i64values round-trip cleanly (unlike the JS bindings). - No cancellation. Once a query is dispatched it runs to
completion — bound traversals and
UNWINDsizes. - Parameters, not f-strings. Never interpolate user input into a query string.
See also
- Ten-Minute Tour — guided walkthrough.
- Queries → Parameters — binding typed values.
- Cookbook — scenario-based recipes.
- Data Types — Python ↔ engine mapping.
- Temporal Functions / Spatial Functions — helpers used above.
- Troubleshooting.