[UE5] Versioning Save Data: Shipping Updates Without Breaking Old Saves

Created: 2026-07-25

What happens to old saves when you add a variable to a SaveGame class? UE's default behavior (new variables fill with defaults), the real dangers of removing or retyping, why SaveVersion belongs there from day one, and the shape of a migration step — with a hands-on that adds a variable without breaking old saves.

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.

A soft blue clay figure comparing an old save against a newer layout and filling in the missing field

What You'll Learn

  • Adding a variable fills it with the class default
  • The real dangers are retyping and renaming
  • Put SaveVersion in 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

Sponsored

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.

Reading a v1 save with a v2 class, showing the mismatch between the fields the class expects and the fields the data contains

One added variable in an update puts you here.

  • On disk: only BestScore (written by v1)
  • In the current class: BestScore and PlayCount (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.

Fields missing from an old save are automatically filled from the class's default values

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.

SituationPlayCountMeaning
Read a v1 save0No data, so the default
Never played on v20Genuinely 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.

Sponsored

The Three Dangerous Operations

Adding is safe. Other operations aren't.

Four cards comparing adding (safe), removing (value lost), retyping and renaming (value unreachable)
OperationWhat happensRisk
Add a variableFilled with the default🟢 Safe
Remove a variableThe value on disk is simply ignored🟡 Value lost, no crash
Change the typeUnreadable. Becomes the default🔴 Dangerous
RenameSame 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.

VariableTypeDefault
SaveVersionInteger1

That's it. Then write the current version number every time you save.

SaveVersion sits at the top of the SaveGame class; you write the latest number on save and branch on it when loading

Why it works: on load, you know which generation the data is.

  • SaveVersion is 1 → old data. PlayCount came from the default
  • SaveVersion is 2 → 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.

A migration step sits right after Load Game from Slot, writing back with Save Game to Slot before anything downstream sees the data

The flow:

  1. Read with Load Game from Slot
  2. Look at SaveVersion
  3. If it's old, fill in what's missing or move values across
  4. Update SaveVersion to the current number
  5. 🚨 Save the migrated result back to the same slot
  6. 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.

Migrating in memory leaves the disk unchanged, so the migration runs every launch until you save it back

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.

Sponsored

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.

ItemDetails
BP_CoinSaveParent class Save Game. Variable BestScore (Integer, default 0)
SavingSave Game to Slot (Slot Name = CoinHighScore)
DisplayOn startup, Load Game from Slot and Print String the high score
Creating a v1 save, adding variables so the new field reads 0, then adding SaveVersion and a migration so it initializes correctly

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.

VariableTypeDefault
SaveVersionInteger1
PlayCountInteger0

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.

  1. Receive it with Cast To BP_CoinSave
  2. Branch on SaveVersion < 2
  3. On True (v1 data) → set PlayCount to 1 (read as "this person has played at least once") → set SaveVersion to 2save it back to the same slot with Save Game to Slot
  4. 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.

A two-row completed node graph: the top row "Load and check the generation" runs Load Game from Slot, Cast and Branch on SaveVersion < 2, wrapping into the bottom row "Normalize and write back" with Set PlayCount = 1, Set SaveVersion = 2, Save Game to Slot and Set SaveObject
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:

  • BestScore reset to 0 → You renamed or retyped the variable. Anything beyond adding needs a migration
  • The migration runs every timeYou aren't saving the migrated result back. Add Save Game to Slot at the end of the migration (fixing it in memory doesn't touch the disk)
  • Cast To BP_CoinSave fails → 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 Slot at the end of the migration so it doesn't run again next launch
  • Pick defaults that mean something: the migration was needed because PlayCount defaulted to 0. Had it defaulted to -1 for "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 < N steps in sequence
  • 🚨 Save the migrated result back to the same slot. Otherwise it runs forever
  • Update SaveVersion on 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?