Your Save Format Will Change Every Patch: Player Data That Survives Updates (2026)
You ship a patch, add three fields, rename one, and every existing save breaks. This is the save-migration problem: how to version player data, migrate on read, and never lose a player's progress to your own update. Covers the one-blob vs many-tables question (is 120-200 values per player normal?), optimistic locking for co-op writes, and why the client should never be the thing that decides what a save looks like.
Quick take
- The hard part of saving is not writing the file. It is writing the file again next patch, when the shape of your data has changed and thousands of old saves still have to load.
- Version every save from day one. A save without a schema version number is a save you cannot safely change.
- Migrate on read, never in a batch job. Load the old shape, upgrade it in memory, write the new shape back on the next legitimate save.
- The "one blob or many tables" question matters less than people think at indie scale. 120-200 values per player is small. Correctness under change matters far more than layout.
- Two clients writing the same save (co-op, or a player on two devices) is a conflict problem, not a storage problem. Solve it with versions, not with hope.
Every few days somebody asks a version of this question: "each player will have around 120-200 values to save - upgrades, buildings, resources, stats. Is this normal? Do I store it all in one row, or as JSON, or split across tables?" It is a good question with a slightly disappointing answer: at that size, almost any layout works. 200 numbers is tiny. Developers who have shipped bigger games say so bluntly, and they are right - studios routinely store a player's whole state as a single JSON blob in a document store and it is fine.
The answer is disappointing because layout is not the thing that will hurt you. What will hurt you is the third patch. You added a prestige system, so there are four new fields. You renamed gold to currency_soft because you added a second currency. You changed inventory from a flat array to a dictionary keyed by slot. None of that is exotic - it is just normal game development. And every single existing save now describes a game that no longer exists.
The failure mode, concretely
It usually plays out in one of four ways, in rising order of how bad your week is:
- Load throws. Your deserializer hits a missing field and raises. The player sees a crash or an empty profile. This is the best case, because it is loud.
- Load silently zeroes. The missing field deserializes to a default. The player's 40 hours of upgrades quietly become zero, they post about it, and you cannot tell them what it was because the old value was overwritten on the next autosave.
- Half-migrated state. Some fields upgraded, some did not, and now you have saves in a shape that neither the old nor the new code expects. These are the bugs that take a week to find.
- The mass-wipe patch. You conclude the only safe move is to reset progress, and you eat the reviews. Recoverable once. Twice is a reputation.
Notice that none of these are storage-engine problems. Postgres, SQLite, a JSON file, a document API - all four failures happen identically in every one of them. That is why "should I use one table or many" is the wrong first question.
Rule 1: version the save, from the very first build
Put a schema version in every save payload before you have any players:
{
"schema": 3,
"player": { "currency_soft": 1250, "prestige": 2 },
"inventory": { "slot_1": "sword", "slot_2": "shield" }
}
That single integer is what makes every later change safe. Without it you are guessing at a save's shape by sniffing which fields exist, which works until two different patches happen to produce the same field set. With it, migration becomes a boring, testable function: migrate(save) looks at schema, applies each step in order, and returns the current shape.
Write the migrations as a chain, not as a pile of special cases:
function migrate(save) {
if (save.schema === 1) { // 1 -> 2: currency rename
save.currency_soft = save.gold ?? 0;
delete save.gold;
save.schema = 2;
}
if (save.schema === 2) { // 2 -> 3: inventory array -> slots
const slots = {};
(save.inventory || []).forEach((item, i) => { slots["slot_" + (i + 1)] = item; });
save.inventory = slots;
save.schema = 3;
}
return save;
}
Each step only has to know how to get from one version to the next. A save from your launch build walks the whole chain; a current save skips it entirely. You never write a migration that handles "some old shape, probably".
Rule 2: migrate on read, not in a big batch
The tempting move is a one-off script that rewrites every save in your database the night you ship the patch. Resist it, for three reasons: it is a single irreversible pass over your most precious data, it has to succeed for every weird save you have ever written (including the truncated ones), and it does nothing for the player who has not launched the game since March.
Migrate on read instead. When a save loads, run it through the chain in memory, use it, and write the upgraded shape back on the next normal save. Old saves upgrade themselves as players return, at their pace, through a code path you exercise on every single load. If a migration is wrong, you find out on your own test account rather than across your whole player base at once.
Keep the raw payload untouched until the new one is safely written. That is the difference between "the migration was wrong and we rolled it back" and "the migration was wrong and the old values are gone".
Rule 3: the client is not allowed to define the save shape
If the game client is the only thing that knows what a valid save looks like, then every old build still in the wild is an authority on your data format. A player on last month's version writes a save in last month's shape - or a modified client writes a shape you never designed - and your current code has to cope with all of it forever.
Whatever you store the save in, keep two things server-side: the current schema version and a validation pass that rejects payloads which cannot be true (negative currency, an inventory of 900 slots when the game has 40, a prestige level above the cap). This is the same principle that makes a leaderboard trustworthy - see why a client-authored score always gets cheated. A save is just a score with more fields, and the same devs who edit one will edit the other.
Rule 4: two writers means you need versions, not luck
The moment a save can be written from more than one place, you have a conflict problem. It shows up in three ordinary situations:
- Co-op on a shared world. Two players both progress the same world state and both write it. The last write wins and somebody's hour disappears.
- One player, two devices. They play on the desktop, then on the laptop that still holds an older cached save, and the older one overwrites the newer.
- Client and server both writing. Your game writes progress while a server-side reward job credits an item. Two writes, one document, no ordering.
The fix is optimistic locking, and it is much simpler than it sounds. Every save carries a version number. When you write, you say which version you read: "store this, but only if the current version is still 41". If someone else wrote in the meantime the store rejects your write, and your code re-reads, re-applies its change, and tries again. You cannot silently clobber a write you never saw, because the store will not let you.
What you must not do is merge conflicting saves by field-picking heuristics ("take the higher gold value"), which produces states that no legal play could reach and are indistinguishable from cheating. Either the write wins cleanly or you retry.
So: one blob or many tables?
At indie scale, one document per player is the default that will not bite you, for a specific reason: a save is read and written as a whole. You almost never need "just the inventory" - you load the player, you write the player. A single document matches that access pattern, migrates in one place, and versions as one unit.
Split it when a piece has genuinely different access rules, not because it feels tidier:
- Leaderboard scores are ranked and read across players, so they belong in a ranked structure, not in each player's blob.
- Shared world state in co-op is written by several players, so it wants its own document with its own version, separate from each player's personal progress.
- Very large or rarely read data (replays, screenshots, long event logs) should not ride along in a document you load on every launch.
For the shape of the data itself - what belongs in a player document versus a relational table - the NoSQL-vs-SQL tradeoffs are worth reading. But make the versioning decision first. Layout you can change later; a save with no version is a save you can never safely change at all.
A short checklist
- Every save payload has a
schemainteger, starting today. - Migrations are an ordered chain of small functions, each handling exactly one version step.
- Migration happens on read. No overnight batch rewrite of everyone's data.
- The previous payload survives until the upgraded one is written successfully.
- The server validates ranges and caps. A save is a claim, not a fact.
- Writes are conditional on the version you read. Retry on conflict, never field-merge.
- You have loaded a save from your oldest shipped build, on purpose, in the last month.
FAQ
Is 120-200 values per player a lot?
No. That is a small save by any standard, and it comfortably fits in a single JSON document. Games with far more state than that store a player as one blob. Spend your worry on how the shape changes across patches, not on the field count.
Do I need migrations if I have not launched yet?
You need the version field. You do not need migration code until you have saves you care about - but adding the field costs one line before launch and is nearly impossible to retrofit cleanly afterwards, because you will not know what the un-versioned saves contain.
What about players who never come back for months?
They are exactly why you migrate on read rather than in a batch. Their save walks the full chain the day they return, years later if necessary, as long as you never delete a migration step. Keep the old steps; they are cheap.
Can I just wipe saves on a big update?
You can, once, if the game is early and you say so clearly in advance. It is a real option for an alpha. It is not a strategy, and it stops being acceptable the moment people have hours invested.
Where Crux fits
Crux stores player data as versioned documents: every write returns a version, and you can make a write conditional on the version you read, so co-op and multi-device conflicts fail loudly instead of silently overwriting. The document is plain JSON whose shape you own, which means your migration chain is ordinary code in your game rather than a database schema change and a deploy. Reads and writes are one HTTP call from Unity, Godot, Roblox or JavaScript, and everything you store is exportable at any time, so a save is never trapped in our format. If you want to see the write-read-conflict loop in code, start with the cloud-save kata, or read persistent data and shared state for the co-op case.
The one-line version: your save format is not something you design once. Version it, migrate it on read, and let the store refuse writes that would clobber somebody's progress.