Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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"
}
FieldRequiredMeaning
queryyesThe Cypher (or DDL) statement.
parametersnoNamed $param bindings (default: none). See below.
dbnoTarget database (default "default"). Overridden by USE.
atnoEngram id for a time-travel read (equivalent to USE … AS OF).
consistencyno"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 $name intact; 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 defense patinadb-indexer (and every Bolt driver) already relies on for user-supplied values. Embedding a value into query yourself 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 same query text 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 using parameters share 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 returns 503 with a leader_id / leader_addr hint (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:

  1. A write response carries the write’s committed Raft log index in the log_index field:

    { "applied": 1, "log_index": 42, "db": "default",
      "stats": { "nodes-created": 1, "properties-set": 2 } }
    

    stats reports 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-set counts assignment and removal (a SET, a SET n += {…} and a REMOVE n.p each increment it), and no labels-added/labels-removed counter is emitted — see Bolt for why.

  2. 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_index you 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:

  1. Full-text DDL (CREATE/DROP FULLTEXT INDEX, SHOW FULLTEXT INDEXES) — schema commands replicate through Raft; SHOW reads the local catalog and returns {"fulltextIndexes": [...]}.
  2. Database DDL (CREATE/DROP DATABASE → replicated; SHOW DATABASES → local registry read).
  3. A leading USE <db> selector (optionally USE <db> AS OF '<id>') picks the target database / time-travel point.
  4. 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? true means you are protected, not that this write was a duplicate: it is true for every ordinary first-time write that carries a token. It is false when 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 a min_index bookmark). duplicate_of is the duplicate signal — dedup_active never 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.

EndpointPurpose
POST /mgmt/initInitialize 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-learnerAdd a node as a non-voting learner.
POST /mgmt/change-membershipPromote 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/clusterFull 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/metricsRaft metrics (leader, term, applied/log indices, snapshot and purge indices).
GET /mgmt/queriesPer-query-shape stats (count, mean, p95, max latency) — this node’s local view.
GET /mgmt/dbsizesPer-database on-disk size, largest first.
GET /mgmt/cacheMulti-level cache governor state: budget + per-level/-scope bytes & hit rates (see below).
GET /mgmt/snapshotStream a portable, leader-anchored backup of every database (see below).
GET /mgmt/exportStream one database as an import-compatible CSV tar (see below).
POST /mgmt/restoreRestore 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:

ParamDefaultMeaning
historyfalseWhen 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/restore installs 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 503 naming 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>], matching Dataset::head on the leader) plus a wall-clock taken_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 (default default).
  • format — csv, parquet, or arrow.

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 :LABEL column.
  • 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/snapshot now also carries these — see above — so both paths preserve the schema; /mgmt/export is single-database + CSV, /mgmt/snapshot is 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/snapshot above), 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=true the 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>__anamnesis companion (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/snapshot above).