1. Why a format version is needed
Imagine you saved users.json, were happy about life, and went to have some tea. A week passes — you added the email field, renamed name to fullName, and also decided that active is now required. Suddenly the old file becomes an “archaeological artifact”: the program reads it, frowns, and crashes. Format versioning is a way to honestly admit that data outlives our design decisions and sometimes even outlives our hopes for “perfect structure on the first try.”
The key idea: version is not a “decoration”, but part of the contract. If the program only knows how to read format v2, it must be able to recognize that it has v1 and either transform it safely or fail with a clear error.
Format version vs application version
When people say “version”, beginners often imagine the app release number: 1.0, 1.1, 2.0. But what matters here is different: the data format version. The format is how JSON looks and what each field means. The application may be updated ten times, but if it still reads and writes the same format, the format version does not change.
To keep things simple, use this mental model: program evolves often, format — rarely but more painfully. We introduce a format version precisely to survive those painful structural changes.
Where to store the format version
Below is a small table where people usually store the version:
| Where to store the version | How it looks | Pros | Cons |
|---|---|---|---|
| At the document root | |
Simple, explicit, easy to migrate | Requires agreement that “root is an object” |
| Inside each object | |
Sometimes convenient for streaming data | Usually redundant, duplication |
| In the filename | |
Quick to see “at a glance” | Rename the file — and everything “breaks” |
| “Heuristics by fields” | “if full_name exists, it means v1” | Seems convenient | Unreliable — turns into coffee-ground divination |
Almost always the winner is “version at the root of the JSON object.”
2. Backward compatibility and safe changes
When you change the format, you do not just change JSON — you change meaning, and meaning tends to be vindictive. Backward compatibility means the new program can read old data. It does not necessarily mean the old program can read new data (that is forward compatibility, and it is often more expensive).
Some changes usually go smoothly. For example, adding a new optional field with a default: old files don't contain it, but the new program can use a default value and continue. Renaming a field is risky: the old file contains full_name, the new program expects name, and without a “bridge” they won't meet.
To help orient yourself, here is another table (not a law of nature, but a useful cheat sheet):
| Format change | Usually compatible? | What to do when reading |
|---|---|---|
| Added a field (optional) | Yes | value("x", default) or contains() + default |
| Added a field (required) | No | Either migration adds the field, or reading fails with a clear error |
| Renamed a field | No | Migration or fallback: read the old name, write the new one |
| Changed field type ("42" → 42) | Usually no | Explicit migration with checks is needed |
| Removed a field | Sometimes | Either ignore the old field, or migration removes it |
| Changed the meaning of a field (most dangerous) | Almost never | Always bump the version; migration must change the data, not just keys |
A particularly tricky case — “same type but different meaning.” For example, price used to be in dollars, and now it's in cents. The JSON looks almost identical, and the user wonders why “coffee costs 3000$”. Such changes almost always require a version bump.
3. Migration before from_json: the common pipeline
If you try to shove support for all versions directly into from_json, it will quickly turn into a monstrous function that does everything and nobody understands. It's much calmer to keep a clear pipeline: read JSON → determine version → if needed transform JSON to the current shape → validate → only then call j.get<Model>().
This is like renovating an apartment: first you level the walls (migration), then check that the walls actually exist (validation), and only then bring in the furniture (building the struct). If you bring the furniture early, you'll be carrying a wardrobe in circles.
Here is a typical processing flow:
flowchart TD
A[Read text/file] --> B[json::parse / in >> j]
B --> C["Determine version: j.value('version', 1)"]
C --> D[Migrate to latest: v1->v2->...]
D --> E[Validate latest-format structure]
E --> F["j.get<Model>() / from_json"]
F --> G[Program logic]
G --> H["Save in latest (with version)"]
The main benefit: after migration the rest of the code lives in a single version of reality. This saves nerves and reduces the number of ifs across the project.
4. Practical example: formats v1 and v2
To avoid speaking in the abstract, let's continue our small demo project: a users.json file with an array of users. We'll evolve the format as if the project lives and changes. Suppose in the first version (v1) each user had the full_name field, and the file did not store a version at all (the classic “we'll add it later”).
Example data for v1 (note: no version, name is stored as full_name):
#include <iostream>
#include <string>
int main() {
const std::string v1 = R"({"users":[{"id":1,"full_name":"Ann"}]})";
std::cout << v1 << '\n'; // {"users":[{"id":1,"full_name":"Ann"}]}
}
In the second version (v2) we make three changes. We add version: 2 at the root, rename full_name to name, and introduce the active field (defaulting to true). Old files know nothing about active, so the migration must add it.
Here is v2:
#include <iostream>
#include <string>
int main() {
const std::string v2 = R"({"version":2,"users":[{"id":1,"name":"Ann","active":true}]})";
std::cout << v2 << '\n'; // {"version":2,"users":[{"id":1,"name":"Ann","active":true}]}
}
Now the task appears: the new program must be able to read both v1 and v2, but internally work as if only v2 exists.
5. Stepwise migration: v1 → v2
Important point: migration does not have to be “a huge function of 300 lines”. On the contrary, it's better to make migrations small and sequential: migrate_v1_to_v2, migrate_v2_to_v3, and so on. Then when you add v3 next month, you won't rewrite everything — you just add another step.
Let's start with a function that reads the version. If the field is missing, we assume v1:
#include <nlohmann/json.hpp>
int read_version(const nlohmann::json& j) {
if (!j.is_object()) return 0; // 0 = "not the right shape at all"
return j.value("version", 1); // if no version, assume v1
}
Now the migration v1→v2. We will expect that the root contains users, and that each user may have full_name. We'll create name, add active, set version = 2, and erase full_name so we don't carry relics forward.
#include <nlohmann/json.hpp>
void migrate_v1_to_v2(nlohmann::json& j) {
j["version"] = 2;
for (auto& u : j.at("users")) {
if (u.contains("full_name") && !u.contains("name")) {
u["name"] = u["full_name"];
u.erase("full_name");
}
if (!u.contains("active")) {
u["active"] = true;
}
}
}
Note two things. First, we use at("users"): the migration assumes the structure is at least roughly expected. If users is missing, this is not “version 1” but “broken data”, and it's better to fail validation. Second, we mutate the JSON in place because that is convenient for sequential steps.
Now assemble migrate_to_latest. Let latest = 2:
#include <nlohmann/json.hpp>
#include <stdexcept>
constexpr int kLatestVersion = 2;
nlohmann::json migrate_to_latest(nlohmann::json j) {
const int v = read_version(j);
if (v == 1) migrate_v1_to_v2(j);
if (read_version(j) != kLatestVersion) throw std::runtime_error("Unsupported version");
return j;
}
Yes, it looks a bit brute-force here. But for a demo project it's fine: it's clear what we do. In real code you would add more diagnostic handling for v > kLatestVersion, and maybe use while (v < kLatestVersion).
6. Reading and writing: read any, write latest
When a developer first implements migrations, they are often tempted: “Oh, I can read v1 and v2 — so I can write v1 if the input was v1.” This is almost always a bad idea: you'll end up with two live formats to support. A much calmer rule is: always write only the current format.
This is like updating documents in an office: you can read old forms, but printing should use the current template, otherwise you'll never migrate to the new standard.
Mini read-from-file function (without streaming nuances):
#include <nlohmann/json.hpp>
#include <fstream>
#include <stdexcept>
nlohmann::json load_json_file(const std::string& path) {
std::ifstream in(path);
if (!in) throw std::runtime_error("Cannot open file");
nlohmann::json j;
in >> j;
return j;
}
And saving (pretty printing via dump(2)):
#include <nlohmann/json.hpp>
#include <fstream>
#include <stdexcept>
void save_json_file(const std::string& path, const nlohmann::json& j) {
std::ofstream out(path);
if (!out) throw std::runtime_error("Cannot write file");
out << j.dump(2) << '\n';
}
Now a glue piece in main: read → migrate → (could validate and call get<UserDb>()) → save in latest:
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
try {
nlohmann::json j = load_json_file("users.json");
j = migrate_to_latest(std::move(j));
save_json_file("users.json", j);
std::cout << "OK\n"; // OK
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << '\n';
return 1;
}
}
This gives a practical effect: if the user provided an old v1 file, the program “heals” it to v2 and saves it back in the new shape. From that moment the data lives in the current format.
7. Update strategy and version support policy
When the project is small, it feels like “we'll just add a field and that's it”. As the project grows, you realize JSON files, configs, and saves are a small time machine — they carry the past into the present. So an update strategy is an agreement with yourself (and your team) about how you change the format so changes are predictable.
The most important rule sounds boring but saves lives: bump the version only when the way data is read/interpreted changes. If you only changed field order or added an ignorable optional field — you might not need to bump the version. But if you renamed a key, made a field required, or changed a type — bump the version.
Think in terms of a “ladder of migrations.” Each migration is one step, not a leap of five steps at once. Then reading looks like: v1 → v2 → v3 → latest. You can drop support for “too old” versions if you agree on a policy (for example: “support the last 3 format versions”).
To visualize, imagine this chain of states:
stateDiagram-v2
[*] --> V1
V1 --> V2: migrate_v1_to_v2
V2 --> V3: migrate_v2_to_v3
V3 --> V4: migrate_v3_to_v4
V4 --> [*]
One more subtle but practical point: decide how to handle “future versions.” If a file contains version: 999, this is not a “parsing error” or a “structure error” — it's a case of “data is newer than the program.” In that situation it's usually fairest to show a clear message like “Your file was created by a newer version of the program — please update the application” rather than trying to guess.
8. Common mistakes in versioning and migrations
Mistake #1: the version exists but nobody checks it.
Sometimes a developer adds "version": 2, but while reading still does j.at("name") and wonders about crashes on old files. The version becomes a sticker that does not make the machine faster. If the version exists, reading must react to it: migrate or refuse.
Mistake #2: mixing migration and validation in one pot.
People start checking validity (age >= 0, name is not empty) while simultaneously renaming keys. It becomes unclear where you “fix the structure” and where you “check correctness.” It's calmer to have migration produce the current shape and validation verify that shape against the contract.
Mistake #3: non-idempotent migration that corrupts data on repeated runs.
For example, a migration appends a suffix to a name every time or overwrites a field without checks. Re-running the program then progressively ruins the file. A good migration is usually designed so that reapplying it either does nothing or at least does not make things worse: use contains() and careful conditions.
Mistake #4: trying to determine version “by presence of fields”.
Today you decide “if full_name exists, it's v1”. Tomorrow you add full_name as a middle name in v3 — the heuristic breaks. Heuristics usually become “guess the melody by two notes” logic, so prefer an explicit version field.
Mistake #5: writing old versions just because that was the input.
That buries you under supporting multiple formats forever. Much simpler rule: read many, write one (latest). The system will gradually “heal” data each time it's saved.
GO TO FULL VERSION