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.
This page states the guarantees, defaults, and limits that matter when KGLite owns the authoritative copy.
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.
One case keeps the older whole-graph checkpoint, and there the write cost is
still proportional to graph size: the disk backend. A disk graph has no
petgraph slot identity for an inverse edit to name, so every mutating statement
on it opens an O(V+E) checkpoint instead. memory and mapped both take the
journal, and a durable graph over either of them does too — durability and
rollback strategy are independent concerns.
Two other graph shapes also use the journal:
A graph that has been saved, loaded, or opened from a file. It carries the same property shape a freshly built graph does, and a
SETjournals the individual cells it overwrote rather than a copy of the type’s whole column store. The write-scaling benchmark covers freshly built, loaded, spilled, and mapped graphs.A graph carrying user-created property, range, or composite indexes. Their bucket edits are journalled with the position they occupied, so
CREATE INDEXand thecreate_indexAPI no longer move a graph’s writes back onto the whole-graph checkpoint.
Uniqueness constraints are the one structure still rebuilt wholesale, and only on the failure path: a statement that rolls back recomputes the occupancy map of each type it touched, which scales with that type’s node count. Successful writes never pay it. If your workload both writes continuously and fails statements often, measure it rather than assuming flat cost.
What a write costs in memory¶
Properties live in per-type columns, from the first node onward — building,
saving, loading and reopening all produce the same shape, so there is no
conversion step to plan around and no second cost profile to discover after the
first save(). Two consequences are worth knowing before you rely on this as a
primary store:
A column is allocated per declared property, per row, whether or not the row has a value. Memory is therefore proportional to schema width rather than to the properties actually set, so a type with many optional properties costs more at rest than the same data in a narrow type.
graph_info()['columnar_heap_bytes']reports how much the stores hold, andset_memory_limit()spills columns to disk when they exceed a budget — the limit is re-enforced after every statement, not only at load time.DELETEtombstones rows;vacuum()reclaims them. Deleted rows keep their space until you compact, so a write-heavy primary store should callvacuum()periodically (it also fires automatically once fragmentation crossesauto_vacuum_threshold).vacuum()is a no-op onstorage="disk"— its node numbering is frozen mmap, so there is no in-place rebuild to do.save()reclaims instead: a disk save rewrites the columns without the rows no live node points at, so the published directory and the graph that reloads from it carry live rows only. What a save does not reclaim is node slots: a deleted node’s 16-byte slot and its free-list entry are kept, so a disk graph’s node capacity only shrinks when the directory is rebuilt from a fresh ingest.compact()is a separate, edge-only operation — it merges overflow edges into the CSR and touches no rows.
Batching mutations into multi-row statements is still worth doing — it amortises per-statement parsing, planning and checkpoint overhead — but it is now a throughput optimisation rather than a workaround. See Data Loading’s throughput ladder.
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. Recovery says which of the two it found: a torn tail
is reported as the ordinary aftermath of a crash, while a corrupt frame with
intact frames after it is named as mid-file damage and reports how much
committed work is being discarded — the log cannot be trusted past corruption,
so that is a storage problem to investigate rather than a routine restart. 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: 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.
What the log carries. Nodes, edges, labels, every declaration you make
about them — identity-field spellings, set_parent_type, ontologies,
constraints, user-created indexes, set_spatial and set_schema_version —
and the two bulk payloads: timeseries channels and embeddings, the
latter with the model id and per-node text hashes that
embed_texts(mode='changed') reads. A crash before your first save()
therefore loses none of it. The one thing replay rebuilds rather than reads is
the HNSW vector index: only the build_vector_index declaration is logged,
and the topology is rebuilt from the replayed vectors, because the index
addresses store slots that replay renumbers.
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.No handle derived from a durable graph may write.
Session.execute(), and equally a selection or view (g.select(...).update(...),view.cypher("CREATE …"),view.save()), land on a working copy that neither the log norsave()can reach, so rather than silently losing the write they raise —kglite.ArgumentErrorfromSession,ValueErrorfrom a derived handle. Usecypher()orbegin()on the graph itself. Because durable is now the default, code that usedSession.execute()for writes against anopen()ed graph has to change — or passdurable=False.A refused log append poisons the handle. If the log cannot take a frame — a full disk is the case this was measured on — the statement raises
kglite.FileIoError, and every later write,save()andsync()on that handle is refused with the same class, because the graph in memory now contains a statement the caller was told did not happen andsave()would commit it. The message names the exit: reopen the path. The failed statement is not in the recovered graph.
Two additional constraints: 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 because its working copy does not contain the first 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. That
includes the write path: a save(), sync() or to_bytes() that fails on I/O
raises kglite.FileIoError (.code == "FileIo"), not a bare OSError.
Integrity constraints are enforced on every write path. Declared through
define_schema, and checked on Cypher CREATE / INSERT / 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 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.
types is the one part of define_schema that is not enforced at write
time. required, unique and primary_key reject the offending write; a
types declaration is advisory — it is checked by validate_schema(), which
you call when you want the audit, and reports every row that disagrees
(error_type: "type_mismatch"). Nothing rejects
CREATE (:Item {age: "not a number"}) on a type whose schema says age is
int. If you want a property type enforced on the way in, declare it as a
constraint instead — CREATE CONSTRAINT FOR (n:Item) REQUIRE n.age IS :: INTEGER
raises ConstraintViolationError on the write, on every write path, for the
property types KGLite can check. lock_schema() is the third option: it rejects
a write whose value disagrees with the property type the node type has actually
recorded.
A large bulk load is all-or-nothing for everything the loader can refuse.
add_nodes and add_connections decide every refusal in a single pass before
the first row is written: the constraint gate checks the whole input up front,
and on_invalid="error" scans it for rows with an unusable id in the same way.
Both raise with nothing written, at any input size. Rows are still flushed to the
graph in chunks of 1000, but that is a memory bound rather than an atomicity
boundary — the flush loop has no failure path of its own, so no error these
loaders raise can leave half a load behind. What chunking does still bound is a
call that never returns: a process killed mid-load leaves the chunks folded in so
far in memory. On a durable graph that costs nothing, because the load reaches
the log as one frame at the end — a crash mid-load recovers to the pre-load
state.
The N-Triples loader and embedding-carry path bypass constraint enforcement;
a graph filled through those can hold violations, which
verify_unique_constraints() can audit. The general RDF loader is a fresh-graph
bootstrap operation: Python and C return a new in-memory graph, and Rust
load_rdf(&mut graph, ...) refuses populated or configured targets before
writing. Load RDF first, then declare and validate its constraints.
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, but a node type’s property set is fixed by its first write (below) |
|
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.
“No schema” does not mean “any property”. The first write to a node type
establishes that type’s property set, and a later CREATE naming a property
outside it is refused:
graph.cypher("CREATE (:Item {sku: 'A1'})")
graph.cypher("CREATE (:Item {sku: 'A2', colour: 'red'})")
# kglite.SchemaError: Schema error: Unknown property 'colour' on Item.
# Valid properties: sku
This is a typo guard, not a schema: CREATE (:Item {sk: 'A3'}) is far more
often a misspelling than a new field, and silently storing it produces a graph
where half the rows answer a query and half do not. It fires whether or not
lock_schema() was called, so schema_locked being False is not a reason to
expect otherwise.
Four things widen the set, and any of them is the way to add a property deliberately:
SET—MATCH (i:Item) SET i.colour = 'red'is never refused, and the property is part of the type afterwards. This is the shortest route when you are adding a field to existing data.define_schema— a property named inrequired,optional,types,uniqueorprimary_keyis accepted byCREATEimmediately, before any node carries it. Declare the shape up front and the guard never gets in the way.A bulk load —
add_nodeswith a new column widens the type, so a loader that is the source of truth for the schema does not need a declaration.A fresh node type — the set is per type, so
:ItemV2starts over.
The guard covers the node-creating patterns—CREATE, INSERT, and MERGE’s
match/create pattern. Relationship properties are not guarded at all, and
neither is a SET clause attached to a MERGE.
What KGLite does not do¶
One process writes — and kglite.open() enforces it between openers.
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. A lease handed back cleanly appends a
released=<timestamp> line to the .lock-owner record, so a record without one
was left by a holder that died still holding the lease — forensics after the
fact, not a liveness signal, since the lock is what decides whether a writer
waits. open(..., lock=False) opts out for callers that coordinate writers some
other way.
The lease is taken by the entry points that claim a path, not by save().
kglite.open(), the CLI’s eager save paths, kglite-bolt-server and
kglite-mcp-server take it; KnowledgeGraph.save() — and its Rust and C
counterparts, kglite::api::io::save_graph and the C ABI save entries — does
not. So a graph obtained from kglite.load(path), mutated in memory and saved
back, publishes straight over a path a lease holder is mid-write on. The file
that results is a complete, valid graph; it is just the loader’s, and whatever
the holder had not saved is not in it. (A serving MCP server then refuses its
own save_graph — the file changed under it — so the loss is the agent’s
unsaved work, not the file.) The rule the lease encodes is that any caller
that may save to a path holds the lease across the whole read-modify-save
interval, which is exactly what open(path) is for: load() + save(path)
is a write that opted out of it.
Taking the lease is also when open() cleans up after a writer that died
mid-save(). A save writes a sibling <name>.tmp.<pid>.<n> and renames it into
place, so a process killed part-way through leaves a full-size copy of the graph
behind — a crash-looping writer used to fill the volume with them. open() now
deletes the ones whose owning process is gone, and only those: a temp belonging
to a running process is never touched, so a concurrent save is safe.
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, kglite-bolt-server is the coordination point: that one process owns
the graph while clients connect over the Bolt protocol. It does not lift the
single-writer model — it centralises it. All writes go through explicit
transactions (auto-commit mutations are refused), they serialize at commit, and
a commit against a stale snapshot conflicts with a retriable status code so
driver-managed transactions retry on their own; that retry is contention-tested,
not merely lifecycle-tested
(tests/test_bolt_server_transactions.py::test_managed_transaction_retries_after_conflict).
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. This is focused
contract coverage rather than a full protocol sweep. Other drivers, including
Go and .NET, are untested.
Constraints cover uniqueness, presence and property type, not arbitrary
rules. Nodes carry all three; relationships carry presence and property type
(FOR ()-[r:T]-() REQUIRE r.p IS NOT NULL / IS :: TYPE) but not uniqueness.
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 { … }, a property type outside the accepted
names, and IS UNIQUE / IS RELATIONSHIP KEY on a relationship — fail with a
specific unsupported-feature error naming the construct and the route that does
work. Unsupported forms are rejected rather than recorded without enforcement.
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 such as MATCH, FILTER,
CREATE/INSERT, the delete forms, ordinary procedures, and terminal FINISH.
A downstream clause that must see the whole result—an aggregate, ORDER BY,
SKIP/OFFSET/LIMIT, DISTINCT, a set operation, cluster(), or a CALL
subquery—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(). disk also keeps the whole-graph write checkpoint described above;
mapped does not — its statements take the same O(changes) journal in-memory
graphs take.
In-memory is the primary mode; the disk modes are for exploring graphs too big
for it.
Three bindings are maintained here. Python and Rust are
first-class, and Java is official since 0.15.9 (Panama/FFM over the C ABI, on
Maven Central as io.github.kkollsga:kglite). Everything else — Go, JavaScript,
.NET — can use the C ABI in crates/kglite-c; KGLite does not ship those
bindings. 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.