Working with JSON

JSON (JavaScript Object Notation) is the plain-text format almost every web API, config file, and log pipeline uses to exchange structured data. Kotlin’s standard library deliberately ships without a JSON parser, so working with JSON in Kotlin means reaching for a library — and the moment you do, Kotlin’s null safety and data classes turn a task that is fiddly and error-prone in many languages into something the compiler actively helps you get right. This lesson covers how JSON works in Kotlin, from hand-rolled basics using only the standard library up to the idiomatic kotlinx.serialization approach used in real projects.

Overview / How It Works

Unlike some ecosystems, Kotlin does not bundle a JSON API in kotlin.*. This is intentional: Kotlin targets the JVM, JS, Native, and multiplatform projects, and the standard library stays small and platform-agnostic. On the JVM you could reach for a Java library like Gson or Jackson through direct Java interop, but the idiomatic, multiplatform-friendly answer that JetBrains ships alongside the language is kotlinx.serialization.

kotlinx.serialization is not “just a library” in the usual sense — it is a compiler plugin plus a runtime. When you annotate a class with @Serializable, the plugin runs during compilation and generates a companion object holding a KSerializer for that class. This generated serializer knows every property’s name, declared type, nullability, and default value, and it can write or read that structure through an abstract Encoder/Decoder pair. That abstraction is why the exact same @Serializable class can be turned into JSON via the Json format, or into other formats like ProtoBuf or CBOR, without touching the data class at all — only the format object changes.

Null safety plays directly into this. A property typed String must be present and non-null in the JSON, or decoding throws a SerializationException. A property typed String? may be JSON null (or, with a default value, may be omitted entirely and fall back to that default). This means your data class’s type signature is your JSON schema — the compiler and the serializer enforce it together, instead of you manually checking for missing keys after the fact.

Because this course’s compile gate only has the Kotlin standard library available (no external dependencies), the examples below take two tracks: first, small, fully compilable programs that build and parse simple JSON by hand using nothing but String functions and data classes, so you can see exactly what a serializer is doing under the hood; second, the real kotlinx.serialization code you would actually write in a Gradle project, shown as illustrative (non-compiled) snippets.

Syntax

The general shape of a kotlinx.serialization round trip looks like this:

@Serializable
data class TypeName(
    val requiredField: String,
    val optionalField: Int = 0,
    val nullableField: String? = null
)

val jsonText: String = Json.encodeToString(instance)
val restored: TypeName = Json.decodeFromString<TypeName>(jsonText)
  • @Serializable — marks the class for the compiler plugin to generate a KSerializer for it.
  • requiredField — a non-null property with no default; the JSON must contain this key with a matching, non-null value or decoding fails.
  • optionalField = 0 — a default value means the key may be missing from the JSON; the default fills in instead.
  • nullableField: String? = null — may be absent, present with a value, or explicitly null in the JSON.
  • Json.encodeToString(instance) — serializes any @Serializable value to a JSON String.
  • Json.decodeFromString<TypeName>(jsonText) — parses a JSON string back into a typed Kotlin object; the type argument tells the compiler which generated serializer to use.

Examples

Example 1: Building JSON text by hand

Before reaching for a library, it helps to see that JSON is just a text format you could, in principle, build yourself with string templates:

data class Person(val name: String, val age: Int, val email: String?)

fun toJson(person: Person): String {
    val emailJson = if (person.email != null) "\"${person.email}\"" else "null"
    return """{"name":"${person.name}","age":${person.age},"email":$emailJson}"""
}

fun main() {
    val alice = Person("Alice", 30, "alice@example.com")
    val bob = Person("Bob", 25, null)

    println(toJson(alice))
    println(toJson(bob))
}

Output:

{"name":"Alice","age":30,"email":"alice@example.com"}
{"name":"Bob","age":25,"email":null}

The triple-quoted string lets us write literal double quotes without escaping them, while string templates (${'$'}{person.name}) splice in each field. The nullable email field is handled explicitly with an if: if it is null, we emit the JSON literal null; otherwise we quote the string. This is exactly the branching a generated serializer performs for every nullable property — it is just doing it for you, for every property, automatically.

