JSON Serialization Explained

DATA › Networking

Every networked Android app parses JSON, and interviewers expect you to compare three libraries: kotlinx.serialization, Moshi, and Gson. The question underneath all of them is when the code that reads your JSON actually gets written. kotlinx.serialization and modern Moshi generate that code at compile time, through a compiler plugin or KSP, so there is no reflection involved at runtime. Gson takes the opposite approach: it inspects your class reflectively every time it needs to build an adapter, at runtime, on the device. Compile-time generation is faster, produces less code to keep around, and plays much more nicely with R8 shrinking, since there is no reflective API for R8 to accidentally strip. That is the whole reason kotlinx.serialization is now JetBrains' recommended default for new Kotlin code, and the only realistic choice once you are targeting Kotlin Multiplatform, since reflection APIs differ or don't exist across KMP targets.

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

val user = Json.decodeFromString<User>("""{"id":1,"name":"Ada"}""")

Getting kotlinx.serialization working in a Gradle module takes two separate pieces, and it is easy to add one and forget the other. First, apply the org.jetbrains.kotlin.plugin.serialization Gradle plugin, which is what actually performs the compile-time codegen. Second, add a runtime dependency for the format you want, kotlinx-serialization-json for JSON, which supplies the Json object and the KSerializer interface the generated code implements. Once both are in place, marking a class @Serializable is enough: the plugin generates its KSerializer automatically, and you call Json.encodeToString to turn an instance into JSON text, or Json.decodeFromString to go the other way.

// build.gradle.kts
plugins {
    kotlin("plugin.serialization") version "2.0.0"
}
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
}

@Serializable
data class Card(val id: String, val title: String)

val json = Json.encodeToString(Card("c1", "Ace"))

By default, kotlinx.serialization is strict about fields it doesn't recognize. If the server sends a key your @Serializable class never declared, decoding throws a SerializationException, which is exactly the kind of thing that can take down a release build the moment a backend team ships an unrelated API change. That default is deliberate: it catches typos and schema drift early in development. But for any API you don't fully control, and that is most of them, you want a Json instance configured with ignoreUnknownKeys = true, which skips fields your class doesn't declare instead of failing the whole decode.

@Serializable
data class Profile(val name: String)

// throws by default: server also sends "age", which Profile doesn't declare
Json.decodeFromString<Profile>("""{"name":"Ada","age":30}""")

val lenient = Json { ignoreUnknownKeys = true }
lenient.decodeFromString<Profile>("""{"name":"Ada","age":30}""") // "age" is skipped

A missing JSON key is not automatically fatal, but whether it survives depends entirely on how the property is declared. If the property has a default value, kotlinx.serialization falls back to that default when the key is absent. If the property is non-nullable and has no default, there is nothing to fall back to, so decoding throws MissingFieldException. This is a design decision in disguise: the fix is almost never to change the JSON, it's to decide whether the property should really be optional and, if so, give it a sensible default.

@Serializable
data class Preferences(val theme: String, val fontSize: Int) // fontSize has no default

// JSON omits "fontSize" -> throws MissingFieldException
Json.decodeFromString<Preferences>("""{"theme":"dark"}""")

@Serializable
data class PreferencesSafe(val theme: String, val fontSize: Int = 14) // now tolerates absence

A missing key and a key that is present but explicitly null are two different failures, and only the first one is fixed by anything covered so far. If the JSON sends null for a non-nullable property, even one with a declared default, kotlinx.serialization still throws by default, because null is a real value being handed to a type that can't hold it, not an absent key falling through to a default. There is a setting that closes that gap: it gives an explicit null, or any value that doesn't fit the expected type, the same forgiving treatment a missing key already gets when a default exists, falling back to it instead of failing the decode.

@Serializable
data class Settings(val timeout: Int = 30)

// present but null -> throws by default, even though timeout has a default
Json.decodeFromString<Settings>("""{"timeout":null}""")

val lenient = Json { coerceInputValues = true }
lenient.decodeFromString<Settings>("""{"timeout":null}""").timeout // 30

Encoding has its own default-related surprise: a property whose current value equals its declared default is left out of the output entirely. Json.encodeToString skips it to keep payloads small, which means the JSON you get back is not a structural mirror of every field on the class, only the ones that differ from their defaults. That is usually what you want for a request body, but it can bite you when you expect a full snapshot, for a cache write, say, or a diff against a previous version. Setting encodeDefaults = true on the Json instance forces every default-valued property to be written, and @EncodeDefault lets you opt a single property in without changing that global setting.

@Serializable
data class Config(val timeout: Int = 30, val retries: Int = 3)

Json.encodeToString(Config())             // "{}", both are still defaults
Json.encodeToString(Config(timeout = 60)) // {"timeout":60}, retries omitted

val full = Json { encodeDefaults = true }
full.encodeToString(Config())             // {"timeout":30,"retries":3}

Renaming a property so it maps to a differently spelled JSON key is one of the easiest things to mix up between libraries, because all three have their own annotation for it and they don't interoperate. kotlinx.serialization uses @SerialName. Moshi uses @Json with a name argument. Gson uses @SerializedName. Reach for the wrong one on the wrong library's class and it either won't compile or will silently do nothing, depending on which annotation you picked.

// kotlinx.serialization
@Serializable
data class Account(@SerialName("user_name") val userName: String)

// Moshi
data class AccountMoshi(@Json(name = "user_name") val userName: String)

// Gson
data class AccountGson(@SerializedName("user_name") val userName: String)

@SerialName only ever accepts a single name, which is a problem the moment an API evolves. Say a server renames a JSON key from login to username but, for backward compatibility, some clients still receive the old key. @SerialName can't express accepting either one, it can only pick one canonical name for both encoding and decoding. There is an annotation built for exactly this: it lists every additional input key decoding should accept for a property, while encoding still always writes out the primary @SerialName. So the property ends up reading two different wire formats and writing exactly one.

