1. Contract: JSON as a tree of values
When you first see #include <nlohmann/json.hpp>, it might feel like “magic”. In reality it’s just a library that can turn JSON text into a convenient json object and back. For this course we assume it is already available in the project, so we won't discuss installation.
Our goal is to learn the basic operations parse() and dump(), and to distinguish “broken JSON” from “JSON that is valid but not the right shape”.
Let’s start with a minimal “skeleton” to agree on the style:
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
using nlohmann::json;
json j = json::object();
std::cout << j.dump() << '\n'; // {}
}
Note two things.
First, json is the type for “any JSON node”: it can be an object, an array, a string, a number, true/false, or null.
Second, dump() is a way to get a string that you can print or write to a file.
2. json::parse(): turning a string into a JSON tree
Usually JSON comes to us as text: from a file, from the network, from settings, from a test, from a user (which is always slightly suspicious). So the first step is almost always the same: take a std::string, call json::parse(), and get a tree of values.
It's important to understand: parse() does not “guess meaning”, it strictly reads syntax. An extra comma or wrong quotes — and you'll get a parsing error.
The most convenient way to keep JSON text inside code is a raw string R"( ... )", so you don't have to escape quotes.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
const std::string text = R"({"id": 7, "name": "Alice", "active": true})";
json j = json::parse(text);
std::cout << j.dump() << '\n'; // {"active":true,"id":7,"name":"Alice"}
}
Yes, the output format is “compact” — that's normal: dump() produces a compact string by default. We'll cover pretty printing later in this lecture.
Another important point: a JSON number is just a “number”. In C++ we have int, long long, double… So 42 and "42" are fundamentally different data.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
json a = json::parse(R"({"age": 42})");
json b = json::parse(R"({"age": "42"})");
std::cout << a.at("age").dump() << '\n'; // 42
std::cout << b.at("age").dump() << '\n'; // "42"
}
Here we currently use at("age") just to show the difference. We'll talk about “how to read fields correctly” later, but without fanaticism: today the focus is on parse() and dump().
3. dump(): turning JSON into a string and printing nicely
After parsing JSON into json j, we often need the reverse operation: save, print, log, or send it somewhere. That's what dump() is for.
It has a handy feature that saves nerves: dump(2) (or dump(4)) prints JSON with indentation. This doesn't change meaning, it only makes the text human-readable.
Let's compare compact and “pretty” output:
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
using nlohmann::json;
json j = {
{"id", 7},
{"name", "Alice"},
{"active", true}
};
std::cout << j.dump() << '\n'; // {"active":true,"id":7,"name":"Alice"}
std::cout << j.dump(2) << '\n'; // pretty-printed JSON with indentation
}
If you want to actually see what dump(2) looks like, it will be roughly:
// {
// "active": true,
// "id": 7,
// "name": "Alice"
// }
And here is a practical thought: when you save data to a file “for humans” (config, settings, sample data), dump(2) almost always wins over compact output. For machine storage compact may be enough, but readability usually pays off.
4. Mini-practice: building an object and an array by hand
Before going into files, it's useful to “feel” JSON as an in-memory data structure. When you construct json programmatically, you effectively build a tree: an object with keys, containing arrays and other objects.
This resembles std::map<std::string, ...> and std::vector<...>, except the value type is a single type — json.
Let's assemble a document representing a “task list”, but for now without serializing a struct (that will be in the next lecture). This will just be JSON that we can print.
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
using nlohmann::json;
json doc;
doc["version"] = 1;
doc["tasks"] = json::array();
doc["tasks"].push_back({{"id", 1}, {"title", "Buy milk"}, {"done", false}});
std::cout << doc.dump(2) << '\n';
}
Here we used json::array() and push_back for the first time. It looks almost like working with std::vector, and that's intentional: the library aims to be friendly.
Note the potentially “dangerous convenience”: doc["version"] and doc["tasks"]. The operator[] for an object is convenient for writing. But for reading it can be treacherous (it may create a field if it wasn't present).
So in this lecture we follow a simple rule: writing — OK with []; reading — prefer contains() + at().
5. Errors: parsing vs wrong structure
When something breaks while working with JSON, it is crucial to understand at which stage.
A parsing error means “the text is not a JSON document”. An access/type error means “the JSON parsed, but the structure doesn't match the program's expectations”. These two cases are handled differently: the first you fix the input text, the second — the format contract or validation.
Catching a parse error: json::parse_error
Let's break JSON on purpose: add an extra comma. This is a classic — “the comma that came back to haunt you”.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
const std::string bad = R"({"x": 1, })"; // extra comma
try {
json j = json::parse(bad);
std::cout << j.dump() << '\n';
} catch (const json::parse_error& e) {
std::cerr << "Parse error: " << e.what() << '\n';
}
}
It's important that we catch specifically json::parse_error. This helps large programs separate “text format problem” from “data structure problem”.
If you catch just std::exception, it will still work, but diagnostics will be less precise.
Structure errors: missing key or wrong type
Now imagine the JSON parsed, but a field is missing or has the wrong type. For example, you expect a number but find a string. This is not parse_error.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
json j = json::parse(R"({"age": "42"})"); // age is a string
try {
int age = j.at("age").get<int>(); // expect int
std::cout << age << '\n';
} catch (const json::type_error& e) {
std::cerr << "Type error: " << e.what() << '\n';
}
}
If a key is missing, at("...") will throw an exception about the missing key (often out_of_range inside nlohmann::json). That's an important diagnostic: “no field” is not the same as “field present but wrong type”.
6. Reading JSON from a file: ifstream + in >> j
So far we've read JSON from strings because that is easier to demonstrate the idea. But real life likes storing data in files: config.json, tasks.json, save.json — name them as you like.
Two classes of problems appear: the file may not open, or the JSON inside may be broken. You must distinguish these cases, otherwise you'll debug the parser when the path was the culprit.
The minimal template to read JSON from a file looks like this:
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
int main() {
std::ifstream in("tasks.json");
if (!in) {
std::cerr << "Cannot open tasks.json\n";
return 1;
}
nlohmann::json j;
in >> j; // parse directly from the stream
std::cout << j.dump(2) << '\n';
}
This is very convenient: the operator>> for nlohmann::json parses from a stream. But remember: if the file contains invalid JSON, a parsing exception may be thrown here. So in a more careful version you usually add try/catch.
Here is a slightly more mature version, still concise:
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
int main() {
using nlohmann::json;
std::ifstream in("tasks.json");
if (!in) return 1;
try {
json j;
in >> j;
std::cout << "Loaded OK\n"; // Loaded OK
} catch (const json::parse_error& e) {
std::cerr << "Bad JSON: " << e.what() << '\n';
return 2;
}
}
Now you know exactly: return 1 — couldn't open the file; return 2 — file opened, but contents were garbage.
7. Writing JSON to a file: ofstream + dump(2)
Writing is the second half of the “data ↔ file” cycle. We already have std::ofstream, and we can print JSON as a string via dump().
So saving looks surprisingly mundane: open file, check it, write the string.
Save the document to tasks.json with indentation:
#include <nlohmann/json.hpp>
#include <fstream>
int main() {
using nlohmann::json;
json doc = {{"version", 1}, {"app", "TaskTracker"}};
std::ofstream out("tasks.json");
if (!out) return 1;
out << doc.dump(2) << '\n';
}
Technically you can write without the trailing '\n', but a newline at the end of the file usually makes life nicer when viewing in an editor and when doing diffs in git.
8. A small application piece: loading and saving a document
Now let's take an important step that looks small but is architecturally useful: move read/write JSON into separate functions. This keeps main() thin and avoids spreading try/catch all over the code.
We still won't do full serialization of a struct Task (that will come later), but we'll learn to store the entire document as json.
Document loading function
Create a function load_json_document that either returns the document or — on error — an empty object (a simplification for the tutorial). Yes, strictly speaking it's better to return optional or expected, but we know those and will use them later. For today we keep the design very simple.
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
#include <string>
nlohmann::json load_json_document(const std::string& path) {
using nlohmann::json;
std::ifstream in(path);
if (!in) return json::object();
try {
json doc;
in >> doc;
return doc;
} catch (const json::parse_error&) {
return json::object();
}
}
This intentionally lacks “smart diagnostics” — we're learning the mechanics of parse()/dump(). But you can already see the convenience: in main() you can simply call load_json_document("tasks.json") and continue working.
Document saving function
Now the symmetric write. Save prettily so someone can open the file and not regret it.
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
bool save_json_document(const std::string& path, const nlohmann::json& doc) {
std::ofstream out(path);
if (!out) return false;
out << doc.dump(2) << '\n';
return true;
}
Now we have a pleasant “interface” feeling: load document and save document are two clear operations that don't clutter the rest of the code.
Mini-usage in main()
Assemble a small scenario: load a document, if empty — create a default, then save it back. This isn't “perfect” logic, but as a demonstration of the file → json → file cycle it's great.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
nlohmann::json load_json_document(const std::string& path);
bool save_json_document(const std::string& path, const nlohmann::json& doc);
int main() {
nlohmann::json doc = load_json_document("tasks.json");
if (doc.empty()) {
doc["version"] = 1;
doc["tasks"] = nlohmann::json::array();
}
std::cout << doc.dump(2) << '\n';
save_json_document("tasks.json", doc);
}
Note: we do not discuss here the “correctness” of the version/tasks fields. Today we just learned how to technically read and write JSON.
9. Common mistakes when working with parse() and dump()
Mistake #1: trying to read JSON without handling parsing errors.
Beginners often write json j = json::parse(text); and assume it will always work. But JSON can be broken: extra comma, wrong quotes, truncated file. If you don't catch json::parse_error, your program becomes “works sometimes”. Minimum protection is to put a try/catch around parsing.
Mistake #2: not distinguishing “file didn't open” from “JSON inside is broken”.
If you do std::ifstream in(path); in >> j; without if (!in), you'll start suspecting everything: permissions, parser, cosmic rays. The proper order is simple: check the stream first, then parse, and catch parse_error separately.
Mistake #3: using operator[] for reading and accidentally “creating” fields.
j["x"] looks convenient, but in some scenarios it mutates the object and adds a key that wasn't there. This is especially annoying in “read → save” flows because you might write ghost fields to the file. For reading it's safer to think in terms of contains() + at().
Mistake #4: assuming dump() is validation.
If you parsed JSON and then did dump(), that doesn't mean the data is “semantically correct”. It only means you have some JSON tree in memory. A field might be a string instead of a number, a key might be missing, an array might be empty. Semantic correctness is a separate topic and will be covered later.
Mistake #5: saving JSON “as is” and then suffering when reading the file by eye.
Technically dump() without indentation is fine, but it's painful for humans. When you open tasks.json and see one line with 5k characters, you feel like closing the file and going to a monastery. dump(2) costs almost nothing in complexity but drastically improves readability and maintainability.
GO TO FULL VERSION