Skip to content

Server Mode

ArcadeDB Python bindings include a full HTTP server with the Studio web UI. This guide covers server setup, configuration, and management.

What it costs you

Server mode is bundled by default.

Disk. The server stack is these JARs, measured on the 26.10.1.dev0 wheel (2026-10-01); the current package sizes are in Package Overview:

JAR MB (uncompressed) contains
arcadedb-studio 2.82 web UI assets, no .class files
undertow-core 2.33 HTTP server, 1,510 classes
micrometer-core 0.92 metrics, required at server startup
arcadedb-server 0.87 the server itself, 307 classes
xnio-api 0.59 undertow's IO layer
wildfly-common 0.28
jboss-threads 0.13
xnio-nio 0.11
micrometer-observation 0.08
jboss-logging 0.06
micrometer-commons 0.05
wildfly-client-config 0.05
total 8.29

Memory and CPU, if you never call create_server(). The JARs sit on the classpath and the JVM loads classes lazily, so nothing is initialised, no threads start, and no heap is allocated for them.

If you do start a server, undertow-core and arcadedb-server load and Undertow starts listener threads and buffer pools. That is the real cost, and it arrives when you ask for it.

Studio specifically is free until browsed. Its JAR contains 126 entries, all static JS/HTML/CSS/SVG/PNG, and zero .class files: it cannot execute anything. Assets are read out of the zip only when a browser requests them. (tests/test_server_packaging.py asserts this, so the claim fails loudly if a future Studio release starts shipping code.)

When to use the Docker distribution instead

Use the official ArcadeDB server image, not this, when you need multi-process access, HA/replication, TLS termination, or a server whose lifetime is independent of your Python process. In-process server mode is for the case where one process wants both embedded access and an HTTP surface.

Overview

Server mode provides:

  • HTTP REST API: Access your database via HTTP
  • Studio Web UI: Visual database explorer and query editor
  • Multi-database Management: Host multiple databases
  • Authentication: User management and security
  • Development & Production: Suitable for both environments

Quick Start

Basic Server

Start a server with default configuration:

import arcadedb_embedded as arcadedb

# Create and start server
server = arcadedb.create_server("./databases", root_password="my_secure_password")
server.start()

print(f"🚀 Server started at: {server.get_studio_url()}")
print("📊 Access Studio UI in your browser")

# Keep server running
input("Press Enter to stop server...")
server.stop()

Context Manager

Use a context manager for automatic cleanup:

with arcadedb.create_server("./databases", root_password="my_secure_password") as server:
    print(f"🚀 Server running at: {server.get_studio_url()}")

    # Server automatically stops on exit
    input("Press Enter to stop...")

Server Configuration

Basic Configuration

server = arcadedb.create_server(
    root_path="./databases",
    root_password="my_secure_password",
    config={
        "http_port": 2480,
        "host": "0.0.0.0",
        "mode": "development"
    }
)

Configuration Options

Option Default Description
root_path "./databases" Directory for database storage
root_password None Root user password (recommended). None on a fresh root_path makes start() prompt for it on stdin
http_port 2480 HTTP API/Studio port (binding pins to a single port; Java default is the 2480-2489 range)
host "localhost" Host to bind to
mode "development" Server mode (development or production). production also flushes the WAL at every commit (arcadedb.txWalFlush=1, unless you set it yourself), serves no Studio, and refuses LOAD CSV file URLs; see Durability

Any other key is forwarded to ArcadeDB as arcadedb.<key with _ replaced by .>. That is how the wire protocols below are configured.

Sizing the server's JVM

The server runs in the JVM of your Python process, so its heap and JVM flags are the process's, and they are fixed when the JVM starts. create_server() takes no jvm_kwargs: call start_jvm(...) before it, or construct the server with ArcadeDBServer(root_path, root_password, config, jvm_kwargs={...}). Either has to come before the first database or server in the process starts the JVM (see the JVM API).

import arcadedb_embedded as arcadedb
from arcadedb_embedded.jvm import start_jvm

start_jvm(heap_size="8g")
server = arcadedb.create_server("./databases", root_password="my_secure_password")

Wire Protocols

The wheel bundles three protocol plugins besides HTTP. They are opt-in: a default server starts HTTP and Studio only, and 5432/6379/7687 stay closed.

