---
title: "Kotlin Serialization: The Definitive Guide to JSON Parsing on Android"
description: "Replace Gson with Kotlin Serialization: reflectionless, multiplatform JSON parsing that enforces nullability and defaults when it decodes, not silently later."
url: https://android-x.com/blog/kotlin-serialization-guide
author: "Rocky Elsalaymeh"
publisher: "Strategia-X (ANDROID-ARCHITECT)"
published: 2026-03-27
updated: 2026-10-11
category: "Kotlin"
reading_time: "14 min read"
---

# Kotlin Serialization: The Definitive Guide to JSON Parsing on Android

> Replace Gson with Kotlin Serialization: reflectionless, multiplatform JSON parsing that enforces nullability and defaults when it decodes, not silently later.

By Rocky Elsalaymeh. Published 2026-03-27, updated 2026-10-11. Canonical page: https://android-x.com/blog/kotlin-serialization-guide

## Why move off Gson?

The strongest argument comes from Gson's own README: "Gson's main focus is on Java. Using it with other JVM languages such as Kotlin or Scala might work fine in many cases, but language-specific features such as Kotlin's non-`null` types or constructors with default arguments are not supported. This can lead to confusing and incorrect behavior" ([Gson](https://github.com/google/gson/blob/main/README.md)). The same README tells Android developers to "consider using Kotlin Serialization".

That is the whole case. A Kotlin data class says a field is never null and has a default. A parser that does not know either rule can hand you an object that breaks both.