Example 2: Parsing simple JSON by hand

Going the other direction — turning JSON text back into Kotlin values — is where hand-rolling gets fragile fast. Here is a parser that works only for a flat object whose values are plain strings:

fun parseFlatJson(json: String): Map<String, String> {
    val trimmed = json.trim().removePrefix("{").removeSuffix("}")
    val result = mutableMapOf<String, String>()
    if (trimmed.isBlank()) return result

    for (pair in trimmed.split(",")) {
        val (rawKey, rawValue) = pair.split(":", limit = 2)
        val key = rawKey.trim().removeSurrounding("\"")
        val value = rawValue.trim().removeSurrounding("\"")
        result[key] = value
    }
    return result
}

fun main() {
    val json = """{"name":"Alice","city":"Paris"}"""
    val parsed = parseFlatJson(json)

    println(parsed["name"])
    println(parsed["city"])
    println(parsed)
}

Output:

Alice
Paris
{name=Alice, city=Paris}

This splits the object body on commas, then each pair on its first colon, trimming surrounding quotes from both key and value. It works for this simple, flat case, but notice how many assumptions it bakes in: no nested objects, no arrays, no numbers or booleans, and — critically — no commas inside the string values themselves. That last assumption breaks in the Common Mistakes section below.

Example 3: The real-world approach with kotlinx.serialization

In an actual project, you would add the kotlinx-serialization-json dependency and the compiler plugin, then write this instead (shown here for illustration; it needs the external dependency, so it is not compiled by this lesson’s gate):

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

@Serializable
data class Person(val name: String, val age: Int, val email: String? = null)

fun main() {
    val alice = Person("Alice", 30, "alice@example.com")
    val json = Json.encodeToString(alice)
    println(json)

    val decoded = Json.decodeFromString<Person>(json)
    println(decoded)

    val lenient = Json { ignoreUnknownKeys = true }
    val fromApi = lenient.decodeFromString<Person>(
        "{\"name\":\"Bob\",\"age\":25,\"extraField\":\"ignored\"}"
    )
    println(fromApi)
}

Output (illustrative):

{"name":"Alice","age":30,"email":"alice@example.com"}
Person(name=Alice, age=30, email=alice@example.com)
Person(name=Bob, age=25, email=null)

Because Person is a data class, println(decoded) uses the generated toString(), which is why the second line reads as a field list rather than a memory address. The third call uses a custom Json instance with ignoreUnknownKeys = true, so an extraField the data class doesn’t know about is silently skipped instead of throwing — essential when consuming real-world APIs that add fields over time.

How It Works Step by Step

Encoding: the compiler plugin generates a serializer for the class at compile time, listing every property in declaration order. When you call Json.encodeToString, the Json format asks that generated serializer to write each property’s name and value into an internal buffer through the Encoder interface, converting Kotlin types to JSON tokens (a String becomes a quoted, escaped string; an Int becomes a bare number; a nested @Serializable object recurses into a nested {...}). Special characters like quotes, backslashes, and control characters are escaped automatically per the JSON spec.

Decoding: Json.decodeFromString first tokenizes the raw text into a structural representation, then walks it alongside the generated serializer’s property list. For each JSON key it finds a matching Kotlin property, converts the JSON value to that property’s declared type, and calls the class’s constructor once everything is gathered. A required non-null property that is missing or explicitly null causes the whole call to throw a SerializationException — the failure happens at the parsing boundary, not somewhere deep in your business logic later.

Common Mistakes

1. Importing kotlinx.serialization without the compiler plugin

A bare import is not enough — @Serializable depends on a Gradle plugin that runs alongside the Kotlin compiler:

import kotlinx.serialization.Serializable

@Serializable
data class User(val id: Int, val name: String)

Without the plugin and dependency configured, this fails with an unresolved reference error, because @Serializable has nothing to trigger without the plugin generating the actual KSerializer code behind it. The fix is in the build file, not the source file:

plugins {
    kotlin("jvm") version "2.0.0"
    kotlin("plugin.serialization") version "2.0.0"
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
}

2. Hand-rolling a parser and assuming values never contain your delimiter