server = arcadedb.create_server(
    root_path="./databases",
    root_password="my_secure_password",
    config={
        "http_port": 2480,
        "server_plugins": (
            "Postgres:com.arcadedb.postgres.PostgresProtocolPlugin,"
            "Redis:com.arcadedb.redis.RedisProtocolPlugin,"
            "Bolt:com.arcadedb.bolt.BoltProtocolPlugin"
        ),
        "postgres_port": 5432,   # -> arcadedb.postgres.port
        "bolt_port": 7687,       # -> arcadedb.bolt.port
    },
)
Protocol Plugin class Port setting Verified
Postgres wire com.arcadedb.postgres.PostgresProtocolPlugin postgres_port yes, connect + query
Bolt (Neo4j drivers) com.arcadedb.bolt.BoltProtocolPlugin bolt_port yes, connect + Cypher
Redis com.arcadedb.redis.RedisProtocolPlugin redis_port yes, binds the given port

tests/test_server_wire_protocols.py speaks Postgres and Bolt with their real clients (psycopg, neo4j) and checks that Redis binds its port, so these rows are measured rather than inferred from the jars being present.

Two things to know before exposing these

Redis requires authentication. A client must present credentials before anything else; set arcadedb.redis.tls (redis_tls) to encrypt the transport.

The wire listeners bind all interfaces by default. host tightens the HTTP listener to loopback by default, but it does not reach the protocol plugins: each one has its own host setting, and each defaults to 0.0.0.0. Pass postgres_host, bolt_host, or redis_host (for example "127.0.0.1") to bind a plugin to one interface. Without them, enabling a plugin on a multi-homed or internet-facing machine exposes it beyond localhost.

Arrow (ADBC) clients over the Postgres wire

