The first time you rename a field in your save data, JsonUtility doesn’t complain. It loads the old file, finds no field with the new name, and quietly leaves it at its default. The player opens your update and their progress is gone, and the reviews tell you about it.
The fix costs about fifty lines, and it has to be in place before the first release, because you can’t go back and add a version number to files that are already on players’ phones.
Put a version in every save
[System.Serializable]
public class SaveData
{
// Files written before versioning have no "version" field, so they load as 1.
public int version = 1;
public int coins;
public int bestLevel;
public string[] unlockedSkins = new string[0];
}One migration step per version
Each step upgrades a save by exactly one version. A player who comes back after a year still walks through every step in order, 1 → 2 → 3, and you never have to reason about skipping versions.
public static class SaveSystem
{
public const int CurrentVersion = 3;
static SaveData Migrate(SaveData data, string json)
{
if (data.version < 2)
{
// v2 renamed "level" to "bestLevel"; the old value only exists in the JSON.
data.bestLevel = JsonUtility.FromJson<V1>(json).level;
}
if (data.version < 3)
{
// v3 added skins; everyone starts with the classic one.
data.unlockedSkins = new[] { "classic" };
}
data.version = CurrentVersion;
return data;
}
[System.Serializable] class V1 { public int level; }
}Write to a temporary file first
If the game is killed halfway through writing — a flat battery, or the OS reclaiming memory while it’s in the background — a half-written file is a corrupted save. Write somewhere else, then swap the finished file into place and keep the previous one as a backup:
public static void Save(SaveData data)
{
string path = Path.Combine(Application.persistentDataPath, "save.json");
string temp = path + ".tmp";
File.WriteAllText(temp, JsonUtility.ToJson(data));
if (File.Exists(path)) File.Replace(temp, path, path + ".bak");
else File.Move(temp, path);
}Load with a fallback
public static SaveData Load()
{
string path = Path.Combine(Application.persistentDataPath, "save.json");
foreach (string file in new[] { path, path + ".bak" })
{
if (!File.Exists(file)) continue;
try
{
string json = File.ReadAllText(file);
SaveData data = JsonUtility.FromJson<SaveData>(json);
if (data != null) return Migrate(data, json);
}
catch (System.Exception e)
{
Debug.LogWarning($"Skipping unreadable save {file}: {e.Message}");
}
}
return new SaveData { version = CurrentVersion };
}If the main file is damaged, the backup from the previous save loads instead: the player loses one session, not everything.
Keep a museum of old saves
Before each release, copy a real save file from every version you’ve shipped into a test folder, and load each one in an Editor test that checks the migrated values. It takes a minute, and it’s the only way to know an update is safe for players who skipped the last three.