JSON Serialization Explained
DATA › Networking
Every networked Android app parses JSON, and there are three libraries you'll be asked to compare: **kotlinx.serialization**, **Moshi**, and **Gson**. The core split is *when* the code that reads your JSON gets built. kotlinx.serialization and modern Moshi generate that code at **compile time**, no reflection at runtime. Gson builds it **reflectively at runtime**, inspecting your class each time it's needed.
@Serializable
data class User(val id: Int, val name: String)
val user = Json.decodeFromString<User>("""{"id":1,"name":"Ada"}""")
Compile-time generation is faster, smaller (no reflection library needed), and R8-friendly. That's the whole reason kotlinx.serialization is now JetBrains' recommended default for new Kotlin and KMP code.
**Two things are required to use kotlinx.serialization in a module**: apply the org.jetbrains.kotlin.plugin.serialization Gradle plugin (which does the compile-time codegen), and add the runtime dependency, kotlinx-serialization-json for JSON:
// build.gradle.kts
plugins {
kotlin("plugin.serialization") version "2.0.0"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
}
Then mark any class you want to serialize with @Serializable, the plugin generates its KSerializer automatically:
@Serializable
data class User(val id: Int, val name: String)
val json = Json.encodeToString(User(1, "Ada"))
**By default, kotlinx.serialization is strict about unknown JSON keys.** If the server sends a field your @Serializable class doesn't declare, decoding throws SerializationException, which is exactly the kind of thing that breaks production when an API adds a field:
@Serializable
data class User(val name: String)
// server sends {"name":"Ada","age":30} -> throws by default
Json.decodeFromString<User>(json)
The fix is a configured Json instance with ignoreUnknownKeys = true, which is standard practice for any API you don't fully control:
val lenient = Json { ignoreUnknownKeys = true }
lenient.decodeFromString<User>(json) // "age" is simply skipped
**Missing fields behave differently depending on whether the property has a default.** If a JSON key is absent but the property has a default value, that default is used. If the property is non-nullable with no default, decoding throws MissingFieldException:
@Serializable
data class User(val name: String, val age: Int) // age has no default
// JSON omits "age" -> throws MissingFieldException
Json.decodeFromString<User>("""{"name":"Ada"}""")
Giving age a default fixes it:
@Serializable
data class UserSafe(val name: String, val age: Int = 0)
Note this only covers *absent* keys. A present key with the wrong type, or explicit JSON null against a non-nullable type, is a separate case (that's what coerceInputValues is for).
**Missing keys and present-but-null keys are not the same failure, and only one has a dedicated fix.** The previous chunk covered an absent key falling back to a default. But if the JSON explicitly sends null for a non-nullable property that already has a default, that's still a thrown SerializationException by default, null is a real value, not an absence.
@Serializable
data class Config(val timeout: Int = 30)
// present but null -> throws SerializationException by default
Json.decodeFromString<Config>("""{"timeout":null}""")
// coerceInputValues falls back to the default instead
val lenient = Json { coerceInputValues = true }
lenient.decodeFromString<Config>("""{"timeout":null}""").timeout // 30
coerceInputValues = true gives an explicit null (or any value that doesn't parse into the expected type) against a defaulted property the same forgiving treatment ignoreUnknownKeys gives unknown keys: fall back instead of failing the whole decode.
**Field renaming syntax differs per library, this is a common mix-up.** kotlinx.serialization uses @SerialName, Moshi uses @Json(name = ...), and Gson uses @SerializedName:
// kotlinx.serialization
@Serializable
data class User(@SerialName("user_name") val userName: String)
// Moshi
data class User(@Json(name = "user_name") val userName: String)
// Gson
data class User(@SerializedName("user_name") val userName: String)
If a server renames a key but still sometimes sends the old one, @SerialName can't help, it only ever accepts a single name. @JsonNames fills that gap: it lists every input key decoding should accept for the property, while encoding always writes the primary @SerialName:
@Serializable
data class Account(@JsonNames("username", "login") val username: String)
Json.decodeFromString<Account>("""{"username":"ada"}""") // works
Json.decodeFromString<Account>("""{"login":"ada"}""") // also works
**Excluding a property from serialization needs @Transient, and it comes with one hard rule.** Mark a property @Transient and kotlinx.serialization skips it entirely on both encode and decode, but the property must have a default value, since decoding can never populate it from JSON, and the compiler plugin enforces that at compile time, not as a runtime surprise.
@Serializable
data class Session(
val token: String,
@Transient val cachedUser: User? = null // excluded from JSON; default required
)
Moshi's equivalent is @Json(ignore = true), same idea, different annotation. Watch for a naming trap here: Kotlin's own built-in kotlin.jvm.Transient (what Gson respects, since it maps to the JVM transient field modifier) is a completely different annotation from kotlinx.serialization.Transient despite sharing a name, importing the wrong one won't even compile against @Serializable.
**A property whose value equals its declared default is omitted from the output entirely, and that surprises people who expect a full snapshot.** By default, encodeDefaults is false, so Json.encodeToString skips any property currently sitting at its default, which keeps payloads small but means the encoded JSON isn't a structural mirror of every field:
@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
To force every default-valued property to be written anyway, set encodeDefaults = true on your Json instance, or annotate a single property with @EncodeDefault to control it per-field instead of globally:
val json = Json { encodeDefaults = true }
json.encodeToString(Config()) // {"timeout":30,"retries":3}
**Moshi has two Kotlin strategies, and only one is recommended.** Codegen, @JsonClass(generateAdapter = true) processed by KSP, generates the adapter at compile time with no runtime reflection cost. The alternative, KotlinJsonAdapterFactory, builds adapters reflectively at runtime and pulls in the roughly 2.5 MiB kotlin-reflect dependency:
// preferred: compile-time, no kotlin-reflect
@JsonClass(generateAdapter = true)
data class User(val name: String, val age: Int)
// avoid for new code: runtime reflection
val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
Either way, Moshi (unlike Gson) understands the Kotlin primary constructor, so it respects non-null types and default values correctly.
**Sealed class hierarchies serialize polymorphically with a class discriminator.** Mark the sealed parent and every subclass @Serializable, and the compiler plugin emits a discriminator field (default key "type") so decoding picks the right subtype automatically:
@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
Use @SerialName on each subclass to set a stable discriminator value instead of the default fully-qualified class name, which breaks if you ever rename or move the class.
**For a type you don't own, or one that needs encoding no built-in serializer provides, you write a KSerializer yourself.** java.time.Instant is the standard example: it's a JDK type, so you can't annotate it @Serializable, but you can implement the KSerializer<T> contract, a descriptor describing the wire shape, plus serialize/deserialize, and attach it with @Serializable(with = ...):
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)
Instant round-trips through ISO-8601 strings, so the descriptor declares a STRING primitive, and serialize/deserialize just delegate to Instant.toString()/Instant.parse().
**Gson is legacy for new Kotlin code, and the reasons are concrete, not just 'it's older'.** It's reflection-based (slower, blind to R8 without careful rules), and critically, it doesn't respect Kotlin's type system: it can construct a non-null property as null if the JSON key is missing, silently ignoring the val name: String guarantee:
data class User(val name: String) // non-nullable
val user = Gson().fromJson("{}", User::class.java)
println(user.name) // null, despite the non-null type -> NPE waiting to happen
It's also effectively unmaintained, no real development pace to speak of, which matters for a library sitting on the critical path of every network response your app parses. None of that rules Gson out for legacy codebases already built on it, but it's not the library to reach for on anything new.
**None of this matters if the Json instance you configured never actually reaches your network calls, and that wiring is a separate step from anything covered so far.** Retrofit doesn't know about kotlinx.serialization out of the box, you add the kotlinx-serialization-converter artifact and hand your configured Json to asConverterFactory:
val json = Json { ignoreUnknownKeys = true }
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(
json.asConverterFactory("application/json".toMediaType())
)
.build()
Every setting you configured on that Json instance, ignoreUnknownKeys, coerceInputValues, encodeDefaults, now applies to every request and response body Retrofit serializes through it. This is also why GsonConverterFactory.create() and MoshiConverterFactory.create() exist as separate artifacts, each library's Retrofit integration is a thin adapter over its own configured instance.