[Kotlin Serialization](https://kotlinlang.org/docs/serialization.html) is the JetBrains library built for the language. Its README describes it as "a compiler plugin, that generates visitor code for serializable classes, runtime library with core serialization API and support libraries with various serialization formats", with "Complete multiplatform support: JVM, JS and Native" ([kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization/blob/master/README.md)). The project calls itself reflectionless in its own title.

One correction to an earlier version of this article, which said the library validates your models at compile time. It does not. The compiler plugin generates the serializers. Whether a payload matches your model is decided when the payload is decoded, and a mismatch is an exception at that moment. That is still the right behaviour: a loud failure at the boundary instead of a null where the type says there cannot be one.

## Setup and Gradle Configuration

There are two pieces and they version differently. The README is explicit: "the plugin has version the same as the compiler one, runtime library has different coordinates, repository and versioning". The plugin is released "in tandem with each new Kotlin compiler version", so its version is your Kotlin version.

Current numbers, read on 11 October 2026: the runtime is 1.11.0, published on 9 April 2026, with 1.12.0-RC available as a release candidate ([releases](https://github.com/Kotlin/kotlinx.serialization/releases)). Kotlin is 2.4.21. Retrofit is 3.0.0 and Ktor is 3.6.0. This article used to pin 1.7.3, Kotlin 2.1.0 and Retrofit 2.11.0.

Two things in the old sample were wrong for a current project. It applied `org.jetbrains.kotlin.android`, which Android Gradle plugin 9 rejects because it compiles Kotlin itself; the [version catalog guide](https://android-x.com/blog/version-catalogs-convention-plugins) covers that change. And it mixed a TOML catalog into a Kotlin script block.

On shrinking: "By default, proguard rules are supplied with the library", and they "keep serializers for all serializable classes that are retained after shrinking, so you don't need additional setup". There is one documented exception: "these rules do not affect serializable classes if they have named companion objects". If you use named companions on serializable classes, the README has the extra rules, and they differ between R8 compatibility mode and full mode. The [R8 guide](https://android-x.com/blog/baseline-profiles-r8-optimization) explains the two modes.

```kotlin
// build.gradle.kts (module)
plugins {
    id("com.android.library")
    // Same version as the Kotlin compiler
    id("org.jetbrains.kotlin.plugin.serialization") version "2.4.21"
}

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

    // Optional: Retrofit converter
    implementation("com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0")

    // Optional: Ktor client
    implementation("io.ktor:ktor-serialization-kotlinx-json:3.6.0")
}
```

## What is checked when a payload is decoded?

Four rules from the [basic serialization guide](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/basic-serialization.md) and the [JSON guide](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md) cover most of what goes wrong in production.

| Situation | What happens | In the docs' words |
|---|---|---|
| A property with no default is missing | `MissingFieldException` | "An object can be deserialized only when all its properties are present in the input." |
| A property with a default is missing | The default is used | Defaults "will be assigned to missing fields during decoding" |
| `null` arrives for a non-null property | Decoding fails | "Even though the `language` property has a default value, it is still an error to attempt to assign the `null` value to it." |
| The payload has a key your class lacks | Decoding fails | "By default, unknown keys encountered during deserialization produce an error." |

The last row is why nearly every Android client sets `ignoreUnknownKeys = true`: a server adding a field should not break shipped apps.

The third row has an escape hatch for third-party APIs you do not control. `coerceInputValues` "treats a limited subset of invalid input values as if the corresponding property was missing", and the list today is null for a non-nullable type and an unknown enum value. Use it deliberately. It trades a loud failure for a default.

Two annotations finish the model. `@SerialName` maps a JSON key to a Kotlin name. `@Transient` excludes a property, with a condition: "Transient properties must have a default value."

```kotlin
@Serializable
data class User(
    val id: Long,
    val email: String,
    val name: String,
    val role: UserRole = UserRole.VIEWER,                     // Default if the key is missing
    @SerialName("avatar_url") val avatarUrl: String? = null,  // Renamed, nullable
    @Transient val localTimestamp: Long = 0L                  // Never serialized
)

@Serializable
enum class UserRole {
    @SerialName("admin") ADMIN,
    @SerialName("editor") EDITOR,
    @SerialName("viewer") VIEWER
}

val json = Json { ignoreUnknownKeys = true }

// Checked here, when the string is decoded
val user: User = json.decodeFromString("""
    {"id": 42, "email": "dev@example.com", "name": "Rocky"}
""")
// user.role == UserRole.VIEWER (default applied)
// user.avatarUrl == null (default applied)

// {"id": 42, "email": null, "name": "Rocky"} would throw:
// email is declared non-null.

val jsonString: String = json.encodeToString(user)
```

## Sealed Classes and Polymorphic Serialization

A feed, a notification stream or an event log returns different shapes in one array. The [polymorphism guide](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md) starts with the simple route: "The most straightforward way to use serialization with a polymorphic hierarchy is to mark the base class `sealed`."

Three facts from that guide are enough to use it correctly:

- "All subclasses of a sealed class must be explicitly marked as `@Serializable`." Miss one and it is not part of the format.
- "A `type` key is added to the resulting JSON object as a discriminator."
- "A value of the `type` key is a fully qualified class name by default." You almost never want a package name in your wire format, so put `@SerialName` on every subtype. A package rename should not be a breaking API change.

If the server calls the discriminator something else, set `classDiscriminator` in the `Json` builder. The modelling side of this, when to reach for a sealed interface over a sealed class, is in [Kotlin sealed interfaces](https://android-x.com/blog/kotlin-sealed-interfaces).

```kotlin
@Serializable
sealed class PushNotification {
    abstract val id: String
    abstract val timestamp: Long

    @Serializable
    @SerialName("message")
    data class ChatMessage(
        override val id: String,
        override val timestamp: Long,
        val senderId: String,
        val text: String
    ) : PushNotification()

    @Serializable
    @SerialName("order_update")
    data class OrderUpdate(
        override val id: String,
        override val timestamp: Long,
        val orderId: String,
        val status: String
    ) : PushNotification()
}

// The "type" key picks the subclass
val notifications: List<PushNotification> = json.decodeFromString("""
    [
        {"type": "message", "id": "1", "timestamp": 1711540800, "senderId": "u42", "text": "Hello!"},
        {"type": "order_update", "id": "2", "timestamp": 1711540900, "orderId": "ord-99", "status": "shipped"}
    ]
""")
```

## Retrofit and Ktor Integration

Both HTTP clients take the library directly. Retrofit ships a first-party converter, `converter-kotlinx-serialization`, whose [README](https://github.com/square/retrofit/blob/trunk/retrofit-converters/kotlinx-serialization/README.md) shows the one call you need: `asConverterFactory()` with a media type. Ktor's documentation puts it in a sentence: "`kotlinx.serialization` requires the `ktor-serialization-kotlinx-json` dependency for JSON" ([Ktor](https://github.com/ktorio/ktor-documentation/blob/main/topics/client-serialization.md)), installed through the `ContentNegotiation` plugin and its `json()` function.

The configuration below is shorter than the one this article used to show, on purpose:

- `isLenient = true` is gone. By default the parser "enforces various JSON restrictions to be as specification-compliant as possible", and a production API client has no reason to relax that.
- `encodeDefaults = false` is gone because it is the default: "Default values of properties are not encoded by default".
- `explicitNulls = false` stays, with its cost stated. It omits null fields from requests, and the guide warns that a nullable property with a non-null default then encodes and decodes asymmetrically.

The old text also said the build would make you declare a field nullable when the API does not always send it. It will not. You find out with a `MissingFieldException` the first time the field is absent, which is an argument for contract tests against real payloads.

```kotlin
// Retrofit setup
val contentType = "application/json; charset=utf-8".toMediaType()

val jsonConfig = Json {
    ignoreUnknownKeys = true   // A new server field must not break old clients
    explicitNulls = false      // Omit null fields from request bodies
}

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/v1/")
    .addConverterFactory(jsonConfig.asConverterFactory(contentType))
    .build()

interface UserApi {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: Long): User

    @POST("users")
    suspend fun createUser(@Body request: CreateUserRequest): User
}

@Serializable
data class CreateUserRequest(
    val email: String,
    val name: String,
    val role: UserRole = UserRole.VIEWER
)

// Ktor setup
val client = HttpClient {
    install(ContentNegotiation) {
        json(jsonConfig)
    }
}
```

## Custom Serializers and Migration Strategies

A custom serializer handles a type the plugin cannot generate for, such as `java.time.Instant`. The [serializers guide](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serializers.md) has one rule people skip: the descriptor "must be used with a unique name of the type that is being serialized". The old sample named it `"Instant"`. Use a qualified name of your own.

To apply one serializer across a project without annotating every property with a class reference, the guide describes contextual serialization: "we define a SerializersModule instance that describes which serializers shall be used at run-time to serialize which contextually-serializable classes". Registering it in the module is half the job. The property must also be marked `@Contextual`, which the earlier version of this article left out.

**Migrating from Gson.** The earlier advice here was wrong in a way that would have shipped. It put the Kotlin Serialization converter first and Gson second on one Retrofit instance, as a fallback. Retrofit's source rules that out for both converters. Of the Kotlin one: "If you are mixing this with something else, you must add this instance last to allow the other converters a chance to see their types" ([Factory.kt](https://github.com/square/retrofit/blob/trunk/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/Factory.kt)). Of the Gson one: "this converter assumes that it can handle all types" ([GsonConverterFactory.java](https://github.com/square/retrofit/blob/trunk/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonConverterFactory.java)). Two converters that each claim every type cannot share an instance: whichever is first takes everything.

The working approach is two Retrofit instances over one `OkHttpClient`, one per converter. Move one API interface at a time from the Gson instance to the new one, test it against recorded payloads, and delete the Gson instance when it has no interfaces left. If the models are shared with iOS later, the same classes work in a [Kotlin Multiplatform](https://android-x.com/blog/kotlin-multiplatform-android) module.

```kotlin
// Custom serializer with a unique, qualified descriptor name
object InstantSerializer : KSerializer<Instant> {
    override val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor("com.myapp.Instant", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: Instant) {
        encoder.encodeString(value.toString())
    }

    override fun deserialize(decoder: Decoder): Instant =
        Instant.parse(decoder.decodeString())
}

// Option 1: name the serializer on the property
@Serializable
data class Event(
    val id: String,
    @Serializable(with = InstantSerializer::class)
    val createdAt: Instant
)

// Option 2: contextual. Mark the property AND register the serializer.
@Serializable
data class AuditEntry(
    val id: String,
    @Contextual val recordedAt: Instant
)

val json = Json {
    ignoreUnknownKeys = true
    serializersModule = SerializersModule {
        contextual(InstantSerializer)
    }
}

// Migration: one Retrofit instance per converter, one shared client
val legacyRetrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/v1/")
    .client(okHttpClient)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/v1/")
    .client(okHttpClient)
    .addConverterFactory(jsonConfig.asConverterFactory(contentType))
    .build()

val ordersApi = legacyRetrofit.create(OrdersApi::class.java) // Not migrated yet
val userApi = retrofit.create(UserApi::class.java)           // Migrated
```

## Key takeaways

- Gson states in its own README that Kotlin non-null types and default arguments are not supported. That is the reason to move.
- Kotlin Serialization checks a payload when it is decoded, not at compile time. A mismatch is an exception at the boundary.
- A missing property without a default throws, null for a non-null property throws, and unknown keys throw unless ignoreUnknownKeys is set.
- For sealed hierarchies, mark every subtype @Serializable and give each a @SerialName so no package name reaches the wire.
- The plugin version is your Kotlin version. The runtime versions separately and is 1.11.0 as of October 2026.
- Do not chain the Gson and Kotlin Serialization converters on one Retrofit instance. Each claims every type. Use two instances.

## Frequently asked questions

### Does Kotlin Serialization validate my models at compile time?

No. The compiler plugin generates serializers at compile time, and the payload is checked when it is decoded. A missing required property throws MissingFieldException, and null for a non-null property is an error even when the property has a default. You still need tests against real payloads to catch a mismatch before users do.

### Do I need ProGuard or R8 keep rules for Kotlin Serialization?

Usually not. The library README states that proguard rules are supplied with the library and keep serializers for all serializable classes retained after shrinking. The documented exception is serializable classes with named companion objects, which need extra rules that differ between R8 compatibility mode and full mode.

### Can Gson and Kotlin Serialization share one Retrofit instance during a migration?

Not usefully. Retrofit documents that each of these converters assumes it can handle all types, so whichever is added first receives every request and response and the second is never consulted. Create two Retrofit instances that share one OkHttpClient, and move API interfaces from the Gson instance to the new one a few at a time.

## Related articles

- [Kotlin Multiplatform for Android Developers: Sharing Code Without Losing Native Quality](https://android-x.com/blog/kotlin-multiplatform-android.md)
- [Kotlin Sealed Interfaces: Modeling Complex Domain State Without Compromise](https://android-x.com/blog/kotlin-sealed-interfaces.md)
- [Version Catalogs and Convention Plugins: Scaling Gradle for Multi-Module Android Projects](https://android-x.com/blog/version-catalogs-convention-plugins.md)
