Schema Migrations¶
Once a graph is long-lived state rather than something you rebuild from source on every run, its shape starts to drift: a property gets added, a value needs backfilling, a type gets split in two. This guide covers how KGLite tracks where a graph is in that history, and how to move it forward safely.
The whole mechanism is two things: ordered Cypher scripts and one integer stored with the graph. There is no ledger table, no checksums, no lock row. That is deliberate — for an embedded single-file database, the ordered filenames are the history.
The user-schema version¶
Every graph carries an integer recording how far it has been migrated:
graph.schema_version # 0 on a graph nobody has stamped
graph.set_schema_version(3)
graph.save('mygraph.kgl') # persisted with the graph
kglite schema-version mygraph.kgl # prints 3
This number is yours, not KGLite’s. The engine stores it, returns it, and never interprets it. Do not confuse it with the two engine-owned versions:
Number |
Owner |
Meaning |
|---|---|---|
|
You |
Your data model’s revision. Your migrations bump it. |
|
KGLite |
The |
|
KGLite |
Which KGLite version last saved the file. |
0 means unversioned. A graph saved before this field existed also reports
0 — the field is additive, so older .kgl files load fine and simply start
from the baseline.
Once you set a version, describe() reports it, so an agent opening the graph
cold can see which schema generation it is looking at.
Writing migrations¶
A migration is a .cypher file named <version>_<name>.cypher, all in one
directory:
migrations/
001_add_email.cypher
002_backfill_country.cypher
003_split_display_name.cypher
Each file holds one or more ;-separated Cypher statements:
-- 001_add_email.cypher
MATCH (p:Person) WHERE p.email IS NULL SET p.email = 'unknown';
Numbering only has to ascend — gaps are fine (001, 005, 010). Zero is
reserved for the unversioned baseline, so migrations start at 1.
Running them¶
kglite migrate mygraph.kgl migrations --dry-run # show the plan, change nothing
kglite migrate mygraph.kgl migrations # apply
mygraph.kgl at user-schema version 0: 0 already applied, 3 pending
001_add_email
002_backfill_country
003_split_display_name
applied 001_add_email (1 statement(s)) — now at version 1
applied 002_backfill_country (1 statement(s)) — now at version 2
applied 003_split_display_name (2 statement(s)) — now at version 3
saved mygraph.kgl at user-schema version 3
What you can rely on:
Re-running is a no-op. Everything at or below the stamp is already applied. A second run reports
nothing to do, exits 0, and does not even rewrite the file.Order is by version number, not filename order —
010runs after002.All-or-nothing at the file level. Statements run against an in-memory copy and the
.kglis written once, at the end. If migration 3 of 5 fails, nothing is saved and the file on disk is byte-for-byte what it was. Fix the migration and run again.Only new migrations run later. Append
004_…and the next run applies just that one.
What it refuses to do¶
A migration runner that quietly does the wrong thing is worse than none, so these are hard errors:
Situation |
Why it is refused |
|---|---|
The stamp names a version no migration declares |
The graph says “I am at 5” but there is no migration 5. You are pointing at the wrong directory, an applied migration was deleted, or the stamp was hand-edited. Applying “everything above 5” would silently skip or repeat work. |
Two migrations declare the same version |
Their relative order would be arbitrary. |
A |
Silently ignoring a file that looks exactly like a migration is how a migration gets lost. |
A migration numbered |
Reserved for the unversioned baseline. |
Adopting migrations on an existing graph¶
If a graph already has the shape migrations 1–2 would produce, declare that instead of replaying them over live data:
kglite schema-version mygraph.kgl --set 2
kglite migrate mygraph.kgl migrations # only 003 onwards runs
--set runs nothing. It asserts a fact about the data, so make sure the fact
is true.
Migrating from Python¶
The CLI is a convenience, not a requirement — the stamp is part of the normal API, so a migration step is just Cypher plus a stamp:
import kglite
graph = kglite.open('mygraph.kgl')
if graph.schema_version < 1:
graph.cypher("MATCH (p:Person) WHERE p.email IS NULL SET p.email = 'unknown'")
graph.set_schema_version(1)
graph.save('mygraph.kgl')
Changing a node’s type: recreate, don’t mutate¶
A node’s primary type is immutable, and the way KGLite says so is worth understanding, because one of the two obvious attempts appears to succeed:
MATCH (n:Contractor) SET n.type = 'Person'
-- error: Cannot SET node type via property assignment
MATCH (n:Contractor) SET n:Person
-- succeeds — but it does NOT change the type
The second statement adds a secondary label. Afterwards n.type is still
'Contractor', labels(n) is ['Contractor', 'Person'], and — the confusing
part — MATCH (n:Person) does match the node. So a migration that used
SET n:Person and checked with MATCH (n:Person) would look like it worked
while every node kept its original type.
If a secondary label is all you need — extra classification, an additional way to match — then that is the cheap and correct tool, and you are done. It does not move the node.
If you genuinely need the primary type changed, you have to recreate the node. The primary type is how nodes are indexed and partitioned in storage, so changing it in place would mean relocating the node across every index that references it — hence the restriction, which is a design decision rather than an oversight.
The documented pattern for a type change is therefore: create the replacement, copy the properties, re-wire the edges, delete the original.
The part people underestimate is the edges. A node’s relationships belong to that node, so deleting it deletes them — the new node does not inherit anything. Every relationship type the old node participated in has to be recreated explicitly, in both directions:
-- 004_contractor_becomes_person.cypher
-- 1. Create the replacement, carrying the properties across.
MATCH (c:Contractor)
CREATE (:Person {id: c.id, title: c.title, email: c.email, started: c.started});
-- 2. Re-wire every outgoing edge type.
MATCH (c:Contractor)-[w:WORKS_AT]->(company), (p:Person {id: c.id})
CREATE (p)-[:WORKS_AT {since: w.since}]->(company);
-- 3. And every incoming edge type — easy to forget, and silent when missed.
MATCH (manager)-[:MANAGES]->(c:Contractor), (p:Person {id: c.id})
CREATE (manager)-[:MANAGES]->(p);
-- 4. Only now remove the originals, which drops their edges with them.
MATCH (c:Contractor) DETACH DELETE c;
Practical advice for this shape of migration:
Enumerate the edge types first.
describe()orgraph.connections()will tell you which relationship types actually touch the type you are replacing. A type you forget is silently dropped at step 4 — no error, just missing relationships.Copy edge properties explicitly. As in step 2 above;
CREATE (p)-[:WORKS_AT]->(company)alone losessince.Do not reuse the
idfor a different entity. Keeping the sameidacross the swap is what lets steps 2 and 3 find the new node.Verify before deleting. Split the migration in two — everything up to step 3 in one file, step 4 in the next — and check the counts in between if the graph matters.
If the type is large, prefer a rebuild. For a big graph, re-running your original loader against the corrected schema is often faster and safer than an in-place type migration.
The recipe above is exercised end to end in KGLite’s own test suite, so the statements are known to work as written — including the property and edge- property carry-over.
Before a risky migration, take a portable copy¶
.kgl is a versioned binary cache. Before a migration you are unsure about,
export something that does not depend on the engine at all:
kglite export-sqlite mygraph.kgl before-004.sql
That gives you a plain-SQL snapshot you can inspect, diff, or reload into SQLite if the migration goes somewhere you did not intend. See Import and Export for the full set of exit formats.
What this deliberately does not do¶
No down-migrations. There is no automatic inverse. If you need to undo
004, write005that reverses it. An inferred rollback of arbitrary Cypher would be a guess.No detection of an edited migration. If you change
002after it has been applied, nothing notices. Treat applied migrations as immutable and append instead.No per-migration ledger. The stamp is a high-water mark. Inserting a migration numbered behind it will be treated as already applied — always append with a higher number.