Your game saved only a high score. In an update you added a play counter. You build, you ship — and then: what happens to the saves of everyone who played the previous version?
This is a question everyone reaches once they start shipping updates. Even solo: demo to full release, an Early Access patch. This article covers how UE actually treats old saves, what genuinely breaks them, and how carrying a single version number makes all of it manageable.
What You'll Learn
- Adding a variable fills it with the class default
- The real dangers are retyping and renaming
- Put
SaveVersionin from day one- Migrate right after loading, then forget versions exist
- 🚨 Save it back after migrating (or it runs every launch)
- Hands-on: add a variable without breaking old saves
What Actually Happens
The save and load mechanism is simple. Make a Save Game class, give it variables, write it with Save Game to Slot, read it back with Load Game from Slot.
The problem is when the class that wrote the data differs from the class reading it.

One added variable in an update puts you here.
- On disk: only
BestScore(written by v1) - In the current class:
BestScoreandPlayCount(v2)
PlayCount isn't written anywhere on disk. Shipping without knowing what happens next is how accidents start.
UE's Default Behavior
The short answer: UE doesn't crash here.
Variables absent from an old save are filled with the defaults declared on the class.

If PlayCount defaults to 0, reading a v1 save gives you PlayCount = 0. No crash, no error.
That's genuinely helpful. Adding a variable works with no effort on your part.
But there's a catch: you can't tell afterward whether a value was filled from the default or genuinely saved that way.
| Situation | PlayCount | Meaning |
|---|---|---|
| Read a v1 save | 0 | No data, so the default |
| Never played on v2 | 0 | Genuinely zero plays |
Same value, two meanings. If "show the tutorial to first-time players" keys off this, every existing player gets the tutorial.
Which is why you need to carry "which generation is this data?" yourself. That's SaveVersion.
The Three Dangerous Operations
Adding is safe. Other operations aren't.

| Operation | What happens | Risk |
|---|---|---|
| Add a variable | Filled with the default | 🟢 Safe |
| Remove a variable | The value on disk is simply ignored | 🟡 Value lost, no crash |
| Change the type | Unreadable. Becomes the default | 🔴 Dangerous |
| Rename | Same as remove + add | 🔴 Dangerous |
Renaming is the one people miss. The urge to clean Score up into BestScore always arrives — but structurally that's creating a different variable. The old Score value has nowhere to go, and the new BestScore fills with its default. Your players' high scores reset to zero.
Retyping is the same. Turning an Integer Score into a Float means the old value doesn't carry over.
Refactor with a migration attached. Renaming and retyping aren't forbidden. You just have to move the old value into the new variable as part of the change.
SaveVersion, next section, is what makes that possible.
Put SaveVersion In From Day One
The fix is almost anticlimactic: add one integer to the SaveGame class.
| Variable | Type | Default |
|---|---|---|
SaveVersion | Integer | 1 |
That's it. Then write the current version number every time you save.

Why it works: on load, you know which generation the data is.
SaveVersionis1→ old data.PlayCountcame from the defaultSaveVersionis2→ current generation. Use as-is
The "was it a default or a real zero?" problem from the previous section disappears.
And it matters that it's there from the start. You can add it later, but saves already shipped won't contain it (they read back as the default 1). You end up hard-coding "no version number means v1." It works, but it's one more thing to reason about.
One variable, added the moment you create the save class. That's the highest-leverage move in this whole article.
The Shape of a Migration
With SaveVersion in place, migrations follow a fixed shape: right after loading, before using it, bring it up to current.

The flow:
- Read with
Load Game from Slot - Look at
SaveVersion - If it's old, fill in what's missing or move values across
- Update
SaveVersionto the current number - 🚨 Save the migrated result back to the same slot
- From here on, treat it as current, always
Skip step 5 and the migration never finishes. Everything up to that point only rewrote the object in memory — the bytes on disk are still v1. Until you save it back, the same migration runs again on the next launch.

You might think "it just runs again, no harm done," but that's not quite true. Migration steps are written against the generation they came from, so the more generations accumulate, the more of them run every single launch. If a step recomputes a value, that computation is redone every time. Migrate, then write it back on the spot.
The key property is that code after the migration never has to think about versions. Sprinkling "if v1 do this, if v2 do that" through your gameplay code is unmaintainable. Normalize once at the door and forget about it.
Once you're three or more generations deep, step up one at a time.
Migrate
if SaveVersion < 2:
// v1 → v2
PlayCount = 0
SaveVersion = 2
if SaveVersion < 3:
// v2 → v3 (operates on the result of the previous step)
BestTimeSeconds = OldBestTimeMilliseconds / 1000.0
SaveVersion = 3
Running the ifs sequentially rather than as alternatives is the trick. v1 data passes through both; v2 data passes through only the second. However many generations behind it is, it arrives at current.
Hands-On: Add a Variable Without Breaking Old Saves
Demo to full release for an indie title, adding achievements in a roguelike patch, adding a best time to a puzzle game. "Saving more things after you've shipped" always arrives. We'll create the breakage first, then fix it.
The subject
We'll use the BP_CoinSave from Building Your First Game. Build the equivalent if you don't have it.
| Item | Details |
|---|---|
BP_CoinSave | Parent class Save Game. Variable BestScore (Integer, default 0) |
| Saving | Save Game to Slot (Slot Name = CoinHighScore) |
| Display | On startup, Load Game from Slot and Print String the high score |

