KGLite as a primary store: scope and limits¶
Most KGLite graphs are a derived index over data owned somewhere else. This page is about the other case: the graph is the authoritative copy, and losing it means losing the data.
That is a much stronger promise, so this page is deliberately conservative. It states what holds, what the defaults are, and what KGLite does not do — with enough detail that you can decide without running an experiment first. Where a limit exists, it is named rather than softened.
What holds¶
A mutating statement is all-or-nothing. A Cypher statement that fails after its first write leaves the graph exactly as it found it — node and relationship identity, properties, labels, index ordering, schema metadata, and the version counter. Rollback replays a statement-scoped journal of inverse operations backwards.
The cost of a write scales with the change, not with the graph. Because the
journal records only what the statement touched, a single SET on a
million-node graph costs about what it costs on a thousand-node graph. This is
the property that separates a store you can write to continuously from an index
you rebuild. It is measured, not assumed:
tests/benchmarks/test_bench_write_scaling.py runs the same statements at 1 k,
100 k, and 1 M nodes, and a reading that grows with size is a regression.
Three cases keep the older whole-graph checkpoint, and there the write cost is still proportional to graph size:
the
mappedanddiskbackends,columnar mode, whose
SETwrites a shared column store the journal cannot observe,graphs carrying user-created property, range, or composite indexes, whose delete path needs per-bucket position undo the journal does not record yet.
That third case is easy to walk into now that indexes are a Cypher surface:
CREATE INDEX, like the create_index API, builds exactly those structures, so
indexing a graph moves its writes back onto the whole-graph checkpoint.
Uniqueness constraints are backed by a different structure whose cost interaction
is not yet characterised. If your workload writes continuously and indexes or
constrains, measure it rather than assuming flat cost.
Crash safety is the default. kglite.open(path) opens in write-ahead-log
mode wherever the storage mode supports it — the default in-memory backend and
storage="mapped". Each committed mutation appends one frame to a <path>-wal
sidecar and fsyncs it before the call returns; on open, the engine loads the
.kgl checkpoint and replays every frame newer than it. A frame carries a
CRC32, and a crash mid-append leaves a torn trailing frame that replay discards
rather than half-applying. Every way of changing a graph is logged, not only
Cypher — add_nodes, add_connections, label changes, and committed
transactions included. save() is separately atomic and fsynced, so a reader
never observes a torn file.
storage="disk" is the exception, and not because it was overlooked: a disk
graph commits by publishing an immutable generation, so a logical write-ahead
log is not its durability boundary. A disk graph opens non-durable and takes
save() checkpoints instead. Asking for any logging level there —
durable=True/"full" or durable="normal" — raises ValueError
explaining that, since the blocker is the commit boundary rather than barrier
strength; only durable="off" is supported. The default does not raise, so
disk callers are unaffected by the default being on elsewhere.
Not everything is logged. State the log cannot express is checkpoint-only
and is persisted by save() rather than by the log: schema and config metadata,
user-created indexes, embeddings, and timeseries. If those matter to you, a
save() is still part of your durability story, not an optimisation.
Three consequences worth internalising before you rely on this:
It costs one barrier per committed mutation. Writes now wait on physical storage, so the cost is device latency rather than graph size — most visible in loops of many small writes, negligible for a few large ones. Reads are unaffected.
durable="normal"keeps the log and drops only that barrier: a committed mutation still survives the process dying, but an OS crash or power cut loses work since the lastsave(), andsync()gives you a power-safe point on demand.durable=False(no log at all) remains fully supported and is the right choice for bulk loading and for graphs you can rebuild from source. Batching writes into one statement, or onebegin()transaction, buys throughput and the strongest guarantee.A
withblock is not a transaction. Mutations commit as they run, so an exception inside the block does not discard them — they are recovered on the nextopen(). What the clean or failed exit controls is whether a checkpoint is written. Usebegin()when you want discard-on-error.Sessionrefuses write queries on a durable graph. Its writes land on a working copy that neither the log norsave()can reach, so rather than silently losing them it raises. Usecypher()orbegin(). Because durable is now the default, code that usedSession.execute()for writes against anopen()ed graph has to change — or passdurable=False.
Two smaller sharp edges: save(fsync=False) is ignored on a durable graph and
warns, because the checkpoint truncates the log and so must itself reach disk;
and a log written by this version is refused by older builds with a clear message
rather than silently truncated.
Readers see a consistent graph. freeze() hands out an immutable, lock-free
snapshot; a session() serializes writers, begins each from the last committed
state, and publishes with a pointer swap only on success — though note the
durable-graph restriction on Session writes below. Explicit transactions
are optimistically concurrent: a commit against a state that moved underneath
raises TransactionConflictError rather than winning silently.
Be precise about how coarse that check is, because it is coarser than most databases you have used. It compares a whole-graph version counter, not the read/write sets of the two transactions — a commit publishes the transaction’s working copy by pointer swap, so a transaction that began before any other commit is working from a stale snapshot regardless of which nodes it touched. Two transactions editing entirely unrelated nodes therefore conflict, and the second one loses. That is not over-caution: its working copy genuinely does not contain the first one’s write, so applying it would silently revert that write.
The practical consequence is that conflicts are ordinary rather than rare, and
every concurrent writer needs a retry loop. Use kglite.retry_on_conflict
rather than writing one:
def signup(tx):
tx.cypher("CREATE (u:User {email: $email})", params={"email": email})
kglite.retry_on_conflict(graph, signup)
If your workload has many short concurrent writers, prefer session() — it
serializes writers and begins each from the last committed state, so they queue
instead of colliding. Concurrency is the full model, and worth
reading before you rely on any of it.
Failures are typed. Errors arrive as a KgError hierarchy with stable
codes, not as strings to match on — see Error handling.
Integrity constraints are enforced on every write path. Declared through
define_schema, and checked on Cypher CREATE / MERGE / SET / REMOVE and
on the bulk loaders alike — add_nodes, and therefore blueprints,
from_records, OKF ingestion, WAL replay, and extend_graph:
graph.define_schema({"nodes": {"Person": {
"primary_key": "email", # unique *and* present (NODE KEY)
"unique": [["first", "last"]], # composite UNIQUE
"required": ["email"], # NOT NULL, at write time
}}})
Three things make this real rather than advisory. primary_key may name any
property, not just id — a key on id routes through the O(1) identity index,
any other key is backed by a unique secondary index that persists and rebuilds on
load. required is enforced at write time, so a CREATE that omits the
property, a SET that nulls it, and a REMOVE that drops it all raise, rather
than surfacing later in validate_schema(). And declaring a constraint the
stored data already violates is refused outright, so you cannot install a
constraint that quietly lies about the rows already present.
A composite unique tuple constrains only nodes carrying every property in it,
and NULL is exempt throughout — a node sits outside a uniqueness constraint
unless every property in the tuple is present and non-null, so many nodes may
share “no email” while email is UNIQUE.
Two gaps to know, because both are the kind that look like guarantees until they
are not. A large bulk load is not all-or-nothing. add_nodes gates each row
before queueing it, so a violation aborts the call — but rows are flushed to the
graph in chunks of 1000, so on an input larger than that, chunks already flushed
stay written. Detection is unaffected; the atomicity is what is chunk-bounded.
Treat a failed large load as needing cleanup, not as a no-op. And two write
paths bypass enforcement entirely: the RDF and N-Triples loaders, and the
embedding-carry path. A graph filled through those can hold data that violates a
declared constraint; verify_unique_constraints() exists to audit exactly that
case.
One thing about the error surface is worth knowing before you write except
clauses. A violation raises ConstraintViolationError and a declaration that
cannot be installed raises ConstraintCreationError; both subclass
ConstraintError, so except ConstraintError catches either. This holds on
every write path — cypher() and the bulk writers alike — so the duplicate-signup
handler is a type check, not a substring match:
try:
graph.cypher("CREATE (u:User {email: $email})", params={"email": email})
except kglite.ConstraintViolationError:
raise Conflict("that email is already registered")
Each carries a stable .code ("ConstraintViolation" /
"ConstraintCreationFailed") for logging and cross-binding dispatch. Note that
define_schema can fail this way, because installing a schema installs the
constraints it declares — nothing is changed when it does, so you can fix the data
and retry. The message still names the constraint, the property, and the
offending value, and is worth logging; the type and code are the contract.
Defaults, and how to change them¶
Default |
To change |
|
|---|---|---|
Crash safety |
On ( |
|
Schema |
No schema; any property on any node |
|
UNIQUE / NOT NULL / node key |
Permissive — a type declaring none keeps the old behaviour |
|
Freshness stamps |
Off, so writes stay deterministic |
|
All of the constraint machinery is opt-in, and older graphs load unchanged.
Durable embedded apps covers the open() lifecycle and the per-commit fsync
cost in more detail.
What KGLite does not do¶
One process writes — and this is now enforced, not just documented.
kglite.open(path) takes an exclusive cross-process writer lease for as long
as the graph can write back to path, so a second process opening the same
path fails immediately with the holder’s pid rather than quietly overwriting
its work at save():
KgError: app.kgl is open for writing by pid 4711 (since 2026-07-26T09:15:03+02:00)
Readers are unaffected: load() and open_session() take no lease, so any
number of processes can read a graph while one writes. The lease is an OS-owned
lock, so a writer killed with SIGKILL releases it immediately — the leftover
<path>.lock (the lock, always empty) and <path>.lock-owner (the pid and
acquisition time, used to name a holder) are records, not the lock itself, and
deleting them achieves nothing. open(..., lock=False) opts out for callers
that coordinate writers some other way.
There is still no shared live multi-process transaction handle and no
replication protocol. Disk mode publishes immutable generations behind the same
kind of lease — that is stable-reader/single-writer publication, not concurrent
multi-process write access. When several processes need to read and write one
graph, the answer is to run kglite-bolt-server and let that one process own
the graph while clients connect over the Bolt protocol. The official Python,
JavaScript, and Java drivers are regression-tested in CI — session and
explicit-transaction lifecycle, managed retry, PackStream type round-trips,
Neo.* error codes, and OCC conflict detection. Read that as a 22-check
conformance suite per driver rather than a full protocol sweep, and note that
every other driver — Go, .NET — remains untested: those clients may connect but
can rely on features outside the documented wire and Cypher contracts.
Constraints cover uniqueness and presence, not arbitrary rules. There is no
CHECK constraint, and no standing referential-integrity constraint between node
types: a relationship to an unknown endpoint auto-vivifies a provisional stub
rather than being rejected. Stubs are deferred, not exempt — the add_nodes
upsert that promotes one is a normal, fully-enforced write, an unpromoted stub
stays reportable via validate_schema(), and purge_provisional() sweeps them.
Individual loads can also be strict up front with
from_records(..., on_missing_endpoint="error"), which validates the whole input
and fails atomically. If your correctness argument needs a rule that is not
uniqueness or presence, it still belongs in your application.
Schema setup is expressible in Cypher, with two asymmetries to know.
CREATE [RANGE] INDEX / DROP INDEX / SHOW INDEXES and
CREATE CONSTRAINT / DROP CONSTRAINT / SHOW CONSTRAINTS both work, so schema
setup no longer has to happen in Python or Rust. What to watch:
Bare
CREATE INDEXis equality-only. One property builds a hash equality index; two or more build a composite index.CREATE RANGE INDEXbuilds two structures — the equality index and a B-tree range index — and reportsindexes_addedof 2. The bare form stays equality-only deliberately, since building both for every statement in a ported script would double index memory. AddRANGEwhen you need range scans; a multi-propertyRANGEindex is rejected, because the B-tree is single-property.Index names are not persisted; constraint names are. An index name is accepted for portability and then discarded — index names here are canonical and derived (
Label.property,Label.(a,b)). SoDROP INDEXwants that dotted canonical name, or the descriptor formDROP INDEX FOR (n:Label) ON (n.prop). The trap: dropping by a name you chose fails, and addingIF EXISTSto that same statement turns the failure into a silent no-op that leaves your index in place.SHOW INDEXESprints the canonical name, and its output pastes straight in. Constraint names, by contrast, persist across save/load and are unique per graph, soCREATE CONSTRAINT person_email_unique …followed byDROP CONSTRAINT person_email_uniqueworks as written.Uniqueness on the identity field is refused, not silently accepted. A
REQUIRE … IS UNIQUE(orIS NODE KEY) that resolves to the structuralid— including the node type’s own id column name — is rejected, because a unique secondary index would never observe those writes and the constraint would admit duplicates while reporting success. Declare identity uniqueness asprimary_keyindefine_schemainstead, which probes the per-type id index on every write path, or useMERGEas the idempotent alternative toCREATE.IS NOT NULLon the id field is accepted, since it is present by construction.
Forms KGLite cannot serve — TEXT, POINT, FULLTEXT, VECTOR, LOOKUP,
relationship indexes, OPTIONS { … }, and IS :: TYPE constraints — fail with a
specific unsupported-feature error naming the construct and the route that does
work. That is the deliberate choice: a type constraint accepted and silently
unenforced would be worse than an error, since it is exactly the kind of promise
data-integrity assumptions get built on. Full grammar in
Cypher Reference.
LOAD CSV works, and file access is a capability you grant. LOAD CSV [WITH HEADERS] FROM <source> AS row [FIELDTERMINATOR <sep>] runs for local files and
file:// URLs, and must lead the query. Fields stay strings — CSV carries no
types, and inferring them would corrupt leading-zero identifiers — so conversion
is explicit (toInteger(row.id)). http(s):// is refused, naming the
network-free design: the engine ships no HTTP client.
The security model deserves stating plainly, because it is default-deny:
In-process callers — the Python API, the Rust library, the CLI — get unrestricted read access to any path the process can read. That is deliberate, on the grounds that they already have the host process’s filesystem access, but it is not a sandbox and should not be read as one.
A Bolt client gets nothing unless an operator passes
--allow-csv-import <DIR>tokglite-bolt-server— a single directory, not a repeatable flag. Imports are then confined to that directory after symlink and..resolution, and a relative path resolves against the import root rather than the server’s working directory. Without the gate, anyone who could open a Bolt connection could readfile:///etc/passwd.The MCP server never grants the capability, so an agent cannot use
LOAD CSVto read the filesystem. Note this holds by construction — the MCP server simply never sets the field, inheriting the deny default — rather than by an explicit test.
Loading streams: the executor reads 1000 rows at a time, so peak memory does not
track file size for row-local pipelines. A downstream clause that must see the
whole result — an aggregate, ORDER BY, DISTINCT, UNION, CALL — cannot be
batched without changing the answer, so those queries take a single capped pass
and fail at 1,000,000 rows naming the clause that forced it, rather than
exhausting memory. add_nodes / add_connections, Blueprints, and the
CLI’s .import remain the higher-throughput routes.
Migrations are a convention plus a CLI verb, not a framework. There is a
user-schema version stamp — your own data-model revision, persisted with the
graph, distinct from the engine’s .kgl format version and never interpreted by
the engine. Read or set it via graph.schema_version / set_schema_version(n),
graph_info()['user_schema_version'], or kglite schema-version <graph> [--set N], and describe() reports it once set, so an agent opening a graph cold
sees which generation it holds.
kglite migrate <graph> <dir> applies ordered <version>_<name>.cypher files —
ascending by parsed integer, so 010 runs after 002, and gaps are fine — and
advances the stamp. --dry-run prints the plan without applying or saving.
Re-running is a no-op. Everything executes against an in-memory copy and the
.kgl is written once, at the end, only if every statement succeeded: the run
is all-or-nothing, so a failure at migration 3 of 5 saves nothing at all, not even
1 and 2. Version 0 is reserved for the unversioned baseline. A stamp the
migration set cannot explain, a duplicate version, and a .cypher file with no
version prefix are all refused rather than guessed at.
Three things it deliberately does not do. No downgrades — reversing a migration means writing the inverse as a new one, since inferring the inverse of arbitrary Cypher would be a guess. No detection of an edited migration — change one after it has been applied and nothing notices, so treat applied migrations as immutable and append. No per-migration ledger — the stamp is a high-water mark, so a migration inserted behind it is treated as already applied. Always append with a higher number.
And a node’s primary type is still immutable, so a type change means recreating
the node: create the replacement, copy the properties, re-wire the edges, delete
the original. Watch out for SET n:NewType, which appears to work — it adds a
secondary label, leaves n.type unchanged, and still matches
MATCH (n:NewType), so a migration written that way looks successful while every
node keeps its original type. See Import and Export for the round-trip paths a
rebuild would use.
The large-graph modes are still the weaker ones. mapped now gets the same
per-commit crash safety as in-memory — the kill-9 suites are parametrised over
both — but disk does not: its durability boundary is the generation publish, so
it relies on save() checkpoints. That boundary is a real primitive rather than
an absence of one, and it is kill-9 tested in its own right
(crates/kglite/tests/disk_crash_guarantee.rs): a crash loses exactly the
mutations made since the last save(), and the last published generation always
reopens complete — never half-written, and never with a partially-applied commit.
What disk does not give you is a smaller unit of durability than a whole
save(). Both keep the whole-graph write checkpoint described above.
In-memory is the product; the disk modes are for exploring graphs too big for it,
and that is the trade-off you are accepting.
There is no JVM or .NET binding. Python and Rust are first-class. Everything
else goes through the C ABI in crates/kglite-c — a supported boundary with a
generated header, but you are writing the binding. See C ABI.
Deciding¶
Reach for KGLite as a primary store when a single process owns the writes, the data fits the storage mode you picked, and uniqueness and presence cover the invariants you need the store itself to hold. That describes a large class of real applications: desktop and CLI tools, single-node services, agent state, embedded analytics.
Look elsewhere when you need several processes writing concurrently without a server in front, integrity rules beyond uniqueness and presence enforced by the store, or a migration tool with a downgrade path. And if the data’s real home is another system, the Derived index over another system of record pattern is both cheaper and better tested.
See also¶
Durable embedded apps —
open()lifecycle, checkpoints, anddurable=True.Derived index over another system of record — the pattern to prefer when the graph is a projection.
Concurrency — the three concurrency models, stated precisely.
Transactions and sessions —
begin()/commit()/rollback(), snapshot isolation, and OCC conflicts.Error handling — the typed exception hierarchy and error codes.