Skip to content

07 · Working with JSON

Almost every real Kotlin project talks to something over JSON — a REST API, a config file, a message queue payload. kotlinx.serialization is the JetBrains-maintained library for this: it works by generating serialization code at compile time via a compiler plugin, so there's no reflection, no runtime surprises, and it fails to compile (rather than fails at runtime) if your model doesn't match what you're asking it to do. Module 10 uses exactly this to parse a real weather API's response.

Setting it up needs two things in Gradle (see Module 8): the kotlinx-serialization Kotlin compiler plugin, and the kotlinx-serialization-json library dependency.

@Serializable data classes

Mark a data class @Serializable and Json.encodeToString/ decodeFromString handle the conversion both ways.

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class Product(val name: String, val price: Double, val inStock: Boolean)

fun main() {
    val product = Product("Widget", 9.99, true)

    val json = Json.encodeToString(product)
    println(json)

    val parsed = Json.decodeFromString<Product>(json)
    println(parsed)
    println(parsed == product)   // true -- data class structural equality
}
{"name":"Widget","price":9.99,"inStock":true}
Product(name=Widget, price=9.99, inStock=true)
true

Nested objects and lists

Serialization works recursively — a @Serializable class containing other @Serializable classes or lists of them just works.

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class Address(val city: String, val zip: String)

@Serializable
data class Customer(val name: String, val address: Address, val orderIds: List<Int>)

fun main() {
    val customer = Customer("Alice", Address("Boston", "02101"), listOf(101, 102, 103))
    val json = Json.encodeToString(customer)
    println(json)

    val parsed = Json.decodeFromString<Customer>(json)
    println(parsed.address.city)     // Boston
    println(parsed.orderIds.sum())   // 306
}
{"name":"Alice","address":{"city":"Boston","zip":"02101"},"orderIds":[101,102,103]}
Boston
306

Optional and nullable fields

A property with a default value doesn't need to appear in the incoming JSON at all; a nullable property can be present but null.

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class UserProfile(
    val username: String,
    val bio: String? = null,       // may be missing OR explicitly null in the JSON
    val loginCount: Int = 0        // may be missing -- defaults to 0
)

fun main() {
    val fullJson = """{"username": "alice", "bio": "Kotlin fan", "loginCount": 42}"""
    val minimalJson = """{"username": "bob"}"""

    println(Json.decodeFromString<UserProfile>(fullJson))
    println(Json.decodeFromString<UserProfile>(minimalJson))
}
UserProfile(username=alice, bio=Kotlin fan, loginCount=42)
UserProfile(username=bob, bio=null, loginCount=0)

Matching real-world JSON field names with @SerialName

APIs frequently use snake_case while idiomatic Kotlin uses camelCase. @SerialName maps a property to whatever the JSON actually calls it, without forcing your Kotlin code to look like the wire format.

import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.Json

@Serializable
data class WeatherReading(
    @SerialName("temperature_2m") val temperature: Double,
    @SerialName("wind_speed_10m") val windSpeed: Double
)

fun main() {
    val apiResponse = """{"temperature_2m": 19.4, "wind_speed_10m": 11.9}"""
    val reading = Json.decodeFromString<WeatherReading>(apiResponse)
    println("${reading.temperature}°C, wind ${reading.windSpeed} km/h")
}
19.4°C, wind 11.9 km/h

Handling fields you don't care about

By default, kotlinx.serialization throws if the JSON contains a key your data class doesn't declare — a real API response almost always has more fields than you need. Configure a Json instance with ignoreUnknownKeys = true to skip them instead of failing.

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class Coordinates(val latitude: Double, val longitude: Double)

private val lenientJson = Json { ignoreUnknownKeys = true }

fun main() {
    // Real API responses include lots of fields we don't model here
    val apiResponse = """
        {"latitude": 51.51, "longitude": -0.13, "elevation": 16.0, "timezone": "GMT"}
    """.trimIndent()

    val coords = lenientJson.decodeFromString<Coordinates>(apiResponse)
    println(coords)   // Coordinates(latitude=51.51, longitude=-0.13) -- extra fields ignored
}
Coordinates(latitude=51.51, longitude=-0.13)

The default Json is strict on purpose

Without ignoreUnknownKeys = true, an unexpected field throws SerializationException rather than silently dropping data. That's a deliberate safety net — if your model is missing a field that actually matters, you find out immediately instead of quietly losing information. Reach for the lenient config only once you've confirmed which fields you genuinely don't need.

Enums in JSON

A @Serializable enum class (de)serializes to its constant name as a plain JSON string, no extra configuration needed.

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
enum class Priority { LOW, MEDIUM, HIGH }

@Serializable
data class Ticket(val title: String, val priority: Priority)

fun main() {
    val ticket = Ticket("Fix login bug", Priority.HIGH)
    println(Json.encodeToString(ticket))

    val parsed = Json.decodeFromString<Ticket>("""{"title":"Fix login bug","priority":"HIGH"}""")
    println(parsed.priority)
}
{"title":"Fix login bug","priority":"HIGH"}
HIGH

How It Actually Works

@Serializable triggers a real compiler plugin (kotlinx-serialization hooks into the Kotlin compiler's IR backend), which runs during compilation and synthesizes an entire companion object method, Product.Companion.serializer(), plus a generated KSerializer<Product> implementation — all as ordinary bytecode sitting right there in Product.class, not generated at runtime and not using reflection at all. You can confirm this with javap -p Product.class: alongside the usual data class methods you'll find a static serializer() method and a nested $serializer class that knows, field by field, how to read and write each property — because the plugin literally writes that code for you at compile time, using the exact same IR the rest of the compiler works with.

This is why kotlinx.serialization fails at compile time, not runtime, when your model doesn't line up: Json.decodeFromString<Product>(json) resolves the generated serializer via Kotlin's reified generics (see the generics module for how <Product> survives erasure here) and calls its compiler-generated deserialize() method directly — there's no Class.forName / field-reflection lookup at any point, so a missing property or type mismatch shows up as a SerializationException built from information the plugin already baked into the generated code, and structural issues (e.g. forgetting @Serializable on a nested class) are caught by the compiler itself before the program ever runs.

Nested @Serializable classes and List<Int> work recursively because the generated serializer() for Customer calls Address.serializer() and ListSerializer(Int.serializer()) internally — each @Serializable type contributes its own self-contained serializer, and the plugin wires them together into a tree that mirrors your object graph exactly, with the JSON encoder walking that tree field by field rather than inspecting the runtime object generically.

Cheat sheet

Task Syntax
Mark a type serializable @Serializable data class Foo(...)
Object to JSON string Json.encodeToString(value)
JSON string to object Json.decodeFromString<Foo>(jsonString)
Rename a field for the JSON key @SerialName("json_key") val ktName: Type
Optional field with a default val field: Type = defaultValue
Nullable field val field: Type? = null
Ignore unmodeled JSON fields Json { ignoreUnknownKeys = true }
Enum as a JSON string @Serializable enum class Foo { A, B }

Exercise

Model a @Serializable data class Book(val title: String, @SerialName("author_name") val authorName: String, val year: Int, val genres: List<String> = emptyList()). Write a JSON string representing a book with a genres list, decode it, and print the result. Then write a second JSON string that omits genres entirely and confirm it still decodes successfully with an empty list. Use a lenient Json { ignoreUnknownKeys = true } instance and add an extra unmodeled field (like "isbn": "...") to confirm it's ignored rather than throwing.