Import and Export¶
Saving and Loading¶
graph.save("my_graph.kgl") # atomic (temp + rename) + fsync by default
graph.save("my_graph.kgl", fsync=False) # skip the flush for speed (still atomic)
loaded_graph = kglite.load("my_graph.kgl")
save() is atomic and crash-safe: it writes to a sibling temp file and
atomically renames it over the target, so a crash mid-save can’t leave a torn
.kgl — a reader always sees the old file or the complete new one. With
fsync=True (default) the file + directory are flushed before returning; pass
fsync=False to skip that for speed. load() raises a typed
kglite.FileFormatError on a corrupt file (see Threading and the
durable apps guide).
Save files (.kgl) use an explicitly versioned binary container. Current files use Postcard payloads; supported older bincode containers are selected only by their legacy format headers. For sharing across machines or long-term archival, prefer a portable format (GraphML, CSV).
open() — load-or-create lifecycle¶
For an app that persists to one file, kglite.open(path) is the ergonomic
entry point: it loads the graph if the file exists and creates a fresh one if
it doesn’t, and the returned graph remembers the path.
g = kglite.open("app.kgl") # loads if present, else creates
g.cypher("CREATE (:Person {name: 'Alice'})")
g.save() # no path needed — writes back to app.kgl
Use it as a context manager to auto-save on clean exit:
with kglite.open("app.kgl") as g:
g.cypher("CREATE (:Person {name: 'Bob'})")
# snapshotted to app.kgl on block exit
save()with no argument writes to the remembered path; passing a path (save("other.kgl")) updates the remembered target (“save as”). A graph built in memory with no path raisesValueErrorif you callsave()with no path.kglite.load(path)also remembers its path, so baresave()works after a load.The context manager skips the save if the block raised — the on-disk file keeps its last good state.
close()persists explicitly.
Not crash safety (by default). Plain
open()auto-save-on-close is a clean-exit checkpoint — a hard crash (kill -9, power loss) mid-session writes nothing. For crash safety, usedurable=Truebelow.
durable=True — crash-safe writes (write-ahead log)¶
Open with durable=True to make every committed Cypher mutation survive a hard
crash. Each mutation is appended to a <path>-wal sidecar and fsync’d before
the call returns; on open, any WAL frames are replayed onto the loaded
checkpoint to recover work committed since the last save().
with kglite.open("app.kgl", durable=True) as g:
g.cypher("CREATE (:Person {id: 1, name: 'Alice'})")
# committed + fsync'd to app.kgl-wal here — survives kill -9
# A later run recovers automatically, even after a crash with no save():
g = kglite.open("app.kgl", durable=True)
g.cypher("MATCH (p:Person) RETURN p.name") # -> Alice
save()writes a full.kglcheckpoint and truncates the WAL. A durable graph that was never saved still recovers entirely from its WAL.The log is idempotent (identity-keyed upsert/remove ops, per-frame CRC), so a torn trailing frame from a crash mid-append is discarded and recovery is safe.
In-memory graphs only in this release —
storage="mapped"/"disk"raiseValueError(the columnar disk modes use explicit-save()checkpoints).Non-durable graphs pay nothing: the capture path is entered only under
durable=True.
Export Formats¶
graph.export('my_graph.graphml', format='graphml') # Gephi, yEd
graph.export('my_graph.gexf', format='gexf') # Gephi native
graph.export('my_graph.json', format='d3') # D3.js
graph.export('my_graph.csv', format='csv') # creates _nodes.csv + _edges.csv
graphml_string = graph.export_string(format='graphml')
Back up before upgrading (the format-stable escape hatch)¶
The .kgl file (and to_bytes()) is a versioned binary cache, not a
forever-stable archive. KGLite occasionally hard-breaks the on-disk format
across major versions (e.g. the v3→v4 Value-layout change), and a newer
binary will refuse an older file rather than silently misread it. If you
still have the original source (CSV, DataFrame, dataset loader), you just
rebuild. If you don’t, you want a portable copy made before you upgrade.
export_csv() + from_blueprint() are that copy — a schema-complete,
format-stable round-trip (plain CSV + a blueprint.json manifest) that
survives version changes, the way sqlite3 .dump does for SQLite:
# Under the version that can still open the graph:
graph.export_csv('backup/') # nodes/*.csv + connections/*.csv + blueprint.json
# Later, on any version — rebuild the full graph from the portable copy:
import kglite
graph = kglite.from_blueprint('backup/blueprint.json')
Treat export_csv('backup/') as the thing you run before a major-version
upgrade. Unlike the lossy visualization exports above (GraphML/GEXF/D3), it
preserves every node, edge, and property and reloads to an equivalent graph.
NetworkX Interop¶
Round-trip with NetworkX for graph algorithms.
KGLite is a directed multigraph with typed nodes/edges, so the lossless
target is networkx.MultiDiGraph: each node’s id is the networkx node
key (with node_type, title, and every property as node attributes),
and the first edge’s connection_type is its edge key. Additional parallel
edges with the same endpoints and type receive collision-safe composite keys,
while every edge retains a connection_type attribute.
to_networkx() preserves same-type parallel edges. The inverse bulk importer
uses KGLite’s endpoint-plus-type DataFrame identity, so importing such a
NetworkX graph collapses duplicates with identical endpoints and type.
Requires the networkx extra: pip install kglite[networkx].
import networkx as nx
# Export, run an algorithm, write the scores back.
nxg = graph.to_networkx() # -> nx.MultiDiGraph
scores = nx.pagerank(nxg) # {node_id: rank} (pagerank needs scipy)
import pandas as pd
df = pd.DataFrame(
[{'id': nid, 'pagerank': rank} for nid, rank in scores.items()]
)
# Update existing nodes in place (matched by id), or with Cypher SET:
graph.add_nodes(df, 'Person', 'id', conflict_handling='update')
# graph.cypher("MATCH (n) WHERE n.id = $id SET n.pagerank = $r", ...)
# Import a plain networkx graph (defaults applied where attrs are absent).
g2 = kglite.from_networkx(nxg, default_node_type='Node', default_edge_type='RELATED')
from_networkx() accepts Graph / DiGraph / MultiGraph /
MultiDiGraph; undirected edges become a single directed edge each.
to_networkx() exports the full graph (the active selection is ignored
in v1).
Neo4j Export¶
Push a graph (or the active selection) to a live Neo4j database over Bolt,
using batched UNWIND writes. Requires the neo4j driver:
pip install neo4j.
import kglite
g = kglite.load("graph.kgl")
report = kglite.to_neo4j(
g,
"bolt://localhost:7687",
auth=("neo4j", "password"),
clear=False, # set True to wipe the target DB first
merge=False, # set True for MERGE (upsert) instead of CREATE
batch_size=5000,
)
# {'nodes_created': ..., 'relationships_created': ..., 'elapsed': ..., 'database': 'neo4j'}
Pass selection_only=True to export just the current selection (otherwise
the full graph is written). Use merge=True for idempotent re-runs against
an existing dataset; clear=True for a clean reload.
Merging Graphs (multi-source ingest)¶
extend() folds one in-memory graph into another in place — the native
alternative to round-tripping through CSV when you build a graph
incrementally from several sources or merge two loaded .kgl files.
g1 = kglite.load("source_a.kgl")
g2 = kglite.load("source_b.kgl")
report = g1.extend(g2) # g2 folded into g1; g2 untouched
report = g1.extend(g2, "preserve") # on conflict, existing g1 values win
Node identity is (node_type, id). The conflict_handling argument shares
the add_nodes vocabulary — 'update' (default, other wins), 'replace',
'skip', 'preserve' (existing wins), 'sum' (adds numeric edge
properties). Secondary labels are unioned (never removed); edges dedup on
(connection_type, source, target) so a merge never silently doubles shared
edges. Scope limits (v1): in-memory storage only, and embeddings are
not merged — re-run set_embeddings / add_embeddings after the merge.
Subgraph Extraction¶
subgraph = (
graph.select('Company')
.where({'title': 'Acme Corp'})
.expand(hops=2)
.to_subgraph()
)
subgraph.export('acme_network.graphml', format='graphml')
Embedding Snapshots¶
Export embeddings to a standalone .kgle file so they survive graph rebuilds. Embeddings are keyed by node ID — import resolves IDs against the current graph, skipping any that no longer exist.
# Export all embeddings
stats = graph.export_embeddings("embeddings.kgle")
# {'stores': 2, 'embeddings': 5000}
# Export only specific node types
graph.export_embeddings("embeddings.kgle", ["Article"])
# Export specific (node_type, property) pairs
graph.export_embeddings("embeddings.kgle", {
"Article": ["summary", "title"],
"Author": [], # all embedding properties for Author
})
# Import into a fresh graph — matches by (node_type, node_id)
graph2 = kglite.KnowledgeGraph()
graph2.add_nodes(articles_df, 'Article', 'id', 'title')
result = graph2.import_embeddings("embeddings.kgle")
# {'stores': 2, 'imported': 4800, 'skipped': 200}
Schema and Indexes¶
Schema Definition¶
graph.define_schema({
'nodes': {
'Prospect': {
'required': ['npdid_prospect', 'prospect_name'],
'optional': ['prospect_status'],
'types': {'npdid_prospect': 'integer', 'prospect_name': 'string'}
}
},
'connections': {
'HAS_ESTIMATE': {'source': 'Prospect', 'target': 'ProspectEstimate'}
}
})
errors = graph.validate_schema()
schema = graph.schema_text()
Indexes¶
Two index types:
Method |
Accelerates |
Use for |
|---|---|---|
|
Equality ( |
Exact lookups |
|
Range ( |
Numeric/date filtering |
Both also accelerate Cypher WHERE clauses. Composite indexes support multi-property equality.
graph.create_index('Prospect', 'prospect_geoprovince') # equality index
graph.create_range_index('Person', 'age') # B-Tree range index
graph.create_composite_index('Person', ['city', 'age']) # composite equality
graph.list_indexes()
graph.drop_index('Prospect', 'prospect_geoprovince')
Indexes are maintained automatically by all mutation operations.
Performance Tips¶
Batch operations — add nodes/connections in batches, not individually
Specify columns — only include columns you need to reduce memory
Filter by type first —
select()beforefilter()for narrower scansCreate indexes — on frequently filtered equality conditions (~3x on 100k+ nodes)
Use lightweight methods —
len(),indices(),node()skip property materializationCypher LIMIT — use
LIMITto avoid scanning entire result sets
Threading¶
The Python GIL is released during heavy Rust operations, allowing other Python threads to run concurrently:
Operation |
GIL Released? |
Notes |
|---|---|---|
|
Yes |
Serialization + compression + file write |
|
Yes |
File read + decompression + deserialization |
|
Yes |
Query parsing, optimization, and execution |
|
Yes |
Similarity computation (uses rayon internally) |
|
Partial |
Model embedding needs GIL; vector search releases it |
|
No |
DataFrame conversion requires GIL throughout |
|
No |
Must hold exclusive lock on graph |
A KnowledgeGraph is single-owner: concurrent reads are fine, but a read that
overlaps a mutation on the same instance raises a RuntimeError. For lock-free
concurrent reads across threads, serve from an immutable graph.freeze()
snapshot (see Concurrency).
Serialize to/from bytes¶
save()/load() go through a filesystem path. To own the write — push to object
storage, a socket, a checksum — serialize the whole graph to a .kgl byte
buffer instead:
blob = graph.to_bytes() # bytes (the same format save() writes)
graph = kglite.from_bytes(blob) # round-trips; raises FileFormatError if corrupt
In-memory / mapped graphs only (a disk-mode graph is a directory, not a stream).
Human-readable diffs (to_text + git textconv)¶
A .kgl is a compressed binary blob, so git diff shows Binary files differ
— useless for reviewing a change to a graph (e.g. an agent-built planning
graph). to_text() projects the whole graph to a deterministic, readable
form (nodes grouped by type + sorted by id, edges sorted by endpoints), stable
across insert order and across save/load:
print(graph.to_text())
# Task (2 node(s))
# t1 | Plan the API | status=done
# t2 | Write tests | status=todo
#
# edges (1)
# (t1)-[BLOCKS]->(t2)
Reserved provenance keys (updated_at/git_sha) are omitted so per-write
metadata churn doesn’t swamp the diff.
Wire it into git so git diff / PR review renders .kgl files readably
(the CLI ships an export-text subcommand — pip install kglite):
git config diff.kglite.textconv "kglite export-text"
echo "*.kgl diff=kglite" >> .gitattributes
Now git diff path/to/graph.kgl shows real content changes. For an explicit
before/after delta of two files:
kglite diff old.kgl new.kgl
# -t2 | Write tests | status=todo
# +t2 | Write tests | status=done # a changed node shows as a -/+ pair
Graph Maintenance¶
After heavy mutation workloads (DELETE, REMOVE), internal storage accumulates tombstones. Monitor with graph_info().
info = graph.graph_info()
# {'node_count': 950, 'node_capacity': 1000, 'node_tombstones': 50,
# 'edge_count': 2800, 'fragmentation_ratio': 0.05, ...}
if info['fragmentation_ratio'] > 0.3:
result = graph.vacuum()
print(f"Reclaimed {result['tombstones_removed']} slots")
vacuum() rebuilds the graph with contiguous indices and rebuilds all indexes. Resets the current selection.
Common Gotchas¶
One primary type per node. Secondary labels (multi-label, 0.10.5+) are preserved;
labels(n)returns a list, primary type first.idandtitleare canonical.add_nodes(unique_id_field='user_id')stores the column asid. The original name works as an alias.Save files use a pinned binary format. Compatible across OS/architecture within the same major version.
Indexes:
create_index()accelerates equality only. For range queries, usecreate_range_index().Flat vs. grouped results. After traversal with multiple parents,
titles()andcollect()return grouped dicts.No auto-persistence. The graph lives in memory.
save()is manual.