The naive parser from Example 2 splits on every comma — including commas that appear inside a string value:

fun parseFlatJson(json: String): Map<String, String> {
    val trimmed = json.trim().removePrefix("{").removeSuffix("}")
    val result = mutableMapOf<String, String>()
    if (trimmed.isBlank()) return result

    for (pair in trimmed.split(",")) {
        val (rawKey, rawValue) = pair.split(":", limit = 2)
        val key = rawKey.trim().removeSurrounding("\"")
        val value = rawValue.trim().removeSurrounding("\"")
        result[key] = value
    }
    return result
}

fun main() {
    val json = """{"bio":"Loves Kotlin, and coffee"}"""
    println(parseFlatJson(json))
}

This crashes: splitting on , chops the string value in two, leaving a second chunk with no colon in it, so the destructuring assignment has nothing to bind to rawValue and throws. Real JSON needs a parser that tracks whether it is currently inside a quoted string before treating a comma or colon as structural — which is precisely the hard part a tested library has already solved for you:

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

@Serializable
data class Note(val bio: String)

fun main() {
    val note = Json.decodeFromString<Note>("{\"bio\":\"Loves Kotlin, and coffee\"}")
    println(note.bio)
}

3. Using !! on a field that came from JSON

Data pulled from an external source is exactly where fields legitimately turn out to be missing — treating them as always present with !! is asking for a crash:

data class Person(val name: String, val age: Int, val email: String?)

fun main() {
    val bob = Person("Bob", 25, null)
    println(bob.email!!.length)
}

Here bob.email is null, so !! throws instead of returning a value. Use a safe call with a fallback instead:

data class Person(val name: String, val age: Int, val email: String?)

fun main() {
    val bob = Person("Bob", 25, null)
    val emailLength = bob.email?.length ?: 0
    println(emailLength)
}

Best Practices

  • Use kotlinx.serialization for new Kotlin projects, especially multiplatform ones — it is maintained by JetBrains and stays in sync with the language.
  • Model optional or absent JSON fields as nullable (String?) or give them defaults, rather than assuming every key is always present.
  • Enable ignoreUnknownKeys = true when consuming third-party APIs that may add fields you don’t yet model, so decoding doesn’t break the moment the API evolves.
  • Keep JSON DTOs (the @Serializable data classes matching the wire format) separate from your internal domain models when the two shapes diverge — map between them explicitly.
  • Never write your own general-purpose JSON parser for production code; string-splitting approaches break on nested structures, escaped characters, and delimiters inside values.
  • Wrap decoding in a try/catch for SerializationException at the boundary where untrusted JSON enters your program, rather than letting a malformed payload crash deep inside business logic.
  • Prefer val for your data class properties — JSON models are typically immutable snapshots of received or about-to-be-sent data.

Practice Exercises

1. Write a data class Book(val title: String, val author: String, val year: Int) and a function that manually builds its JSON string representation using string templates, similar to Example 1. Test it with a book that has an apostrophe in the title and think about what would need escaping.

2. Extend the parseFlatJson function from Example 2 to also handle a numeric value (e.g. {"name":"Alice","age":30}), returning a Map<String, String> where the number is still kept as text. What happens if a value has no surrounding quotes at all?

3. Sketch (in comments or prose, no need to compile it) what a @Serializable data class for a JSON array of objects would look like, and what Kotlin type Json.decodeFromString would need to return for a top-level JSON array — hint: think about List<YourType>.

Summary

  • Kotlin’s standard library has no built-in JSON support by design; use kotlinx.serialization (or a Java library via interop) instead.
  • @Serializable triggers a compiler plugin that generates a KSerializer for the class at compile time — it is not a runtime reflection trick.
  • A data class’s property types double as your JSON schema: non-null types require the key to be present and non-null; nullable types and defaults make a key optional.
  • Hand-rolled string-splitting JSON parsing breaks on nested structures, arrays, and delimiters inside string values — it’s useful for learning, not for production.
  • Prefer safe calls (?., ?:) over !! when working with fields decoded from external JSON, since missing or null data is the normal case, not the exception.
  • Configure ignoreUnknownKeys and sensible defaults so your code tolerates APIs evolving over time.