From 26.10.1, Arrow's native PostgreSQL ADBC driver (adbc-driver-postgresql, measured with 1.12.0) connects to a server started from this wheel with the Postgres plugin, and fetch_arrow_table() returns Arrow tables directly. On 26.9.1 it cannot connect: the driver's type bootstrap fails with "Expected 5 or 6 columns from type resolver pg_type query but got 0" (ArcadeDB #7178, fixed for 26.10.1).

import adbc_driver_postgresql.dbapi as pg

with pg.connect("postgresql://root:<password>@localhost:5432/mydb") as conn:
    with conn.cursor() as cur:
        cur.execute("SELECT n, s, x FROM Typed WHERE n > $1", parameters=(10,))
        table = cur.fetch_arrow_table()   # a pyarrow.Table

Declared properties and computed columns both arrive typed. A property declared in the schema (LONG, STRING, DOUBLE, BOOLEAN) comes back as int64, string, double, bool, and a computed column as its real type: count(*) and max(n) as int64, sum(x) as double, n * 2 as int64. Development builds before 2026-09-24 described computed columns as varchar before execution, so the driver returned them as strings ('3', not 3), and pgjdbc's PreparedStatement returned them as String; fixed for 26.10.1 (ArcadeDB #8285).

Bound parameters are served from indexes. Postgres-wire clients send a bound value as $1; from 26.10.1 an equality on an indexed property with a $1 uses the index, where earlier builds scanned the whole type (ArcadeDB #8288). pgjdbc also works at its default prepareThreshold from 26.10.1; earlier builds failed a prepared statement's sixth execution (ArcadeDB #8244).

Measured on a laptop against the 26.10.1 development wheel (engine 3440a871a9); tests/test_server_wire_protocols.py connects with the driver, checks the typed columns, values, a bound parameter, and a computed column's type, so the test fails the day any of it changes.

The other ADBC route, adbcBridge over the psqlodbc driver, is described in ArcadeDB's announcement and was not measured here.

Not bundled

Mongo wire, gRPC, and Raft replication are excluded from the wheel to keep it installable. This server is single-node by construction: it cannot replicate or fail over. Use the Docker distribution for HA, gRPC, or Mongo-protocol access.

Server Info Endpoint

The server exposes /api/v1/server for metadata such as version, server name, and supported query languages. Add ?mode=basic when that is all you need: the full form also computes a metrics section, which on 26.9.1 and earlier made the first call after a start take about 20 s (ArcadeData/arcadedb#8909). To wait for a server to come up, poll /api/v1/ready, which answers 204 without authentication once the server accepts requests:

import requests
from requests.auth import HTTPBasicAuth

base_url = f"http://localhost:{server.get_http_port()}"
auth = HTTPBasicAuth("root", "password123")

requests.get(f"{base_url}/api/v1/ready").raise_for_status()  # 204 once it is up
info = requests.get(f"{base_url}/api/v1/server?mode=basic", auth=auth).json()
print("Server version:", info.get("version"))
print("Languages:", info.get("languages"))

Authentication Tokens (HTTP API)

If you make many HTTP requests, you can obtain a token once and use Bearer authentication afterward:

import requests
from requests.auth import HTTPBasicAuth

base_url = f"http://localhost:{server.get_http_port()}"
auth = HTTPBasicAuth("root", "password123")

# Exchange Basic Auth for a token
token = requests.post(f"{base_url}/api/v1/login", auth=auth).json()["token"]

# Use Bearer token in subsequent requests
headers = {"Authorization": f"Bearer {token}"}
requests.post(
    f"{base_url}/api/v1/query/mydb",
    headers=headers,
    json={"language": "sql", "command": "SELECT FROM Person"},
)

Transactions, Database Commands, and Time-Series Writes over HTTP

Three server features the bindings do not wrap, because they are the server's HTTP API rather than the embedded API. They matter as soon as a second process talks to the server you started with create_server().

One transaction across several requests

POST /api/v1/begin/{db} opens a server-side transaction and returns its id in the arcadedb-session-id response header. Send that header on every command that belongs to the transaction, then POST /api/v1/commit/{db} or POST /api/v1/rollback/{db} with the same header. Without the header each command is its own transaction.

import requests
from requests.auth import HTTPBasicAuth

base_url = f"http://localhost:{server.get_http_port()}"
s = requests.Session()
s.auth = HTTPBasicAuth("root", "password123")

r = s.post(f"{base_url}/api/v1/begin/mydb")
sid = r.headers["arcadedb-session-id"]
headers = {"arcadedb-session-id": sid}
s.post(f"{base_url}/api/v1/command/mydb", headers=headers,
       json={"language": "sql", "command": "INSERT INTO Person SET name = 'a'"})
s.post(f"{base_url}/api/v1/command/mydb", headers=headers,
       json={"language": "sql", "command": "INSERT INTO Person SET name = 'b'"})
s.post(f"{base_url}/api/v1/commit/mydb", headers=headers)   # or /rollback/mydb

A session that is never committed is discarded when it times out, so a client that dies mid-operation leaves nothing half-written.

Database commands

POST /api/v1/server takes server-level commands as JSON: create database, drop database, open database, and close database. Closing a database releases its files and page cache on the server; opening it again reads them back, which is the served equivalent of closing and reopening an embedded database.

s.post(f"{base_url}/api/v1/server", json={"command": "create database mydb"})
s.post(f"{base_url}/api/v1/server", json={"command": "close database mydb"})
s.post(f"{base_url}/api/v1/server", json={"command": "open database mydb"})

Time-series writes with line protocol

A TIMESERIES type accepts writes through POST /api/v1/ts/{db}/write, one InfluxDB line-protocol sample per line, with ?precision=ns|us|ms|s naming the timestamp unit. The measurement name is the type name; tags and fields map to the type's declared tags and fields.

s.post(f"{base_url}/api/v1/command/mydb", json={
    "language": "sql",
    "command": "CREATE TIMESERIES TYPE Reading TIMESTAMP ts "
               "TAGS (sensor STRING) FIELDS (value DOUBLE)"})
body = "\n".join(f"Reading,sensor=s1 value={v} {1700000000 + i}"
                 for i, v in enumerate([1.0, 2.0, 3.0]))
s.post(f"{base_url}/api/v1/ts/mydb/write?precision=s",
       data=body.encode(), headers={"Content-Type": "text/plain"})
rows = s.post(f"{base_url}/api/v1/query/mydb", json={
    "language": "sql", "command": "SELECT count(*) AS n FROM Reading"}).json()["result"]

In-process, the same type is fed with db.async_executor().append_samples(...) (see Time Series End to End), which skips the parse and the socket; the HTTP path is what any client without the wheel gets.

After a bulk write, COMPACT TIMESERIES TYPE Reading through /api/v1/command seals the samples still in the mutable tail and returns mutableSamples (0 once everything is sealed), rather than waiting for the 60-second background pass (26.10.1, ArcadeData/arcadedb#8574). See append_samples. If the type's main query is an hourly aggregate, create it with COMPACTION_INTERVAL 1 HOURS and leave SHARDS at its default (the CREATE TIMESERIES TYPE above takes both clauses after FIELDS).

Bulk Loading over the Server

Which served path loads fastest depends on what the rows carry. Measured on a laptop on 26.10.1-SNAPSHOT with a lineitem-shaped type, 2,000 rows per request and the write-ahead log on in every path (ArcadeData/arcadedb#8337), in rows per second:

Path Plain rows Rows with a 96-float vector
sqlscript of INSERT ... SET with the values in the text 13k 4.8k
INSERT INTO T CONTENT :rows, the batch bound as one parameter 21k 8.3k
Postgres wire, prepared INSERT, executeBatch 12.6k 12.1k
gRPC BulkInsert, official server only (gRPC is not bundled in the wheel) 20.6k 7.9k

Upstream's recommendation, from the same issue:

  • Documents: POST /api/v1/command with INSERT INTO <Type> CONTENT :rows and the batch bound as a list. The statement is parsed once and the rows travel as data, with no quoting to get wrong. gRPC BulkInsert is equally good if the client already speaks gRPC, on the official server only (gRPC is not bundled in the wheel).
  • Rows with a vector property: the Postgres wire with float4[] parameters. HTTP and gRPC send each float as its own value, and neither has a packed vector encoding yet.
  • Vertices and edges: POST /api/v1/batch/{db}?wal=true (see Graphs).
  • Batch size: 2,000 rows is reasonable; 5,000 to 10,000 can amortize a little more for small rows, 2,000 to 5,000 for vector rows. The curve is flat, so try 2k, 5k, and 10k on your hardware and keep the best.
  • Durability: all four paths commit through ordinary transactions with the WAL on. Only the GraphBatch-based loaders (/api/v1/batch and gRPC GraphBatchLoad, official server only) skip it unless you pass wal=true.
rows = [{"k": i, "name": f"item-{i}", "x": i * 0.5} for i in range(2000)]
r = requests.post(
    "http://localhost:2480/api/v1/command/mydb",
    auth=("root", password),
    json={"language": "sql", "command": "INSERT INTO Item CONTENT :rows",
          "params": {"rows": rows}},
)
r.raise_for_status()

In-process, db.insert_many(...) is the equivalent and skips the parse and the socket.

Multi-Process Access

ArcadeDB's embedded mode uses file-based locking, which prevents multiple processes from accessing the same database simultaneously. Server mode solves this problem by providing a central HTTP endpoint that multiple processes (or applications) can connect to.

Why Use Server Mode for Multi-Process?

❌ Embedded mode - Only ONE process can access the database

import arcadedb_embedded as arcadedb

# Process 1
db1 = arcadedb.open_database("./mydb")  # Gets file lock

# Process 2 (different Python process)
db2 = arcadedb.open_database("./mydb")  # ❌ ERROR: Lock conflict!

✅ Server mode - Multiple processes/apps can access

import arcadedb_embedded as arcadedb

# Start server once (Process 1)
with arcadedb.create_server("./databases", root_password="my_secure_password") as server:
    print(f"Server at: {server.get_studio_url()}")

    # Now ANY number of clients can connect via HTTP
    # - Web applications
    # - Background workers
    # - Data analysis scripts
    # - Multiple Python processes

    input("Server running... Press Enter to stop")

Benefits of Server Mode

  1. True Multi-Process Access: Multiple Python processes can work with the same database
  2. Language Agnostic: Access from JavaScript, Java, Python, curl, etc.
  3. Network Access: Remote applications can connect
  4. Web UI: Built-in Studio for visual database exploration
  5. Production Ready: Proper authentication and security

When to Use Each Mode

Use Case Mode Reason
Single script/notebook Embedded Zero setup; keep everything in-process
Agent/AI workloads in one process Embedded Fast, low-latency, no network hop
Multi-process on one machine Server One shared endpoint avoids file locks
Web app / API clients Server Network access for many clients
Distributed workers / pipelines Server Parallel workers connect concurrently
Long-lived production server Official server distribution Outlives any one Python process; see When to use the Docker distribution instead

Multi-Threaded Access

Within a single Python process, multiple threads can share an embedded database. Concurrent writers conflict: a transaction that loses raises ConcurrentModificationException at commit, and with db.transaction(): cannot retry it. Write from threads with db.run_in_transaction(fn), which rolls back and runs fn again on a conflict (12 retries with a linear backoff by default; see run_in_transaction):

import arcadedb_embedded as arcadedb
from threading import Thread

# Use context manager so the database closes cleanly after threads finish
with arcadedb.create_database("./mydb") as db:
    db.command("sql", "CREATE DOCUMENT TYPE Log")

    def worker(thread_id):
        # ✅ Multiple threads in SAME process can share the database
        db.run_in_transaction(
            lambda: db.command("sql", "INSERT INTO Log SET thread = ?", thread_id)
        )

    # Start multiple threads, and join them before the block closes the database
    threads = [Thread(target=worker, args=(i,)) for i in range(10)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

For more details, see Concurrency Tests.

Next Steps