1. What we are building today: object → JSON string → file → object
When you first hear “object to file”, your brain often imagines magic like “shove a variable into a file — pull out the same one tomorrow”. In reality, there is no magic: a file can store bytes/text, not Kotlin objects. So we will do a very honest chain: first we turn an object into a JSON string, then we write the string to a file. Then we read the string from the file and turn it back into an object.
To make the examples connected into one application, we will continue our conditional console mini-project: expense tracking (Expense Tracker). Still no database, no networking, and no “grown-up” architecture — right now it is more important to learn how to save and load data so that tomorrow the app does not start life from a blank slate and a sad println("Well then, again doing everything by hand?").
Pipeline diagram
flowchart LR
A[Expense / List⟨Expense⟩
in memory] --> B[encodeToString
JSON String]
B --> C[writeText
expenses.json]
C --> D[readText
JSON String]
D --> E[decodeFromString
List⟨Expense⟩]
2. Step 1: object → JSON string
When we say “serialize to JSON”, essentially we mean: “make a string from an object” that you can print to the console, write to a file, send anywhere. And here it is very important not to mix things up: toString() also gives you a string, but it is not a data format—it is just a technical representation “for humans and debugging”. JSON is a format that the library can read back, and it can be treated as a contract.
Mini-model for our app
Suppose that in the previous lecture (about @Serializable) we already created the following expense model:
import kotlinx.serialization.Serializable
@Serializable
data class Expense(
val id: Int,
val title: String,
val amount: Int,
val category: String,
val note: String? = null,
)
Everything here is deliberately down-to-earth: id is an integer, title is a string, amount is also an integer (for example, dollars/francs without cents—not ideal, but enough to start), category is a string, and note is nullable so we can practice null.
The simplest encoding: a single object
First, without files and without lists—so we can see “what we even get”. To encode into a string, use Json.encodeToString(...):
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val e = Expense(id = 1, title = "Coffee", amount = 250, category = "Food")
val jsonText = Json.encodeToString(e)
println(jsonText)
// {"id":1,"title":"Coffee","amount":250,"category":"Food","note":null}
}
Note: the JSON came out “dense”, without line breaks. That is fine. Making it human-pretty is something we will do in the next lecture about Json { ... } settings, but for now it is more important that it works reliably as a storage format.
Encoding a list of expenses
Our app does not store a single expense—usually it stores a list. JSON for a list looks like an array [...], and the library supports this directly:
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val expenses = listOf(
Expense(1, "Coffee", 250, "Food"),
Expense(2, "Fare", 60, "Transport", note = "Metro"),
)
val jsonText = Json.encodeToString(expenses)
println(jsonText)
// [{"id":1,"title":"Coffee","amount":250,"category":"Food","note":null},{"id":2,"title":"Fare","amount":60,"category":"Transport","note":"Metro"}]
}
Here it helps to keep a simple mapping in mind: List<Expense> turns into a JSON array [...], and each Expense inside becomes a JSON object {...}.
3. Step 2: JSON string → object
Now we go in the opposite direction. The idea of deserialization is simple: we have a JSON string, and we want to get a Kotlin object of the required type. It matters that you specify the type. A JSON string by itself does not say “I am User” or “I am Expense”. It is just data, and the type is a decision made by your code.
To avoid illusions, I will say it plainly: this step can fail with an error if the JSON does not match the model. And that is normal. We are learning to live in a world where input data can be “dirty”.
For decoding, use Json.decodeFromString<T>(...).
Decode a single object
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun main() {
val input = """{"id":10,"title":"Tea","amount":120,"category":"Food","note":null}"""
val e = Json.decodeFromString<Expense>(input)
println(e)
// Expense(id=10, title=Tea, amount=120, category=Food, note=null)
}
Note the <Expense>: we explicitly say “read this as Expense”.
Decode a list
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun main() {
val input = """[{"id":1,"title":"Coffee","amount":250,"category":"Food","note":null}]"""
val expenses = Json.decodeFromString<List<Expense>>(input)
println(expenses.size) // 1
println(expenses[0].title) // Coffee
}
If you mix up {} and [], you will get an error. This is one of the most common bugs: you think the file contains one object, but it contains an array, or vice versa. So before decoding it is useful to at least visually check what the string starts with: { or [.
4. Step 3: a file as transport for a string
At this stage we make an important mental shift: a file does not “store objects”, a file “stores a string”. We simply agreed that this string would be JSON.
From the code perspective, this means that operations split into two independent parts: serialization (to a string) and I/O (to/from a file). This separation helps debugging a lot: if something breaks, you immediately know whether the problem is in the JSON or in the path/permissions/directory.
Writing a JSON string to a file
import java.io.File
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val dir = File("data")
dir.mkdirs()
val file = File(dir, "expenses.json")
val text = Json.encodeToString(listOf(Expense(1, "Coffee", 250, "Food")))
file.writeText(text)
println("Saved to ${file.path}") // Saved to data/expenses.json
}
The key point is dir.mkdirs(): if the folder does not exist, writing may fail. Yes, sometimes the computer requires you to create the folder first. It is stubborn, but honest.
Reading a JSON string from a file
import java.io.File
fun main() {
val file = File("data/expenses.json")
val text = file.readText()
println(text) // JSON contents in a single line
}
Everything here is as straightforward as it gets: read the string—and then you can feed it into decodeFromString.
5. Mini pipeline: save and load a list
Now let’s glue everything into two steps: save the list and then load it. To make the code easier to read and reuse, we will extract small functions. This is not “architecture for the ages”—just a human habit: not writing the same code in three places.
Save function
import java.io.File
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun saveExpenses(file: File, expenses: List<Expense>) {
val text = Json.encodeToString(expenses)
file.parentFile?.mkdirs()
file.writeText(text)
}
Here we add a small safeguard: file.parentFile?.mkdirs() will create the directory if there is one (and if parentFile is not null). This is convenient when you pass a path like "data/expenses.json".
Load function
import java.io.File
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun loadExpenses(file: File): List<Expense> {
if (!file.exists()) return emptyList()
val text = file.readText()
return Json.decodeFromString(text)
}
There is an important UX point here: if the file does not exist, that is not a “world error”. It is a normal situation for the first run. So instead of crashing, we return emptyList().
Note: Json.decodeFromString(text) works without an explicit <List<Expense>>, because the compiler can infer the type from the function’s return type. If loadExpenses returned Any, we would have to specify the type explicitly—but we will not do that; we want to be friends with the compiler, not fight it.
Verify in main that the round-trip works
Now we will write a small main that creates a list of expenses, saves it, loads it back, and prints the result. This is an important ritual: first make sure the pipeline works in a vacuum, and only then hook it up to commands like add/list/remove (which you might have done in earlier days).
import java.io.File
fun main() {
val file = File("data/expenses.json")
val expenses = listOf(
Expense(1, "Coffee", 250, "Food"),
Expense(2, "Fare", 60, "Transport", note = "Metro"),
)
saveExpenses(file, expenses)
val loaded = loadExpenses(file)
println("Loaded: ${loaded.size}") // Loaded: 2
println(loaded[0]) // Expense(id=1, title=Coffee, amount=250, category=Food, note=null)
}
If this example works, the base is ready: we learned how to store data “between launches”. This is already almost a grown-up program (just a bit left: stop breaking on every weird file).
Integrating the file into a CLI expense tracker
When you build (or already built) a console interface with commands like add, list, remove, it is important to integrate the file correctly: loading should happen at the start of the program, and saving should happen after changes. If you only save at the end and the program crashes earlier, the data is lost. If you save after each command, it becomes almost impossible to lose data (and yes, it writes to the file a bit more often, but for a learning project that is fine).
Below is a very simplified main “skeleton”, without complex command parsing. It shows the idea “load → change → save”.
import java.io.File
fun main() {
val file = File("data/expenses.json")
val expenses = loadExpensesSafe(file).toMutableList()
expenses.add(Expense(3, "Cookies", 90, "Food"))
saveExpenses(file, expenses)
println("Now total: ${expenses.size}") // Now total: 3
}
The key idea here: in memory we work with a list (MutableList), but in the file we store a JSON string. The file is not “smarter” than the list. It simply stores the result of serialization.
6. Errors and diagnostics: where loading fails
Any “load data” operation lives in a harsh world. The file might be missing, might be empty, might be corrupted, might not be JSON, might be JSON but of the wrong shape. And if you do not separate the stages “read the string” and “parse JSON”, diagnostics turns into fortune-telling on coffee grounds (in our expense app—that is especially fitting).
Two classes of problems
In reality, problems almost always fall into two big categories:
| Where it broke | What it looks like | What it means in plain language |
|---|---|---|
| I/O (file) | not found, no permissions, directory instead of a file | “we could not obtain the string” |
| JSON/type | parsing error, type mismatch | “we got the string, but it does not match the model” |
Minimal diagnostics via try/catch
Let’s make a version of loadExpenses that does not crash, but returns an empty list and prints a clear message. This is not an ideal strategy for all programs, but for a learning console app it is a great start.
import java.io.File
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun loadExpensesSafe(file: File): List<Expense> {
if (!file.exists()) return emptyList()
return try {
val text = file.readText()
Json.decodeFromString(text)
} catch (e: Exception) {
println("Failed to load ${file.path}: ${e.message}")
emptyList()
}
}
Why catch Exception instead of something more specific? Because right now our goal is to learn not to crash and to see the message. You can refine exception types later, when you feel confident. At this level the important part is the principle: “read” and “parse” are different steps.
7. Common mistakes
Mistake #1: trying to “save an object” via toString().
A data class has a very convenient toString(), and it really looks “almost like data”. But it is a trap: it is a debugging string, not a storage format. Today you save Expense(id=1, title=Coffee, ...), and tomorrow you change the field order or a property name—and suddenly the “format” becomes different. JSON is produced by the library and read by the library, so the round-trip actually makes sense.
Mistake #2: mixing I/O and JSON into one mess, and then not understanding where it failed.
If you do everything in one line like Json.decodeFromString(File("x").readText()), then on error you see an exception but do not know whether the problem was reading the file or parsing. When you keep the steps separate, debugging becomes almost boring: “file read—string is here—parsing failed—so the JSON does not match”.
Mistake #3: not creating the directory before writing.
File("data/expenses.json").writeText(...) looks nice, but if the data folder does not exist, the write may fail. Because of this, beginners sometimes think “serialization does not work”, although serialization did work and writing failed. Create the directory via mkdirs() (or via file.parentFile?.mkdirs() inside the save function).
Mistake #4: trying to decode the wrong top level: {} instead of [].
If you save a list of expenses, the file will contain a JSON array [...]. If you then accidentally try to decode that as a single Expense, you will get an exception. The simplest self-check is to look at the first JSON character: { means an object, [ means a list.
Mistake #5: treating a missing file as an error.
The first run of a program almost always happens without a file. If you crash there with an exception, you are building an app that cannot start its life. It is better to treat “no file” as “no data yet” and return emptyList()—it is much friendlier to the user (and to you while testing).
Mistake #6: thinking that “if JSON parsed, then the data is correct”.
Parsing validates structure and types, but not meaning. For example, amount = -999999 can parse just fine. So it is useful to remember: serialization/deserialization is the “data shape” layer, while correctness checks are separate application logic. Today we simply mark this boundary so we do not demand the impossible from JSON.
GO TO FULL VERSION