Python — HTTP Driver

arcadedb-driver is ArcadeDB’s Python HTTP client, generated from the server’s OpenAPI contract. It wraps queries, commands and transactions in a small facade over httpx, plus a matching asyncio facade for async applications.

Install

pip install arcadedb-driver

Or with uv:

uv add arcadedb-driver

Requires Python 3.10 or later.

Connect and query

import httpx
from arcadedb_driver import ArcadeDBServer, basic_auth

with ArcadeDBServer(
    base_url="http://localhost:2480",
    auth=basic_auth("root", "playwithdata"),
    timeout=httpx.Timeout(5.0),
) as srv:
    db = srv.db("mydb")

    envelope = db.query(language="sql", command="SELECT FROM Person WHERE age > ?", params={"0": 21})
    print(envelope.result, envelope.returned, envelope.truncated)
[{'@rid': '#1:0', '@type': 'Person', '@cat': 'v', 'name': 'Alice', 'age': 30}, {'@rid': '#1:1', '@type': 'Person', '@cat': 'v', 'name': 'Bob', 'age': 25}] 2 False

Positional parameters are zero-indexed — {"1": 21} silently returns nothing.

Positional parameters are keyed by their zero-based index as a string. For the single placeholder above, the key is "0", not "1". Passing params={"1": 21} does not raise and does not match the wrong rows — it runs the query with no bound value for ? at all and comes back empty:

[] 0 False

The driver’s own README shows {"1": 21}; it looks plausible and returns quietly, which is exactly what makes it easy to ship. Always start positional parameter keys at "0".

basic_auth("root", "playwithdata") builds the Authorization header for HTTP basic auth; bearer_auth("AU-…​") substitutes directly for it wherever you already hold a session token from srv.login() (see Sessions and API tokens), or any other bearer credential.

An EXPLAIN or PROFILE statement answers with its execution plan instead of rows: the envelope’s explain holds it as indented text and explain_plan as the same plan in structured form, while result is empty and returned is 0. An empty result there is not "no rows matched".

Sync and async

Every facade method has an async twin. AsyncArcadeDBServer mirrors ArcadeDBServer method for method, just await-ed:

import asyncio
from arcadedb_driver import AsyncArcadeDBServer, basic_auth


async def main() -> None:
    async with AsyncArcadeDBServer(
        base_url="http://localhost:2480", auth=basic_auth("root", "playwithdata")
    ) as srv:
        envelope = await srv.db("mydb").query(language="sql", command="SELECT FROM Person")
        print(envelope.result)


asyncio.run(main())
[{'@rid': '#1:0', '@type': 'Person', '@cat': 'v', 'name': 'Alice', 'age': 30}, {'@rid': '#1:1', '@type': 'Person', '@cat': 'v', 'name': 'Bob', 'age': 25}]

Beyond query, command and transaction, a database handle also exposes db.ts, db.grafana and db.promql for time-series ingestion and querying (including db.promql.label_values), and db.vector for vector, hybrid and full-text search — see the package README linked below for those. Everything in the sections below has the same async twin.

Close your client

Both ArcadeDBServer and AsyncArcadeDBServer are context managers, because each owns an httpx client with its own connection pool that must be released. Prefer with / async with. When you cannot use one — a long-lived client held across requests, say — call close() on the sync class or await aclose() on the async one yourself.

Timeouts are off by default.

Omitting timeout does not mean "use httpx’s five-second default" — it means no timeout at all. ArcadeDBServer and AsyncArcadeDBServer default timeout to None, and in httpx an explicit timeout=None means exactly that: requests are free to hang forever on a stalled connection. Pass an explicit httpx.Timeout to bound requests, as in the example above: timeout=httpx.Timeout(5.0).

Transactions

with db.transaction() as tx:
    tx.command(language="sql", command="INSERT INTO Account SET balance = 100")
    total = tx.query(language="sql", command="SELECT sum(balance) AS total FROM Account").result[0]["total"]
print("total:", total)
total: 100

db.transaction() returns a context manager whose enter gives you a second database handle — here, tx — that carries the transaction’s session id. The contract:

  • If the block exits cleanly, the transaction commits.

  • If the block raises, the transaction rolls back and the block’s own exception propagates. If the rollback itself also fails, that failure is attached as the exception’s cause rather than replacing it — the block’s error is what you asked about.

  • If the commit itself fails, a best-effort rollback is issued first, so the server-side session is not left open for arcadedb.server.httpSessionExpireTimeout to reap it.

