Snapshots
LoraDB can dump the in-memory graph to a single file and restore it later. A snapshot is the full graph frozen from the database's current Arc snapshot — taken on demand, atomic on rename, readable from any binding.
Snapshots are shipped as of v0.3. They close the "no persistence at all" gap for workloads that only need occasional save / restore operations (seeded services, notebooks, graceful-shutdown saves, scheduled backups). Continuous durability is now available through the WAL on every filesystem-backed surface, but snapshots are still the portable file primitive those surfaces checkpoint to.
What a snapshot is
- A single encoded payload containing the full graph — every node, every relationship, every property — plus a short header describing the format. Filesystem bindings write that payload as one file; WASM returns bytes or web-native wrappers around those bytes.
- A point-in-time dump. The save loads the current Arc snapshot and encodes that graph consistently; readers already using an older Arc continue normally.
- Atomic on rename for path-based saves. Writes land in
<path>.tmp, arefsync'd, then renamed over the target; a crashed save can leave a stale.tmpfile but never a half-written target. - Format-versioned. Files declare an envelope/body format version; the reader accepts supported versions and rejects unsupported ones rather than partially loading unknown data.
What snapshots are not
The bright line, stated explicitly so it cannot be missed:
- Not continuous durability. A crash between two saves loses every mutation in the window unless you pair snapshots with the WAL on a filesystem-backed surface.
- Not a wall-clock checkpoint scheduler. Manual snapshots run when you call them. Explicit WAL helpers can also write managed snapshots after N committed transactions, but there is no built-in timer.
- Not a general persistent storage layer. There is no alternative backend; a snapshot is a dump of the in-memory graph, not a format a different engine writes into.
- Not free. Saves and loads are whole-graph operations. Existing readers can keep using their pinned Arc snapshot, but the encode/decode work is still proportional to graph size.
- Not a multi-tenant boundary. One process holds one graph; each process you run needs its own snapshot file.
When to use snapshots
Good fits:
- Seeded services and agents. Build the graph offline from a cheaper source, snapshot it, and ship the file alongside the deployment. Every restart boots in one file-read.
- Notebooks, demos, research tooling. Save what you've curated; reload it tomorrow with one call.
- Graceful shutdown. A final
save_snapshot(orPOST /admin/snapshot/savefrom systemdExecStop) preserves the graph across planned restarts. - Scheduled backups. A cron that calls the admin endpoint with a rotating filename every N minutes is a complete backup policy for graphs that fit the save window.
Bad fits:
- Hard-durability workloads. If losing even a minute of mutations on crash is unacceptable, snapshots alone are not enough — use one of the WAL-enabled surfaces.
- Very large graphs where save time exceeds your query window. Save work is proportional to graph size and can add meaningful CPU/I/O pressure.
Metadata
Path-based saves, load calls, and HTTP admin calls return a small
metadata record. Byte-output save APIs such as WASM saveSnapshot() or
Node saveSnapshot() return the snapshot bytes directly instead.
{
"formatVersion": 1,
"nodeCount": 1024,
"relationshipCount": 4096,
"walLsn": null
}
| Field | Type | Meaning |
|---|---|---|
formatVersion | integer | On-disk file format the payload is written in. Currently 1. |
nodeCount | integer | Nodes in the saved / restored graph. |
relationshipCount | integer | Relationships in the saved / restored graph. |
walLsn | integer or null | null for a pure snapshot; non-null for a checkpoint snapshot written with WAL enabled. |
Whenever metadata is returned, every binding uses the same four fields; the spelling of the field names matches the wire shape (camelCase).
Compression and encryption
Embedded bindings can save snapshots with codec options:
| Option | Supported values |
|---|---|
compression | "none", "gzip", or { format: "gzip", level: 0..9 } |
encryption | Password / passphrase encryption, or a raw 32-byte key |
Password encryption is the most portable shape:
const encryption = {
type: 'password',
keyId: 'backup-key',
password: process.env.LORA_SNAPSHOT_PASSWORD!,
};
await db.saveSnapshot('graph.lorasnap', {
compression: { format: 'gzip', level: 1 },
encryption,
});
await db.loadSnapshot('graph.lorasnap', { credentials: encryption });
Node, Python, WASM, and Ruby accept this JSON-style option shape; Go
mirrors it with SnapshotOptions, SnapshotCompression, and
SnapshotEncryption structs; Rust uses the typed SnapshotOptions,
SnapshotPassword, and EncryptionKey structs. Load calls accept the
credentials either under credentials or encryption, so the same
object used to save can usually be reused to load. HTTP admin snapshot
routes do not accept per-call codec options today; use an in-process
binding when you need custom compression or encryption.
Binding examples
Snapshots are exposed on every binding that exposes the engine. The shape is always "save produces a file or byte payload, load consumes a source, and metadata is returned whenever the operation has a metadata surface".
Rust
The reference surface. Every other binding wraps these two methods.
use lora_database::Database;
let db = Database::in_memory();
db.execute("CREATE (:Person {name: 'Ada'})", None)?;
// Dump the graph to a file. Atomic on rename.
let meta = db.save_snapshot_to("graph.bin")?;
println!("saved {} nodes, {} relationships",
meta.node_count, meta.relationship_count);
// Boot a fresh Database directly from the snapshot.
let db2 = Database::in_memory_from_snapshot("graph.bin")?;
// Or overlay a snapshot onto an existing handle.
db.load_snapshot_from("graph.bin")?;
SnapshotMeta is re-exported from lora_database:
use lora_database::SnapshotMeta;
fn log_meta(m: SnapshotMeta) {
tracing::info!(
format = m.format_version,
nodes = m.node_count,
rels = m.relationship_count,
wal = ?m.wal_lsn,
);
}
Python
Synchronous:
from lora_python import Database
db = Database.create()
db.execute("CREATE (:Person {name: 'Ada'})")
meta = db.save_snapshot("graph.bin")
print(meta["nodeCount"], meta["relationshipCount"])
db2 = Database.create()
db2.load_snapshot("graph.bin")
Async — same methods as coroutines on AsyncDatabase:
import asyncio
from lora_python import AsyncDatabase
async def main():
db = await AsyncDatabase.create()
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 the sync and async forms run with the GIL released (sync) / on a worker thread (async) so other Python threads / coroutines make progress during the call. A large save still consumes native engine work and filesystem I/O proportional to graph size.
Node.js / TypeScript
import { createDatabase, type SnapshotMeta } from '@loradb/lora-node';
const db = await createDatabase(); // in-memory by default
// const db = await createDatabase('app', { databaseDir: './data' }); // container-backed
await db.execute("CREATE (:Person {name: 'Ada'})");
const meta: SnapshotMeta = await db.saveSnapshot('graph.bin');
console.log(meta.nodeCount, meta.relationshipCount);
const db2 = await createDatabase();
await db2.loadSnapshot('graph.bin');
saveSnapshot(path) writes atomically and resolves to SnapshotMeta.
Calling saveSnapshot() with no path returns a Node Buffer; you can
also request uint8Array, arrayBuffer, base64, or a Node stream.
loadSnapshot accepts a string / URL path, Buffer, Uint8Array,
ArrayBuffer, Node Readable, Web ReadableStream, or async
iterable. The native engine work still encodes or decodes the whole graph for
the duration of the save or load.
WebAssembly
WASM has no filesystem path API. The snapshot API is byte/source based:
the caller chooses where to persist the bytes, and loadSnapshot never
accepts a string path.
import { createDatabase } from '@loradb/lora-wasm';
const db = await createDatabase();
await db.execute("CREATE (:Person {name: 'Ada'})");
// Serialize the graph to a Uint8Array.
const bytes: Uint8Array = await db.saveSnapshot();
// Persist however your app already stores state:
// IndexedDB, localStorage, OPFS, a POST to your backend,
// `fs.writeFileSync` in Node — all work.
// Later (same or a new session), restore from bytes.
const db2 = await createDatabase();
await db2.loadSnapshot(bytes);
saveSnapshot can also return { format: "arrayBuffer" },
"blob", "response", "stream", or "url". loadSnapshot
accepts URL, Uint8Array, ArrayBuffer, Blob, Response, or a
ReadableStream<Uint8Array | ArrayBuffer>.
The Worker-backed surface (createWorkerDatabase) exposes the same
saveSnapshot / loadSnapshot methods and moves the snapshot work to
the worker thread.
Persist across reloads with IndexedDB
const DB = 'loradb-snapshots', STORE = 'graph', KEY = 'main';
async function idb(): Promise<IDBDatabase> {
return await new Promise((ok, err) => {
const r = indexedDB.open(DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore(STORE);
r.onsuccess = () => ok(r.result);
r.onerror = () => err(r.error);
});
}
async function saveToIdb(db: Database) {
const bytes = await db.saveSnapshot();
const idbDb = await idb();
await new Promise<void>((ok, err) => {
const tx = idbDb.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(bytes, KEY);
tx.oncomplete = () => ok();
tx.onerror = () => err(tx.error);
});
}
async function loadFromIdb(db: Database) {
const idbDb = await idb();
const bytes = await new Promise<Uint8Array | undefined>((ok, err) => {
const tx = idbDb.transaction(STORE, 'readonly');
const r = tx.objectStore(STORE).get(KEY);
r.onsuccess = () => ok(r.result);
r.onerror = () => err(r.error);
});
if (bytes) await db.loadSnapshot(bytes);
}
Go
import lora "github.com/lora-db/lora/crates/bindings/lora-go"
db, err := lora.New()
if err != nil { log.Fatal(err) }
defer db.Close()
if _, err := db.Execute("CREATE (:Person {name: 'Ada'})", nil); err != nil {
log.Fatal(err)
}
meta, err := db.SaveSnapshot("graph.bin")
if err != nil { log.Fatal(err) }
fmt.Printf("nodes=%d rels=%d\n", meta.NodeCount, meta.RelationshipCount)
db2, err := lora.New()
if err != nil { log.Fatal(err) }
defer db2.Close()
if _, err := db2.LoadSnapshot("graph.bin"); err != nil {
log.Fatal(err)
}
SnapshotMeta.WalLsn is a *uint64; it is nil for pure snapshots
and non-nil when you load or save a checkpoint snapshot stamped by a
WAL-enabled surface.
Ruby
require "lora_ruby"
db = LoraRuby::Database.create
db.execute("CREATE (:Person {name: 'Ada'})")
meta = db.save_snapshot("graph.bin")
puts "#{meta['nodeCount']} nodes, #{meta['relationshipCount']} relationships"
db2 = LoraRuby::Database.create
db2.load_snapshot("graph.bin")
HTTP admin surface
lora-server exposes snapshot save and load as two HTTP endpoints.
Both are opt-in: they are mounted only when the server is started
with --snapshot-path.
Enabling the endpoints
lora-server \
--host 127.0.0.1 --port 4747 \
--snapshot-path /var/lib/lora/db.bin
Without the flag, the routes return 404. This is deliberate — the
admin surface has no authentication, and an unauthenticated admin
endpoint on a network-reachable port is a footgun. Off by default
means "never exposed by accident".
The same path can also be provided via the LORA_SERVER_SNAPSHOT_PATH
environment variable.
Saving and loading
curl -sX POST http://127.0.0.1:4747/admin/snapshot/save
# => {"formatVersion":1,"nodeCount":1024,"relationshipCount":4096,"walLsn":null,"path":"/var/lib/lora/db.bin"}
curl -sX POST http://127.0.0.1:4747/admin/snapshot/load
Both endpoints accept an optional { "path": "…" } body that
overrides the configured default for one request — useful for ad-hoc
backups to a rotated filename:
curl -sX POST http://127.0.0.1:4747/admin/snapshot/save \
-H 'content-type: application/json' \
-d '{"path": "/var/backups/lora/2026-04-24.bin"}'
The response includes the same four metadata fields as every other
binding, plus the path that was actually used.
When WAL is enabled, POST /admin/checkpoint writes the same snapshot
format but stamps walLsn with the durable WAL fence. See
WAL and checkpoints.
Restoring at boot
--restore-from <PATH> loads a snapshot once, at startup, before the
server begins accepting queries:
lora-server \
--restore-from /var/lib/lora/seed.bin \
--snapshot-path /var/lib/lora/runtime.bin
- A missing file at boot is fine: the server logs a message and starts with an empty graph.
- A malformed file at boot is fatal.
--restore-fromis independent of--snapshot-path. You can restore from a read-only seed and snapshot to a writable runtime path, or pass the same path to both for the "boot from the last save every time" pattern:
lora-server \
--host 127.0.0.1 --port 4747 \
--snapshot-path /var/lib/lora/db.bin \
--restore-from /var/lib/lora/db.bin
Security warning
The admin endpoints have no authentication, and the optional
path body field is passed straight to the OS. Any client that can
reach the admin port can:
- write files anywhere the server UID can write, or
- swap the live graph by pointing
loadat an attacker-staged file.
Do not expose the admin surface on a network-reachable host without authenticated ingress in front of it. A reverse proxy with auth, a Unix socket, or simply not binding the port at all are all acceptable answers. Future releases may add authentication; until then, the correct deployment is "admin surface disabled by default, enabled only behind an auth boundary".
See Limitations → HTTP server and HTTP API → Admin endpoints (opt-in) for the detailed security profile.
File format (reference)
The current database snapshot format is column-oriented. Files declare an
envelope format version and are rejected when the reader does not support that
version. The previous LORASNAP legacy format has been retired.
[0..8) magic "LORACOL1"
[8..12) format u32 — envelope format, currently 2
[12..16) manifest_len u32
[16..24) body_len u64
[24..56) checksum BLAKE3(manifest || body)
[56..) manifest explicit binary manifest
[...end) body column-oriented graph payload plus index and constraint catalog trailers
The manifest carries walLsn, node and relationship counts,
compression, encryption metadata, and the body length. The body stores
nodes, labels, relationships, relationship types, and properties in
separate columns. The current body format is version 4: version 3 added
the explicit index catalog trailer, and version 4 adds the constraint
catalog trailer. That means SHOW INDEXES and SHOW CONSTRAINTS state
survives snapshot save/load. Older catalog-free LORACOL1 bodies still
load with empty index and constraint lists as needed. The body then
applies compression and authenticated encryption if requested.
Readers validate the magic bytes, the format version, total length, and BLAKE3 checksum before decoding the payload. A file that fails any of those checks is rejected — the graph in memory is never touched until the load succeeds.
Limitations
Worth restating, because the failure modes are where snapshots bite:
- Manual save and restore only. Manual snapshots run when you call them. Explicit WAL helpers can also write managed snapshots after N committed transactions, but there is no wall-clock scheduler.
- Snapshots alone are not continuous durability. A crash between saves loses every mutation in the window unless you pair snapshots with the WAL.
- Whole-graph work. Save and load encode/decode the entire graph. Existing readers can continue on already-loaded Arc snapshots, but new queries see the restored graph only after load publishes it.
- One process, one graph. Each process you run needs its own snapshot file.
- No partial or incremental snapshots. Every save serializes the whole graph.
- Admin surface is unauthenticated. Opt-in is the only safety control today; put an authenticated ingress in front of it on any host that isn't exclusively localhost.
For the underlying engine internals (wire format, mutation-event surface, forward-compatibility rules, atomicity guarantees on the parent-dir fsync), see the internal Snapshots operator doc.
See also
- Rust guide → Persisting your graph
- Python guide → Persisting your graph
- Node guide → Persisting your graph
- WASM guide → Persisting your graph
- Go guide → Persisting your graph
- HTTP server → Snapshots, WAL, and restore
- HTTP API → Admin endpoints (opt-in)
- WAL and checkpoints
- Cookbook → Backup and restore
- Limitations → Storage
- Troubleshooting → Snapshots