1. Introduction
When you first see how Json.encodeToString() “somehow on its own” produces nice-looking JSON, you get the feeling the problem is solved forever. But serialization is not only “save to a file.” It’s also a contract: what the data format will be, whether another program can understand it, whether you can read it yourself a month later without crying, and how painful it will be to change the format later.
JSON is a language for communication, not just a “string for storage.” And JSON has its own rules: it supports arrays [...], objects {...}, strings, numbers, true/false, and null. Kotlin, as you know, is richer in types: we have lists, sets, maps, nullable types, data class, and much more. So today our task is to learn to anticipate what exactly a Kotlin collection will turn into in JSON, and where the traps are.
Mappings: Kotlin collections and JSON shapes
Before writing code, it’s useful to build a mapping table “in your head.” It’s like a translator: from Kotlin to JSON.
| Kotlin type | What it means conceptually | What it almost always becomes in JSON |
|---|---|---|
|
ordered sequence, duplicates allowed | JSON array [...] |
|
unique elements, order is usually not important | JSON array [...] (yes, still an array) |
|
key → value, string keys | JSON object { "key": value } |
| Map<K, V> where K is not String | key → value, but keys are “non-string” | most often still a JSON object, but keys become strings (and that’s where the nuances begin) |
For Map, it’s important to remember one basic thing: it’s a collection of key-value pairs; keys are unique, values can repeat. Kotlin explicitly highlights that Map is a special kind of collection that stores pairs and provides lookup by key.
Now let’s go through each case separately, with short and clear examples.
3. List and Set in JSON: arrays
List<T> in JSON: an array [...]
Lists are the most JSON-friendly type. If you have a List<String>, almost anyone (and any system) can easily understand a JSON array of strings. A list stores elements in order, indices start at zero, and index-based access is possible — that’s an important part of the List contract.
Let’s imagine that in our mini training app (let it be “BudgetBuddy,” an expense tracker) we want to store tags for an expense: food, coffee, taxi. Conceptually, tags are often more convenient to keep as a list.
Example 1: List<String> becomes a JSON array
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class Tags(val items: List<String>)
fun main() {
val json = Json { prettyPrint = true }
println(json.encodeToString(Tags(listOf("kotlin", "json"))))
// {
// "items": [
// "kotlin",
// "json"
// ]
// }
}
Everything here is as predictable as it gets: the list became an array. This is exactly the case where serialization works “just as you expected.”
Set<T> in JSON: also an array
With Set, the first mild cognitive dissonance appears: in Kotlin we clearly distinguish List and Set. A Set stores only unique elements, and that is its core idea. Kotlin documentation explicitly emphasizes: Set stores unique elements, order is not guaranteed in the general case, and even null can appear only once.
But JSON does not have a separate “set” type. So serialization usually does the most reasonable thing: it turns a Set into a JSON array. That is, in shape the JSON will look the same as for a List.
And this is important: uniqueness is not a property of JSON. Uniqueness is your agreement (and Kotlin code logic).
Example 2: Set<String> also becomes an array
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class UniqueTags(val items: Set<String>)
fun main() {
val json = Json { prettyPrint = true }
println(json.encodeToString(UniqueTags(setOf("food", "coffee"))))
// {
// "items": [
// "food",
// "coffee"
// ]
// }
}
Visually, it’s just a normal array. So if you later send this JSON “outside” (for example, to another system), it won’t know it represents a set unless you state that in words or documentation.
4. Map<String, V> in JSON: an object {...}
Now let’s move on to maps (Map). The meaning of a Map is to store “key → value” pairs, with unique keys, and lookup by key as the primary operation.
A JSON object {...} is naturally also “key → value,” where keys are strings. That’s why Map<String, V> fits JSON almost perfectly: you get a plain object where field names are the map keys.
For our BudgetBuddy this is very useful when we want to store, for example, the total expenses by category: "food" -> 1200, "taxi" -> 450.
Example 3: Map<String, Int> becomes a JSON object
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class Counters(val byName: Map<String, Int>)
fun main() {
val json = Json { prettyPrint = true }
val data = Counters(mapOf("apples" to 2, "bananas" to 5))
println(json.encodeToString(data))
// {
// "byName": {
// "apples": 2,
// "bananas": 5
// }
// }
}
It looks human-friendly, compact, and easy to read. That’s why Map<String, V> is a frequent guest in JSON.
5. Non-string Map keys: what happens in JSON
In JSON, object keys are always strings. Even if they look like numbers, they are still strings (for example, "10"). So if you serialize a Map<Int, Int>, then “in JSON shape” it will still be an object {...}, and its keys will become strings.
Sometimes this looks harmless. And sometimes it breaks an integration because someone expects “numeric keys,” which simply do not exist in a JSON object.
Example 4: Map<Int, Int> — keys become strings "10", "20"
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class IntKeyCounters(val byId: Map<Int, Int>)
fun main() {
val json = Json { prettyPrint = true }
val data = IntKeyCounters(mapOf(10 to 3, 20 to 7))
println(json.encodeToString(data))
// {
// "byId": {
// "10": 3,
// "20": 7
// }
// }
}
Notice: in Kotlin the keys were 10 and 20 (numbers), but in JSON they became "10" and "20" (strings).
If you read this back into Kotlin using the same Map<Int, Int>, the library can usually parse string keys into Int. But if this JSON ends up in someone else’s system, and there the key "010" is different from "10", or the keys are not always numbers — the fun begins.
6. When Map is a bad format
A Map in Kotlin is a convenient structure. But a structure that is convenient “for code” and a structure that is convenient “for a data format” are different things. Kotlin makes it clear that a Map is a set of pairs, and the order of pairs is not part of the meaning (two maps are equal regardless of pair order).
Now imagine typical problematic situations:
You want to use not a string but a complex object as the key (for example, data class CategoryKey(val name: String, val year: Int)). In Kotlin this is possible, but you can’t put such a key into a JSON object “nicely.”
You want to use Int as the key, but it has an important format, for example "00123" as a “code,” where leading zeros are significant. In Kotlin you would store this as a string, but if you decide to store it as an Int, then after serialization you won’t be able to distinguish "00123" from "123".
You want to evolve the format. Today, one number is enough for a “key → value” pair; tomorrow you need to add a comment, date, author. Map starts resisting because the value becomes complex, and the format sometimes becomes less obvious.
In such cases it’s often better to make the format more explicit, even if it’s slightly longer.
7. Alternative: “a map as a list of entries” List<Entry>
If JSON matters more than “one-line prettiness,” then instead of Map<K, V> people often use a list of objects like:
[
{"key": 10, "value": 3},
{"key": 20, "value": 7}
]
It’s longer, but:
- the key remains a number as a value, rather than being hidden in a string key of a JSON object;
- the format is easier to extend (you can add fields);
- it’s easier to validate and document.
Example 5: a list of entries instead of Map<Int, Int>
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class IntCounterEntry(val key: Int, val value: Int)
@Serializable
data class IntCounterList(val items: List<IntCounterEntry>)
fun main() {
val json = Json { prettyPrint = true }
val data = IntCounterList(listOf(IntCounterEntry(10, 3), IntCounterEntry(20, 7)))
println(json.encodeToString(data))
}
JSON will look roughly like this (logically important; we’re not nitpicking indentation):
{
"items": [
{ "key": 10, "value": 3 },
{ "key": 20, "value": 7 }
]
}
Yes, it’s more text. But the format is “self-describing”: you can immediately see that key is a number and value is a number.
8. Example: an expense report as collections + Map
So the examples don’t float in a vacuum, let’s tie this to our hypothetical BudgetBuddy. Suppose we have a list of expenses, and we want to build a report: total amount by category.
The expense list is a List<Expense>, and the final report is convenient to keep in a Map<String, Int> (category → sum). Here the Map fits perfectly by meaning, because the category is a string, so the JSON object is natural.
Example 6: the “expenses + totals by category” model
import kotlinx.serialization.Serializable
@Serializable
data class Expense(val title: String, val category: String, val amount: Int)
@Serializable
data class Report(val expenses: List<Expense>, val totalByCategory: Map<String, Int>)
Here we deliberately chose:
- expenses: List<Expense> — a sequence of records (natural for a file).
- totalByCategory: Map<String, Int> — a summary with string keys, so the JSON object will be valid and readable.
9. How to build a Map from a list without “magic”
Since we started talking about a report, a practical question comes up almost immediately: “OK, how do I build a Map<String, Int> from a list of expenses?”
You could write a loop and accumulate sums manually. But Kotlin collections provide many tools for transformations. For maps, for example, there are handy operations mapKeys() and mapValues() — when you want to transform keys or values.
For now, we’ll do the simplest and clearest option — with a plain for loop (it’s easier for beginners to read). In later days you’ll write this more functionally.
Example 7: accumulating totals in a MutableMap
fun buildTotals(expenses: List<Expense>): Map<String, Int> {
val totals = mutableMapOf<String, Int>()
for (e in expenses) {
val old = totals[e.category] ?: 0
totals[e.category] = old + e.amount
}
return totals
}
Notice the style: totals[e.category] ?: 0 — the classic pattern “if there is no value yet, assume it was 0.”
10. Converting List<Entry> and Map
Sometimes, a “list of entries” is better for the format, but in code you want to work with a Map. Then you need to convert back and forth.
Kotlin offers several ways to build a Map from a collection. One of them is associate(): you return a Pair(key, value) for each element, and Kotlin builds a map from it. The documentation describes associate() and emphasizes that it builds a Map from Pair.
Example 8: list of entries → Map
fun asMap(items: List<IntCounterEntry>): Map<Int, Int> {
return items.associate { it.key to it.value }
}
Example 9: Map → list of entries
fun asList(map: Map<Int, Int>): List<IntCounterEntry> {
return map.map { (k, v) -> IntCounterEntry(k, v) }
}
What matters here: a Map is not just a “list of pairs”; it has its own properties (unique keys and lookup by key). So you choose the structure depending on what matters more: convenience in code, or clarity/compatibility of the format.
11. How to choose: Map or List<Entry>
When you design JSON, it helps to keep a small selection rule in mind. Not as an “absolute truth,” but as a common-sense hint.
flowchart TD
A["Need to preserve the key → value mapping"] --> B{Are the keys strings?}
B -- "Yes" --> C["You can use Map⟨String, V⟩ → JSON object"]
B -- "No" --> D{Are the keys definitely numbers and everyone is OK with it?}
D -- "Yes" --> E["Map⟨Int, V⟩ is possible, but keys will become strings"]
D -- "No / not sure" --> F["Better: List⟨Entry⟩ with key/value fields"]
And yes: sometimes the right answer is “not sure.” In programming, that’s a perfectly valid position, especially before the first integration with an external service.
12. Common mistakes
Mistake #1: expecting a Set to be preserved as a “set” in JSON.
It’s very tempting to think that if it’s a Set in Kotlin, then JSON will have something “special” too. But JSON is a simple format: it has no separate “set” type, so a Set turns into an array, just like a List. Element uniqueness is your logic, not a property of the format. If it’s critical, you must explicitly enforce uniqueness when adding and when reading data.
Mistake #2: using Map<Int, ...> and forgetting that keys in JSON will be strings.
The problem is not that serialization is “bad” — it’s doing the only possible thing, because JSON object keys are strings. The problem is expectations: today you look at the JSON and see "10": 3, and tomorrow someone else (or you) starts treating it as a string key, where leading zeros, spaces, and special formats are possible. If keys are truly “numeric by meaning” and the format matters, it’s often simpler to switch to a list of entries.
Mistake #3: making a Map with a “complex key” and hoping for “pretty JSON.”
In Kotlin, a map key can be almost anything, but a JSON object cannot: keys must be strings. So object keys almost always lead to an awkward or surprising format. In such cases it’s better to create an explicit model: List<Entry>, where the key is a separate field, and it can be a complex object (because a JSON value can be an object, but a key cannot).
Mistake #4: choosing a structure “because it looks nicer,” not because it can be unambiguously read back.
This is a very common beginner trap: “Map looks compact, so we’ll use it.” And then it suddenly turns out that reading it back requires lots of caveats: which keys are allowed, whether they can be parsed as numbers, what to do with empty strings, and so on. A good criterion is to ask yourself: “Will I be able to write decode in six months without hints and not make a mistake?” If you’re unsure, make the format more explicit.
Mistake #5: mixing “a convenient storage format” and “a convenient in-code format” into one mess.
In code, it’s often convenient to have a Map (fast lookup by key), while in a file it’s convenient to have a List (explicit order, extensibility, typed fields). That’s normal — treat conversion “to file” and “from file” as a separate step. Kotlin collections are great precisely because you can usually convert between List and Map in a couple of lines via associate() and map { ... }.
GO TO FULL VERSION