The trap: calls made through the outer db handle while a transaction is open do not join that transaction — they auto-commit individually, outside it. Only calls made through the handle transaction() gives you (tx above) take part.

Two error models

Facade methods raise ArcadeDBError on any non-2xx response, carrying status, error, detail and request_id. This holds for query, command and transaction, and equally for the ts, grafana and promql namespaces — every one of them raises the same ArcadeDBError rather than letting an httpx exception or anything else through:

from arcadedb_driver import ArcadeDBError

try:
    db.query(language="sql", command="SELECT FROM NoSuchType")
except ArcadeDBError as err:
    print(err.status, err.error, err.detail, err.request_id)
500 Internal error Type with name 'NoSuchType' was not found 7f43ebd2c31c5c12-24

request_id is the server’s X-Request-Id for that call and is different on every request; use it to correlate a failure with the server log. Two more fields come from the response’s headers: retry_after, the seconds the server asked you to wait before retrying (on a 503, or a 409 for an identical request still in flight), and commit_index, the Raft index to send back as X-ArcadeDB-Read-After on a later read. Both are None when the server sent none.

A 409 has two meanings. It is either "an identical request is still in flight", which is retryable after retry_after, or "query terminated", because an operator stopped the statement and its writes were rolled back. Retrying the second undoes the operator’s decision. Tell them apart with is_query_terminated(err), never by err.status == 409.

A rejected time-series write raises TimeSeriesWriteError, a subclass of ArcadeDBError that says how many samples were written and dropped and why. A write is not atomic: the written samples landed, so re-sending the whole batch writes them twice.

srv.raw takes the opposite approach: it never raises. It is not a second facade with its own query and command methods — it is the generated openapi-python-client Client object, the value you hand to the generated endpoint functions as their client= argument. Each of those functions returns a response whose status_code and parsed body you inspect yourself, the same shape you get for a successful call. Use raw when you would rather branch on a status code than catch an exception:

from arcadedb_driver import ArcadeDBServer, basic_auth
from arcadedb_driver._generated.api.database import list_databases

with ArcadeDBServer(base_url="http://localhost:2480", auth=basic_auth("root", "playwithdata")) as srv:
    response = list_databases.sync_detailed(client=srv.raw)
    print(response.status_code, response.parsed.result)
200 ['mydb']

Labelling work with a query tag

query, command, query_stream, command_stream, batch_load, batch_load_stream and transaction() take an optional query_tag, sent as the X-ArcadeDB-Query-Tag header, so you can list and terminate the work by it:

db.query(language="sql", command="SELECT FROM Event", query_tag="nightly-report")

with db.transaction(query_tag="etl-run-42") as tx:
    tx.command(language="sql", command="INSERT INTO Event SET n = 1")  # labelled etl-run-42

A transaction’s tag is sent on the begin request only; the server applies it to every statement in the transaction that carries no tag of its own.

Duplex insert session

db.insert_session() opens an insert session on the server’s /ws endpoint. Unlike batch_load, whose commit policy is fixed before the load starts, a session lets you read each chunk’s acknowledgement before deciding what to send next, and whether to commit at all:

with db.insert_session(target_type="Person") as session:
    for batch in batches:
        ack = session.send_chunk(batch)  # nothing is durable yet
        if ack["failed"]:
            session.rollback()
            break
    else:
        session.commit()

Leaving the with block with the session still open rolls it back.

Administration

create_database, drop_database, create_user, update_user, drop_user, list_users, save_group, delete_group and list_groups are on the server handle, and server_command runs any other server command. All of them are root-only:

srv.create_database("tenant_a")
srv.create_user("alice", "a-long-password", databases={"tenant_a": ["admin"]})
users = srv.list_users()
srv.drop_user("alice")
srv.drop_database("tenant_a")

A user or database that already exists is a 409 on ArcadeDB 26.10.2 and later. Older servers answer 403 for a duplicate user and 400 for a duplicate database.

Sessions and API tokens

# Log in as each user once, then keep a client that holds only that user's token.
login = srv.login_as("alice", "a-long-password")  # or srv.login()
with srv.with_token(login.token) as alice:         # srv is unchanged
    alice.list_databases()                          # only what alice may see
    alice.logout()

