1. Json { ... } is part of the data contract
When you first see Json { prettyPrint = true }, it is tempting to think: “Oh, this is just to make it look pretty.” Partly yes, but in practice Json settings are an agreement about the format—almost like “traffic rules” for your file. One team writes JSON, another team reads JSON, and both must interpret the same way which fields may be missing, what to do with null, and how tolerant we are of “sloppy” input.
It is important to catch the idea: Json is not “JSON in general”, but a specific codec with specific rules. If in one part of the project you call Json.encodeToString(...) (with the default instance), and elsewhere you create Json { ignoreUnknownKeys = true } and read with it, then you are already living in two slightly different worlds. And the bugs will be of the “but it worked for me...” variety, because it worked in a different world.
Let’s choose the right habit up front: the application should have one (or a very limited number) of Json instances, created explicitly and used everywhere.
A small “skeleton” to start:
import kotlinx.serialization.json.Json
private val appJson = Json {
prettyPrint = true
}
2. Settings that make a file convenient for humans
prettyPrint: readability and diffs
When you store JSON in a file (settings, application data, results), sooner or later you will open it with your own eyes. Sometimes voluntarily, sometimes because “something broke and the deadline is on fire.” And here the difference between one-line JSON and indented JSON is roughly like the difference between “everything in one paragraph with no spaces” and “normal text.”
The prettyPrint = true option turns on formatting: line breaks and indentation. The meaning of the data does not change; only the appearance changes. This is handy for debugging, manual verification, and even for Git (diffs become easier to read).
First, let’s show the effect on a minimal example.
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class User(val id: Int, val name: String)
fun main() {
val user = User(1, "Ada")
val compact = Json { prettyPrint = false }
val pretty = Json { prettyPrint = true }
println(compact.encodeToString(user)) // {"id":1,"name":"Ada"}
println(pretty.encodeToString(user)) // (will be with line breaks and indentation)
}
If you want to see the result neatly in the console without “staircases” of spaces in the code, it is convenient to print multi-line strings using trimIndent(). It is a good habit for any triple quotes, not just for JSON.
A slightly more “real-world” example: in our educational expense-tracker app (let’s call it BudgetCLI), we store data in the file data/state.json. When prettyPrint is enabled, the file becomes “self-documenting”: even a beginner can see the structure.
encodeDefaults: write defaults explicitly or save space
Kotlin (and especially data class) loves default values: they make constructors convenient and help keep the model valid. But JSON can be written in two ways:
- write all fields, even those equal to the default value;
- write only the “essential” fields, and restore the rest from defaults when reading.
The encodeDefaults option controls this behavior during encoding (writing to JSON).
Why does this matter? Because it affects what you consider the “truth of the format.” If a field is not written, then it is “as if” equal to the default value—but only if the reading side also knows about that field and its default. If you want JSON to be as explicit as possible (for example, for configurations), then it makes sense to write defaults.
Let’s look at a small “settings” example:
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class Settings(
val theme: String = "light",
val pageSize: Int = 20
)
fun main() {
val a = Json { encodeDefaults = false }
val b = Json { encodeDefaults = true }
println(a.encodeToString(Settings())) // {}
println(b.encodeToString(Settings())) // {"theme":"light","pageSize":20}
}
This is the moment where beginners often get surprised: “Why is {} valid settings at all?” Because the Kotlin model can be fully reconstructed from defaults. And it is even convenient: a minimal file, less noise.
But there is also a downside. If you treat the file as a “contract” that must be self-sufficient, then {} looks like a joke to the person who opened the file by hand and wanted to “see the settings.” So the choice of encodeDefaults is not about “right/wrong”, but about UX and contract stability.
For our BudgetCLI it is usually reasonable to follow this logic: we write data (expenses) fully, but some UI/sorting settings can be written more compactly. However, to avoid creating two different worlds, teams often choose one style for the project and stick with it.
explicitNulls: “field = null” and “field is missing” are different states
Nullable fields are normal. Not everyone loves null, but it stubbornly returns to our lives—like a cat that “will just take a look” and accidentally eats your fish.
In JSON there are two different states:
- the field is present and equals null: "note": null
- the field does not exist at all: (the key is absent)
To a human this looks similar (“well, there is basically nothing here”), but for a program these can be fundamentally different situations. For example, “the user explicitly cleared the note” vs “in the old file there was no note at all.”
The explicitNulls option controls whether null will be written to JSON explicitly or omitted.
Let’s make an expense model with a nullable note:
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class Expense(
val title: String,
val amount: Double,
val note: String? = null
)
fun main() {
val x = Expense(title = "Coffee", amount = 3.5, note = null)
val a = Json { explicitNulls = true }
val b = Json { explicitNulls = false }
println(a.encodeToString(x)) // {"title":"Coffee","amount":3.5,"note":null}
println(b.encodeToString(x)) // {"title":"Coffee","amount":3.5}
}
This is where “grown-up” format architecture starts: if you choose explicitNulls = false, there is less clutter in the file and it looks nicer. But you lose the ability to distinguish “the value is missing because it was not in the file” from “the value is missing because we wrote it as null (or decided to omit it).” In our simple console app this is usually not critical, but it is important to understand the difference itself—otherwise you can end up arguing with a teammate for a long time about “why my field got reset.”
3. Read settings: compatibility and tolerance
ignoreUnknownKeys: compatibility when reading old and new files
Imagine a real-life situation: you saved data as JSON, then updated the program, changed the model (for example, added a new field), and the user still has an old file. Or the other way around: the file came from somewhere else and contains more fields than your current model. At this point serialization turns into a very strict inspector: “Extra field? Not dressed according to the rules? Goodbye.”
The ignoreUnknownKeys = true option tells the parser: “If the JSON has fields that are not in the model, just skip them.” This greatly improves compatibility. But like any “leniency”, it can hide a problem: if you expected a field but misspelled its name, with ignoreUnknownKeys enabled you will not see an error—the field will simply be ignored.
Mini demo:
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@Serializable
data class User(val id: Int, val name: String)
fun main() {
val input = """{"id":1,"name":"Ada","age":20}"""
val strict = Json { ignoreUnknownKeys = false }
val tolerant = Json { ignoreUnknownKeys = true }
// strict.decodeFromString<User>(input) // will crash: field "age" is unknown
val user = tolerant.decodeFromString<User>(input)
println(user) // User(id=1, name=Ada)
}
Now let’s connect this to our BudgetCLI. Suppose we added the note field to expenses, but someone’s file also contains a currency field (for example, it was added by another team member or by an older version). If we enable ignoreUnknownKeys, reading does not break: we load what we understand and ignore the rest.
In practice, this is a very common “first switch” to turn on in projects with files and long-term data storage. But you should enable it consciously: if the format is fully under your control and you want to catch any mismatches strictly, strict mode also has a right to exist.
isLenient: a tolerant parser for import, but not for “your own” format
Sometimes data does not come from your file, but “from somewhere”: a user copied JSON from the internet, someone edited the file by hand, or another system generates almost-JSON (yes, such systems exist; apparently they just get bored living by the standard). At that point, a strict JSON parser becomes your enemy: it fails on any deviation from the format.
The isLenient = true option makes the parser more tolerant of certain non-strict cases. The point is to read something that looks like JSON even if it is not perfect by the spec. But the cost is a blurred contract: you start “accepting whatever”, and later you wonder why a strange format suddenly appears in your file.
Within this course, a good practical rule is: for your own files (which you write yourself) isLenient is usually not needed. For importing external data, it can sometimes be useful, but you should only enable it where you are truly ready to accept “dirty” input.
The code stub looks like this:
import kotlinx.serialization.json.Json
private val importJson = Json {
isLenient = true
ignoreUnknownKeys = true
}
Notice the combination: “tolerance to format” plus “tolerance to extra keys” is a typical pairing for import. But this is import specifically, not the primary way you write your state file.
4. A single Json for the application and file storage
Now we will take the most practical step: stop creating Json { ... } “on the fly” in every main, and create one codec instance for the whole application. It is almost like having a single quote style in a project: it seems like a small thing, but later it suddenly turns out to save hours of your life.
Let’s assume our BudgetCLI stores state as a list of expenses.
Minimal model:
import kotlinx.serialization.Serializable
@Serializable
data class Expense(
val title: String,
val amount: Double,
val category: String = "other",
val note: String? = null
)
@Serializable
data class BudgetState(
val expenses: List<Expense> = emptyList()
)
Now, a single codec. I will choose a set of settings that usually works well for “a file on disk that you can open with your eyes”:
- prettyPrint = true — readability
- ignoreUnknownKeys = true — compatibility when reading old/new files
- encodeDefaults = true — the file is self-sufficient, fewer surprises
- explicitNulls = false — so we do not produce "note": null everywhere
import kotlinx.serialization.json.Json
private val appJson = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
And now the save/load functions (keep them short and “to the point”):
import java.io.File
import kotlinx.serialization.encodeToString
fun saveState(file: File, state: BudgetState) {
val text = appJson.encodeToString(state)
file.parentFile?.mkdirs()
file.writeText(text)
}
import java.io.File
import kotlinx.serialization.decodeFromString
fun loadState(file: File): BudgetState? {
return try {
if (!file.exists()) return null
val text = file.readText()
appJson.decodeFromString<BudgetState>(text)
} catch (e: Exception) {
null
}
}
A small main that shows everything works:
import java.io.File
fun main() {
val file = File("data/state.json")
val state = BudgetState(
expenses = listOf(
Expense(title = "Coffee", amount = 3.5, category = "food"),
Expense(title = "Book", amount = 12.0, note = "Kotlin")
)
)
saveState(file, state)
val loaded = loadState(file)
println(loaded) // BudgetState(expenses=[Expense(...), Expense(...)])
}
What matters here is not how “cool the app” is, but boundary discipline: Json is responsible for formatting and encoding/decoding rules, the file is responsible for transporting the string, and main is responsible for the scenario. When these layers are not mixed, debugging becomes much easier.
A small diagram to lock the data flow into your head:
flowchart TD
A[BudgetState in memory] --> B[appJson.encodeToString]
B --> C[JSON string]
C --> D[file.writeText]
D --> E[file.readText]
E --> F[appJson.decodeFromString]
F --> G[BudgetState in memory]
5. Common mistakes when configuring Json
Mistake #1: “In one place I write with Json.encodeToString, and in another I read with my own Json { ... }—so what?”
The problem is that these are two different sets of rules. Today they may be accidentally compatible, and tomorrow you will enable encodeDefaults or change explicitNulls, and you will start getting weird situations: the file looks one way, the program expects another. A simple rule saves you: one appJson per application, and no “random” Json instances in the middle of the code.
Mistake #2: enable ignoreUnknownKeys = true and stop noticing key errors.
This setting saves compatibility, but it can hide typos. If in JSON a field is named "catrgory" instead of "category", it will be ignored and you will get the default value. To the user it looks like “the program lost data.” So it is good to at least sometimes inspect incoming JSON with your eyes or log the fact of loading/errors (in our simplified example, for now we just return null).
Mistake #3: think that prettyPrint changes the data, not the representation.
Sometimes students start thinking that “pretty JSON” is a different format that must be supported separately. No: it is the same JSON, just with spaces and line breaks. It should be read by a normal parser exactly the same way as the compact version.
Mistake #4: not understand the difference between “field is missing” and “field = null”, and then be surprised by explicitNulls behavior.
If you do not distinguish these states, you can accidentally lose meaning. For example, “the note is missing because this is an old version of the file” and “the note is missing because the user deleted it” can be different events. explicitNulls affects whether you will later be able to tell these cases apart from the file.
Mistake #5: enable isLenient “just in case.”
This is almost always a bad idea for your application’s own files. You generate the JSON yourself, so why weaken the validation? Tolerance is needed for importing other people’s data, but there it should be limited via a separate codec or a separate function so that “dirty input” does not leak into the main storage format.
Mistake #6: change Json settings and forget that it changes the storage format.
The most unpleasant trap: you changed encodeDefaults or explicitNulls, wrote a new file, and then try to read it with an older version of the program or a different part of the project. So treat settings as part of the format: if you change them, that is a contract change, not a “refactor with no consequences.”