REST API
The server exposes a small JSON-over-HTTP API on its --addr. Every route
except /health and /version requires HTTP Basic auth when a password is set
(see Authentication & TLS).
POST /cypher (alias POST /query)
Run a Cypher query. Request body:
{
"query": "MATCH (n:Person {name: $name}) RETURN n.name AS name LIMIT 10",
"parameters": { "name": "Ada" },
"db": "default",
"at": null,
"consistency": "local"
}
| Field | Required | Meaning |
|---|---|---|
query | yes | The Cypher (or DDL) statement. |
parameters | no | Named $param bindings (default: none). See below. |
db | no | Target database (default "default"). Overridden by USE. |
at | no | Engram id for a time-travel read (equivalent to USE … AS OF). |
consistency | no | "local" (default) or "linearizable". See below. |
parameters — a flat JSON object of named values, bound the same way
Bolt drivers have always bound $param (issue #899: REST had no way to pass
parameters before this — a request had to embed every value directly into
query as a literal). Works everywhere a literal is valid (MATCH, WHERE,
SET, RETURN, …), on both reads and writes, and composes with at
(a parameterized time-travel read is one request, not two features that
happen to coexist).
Prefer parameters over embedding values in query for two reasons that
matter beyond style:
- It is a real bind, never textual substitution. The query is parsed with
$nameintact; the value travels as data and is resolved at the AST/eval layer, so it is structurally incapable of being read as Cypher syntax — the same injection defensepatinadb-indexer(and every Bolt driver) already relies on for user-supplied values. Embedding a value intoqueryyourself reproduces exactly the string-concatenation risk that defense exists to avoid. - It lets repeated requests actually hit the L3 result cache.
L3 keys a cached result on
(query text, parameter values); two requests with the samequerytext but embedded-literal values that differ are, as far as the cache is concerned, two unrelated query texts and can never share admission or a cached hit. The same two requests usingparametersshare the query text and simply vary the bound values — the shape the cache is designed to reuse.
Omitting parameters is unchanged from before this field existed (an empty
bind set) — no existing client needs to change anything.
consistency controls read freshness (ignored for writes):
"local"(default) — serve this node’s applied state. Fast, causally consistent within a database, eventually consistent across replicas."linearizable"— reflect every write committed before the read began, via a leader read-index barrier. Leader-only: a follower returns503with aleader_id/leader_addrhint (like a misrouted write). Costs one intra-cluster round-trip. See High Availability.
Read-your-writes over REST: log_index → ?min_index=
For the common “a backend reads back what it just wrote” case there is a cheaper
option than linearizable — the REST counterpart of the Bolt driver’s causal
bookmarks:
-
A write response carries the write’s committed Raft log index in the
log_indexfield:{ "applied": 1, "log_index": 42, "db": "default", "stats": { "nodes-created": 1, "properties-set": 2 } }statsreports the write’s side-effect counters — the same Neo4j-named counters Bolt returns (nodes-created,nodes-deleted,relationships-created,relationships-deleted,properties-set). Only non-zero counters appear, and the key is absent entirely for a pure read.properties-setcounts assignment and removal (aSET, aSET n += {…}and aREMOVE n.peach increment it), and nolabels-added/labels-removedcounter is emitted — see Bolt for why. -
Pass that index back as the
?min_index=query parameter on a subsequent read. The serving node then waits (bounded, see below) until its own state machine has applied at least that index, and only then runs the read:curl -s -u neo4j:secret -X POST 'http://127.0.0.1:21002/cypher?min_index=42' \ -H 'content-type: application/json' \ -d '{"query":"MATCH (n:Person {name:\"Ada\"}) RETURN n"}'This works on any node, followers included — a caught-up node answers immediately, a lagging follower blocks until the write’s log entry lands. No leader pinning, no read-index round-trip; the client only needs to remember one integer (track the maximum
log_indexyou have seen, like a driver’s session bookmark).
If the node cannot catch up within the wait window (default 10s;
--bookmark-wait-timeout-secs, the same knob as the Bolt bookmark wait; 0
disables the gate and min_index is then ignored), the read fails with a
retryable 503 carrying
"neo4j_code": "Neo.TransientError.Transaction.BookmarkTimeout" — it never
silently serves the stale result. Retry the request (against any member; another
node may already be caught up). min_index applies to reads only — writes are
leader-only and always observe their own predecessors — and omitting it keeps
the default local behavior unchanged.
curl -s -u neo4j:secret -X POST http://127.0.0.1:21001/cypher \
-H 'content-type: application/json' \
-d '{"query":"CREATE (n:Person {name:\"Ada\"}) RETURN n"}'
Reading scalar result rows
The JSON query response is column-oriented. For scalar projections, each entry
in results[column] is a scalar wrapper with the nil UUID, label __scalar__
and the projected value in properties[column]. For example, the row from
RETURN 42 AS answer is represented as:
{
"results": {
"answer": [{
"id": "00000000-0000-0000-0000-000000000000",
"label": "__scalar__",
"properties": {"answer": 42}
}]
}
}
Other response fields are omitted here. Reconstruct rows by matching array
positions across the projected columns; do not compare the wrapper itself to
the scalar value. An empty read result can be {}: the response omits
results rather than predeclaring columns with empty arrays. Use explicit
projection aliases for stable field names.
Bolt drivers expose projected values directly instead of these REST wrappers.
Statement routing
The handler dispatches a statement in this order:
- Full-text DDL (
CREATE/DROP FULLTEXT INDEX,SHOW FULLTEXT INDEXES) — schema commands replicate through Raft;SHOWreads the local catalog and returns{"fulltextIndexes": [...]}. - Database DDL (
CREATE/DROP DATABASE→ replicated;SHOW DATABASES→ local registry read). - A leading
USE <db>selector (optionallyUSE <db> AS OF '<id>') picks the target database / time-travel point. - Otherwise: writes go through Raft and apply on every node; reads serve from the local applied graph.
The response is JSON with the result rows (column order and per-row alignment are preserved). Errors come back as a JSON error with an appropriate HTTP status.
Writes are at-least-once
A write that you did not get an ack for may or may not have committed —
and retrying it is not transparently safe. If the ack is lost after commit
(a 503, a timeout, a connection reset mid-response), resending the statement
runs it again as a brand-new execution: a CREATE mints fresh vertex UUIDs
on every resolve, so the retry inserts a duplicate. See
Client Retry Contract
for the rules (UNIQUE constraints / MERGE on a business key, retry-once
discipline).
Idempotent writes: the Idempotency-Key header
A write request may carry an Idempotency-Key header whose value is a
client-chosen token unique to that logical write (printable ASCII, no spaces,
non-empty, at most 128 bytes; an invalid token is a loud client error, never a
silent “no token”). If the same request reaches the cluster again — a retry
after a lost response, possibly to a different leader — the retry applies
nothing and reports where the original landed. The response carries two fields
for this:
dedup_active(bool) — was the token check active for this write?truemeans you are protected, not that this write was a duplicate: it istruefor every ordinary first-time write that carries a token. It isfalsewhen you sent no token, or while a rolling upgrade has the cluster on a mixed protocol version (the protection pauses; writes never stop).duplicate_of(integer, usually absent) — did the check fire? When present, this exact request had already committed; this call applied nothing, and the value is the Raft log index where the original landed (usable as amin_indexbookmark).duplicate_ofis the duplicate signal —dedup_activenever is.
A recognised duplicate is a success, not an error; its stats are all zero,
because this call changed nothing. The full contract — token uniqueness is
yours and undetectable server-side, the 10-minute / 100 000-newer-writes
capacity bound, and the rolling-upgrade pause — is in
Client Retry Contract → Idempotency tokens.
GET /health
Liveness check. No auth. Returns OK whenever the node’s HTTP server is up —
even if the node isn’t ready to serve (see /ready, below).
GET /ready
Readiness check. No auth, never shed by the concurrency limiter. Returns 200
only when the node has a known leader, isn’t installing a snapshot, isn’t
community-degraded, isn’t lagging, and isn’t shedding load — otherwise 503
with a machine-readable reason. Point a load balancer / Kubernetes readiness
probe / neo4j:// read rotation at this, and /health at the liveness probe.
See Day-2 Operations → Health vs. readiness probes.
GET /version
Server version string, protocol version, and (for a licensed/entitled node) resolved tier + usage. No auth.
Management endpoints
On --addr, for cluster operations (see High Availability).
This table covers the core cluster/backup surface; several other /mgmt/*
routes are documented alongside the feature they belong to rather than
repeated here: /mgmt/evict-voter (Day-2
Operations), /mgmt/audit,
/mgmt/rotate-cluster-secret, /mgmt/rotate-admin-password,
/mgmt/retire-admin-password, /mgmt/unlock-user (Authentication &
TLS), /mgmt/transactions (SHOW/TERMINATE TRANSACTIONS),
and /mgmt/upsert / /mgmt/upsert-edges / /mgmt/upsert-graph (Bulk Upsert).
Every /mgmt/* route needs admin credentials.
| Endpoint | Purpose |
|---|---|
POST /mgmt/init | Initialize a cluster (alternative to --bootstrap). An even voter count is refused (409) unless the body is wrapped as {"members": {…}, "allow_even": true} — see Cluster sizing. |
POST /mgmt/add-learner | Add a node as a non-voting learner. |
POST /mgmt/change-membership | Promote learners to voters / change the voter set. An even resulting voter count is refused (409) unless the body is wrapped as {"members": […], "allow_even": true}. |
GET /mgmt/cluster | Full voter/learner topology + addresses + current leader, plus admin_password_rotation_in_progress (this node’s dual-accept grace window state — issue #1071 review follow-up). |
GET /mgmt/metrics | Raft metrics (leader, term, applied/log indices, snapshot and purge indices). |
GET /mgmt/queries | Per-query-shape stats (count, mean, p95, max latency) — this node’s local view. |
GET /mgmt/dbsizes | Per-database on-disk size, largest first. |
GET /mgmt/cache | Multi-level cache governor state: budget + per-level/-scope bytes & hit rates (see below). |
GET /mgmt/snapshot | Stream a portable, leader-anchored backup of every database (see below). |
GET /mgmt/export | Stream one database as an import-compatible CSV tar (see below). |
POST /mgmt/restore | Restore a backup into the registry (leader-only, admin; see below). |
/raft/append, /raft/vote, /raft/snapshot are the internal peer RPC
receivers — not for client use.
Since 0.13.0, GET /mgmt/metrics also returns snapshot_index (the node’s
current Raft snapshot cut) and last_purged_log_index (its purged-log boundary).
Both are nullable unsigned indices: null means no such index is available.
Compare these with last_applied when diagnosing learner recovery; a snapshot
file’s presence alone does not prove log purge or completed catch-up. These
indices do not report asynchronous history completeness.
GET /mgmt/cache (cache observability)
Reports the node-local, RAM-budget-governed cache hierarchy. Admin-only
(it is under /mgmt/). The JSON carries the resolved budget (bytes per region:
total, cache_limit, work_mem_limit, headroom, min_free), the overall
utilization, a governor block (admission + the free-floor-vs-cap eviction-byte
split), the per-levels resident bytes / hit rates with per-scope accounting
(which database, collection (label), or query shape is hot), a sankey read-flow
block, and the governor’s last free-RAM sample (mem_available_bytes, read from
/proc/meminfo). While caching is disabled (the default) it is a truthful
all-zeros “just the OS page cache” report:
{
"enabled": false,
"budget": { "total": 0, "cache_limit": 0, "work_mem_limit": 0, "headroom": 0, "min_free": 0 },
"total_resident_bytes": 0,
"utilization": 0.0,
"governor": { "admissions": 0, "rejections": 0, "evicted_free_floor_bytes": 0, "evicted_cap_bytes": 0 },
"levels": [],
"sankey": { "total_lookups": 0, "misses": 0, "layers": [ { "name": "miss", "label": "miss → storage", "value": 0 } ] },
"mem_available_bytes": 12884901888
}
An enabled node fills levels (one entry per cache layer, each with
bytes/entries/hit_rate/utilization/avg_entry_bytes/evicted_entries/
gen_invalidations/scope_invalidations + hottest-first per-scope scopes rows)
and the sankey layers with per-layer absorbed hits.
The same accounting is available in-query as CALL patinadb.cache.stats() YIELD scope, kind, bytes, entries, hit_rate, generation, and as Prometheus gauges on
/metrics (patinadb_cache_bytes{level,db}, patinadb_cache_hit_ratio{level},
patinadb_cache_evictions_total, patinadb_mem_available_bytes, …). For the full
metric reference and a tuning playbook see
Cache Observability & Tuning.
GET /mgmt/snapshot (backup / export)
Streams a portable backup of the whole registry — every database’s graph
plus its schema and access-control surface — as a single downloadable JSON
document (Content-Type: application/json, Content-Disposition: attachment; filename="patinadb-backup.json").
Query parameters:
| Param | Default | Meaning |
|---|---|---|
history | false | When true, additionally stream each database’s full engram history (delta + snapshot bodies) for point-in-time restore (PITR). See “Point-in-time restore” below. |
The backup is built and streamed in O(chunk) memory, not O(graph): each database’s graph is serialized lazily straight from its scan iterators to a temp file (the same streaming serializer the Raft snapshot build uses), and that file is streamed back as the response body and deleted once sent. The payload carries no Raft metadata (no log id / membership), so it is a portable data dump — readable and restorable into any fresh registry:
{
"databases": { "<db>": <graph snapshot> }, // graph (HEAD state)
"fulltext": { "<db>": [<index def>] }, // full-text indexes
"vector": { "<db>": [<index def>] }, // vector indexes (trained centroids)
"constraints": { "<db>": [<unique constraint>] },
"schema_constraints":{ "<db>": [<existence / node-key / property-type constraint>] },
"point_indexes": { "<db>": [<point index def>] },
"edge_sorted": { "<db>": [<edge-sorted index def>] }, // since #553
"wasm_functions": { "<db>": [<wasm function def, module bytes>] }, // since #553
"tags": { "<db>": [["<name>", "<engram uuid>"]] }, // tag names
"anamnesis_enabled": { "<db>": <true|false> }, // provenance flag, since #522
"users": [<rbac user>], // global: hash + per-db roles
"engram_history": { "<db>": <full engram history> }, // ONLY with ?history=true
"heads": { "<db>": "<engram uuid>" }, // informational
"taken_at_unix_ms": <millis> // informational
}
So a /mgmt/snapshot → /mgmt/restore round-trip reconstitutes each database
including its UNIQUE / existence / node-key / property-type constraints,
compound / vector / full-text / point / edge-sorted indexes, WASM
functions (re-registered through the restoring node’s WASM registrar), named
tags, the anamnesis flag (re-applied via the replicated
SetAnamnesis command, since #522), and the RBAC user directory (argon2
hashes + per-database role grants) — not just the graph. Every schema/RBAC field
is optional, so an older backup (from before these were carried) still restores.
(restore ignores the heads / taken_at_unix_ms fields — informational labels.)
Point-in-time restore (PITR) — ?history=true
By default (?history=true omitted) the backup is a compact HEAD-state
dump: it captures each database’s current graph plus its schema and RBAC, but
not its engram delta/snapshot history. After restoring a HEAD-only backup the
graph, constraints, indexes, users, and tag names are all back, but time-travel
over pre-backup history is gone — AS OF <old-engram-id> / CALL patinadb.diff
on engrams that predate the backup are unavailable, and every restored tag
resolves to the restored-HEAD engram.
Add ?history=true to make it a PITR archive: the backup additionally
streams each database’s full engram history (engram_history above — the
per-engram delta bodies and periodic snapshot bodies). A /mgmt/restore
of such a backup reconstitutes the whole timeline, so on the restored node:
USE <db> AS OF '<old-engram-id>'reconstructs that historical state (not HEAD),USE <db> AS OF TAG <name>resolves to the tagged engram’s point in history,CALL patinadb.diff('<engram-id>')returns that engram’s historical delta,- and HEAD is the live restored graph, as always.
The history is streamed body-by-body (one delta / snapshot at a time), so a
PITR backup keeps peak memory bounded rather than materialising the whole
timeline. The engram_history field is omitted entirely from a HEAD-only backup,
and it decodes as empty on restore (#[serde(default)]), so an old backup — or a
new HEAD-only one — still restores unchanged.
Cluster caveat.
/mgmt/restoreinstalls the engram history on the node that receives the request (the leader). The HEAD graph replicates to followers via Raft, but the delta/snapshot bodies do not — so PITR is a leader-local capability. A follower that later bootstraps purely from a Raft snapshot carries only the metadata summary (the pre-existing engram-log limitation). For a single-node restore (the documented use), full PITR works everywhere.
Leader-anchored, point-in-time labelled
The export takes a leader linearizability barrier (ensure_linearizable)
before it reads anything, so:
- It is leader-only. A follower (or any non-leader) refuses the backup with a
503naming the current leader ({ "leader_id", "leader_addr", … }), the same contract as a misrouted write or a linearizable read — you can never accidentally take a stale backup from a lagging replica. - It reflects every write committed before it began, and records as of what
point each database was captured: under the barrier it captures each database’s
HEAD engram id (
heads[<db>], matchingDataset::headon the leader) plus a wall-clocktaken_at_unix_ms.
Consistency: a consistent cut, no quiesce needed. Under the barrier the
backup pins one point in time per database, and every part of that database —
HEAD label, graph, and engram history — is read from that pinned point. A write
that lands while the backup streams does not leak into the export: the
restored backup satisfies the same reconstruct(HEAD) == live graph invariant
the source did, and its AS OF timeline is monotone. Writes are briefly
serialized only while the point is pinned (one O(1) pin per database), never for
the duration of the stream — measured under issue #1105 on a 62 GiB/NVMe box, a
212 MB PITR backup taking 1.73 s moved a concurrent writer’s median latency from
3116 µs to 3333 µs (+7%), with no stall resembling the backup’s own duration
(that box was shared, so the measurement is honest about the median and the
absence of a stall, not precise enough for a tail-latency figure). The full
write-up is in
Production Deployment → Backup & disaster recovery —
including the caveat for older builds predating the fix, where a backup
taken under load could be silently torn and quiescing writes was required.
Auth-protected like every other management route (it is a full data dump): when a password is set, valid HTTP Basic credentials are required.
curl -s -u neo4j:secret http://127.0.0.1:21001/mgmt/snapshot \
-o patinadb-backup.json
GET /mgmt/export (portable CSV export)
Streams one database’s graph as a portable, import-compatible dump —
neo4j-admin-style node/relationship files a LOAD CSV load or a bulk-upsert
call can read back.
GET /mgmt/export?db=<name>&format=csv
db— which database to export (defaultdefault).format—csv,parquet, orarrow.
The response is a tar archive (Content-Type: application/x-tar,
Content-Disposition: attachment; filename="patinadb-export-<db>.tar")
containing, in the exact neo4j-admin convention the importer reads:
nodes_<Label>.csv— a:ID(uuid)column, one typed column per property (age:int,score:float, …), and a trailing:LABELcolumn.rels_<TYPE>.csv—:START_ID(uuid),:END_ID(uuid),:TYPE, and one typed column per edge property._schema.json— a sidecar carrying the database’s UNIQUE / existence / node-key constraint defs plus compound / full-text / vector / point / edge-sorted index defs and WASM function defs (the full definition-family set, since #522), so an exported database can be fully reconstituted. (/mgmt/snapshotnow also carries these — see above — so both paths preserve the schema;/mgmt/exportis single-database + CSV,/mgmt/snapshotis whole-registry + JSON and additionally carries RBAC users, tags, and the anamnesis flag.)
Because the id column is the raw vertex UUID under an :ID(uuid) header, a
re-import reproduces the same graph with byte-identical UUIDs (the importer
uses the cell literally instead of hashing it). Round-trip:
curl -s -u neo4j:secret \
"http://127.0.0.1:21001/mgmt/export?db=default&format=csv" -o export.tar
mkdir out && tar xf export.tar -C out
Reload it with LOAD CSV against the extracted files (see
Bulk Loading & Import), or drive it through
POST /mgmt/upsert/upsert-edges (see Bulk Upsert) if
you want match-or-create semantics on the reload.
Like /mgmt/snapshot, it takes a leader linearizability barrier (so it is
leader-only and reflects every committed write; a follower answers 503 with a
leader hint) and streams in O(chunk) memory (per-label CSVs are written
row-by-row to a temp dir, then tar’d to a temp file that is streamed back and
deleted). Auth-protected — it is a /mgmt/ route, so it needs admin
credentials.
POST /mgmt/restore (restore / import)
Restores a backup produced by GET /mgmt/snapshot into the registry. Send it
to the leader (every step is a replicated write, so a follower answers with a
503 leader hint) and it needs admin credentials (it’s a /mgmt/ route).
curl -s -u neo4j:secret -X POST http://127.0.0.1:21001/mgmt/restore \
-H 'content-type: application/json' \
--data-binary @patinadb-backup.json
# → {"databases": 2, "ops_applied": 12345, "users_restored": 3}
For each database in the payload it issues CREATE DATABASE (idempotent), then
streams the graph back as chunked replicated writes (so the restore commits
through Raft and appears on every node), then re-registers, in order: the
full-text index defs, the compound-index defs, the vector-index defs
(with their trained centroids), the UNIQUE + existence/node-key constraints
(rendered back to CREATE CONSTRAINT … IF NOT EXISTS DDL and replayed via the
same replicated path interactive constraint DDL uses), and the tag names
(re-created pointing at the restored HEAD). The anamnesis flag is re-applied
per database via the replicated SetAnamnesis command (since #522 — only true
is re-applied; a merge-restore never silently disables a flag). Finally it
restores the RBAC user
directory — each user (argon2 hash replayed verbatim, so the original password
still works) plus its per-database role grants. Every step is a replicated,
idempotent write, so a multi-node cluster converges and re-running the same
backup is a no-op — ideal for loading a backup into a fresh (empty) cluster.
Non-empty-target guard. Before touching anything, the server checks each
database named in the payload: if a database of that name already exists and
is non-empty, the restore is refused outright (409 Conflict, naming the
non-empty database(s)) — nothing is written. This exists precisely because the
restore is additive, not a wipe-then-replace: pointing it at the wrong (already
populated) target would otherwise silently merge/duplicate data with no
warning. ?force=true lifts the refusal so the restore may proceed — but it
is still an additive merge: the backup’s ops are replayed on top of the
existing data and nothing is ever deleted. There is no “wipe and replace”
mode on this endpoint; for that, restore onto a freshly bootstrapped, empty
cluster instead (see
Disaster Recovery).
An absent or already-empty target database restores with no flag needed
— the common fresh-restore case is unaffected. PITR is stricter: if the
backup carries engram history (?history=true), installing it onto a database
that has data or a timeline of its own is refused with or without force
— a history restore requires a pristine target.
curl -s -u neo4j:secret -X POST 'http://127.0.0.1:21001/mgmt/restore?force=true' \
-H 'content-type: application/json' --data-binary @patinadb-backup.json
What restore does NOT bring back. Unless the backup was taken with
?history=true(see/mgmt/snapshotabove), it is a HEAD-state dump, not a PITR archive: engram history is not carried, so after a restore, time-travel over pre-backup engrams and pre-backup diffs are gone, and each restored tag resolves to the single restored-HEAD engram (not its original point in history). With?history=truethe whole timeline is restored — but only on the node that received the restore: the HEAD graph replicates to followers via Raft, the historical delta/snapshot bodies do not. If that node later dies, the restored timeline is gone until you restore it again (see Disaster Recovery). The anamnesis-enabled flag is carried and re-applied on restore (since #522) — but only future writes are projected from that point on; the restored graph itself is not retro-projected into the<db>__anamnesiscompanion (the companion rides the backup as ordinary data). The backup is leader-anchored, HEAD-labelled, and a consistent cut — each database is read from one pinned point in time, so cross-database consistency holds even while writes continue (no quiesce needed; see/mgmt/snapshotabove).