Steps
① Create a v1 save
Play as-is and actually save a high score. End with something like 50 in BestScore. This stands in for "a save already out in the wild."
② Add variables (and watch it break)
Add two variables to BP_CoinSave.
| Variable | Type | Default |
|---|---|---|
SaveVersion | Integer | 1 |
PlayCount | Integer | 0 |
Add Print String for SaveVersion and PlayCount after loading, and Play again.
BestScore still reads 50. SaveVersion is 1, PlayCount is 0. Nothing crashed — that's UE being helpful.
But that 0 in PlayCount is "a zero from the default", not "played zero times."
③ Write the migration
In BP_CoinGameState (or wherever you load), insert a step right after Load Game from Slot.
- Receive it with
Cast To BP_CoinSave BranchonSaveVersion < 2- On True (v1 data) → set
PlayCountto1(read as "this person has played at least once") → setSaveVersionto2→ save it back to the same slot withSave Game to Slot - Both branches feed the same variable (
SaveObject) and continue
Don't skip that final "save it back" in step 3. Without it, the disk still holds v1, so the migration runs again on the next launch.
④ Update the save side too
In Save High Score, set SaveVersion to 2 before writing as well. Not just during migration — every normal save should write the current number.

BP_CoinGameState (Event Graph)
Load High Score
→ Does Save Game Exist (Slot = "CoinHighScore")
→ (exists) Load Game from Slot → Cast To BP_CoinSave
// ── Migration: normalize before anything uses it ──
→ Branch (Condition: SaveVersion < 2)
True → Set PlayCount = 1 // v1 had no PlayCount
Set SaveVersion = 2
Save Game to Slot(SaveObject, "CoinHighScore") // ← write it back to disk
False → (nothing)
→ Set SaveObject (both branches merge here)
// ── Nothing below this cares about versions ──
→ Print String("Best: " + BestScore + " / Plays: " + PlayCount)
Checking it
Play and you get Best: 50 / Plays: 1. The high score carried over, and PlayCount is the 1 the migration wrote, not the default 0.
Now launch it once more. SaveVersion is 2 on disk, so the migration doesn't run. PlayCount grows only from your gameplay logic.
Try it again with the post-migration save deliberately removed and the difference shows: the high score and PlayCount both display correctly, yet the migration runs on every single launch. Nothing on disk ever changed.
Troubleshooting:
BestScorereset to 0 → You renamed or retyped the variable. Anything beyond adding needs a migration- The migration runs every time → You aren't saving the migrated result back. Add
Save Game to Slotat the end of the migration (fixing it in memory doesn't touch the disk) Cast To BP_CoinSavefails → The class that saved differs from the class reading. A typo in the slot name is the other classic
Two things to take away.
- Migrate, then save it back immediately: fixing it in memory leaves the disk on the old generation. Add
Save Game to Slotat the end of the migration so it doesn't run again next launch - Pick defaults that mean something: the migration was needed because
PlayCountdefaulted to0. Had it defaulted to-1for "unset," you could distinguish the cases without one. Default choice is design.
When the variables you're saving start to sprawl, grouping them into a struct makes them easier to manage.
Bonus: Good to Know for Later
Don't mix settings into save data. Volume, resolution, key bindings — these have a different lifetime from game progress. UE has GameUserSettings for exactly this, so keep them separate. Mixing them produces the unpleasant behavior of "deleting my save wiped my settings."
Prepare for corrupt data. A damaged disk or a power cut mid-write makes Load Game from Slot return None. Always check with Is Valid and have a path that starts fresh. Does Save Game Exist confirms existence, not readability.
Multiple slots need nothing extra. With three save slots, all three may be different generations. Since migration runs once per load, it applies per slot naturally.
Version numbers only go up. Never reuse a number. Migration steps accumulating is normal. As long as someone might still load three-generation-old data, that branch has to stay. You only get to delete them when you consciously decide to drop those players.
Summary
- Adding is safe. UE fills missing variables with class defaults
- The dangers are retyping and renaming, both of which act as remove + add
- Put
SaveVersion(Integer) in from day one. Everything else builds on it - Migrate once, right after loading, running
if version < Nsteps in sequence - 🚨 Save the migrated result back to the same slot. Otherwise it runs forever
- Update
SaveVersionon the save side too
It's one variable and one Branch. Put it in early and updates stop being a source of dread. What's the next thing your save data will need to hold?