# A long-lived machine credential (root only).
created = srv.create_api_token("ci", database="tenant_a")
store(created.token)                       # the only time the plaintext exists
srv.delete_api_token(created.token_hash)   # by hash, never by token

list_sessions and list_api_tokens list what exists. Each user holds at most 100 sessions, and a further login silently evicts the oldest, so log in once per user and reuse the token rather than logging in per request. Wrong Basic credentials are answered 403, not 401; 401 is what an invalid, expired or logged-out token gets.

Running work

from datetime import timedelta
from arcadedb_driver import is_query_terminated

running = srv.list_queries(tag="nightly-report")  # or database=...
results = srv.terminate_queries_by_tag("nightly-report", wait=timedelta(seconds=3))
srv.terminate_query(running[0].id)
open_txs = srv.list_transactions()
srv.terminate_transaction(open_txs[0].id)
progress = srv.db("mydb").progress()  # long-running maintenance operations

Any authenticated user may call these: root sees and stops every statement, another user only their own. Each terminate answer carries a status, and which one you get decides whether you have anything to undo:

status Meaning

terminated

It stopped, and what it wrote was rolled back.

completed

It finished before it noticed, so what it did stands.

terminating

It had not stopped when the wait ended; the request’s own outcome is the answer.

not found

No such statement on this server, or another user’s.

  • Each server sees only its own work. These commands are not forwarded to the cluster leader, so behind a load balancer the server you reach may not be the one running the statement.

  • A commit after terminate_transaction still reports success. The transaction’s writes are rolled back, but the server answers its owner’s commit with 204, as if it had committed (ArcadeData/arcadedb#10164).

Choosing a protocol from Python

ArcadeDB can be queried from Python over three protocols. For small results (a few rows per call) they perform the same; for large results the choice matters.

HTTP API Bolt (neo4j driver) gRPC (grpcio)

Install

pip install requests

pip install neo4j

pip install grpcio protobuf, plus stubs generated from arcadedb-server.proto

Server setup

enabled by default (port 2480)

add the Bolt plugin (port 7687)

add the GRPC plugin (port 50051)

Authentication

HTTP Basic

user and password

call metadata x-arcade-user / x-arcade-password

Transactions

yes (/begin, /commit)

yes

yes

Streams a large result

no, one JSON body

yes, in fetch_size batches

yes, in batch_size batches

633K vertex ids

0.54 s

2.59 s

0.30 s

633K PageRank scores

0.98 s

2.81 s

0.79 s

2M edge rows

1.61 s

8.55 s

1.34 s

Few rows per call (a count)

fast

fast

not measured

Times are for the whole result consumed in Python (warm median of 3). Bolt is shown with the whole result in one PULL (fetch_size=-1); with the driver’s default of 1,000 rows per PULL the 2M-row call took about 15 s. gRPC is shown with 100,000 rows per batch; 10,000 is nearly as fast, while 1,000 is slower than HTTP on wider rows (1.8 s and 2.9 s for the last two calls).

Recommendation

  • Large results from Python: use gRPC streaming with a batch size of 10,000 or more, or the HTTP API when you want the simplest setup.

  • Use Bolt when you need Neo4j tooling and driver compatibility. From Python, set a large fetch_size for big results. The Python driver is the limit here, not the server: the official Java driver reads the same 2M rows over Bolt in 1.5 s.

  • Small results, or many small calls: any of the three.

Measurement notes

  • Server: ArcadeDB 26.10.2-SNAPSHOT image, with the Bolt buffering fix (#9165).

  • Machine: Docker Desktop on macOS, on battery power, so only the ratios are meaningful. The benchmark script is scripts/grpc_transfer_bench.py, and the details are in ArcadeDB-release-progress.md.

  • Rows are narrow (one or two numeric columns). Wide rows, strings and whole vertices or edges were not measured.

  • Docker Desktop adds about 1.3 ms to each client-server round trip, which penalizes small batches. A native Linux server will show less of that penalty.

Next steps

  • Native Drivers — the result envelope and why truncated matters, and how this driver compares to the others.

  • Python — gRPC Driver — the gRPC counterpart for throughput-sensitive server-to-server work.

  • The package README for the full method list and every failure mode of the methods above, including db.ts, db.grafana, db.promql and db.vector.