Migrating old Godot save data when your schema changes
A versioned Godot 4 save format with forward schema migration: read the version field, walk an old file one hop at a time to the current shape, refuse what you cannot upgrade.
You ship a build. A player puts forty hours into it. Then you rename hp to health and turn gold from a flat int into a wallet dictionary that tracks two currencies. Next patch, that player double-clicks their save and the game reads a file written against a schema that no longer exists. Best case a field is missing and they spawn at the origin with zero gold. Worst case a type flipped and a set() on the wrong type throws deep inside a node's load_state, mid-load, with half the world already restored. Schema migration for a Godot 4 save system is how you stop that from ever reaching a player.
The fix is a version number on the save plus a set of migration steps that walk an old file forward to the current shape before any node touches it. This post builds that on top of Save/Load Lite. The Lite core already stamps a version into every file it writes. It just never reads it back. That gap is the whole story, so start there.
What Lite actually writes to disk
SaveLite.save() walks every node in the save_load_contract_lite group, calls save_state() on each, and stores the results under their save id. The envelope it writes is fixed:
# from save_lite.gd, the shape it stringifies
var data: Dictionary = {
"version": 1,
"nodes": {},
}
# ... fills data.nodes[id] = node.save_state() ...
f.store_string(JSON.stringify(data, " "))So a real user://save.json from Lite looks like this. The version sits at the top level, and every node's state is nested under nodes keyed by get_save_id():
{
"version": 1,
"nodes": {
"player_state": { "hp": 88, "position": { "x": 400, "y": 240 }, "gold": 120 },
"world_flags": { "boss_dead": true }
}
}Now the load side, and this is the part that matters:
# from save_lite.gd, load()
var parsed = JSON.parse_string(text)
if not (parsed is Dictionary):
load_failed.emit("malformed_json: %s" % path)
return false
var nodes_data: Dictionary = parsed.get("nodes", {})
for node in get_tree().get_nodes_in_group(CONTRACT_GROUP):
# ... dispatch nodes_data[id] to node.load_state() ...Read it twice. load() pulls parsed.get("nodes", {}) and hands each slice straight to load_state(). It never looks at parsed.version. A v1 file and a v9 file take the identical path through this function. The version is written, ignored on read, and that is exactly the seam a migration layer slots into. Lite leaves the field for you. Wiring it up is the work below.
Where migration has to happen
There are two honest places to put an upgrade, and they are not equal. You could make every node's load_state defensive, reading data.get("health", data.get("hp", 100)) and coping with both spellings forever. That works for one rename. By the third schema change each node is a museum of every field name it has ever had, and the logic for a v1-to-v2 change is smeared across nine unrelated files. Don't do that.
The other place is one layer above the nodes: transform the parsed dictionary from whatever version it is on disk up to the current version, then let the nodes load a shape they always expect. Each node stays simple and only ever sees current data. All the history lives in one ordered list of steps. That is the version everyone actually wants, so that is what we build. The catch is that Lite's load() gives you no hook between parse and dispatch, so we read and migrate the file ourselves, then call into the node contract directly.
A migrator that walks one version at a time
The core idea: define the current version as a constant, keep a step function per hop (1 to 2, 2 to 3, and so on), and loop from the file's version up to current, applying one step each pass. Each step takes the whole save dictionary and returns the next one. Steps never skip. A v1 file bound for v4 runs three functions in order, so you only ever write the delta between two adjacent versions, and a save from any past version reaches the present by composition.
# save_migrator.gd -- a plain RefCounted, no autoload needed.
class_name SaveMigrator
extends RefCounted
const CURRENT_VERSION := 4
# version N here upgrades a save FROM version N to version N+1.
# keep them pure: take the whole save dict, return the next one.
static func _steps() -> Dictionary:
return {
1: _v1_to_v2,
2: _v2_to_v3,
3: _v3_to_v4,
}
# returns the migrated save dict, or null if it can't be salvaged.
static func migrate(save: Dictionary) -> Variant:
var version := int(save.get("version", 0))
if version == 0:
push_error("save has no version field. Refusing to guess")
return null
if version > CURRENT_VERSION:
push_error("save is from a newer build (v%d > v%d)" % [version, CURRENT_VERSION])
return null
var steps := _steps()
var work := save.duplicate(true) # never mutate the caller's dict
while version < CURRENT_VERSION:
if not steps.has(version):
push_error("no migration step from v%d. Save cannot be upgraded" % version)
return null
work = steps[version].call(work)
version += 1
work["version"] = version
return workTwo design calls worth defending. The whole save dict goes through each step, top-level keys and all, because some migrations are structural. Splitting one save id into two, or renaming a node's key, happens at the nodes level and a step needs to reach it. And migration runs on the parsed dictionary before a single node exists in the fixup path, so a step is pure data to data. There is no scene tree in reach and nothing that can fail for reasons unrelated to the schema.
The steps themselves are boring, which is the point. Here is the trio matching the opening example. Renaming a field, restructuring a value, then promoting a scalar into a dictionary:
# v1 -> v2: player 'hp' became 'health'
static func _v1_to_v2(save: Dictionary) -> Dictionary:
var player: Dictionary = save["nodes"].get("player_state", {})
if player.has("hp"):
player["health"] = player["hp"]
player.erase("hp")
return save
# v2 -> v3: flat 'position' split into a room id + local offset
static func _v2_to_v3(save: Dictionary) -> Dictionary:
var player: Dictionary = save["nodes"].get("player_state", {})
if player.has("position"):
# old saves predate rooms. Drop everyone at the start room
player["spawn_room"] = "room_00"
player["offset"] = player["position"]
player.erase("position")
return save
# v3 -> v4: gold int became a wallet dict
static func _v3_to_v4(save: Dictionary) -> Dictionary:
var player: Dictionary = save["nodes"].get("player_state", {})
if player.has("gold") and player["gold"] is float:
player["wallet"] = { "gold": int(player["gold"]), "gems": 0 }
player.erase("gold")
return saveLoading through the migrator
Because Lite's load() has no pre-dispatch hook, don't call it for a possibly-old file. Read the file yourself, run it through SaveMigrator.migrate, and dispatch the migrated nodes block into the contract group by hand. The dispatch loop is the same handful of lines Lite uses internally, so nothing surprising happens once the data is current:
# call this instead of SaveLite.load() for files that might be old.
func load_migrated(path := "user://save.json") -> bool:
if not FileAccess.file_exists(path):
return false
var f := FileAccess.open(path, FileAccess.READ)
var parsed = JSON.parse_string(f.get_as_text())
f.close()
if not (parsed is Dictionary):
push_error("malformed save: %s" % path)
return false
var migrated = SaveMigrator.migrate(parsed)
if migrated == null:
return false # unversioned, too new, or a missing step. don't guess.
# optional but wise: rewrite the file at the current version so the next
# boot skips all this. do it atomically if you can; Lite's plain write is
# a straight overwrite, so a crash here can still truncate the file.
var out := FileAccess.open(path, FileAccess.WRITE)
out.store_string(JSON.stringify(migrated, " "))
out.close()
# dispatch exactly like SaveLite.load does, but on migrated data.
var nodes_data: Dictionary = migrated.get("nodes", {})
for node in get_tree().get_nodes_in_group(SaveLite.CONTRACT_GROUP):
if not node.has_method("load_state") or not node.has_method("get_save_id"):
continue
var id := String(node.get_save_id())
if nodes_data.has(id):
node.load_state(nodes_data[id])
return trueNote the contract methods here are the same ones your nodes already implement for Lite: get_save_id(), save_state(), and load_state(data), joined via SaveLite.CONTRACT_GROUP. Migration changes nothing about how a node persists itself. It only guarantees that by the time load_state runs, the dictionary it receives is shaped for the current build. A SaveableLite child with save_properties set works through this unchanged, because it too is just a contract-group node.
Why you refuse instead of guessing
The migrate function returns null in three situations, and none of them try to be clever. A save with no version field gets rejected outright, because version 0 could mean a pre-versioning build or a corrupted file, and there is no safe default. A save from a version higher than CURRENT_VERSION gets rejected too. That is a player who ran a newer build, and downgrading data is a different and much harder problem than upgrading it. The last case is a gap in the step chain: you have a step from 2 and a step from 4 but nothing from 3, so the loop stops the load cold rather than skipping a version it does not understand.
Loading a save you cannot fully understand is worse than refusing it. A refused load shows the player an error and leaves their file untouched on disk, recoverable by a future patch. A blind load writes partial, half-migrated state back over the only copy they had. When you are unsure whether you can upgrade a file, the correct move is to stop, say so, and leave the bytes alone. That single decision is the difference between a save system players trust and one they learn to back up manually.
That is the whole mechanism. A version constant, a dictionary of single-hop steps, a loop that composes them, and a load path that migrates before it dispatches. Add a field next month and you write one more step function and bump CURRENT_VERSION by one. Every save ever written by every past build still opens, and no node downstream has to know that any of this happened.
Or use mine
Save/Load Lite, free on GitHub · Save/Load System, $11.24 (25% off)