@Serializable
data class Account(
    @JsonNames("username", "login") // both keys decode into this property
    val username: String
)

Json.decodeFromString<Account>("""{"username":"ada"}""") // works
Json.decodeFromString<Account>("""{"login":"ada"}""")    // also works
Json.encodeToString(Account("ada"))                       // always {"username":"ada"}

Sometimes a property genuinely should never touch JSON at all, a cached object, a derived value, something computed after decoding. @Transient tells the compiler plugin to skip a property entirely on both encode and decode. It comes with a hard rule: the property must have a default value, because decoding can never populate it from the JSON, and the plugin enforces that at compile time rather than letting it blow up later. There is also a naming trap worth knowing for an interview: Kotlin's built-in kotlin.jvm.Transient, the one Gson respects because it maps to the JVM's own transient field modifier, is a completely different annotation from kotlinx.serialization's own Transient despite the identical name. Import the wrong one and the class won't even compile against @Serializable.

@Serializable
data class Session(
    val token: String,
    @Transient val cachedUser: User? = null // excluded from JSON; default required
)

Polymorphic serialization means encoding and decoding a hierarchy where the concrete subtype has to survive the round trip, and kotlinx.serialization solves it with a class discriminator. Mark the sealed parent and every subclass @Serializable, and the compiler plugin automatically writes a discriminator field, type by default, into the encoded JSON, then reads it back to pick the right subtype on decode. You don't write any dispatch logic yourself. The one thing worth overriding is the discriminator's value: by default it's the fully qualified class name, which breaks the moment you rename or move a class, so giving each subclass its own @SerialName sets a stable value that survives refactors.

@Serializable
sealed class Shape {
    @Serializable
    data class Circle(val radius: Double) : Shape()
    @Serializable
    data class Rect(val w: Double, val h: Double) : Shape()
}

val encoded = Json.encodeToString<Shape>(Shape.Circle(5.0))
// {"type":"Shape.Circle","radius":5.0}
val decoded = Json.decodeFromString<Shape>(encoded) // Circle

Not every type you need to serialize is one you can annotate. java.time.Instant is the standard example, it's a JDK type, so @Serializable can never go on its declaration. For a case like that, or for any type that needs encoding no built-in serializer provides, you implement the KSerializer contract yourself: a descriptor that describes the wire shape, plus serialize and deserialize functions, then attach it to a property with @Serializable(with = YourSerializer::class). This is also how you'd represent a value class as something other than its default form, a Cents value class that should read and write as a decimal string like 12.34 instead of a raw integer count of cents, for instance.

object InstantSerializer : KSerializer<Instant> {
    override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
    override fun serialize(encoder: Encoder, value: Instant) =
        encoder.encodeString(value.toString())
    override fun deserialize(decoder: Decoder): Instant =
        Instant.parse(decoder.decodeString())
}

@Serializable
data class Event(@Serializable(with = InstantSerializer::class) val time: Instant)

Moshi has two competing strategies for Kotlin classes, and only one of them is recommended for new code. Codegen, triggered by @JsonClass(generateAdapter = true), generates the adapter at compile time. Modern Moshi runs that codegen through KSP, Kotlin Symbol Processing, which replaced the older kapt processor for faster, more Kotlin-aware builds. The alternative, KotlinJsonAdapterFactory, builds the adapter reflectively at runtime instead, and to do that it pulls in kotlin-reflect, a dependency that weighs in around 2.5 MiB. Either strategy respects the Kotlin primary constructor correctly, honoring non-null types and default values the way Gson never does, the difference between them is purely when the adapter gets built and what it costs to build it.

// preferred: compile-time via KSP, no kotlin-reflect
@JsonClass(generateAdapter = true)
data class User(val name: String, val age: Int)

// avoid for new code: reflective, pulls in kotlin-reflect
val moshi = Moshi.Builder()
    .addLast(KotlinJsonAdapterFactory())
    .build()

Gson is still common in older codebases, but it's not the library to reach for on anything new, and the reasons are concrete rather than just it being older. It builds adapters reflectively at runtime, inspecting your class each time one is needed, which is slower than compile-time codegen and needs careful R8 rules to avoid being stripped. Worse, it doesn't respect Kotlin's type system at all: it can construct a non-null property as null when a JSON key is missing, silently ignoring the guarantee a non-nullable type is supposed to make. And it's effectively unmaintained now, with no meaningful development pace, which matters for a library sitting on the critical path of every network response your app parses. None of that means you have to rip Gson out of a legacy codebase, but there's no good reason to add it to a new one.

data class User(val name: String) // non-nullable

val user = Gson().fromJson("{}", User::class.java)
println(user.name) // null, despite the non-null type, an NPE waiting to happen

None of the Json configuration you've set up matters if it never reaches your actual network calls, and wiring it in is a distinct step from anything covered so far. Retrofit has no built-in awareness of kotlinx.serialization, so you add the kotlinx-serialization-converter artifact and hand your configured Json instance to its asConverterFactory function. Every setting on that instance, ignoreUnknownKeys, coerceInputValues, encodeDefaults, then applies uniformly to every request and response body Retrofit serializes through it. This is also why GsonConverterFactory.create() and MoshiConverterFactory.create() exist as their own separate artifacts: each library's Retrofit integration is a thin adapter wrapping its own configured instance, so it can never be shared across libraries.

val json = Json { ignoreUnknownKeys = true }

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(
        json.asConverterFactory("application/json".toMediaType())
    )
    .build()

Back to JSON Serialization