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:

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.