Quick answer: Version your save files with a header integer, write migration functions for each version change, and maintain a library of sample save files from every released version that you test against automatically before each release.
Few bugs generate more player fury than save file corruption. A player puts 40 hours into your RPG, updates to the latest version, and their save won’t load — or worse, it loads but their inventory is empty, quest progress is reset, or their character stats are scrambled. Save compatibility bugs are uniquely destructive because they affect your most invested players and are extremely difficult to undo once a broken update ships.
Why Save Files Break Between Versions
Save files are snapshots of your game’s runtime state serialized to disk. Every time you change the structure of that state — add a field, remove a field, rename a field, change a field’s type, reorder fields in a binary format, change an enum’s values — existing save files become structurally incompatible with the new code.
Common changes that break saves:
- Adding a new field without a default: The deserialization code expects a field that doesn’t exist in old saves, causing a crash or garbage data.
- Removing a field: The old save has data the new code doesn’t expect, causing offset errors in binary formats or ignored data in text formats.
- Changing an enum: Inserting a new value in the middle of an enum shifts all subsequent values. A save that stored enum value 3 (meaning “Sword”) now deserializes as value 3 (which is now “Shield” because you inserted “Axe” at position 2).
- Restructuring nested data: Moving a field from the player object to an inventory object changes the serialization path even if the field name stays the same.
These changes are inevitable during development. You can’t freeze your save format — new features require new data. What you can do is manage the transitions deliberately.
Designing a Versioned Save Format
Every save file should start with a version number. This is the single most important thing you can do for save compatibility. The version tells your loading code which format to expect and which migrations to apply.
// C# (Unity) - Versioned save format
[System.Serializable]
public class SaveData
{
public int version = CURRENT_VERSION;
public string playerName;
public PlayerStats stats;
public List<InventoryItem> inventory;
public Dictionary<string, bool> questFlags;
// ... other game state
public const int CURRENT_VERSION = 5;
}
public static class SaveManager
{
public static SaveData Load(string path)
{
string json = File.ReadAllText(path);
// Read version first using a lightweight parse
var header = JsonUtility.FromJson<SaveHeader>(json);
int version = header.version;
// Migrate if needed
if (version < SaveData.CURRENT_VERSION)
{
json = MigrationRunner.Migrate(json, version);
}
return JsonUtility.FromJson<SaveData>(json);
}
}
[System.Serializable]
public class SaveHeader
{
public int version;
}
# GDScript (Godot) - Versioned save format
const CURRENT_SAVE_VERSION = 5
func save_game(path: String) -> void:
var data = {
"version": CURRENT_SAVE_VERSION,
"player_name": player.name,
"stats": player.stats.to_dict(),
"inventory": inventory.to_array(),
"quest_flags": quest_manager.flags.duplicate()
}
var file = FileAccess.open(path, FileAccess.WRITE)
file.store_string(JSON.stringify(data))
func load_game(path: String) -> Dictionary:
var file = FileAccess.open(path, FileAccess.READ)
var data = JSON.parse_string(file.get_as_text())
var version = data.get("version", 1) # Default to 1 for pre-versioned saves
if version < CURRENT_SAVE_VERSION:
data = migrate_save(data, version)
return data
Use a text-based format (JSON, YAML) for save files unless you have a specific reason not to. Text formats are key-based, meaning new fields can be added without breaking old saves (they simply won’t have the key, and you handle the default). Binary formats are position-based, meaning any structural change breaks backward compatibility unless you’re very careful with field ordering.
Building a Migration System
Each save version needs a migration function that transforms data from that version to the next. Migrations are chained: a version 1 save migrates to 2, then 2 to 3, then 3 to 4, all the way to the current version. This means you only ever write one migration per version change, and old saves can always be brought up to date.
// C# Migration runner
public static class MigrationRunner
{
private static readonly Dictionary<int, Func<string, string>> Migrations = new()
{
{ 1, MigrateV1ToV2 },
{ 2, MigrateV2ToV3 },
{ 3, MigrateV3ToV4 },
{ 4, MigrateV4ToV5 },
};
public static string Migrate(string json, int fromVersion)
{
for (int v = fromVersion; v < SaveData.CURRENT_VERSION; v++)
{
if (Migrations.TryGetValue(v, out var migrator))
{
Debug.Log($"Migrating save from v{v} to v{v + 1}");
json = migrator(json);
}
else
{
Debug.LogError($"No migration found for v{v} to v{v + 1}");
throw new SaveMigrationException(v, v + 1);
}
}
return json;
}
// Example: v2 added a "currency" field to player stats
static string MigrateV2ToV3(string json)
{
var obj = JObject.Parse(json);
obj["version"] = 3;
// Add new currency field with default value
if (obj["stats"]["currency"] == null)
obj["stats"]["currency"] = 0;
return obj.ToString();
}
// Example: v3 renamed "hp" to "health" and added "maxHealth"
static string MigrateV3ToV4(string json)
{
var obj = JObject.Parse(json);
obj["version"] = 4;
var stats = obj["stats"];
stats["health"] = stats["hp"];
stats["maxHealth"] = 100; // Default max
((JObject)stats).Remove("hp");
return obj.ToString();
}
}
Key rules for migration functions:
- Never delete data you might need later. Instead of removing fields, move them to a
deprecatedsection of the save. You can clean them up in a future migration once you’re sure they’re not needed. - Always provide defaults for new fields. A new “difficulty” setting should default to “normal,” not crash because the field is missing.
- Handle enum changes carefully. If you must insert a new enum value, add it at the end. If you can’t, the migration must remap all affected integer values.
- Log migrations. Write to the game log every time a migration runs, including the from/to versions and any data transformations. This is invaluable for debugging.
Testing Save Compatibility
Maintaining a library of sample save files from every released version is the most reliable way to catch save bugs before players do. This library becomes an automated test suite that runs before every release.
Build the library systematically:
- Before every release, create representative save files that cover key scenarios: new game, mid-game, end-game, edge cases (empty inventory, maximum stats, etc.).
- Name them with the version:
v1_new_game.json,v1_max_level.json,v2_mid_quest.json. - Write automated tests that load each save file and verify correctness after migration.
# GDScript test example
func test_save_migration():
var test_saves = [
"res://tests/saves/v1_new_game.json",
"res://tests/saves/v1_max_level.json",
"res://tests/saves/v2_mid_quest.json",
"res://tests/saves/v3_full_inventory.json",
]
for save_path in test_saves:
var data = load_game(save_path)
# Verify migration brought it to current version
assert(data["version"] == CURRENT_SAVE_VERSION,
"Save %s not migrated to current version" % save_path)
# Verify required fields exist
assert(data.has("player_name"), "Missing player_name in %s" % save_path)
assert(data.has("stats"), "Missing stats in %s" % save_path)
assert(data["stats"].has("health"), "Missing health in %s" % save_path)
assert(data["stats"].has("maxHealth"), "Missing maxHealth in %s" % save_path)
# Verify data integrity
assert(data["stats"]["health"] > 0, "Invalid health in %s" % save_path)
assert(data["stats"]["health"] <= data["stats"]["maxHealth"],
"Health exceeds max in %s" % save_path)
print("PASS: ", save_path)
Run these tests in your CI pipeline before every build. When a test fails, you know immediately that a code change broke save compatibility, and you can fix it before it reaches players.
Handling Unrecoverable Saves
Sometimes a save file is genuinely corrupted — truncated by a crash during write, modified by a player, or from a version so old that migration isn’t possible. Your game needs to handle this gracefully.
Best practices for failure cases:
- Backup before migration: Before applying migrations, copy the original save to a
.backupfile. If the migration fails, the player still has their data. - Fail gracefully: If a save can’t be loaded, show a clear error message explaining what happened. Never silently start a new game when a save load fails.
- Offer partial recovery: If the save header and basic data are intact but some sections are corrupted, offer to load what you can and reset the rest. “Your save file is partially corrupted. We recovered your character and inventory, but quest progress has been reset.”
- Write atomically: Use a write-to-temp-then-rename pattern to prevent save corruption from interrupted writes. Never write directly to the save file.
// Safe save writing pattern
func save_game_safe(path: String, data: Dictionary) -> bool:
var temp_path = path + ".tmp"
var backup_path = path + ".backup"
# Write to temp file first
var file = FileAccess.open(temp_path, FileAccess.WRITE)
if not file:
push_error("Cannot write temp save file")
return false
file.store_string(JSON.stringify(data))
file.close()
# Backup existing save
if FileAccess.file_exists(path):
DirAccess.rename_absolute(path, backup_path)
# Rename temp to final
var err = DirAccess.rename_absolute(temp_path, path)
if err != OK:
# Restore backup if rename failed
if FileAccess.file_exists(backup_path):
DirAccess.rename_absolute(backup_path, path)
return false
return true
Save file bugs are uniquely impactful because they affect your most dedicated players — the ones with the most hours, the most progress, and the most attachment to your game. Investing in robust save versioning and testing is one of the best ways to protect that relationship.
Every save format change should be a three-step process: add the migration, add the test save file, then make the change. In that order.