Quick answer: Version every save file, write chainable migrations between versions, and maintain a corpus of real player save files that you run through every release in CI. The most common cause of save bugs is renaming or removing fields without handling the old format. Treat save files as a public API that cannot be broken, because to your players, they are one.
Shipping an update that breaks save files is a catastrophic bug. Players lose hours of progress, rage-quit, refund, and leave negative reviews — all in a feedback loop that can tank your release. And the worst part is that save migration bugs are almost always avoidable with a small amount of discipline. Here’s how to diagnose, fix, and prevent them.
Why Save Files Break Across Updates
Save files are effectively a snapshot of your game’s runtime state serialized to disk. Whenever you change the code that produces or consumes that state, you risk breaking existing files. Here are the seven most common causes we’ve seen in production bug reports:
- Renamed fields. You rename
playerHPtocurrentHealthand the old save reads zero because the new key doesn’t exist in the JSON. - Changed types. You change
goldfrominttolong, and deserializing the old file crashes on a type mismatch. - Removed enum values. You delete an enum case that existed in old saves, and the loader throws because the enum value is invalid.
- New required fields. You add a new field with no default value, and loading old saves leaves it at null, causing NullReferenceException downstream.
- Changed array shapes. You convert an array of integers to an array of structs, breaking deserialization.
- Moved nested objects. You move
inventory.itemsup toitems, and the old path is missed by the loader. - Changed serialization library. You switch from BinaryFormatter to Newtonsoft, and the file format is now incompatible.
Version Your Save Files From Day One
The foundation of safe save migration is a schema version number at the top of every save file. This number tells the loader which migration path to run. Add it before you ship 1.0 so you never have to retrofit versioning to saves that already exist in the wild.
[Serializable]
public class SaveFileV3
{
public int SchemaVersion = 3; // ALWAYS increment when schema changes
public string PlayerName;
public int CurrentHealth; // renamed from PlayerHP in v2
public long Gold; // was int in v1, long in v2
public List<InventoryItem> Items;
public Dictionary<string, bool> Achievements;
public DateTime LastPlayed;
}
public class SaveFileLoader
{
public SaveFileV3 Load(string path)
{
string json = File.ReadAllText(path);
// Read just the version field first
var stub = JsonUtility.FromJson<VersionStub>(json);
switch (stub.SchemaVersion)
{
case 1: return MigrateV1(JsonUtility.FromJson<SaveFileV1>(json));
case 2: return MigrateV2(JsonUtility.FromJson<SaveFileV2>(json));
case 3: return JsonUtility.FromJson<SaveFileV3>(json);
default: throw new SaveFormatException(
$"Unknown save version: {stub.SchemaVersion}");
}
}
private SaveFileV3 MigrateV1(SaveFileV1 old) => MigrateV2(new SaveFileV2 {
SchemaVersion = 2,
PlayerName = old.PlayerName,
CurrentHealth = old.PlayerHP, // renamed
Gold = (long)old.Gold, // widened
Items = old.Items ?? new List<InventoryItem>(),
Achievements = new Dictionary<string, bool>(),
LastPlayed = DateTime.UnixEpoch
});
private SaveFileV3 MigrateV2(SaveFileV2 old) => new SaveFileV3 {
SchemaVersion = 3,
PlayerName = old.PlayerName,
CurrentHealth = old.CurrentHealth,
Gold = old.Gold,
Items = old.Items,
Achievements = old.Achievements,
LastPlayed = old.LastPlayed == default ? DateTime.UtcNow : old.LastPlayed
};
}
Notice that MigrateV1 calls MigrateV2. This chaining means you only ever have to write a single-step migration per version, and the loader can upgrade arbitrarily old saves to the current version in one call. Never skip versions, and never delete old migration functions even if you think nobody uses them — someone always does.
Build a Save File Corpus
The most valuable asset for catching save bugs is a collection of real save files from every shipped version. When you release 1.0, save a snapshot of the save file format. When you release 1.1, save another. When players send you their saves in bug reports, add them to the corpus (with their permission).
Before every release, run an automated test that loads every file in the corpus with the new build. The test fails if any file throws an exception, returns null, or produces a state that fails basic validity checks (player name empty, health negative, inventory count off by orders of magnitude).
// Integration test: load every save in the corpus
[Test]
public void AllCorpusSavesLoadCleanly()
{
var corpusDir = Path.Combine(Application.dataPath, "Tests/SaveCorpus");
var files = Directory.GetFiles(corpusDir, "*.sav", SearchOption.AllDirectories);
Assert.Greater(files.Length, 0, "Corpus is empty - add at least one save");
var loader = new SaveFileLoader();
foreach (var file in files)
{
TestContext.CurrentContext.Out.WriteLine($"Loading {Path.GetFileName(file)}");
var save = loader.Load(file);
Assert.NotNull(save, $"Loading {file} returned null");
Assert.IsNotEmpty(save.PlayerName, $"{file}: empty player name");
Assert.GreaterOrEqual(save.CurrentHealth, 0, $"{file}: negative HP");
Assert.GreaterOrEqual(save.Gold, 0, $"{file}: negative gold");
Assert.NotNull(save.Items, $"{file}: null items list");
}
}
Handle Corrupted Saves Gracefully
Not every save bug is a migration bug. Sometimes a save file is genuinely corrupted — the player killed their console mid-write, or the file system lost a block. Your loader should distinguish between “unknown schema version” (your fault, fix it) and “cannot parse at all” (file is corrupt, offer recovery).
Keep a backup save. When a player triggers a save, write to a .sav.tmp file first, verify the write succeeded, then atomically rename over the previous save while also keeping a .sav.bak from the previous successful save. On load, if the main file fails, fall back to the backup. This single pattern prevents probably 80% of save corruption bug reports.
Debugging Save Bugs From Player Reports
When a bug report arrives saying “my save is gone” or “my character reset,” the first thing you need is the actual save file. Add a “attach my save file” checkbox to your bug report dialog that players can opt into. With the file in hand, you can reproduce the issue in your local environment.
Load the save in a debug build with breakpoints in your migration functions. Step through each field as it’s read. You’ll typically see one of two patterns: the file loads cleanly but the resulting state is wrong (your migration logic has a bug), or the loader throws mid-way (your schema assumption is wrong for this version). Both are fixable; both are preventable next time.
Related Issues
For broader save corruption debugging, see How to Debug Game Save Corruption Bugs. For tracking save issues across versions, check How to Track Save File Bugs Across Game Versions. And to avoid save corruption in the first place, read How to Prevent Save File Corruption Bugs in Your Game.
A save file is a contract with the player. Breaking it is breaking trust.