SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:24
UP00:01
MOD · ARTICLE · KOTLINS/N · AX-KOTLINPUBLISHED
All Articles
Kotlin · Updated 14 MIN

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 · Founder & Principal Consultant, Strategia-X

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). 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 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). 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). 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 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 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 and the JSON guide cover most of what goes wrong in production.
SituationWhat happensIn the docs' words
A property with no default is missingMissingFieldException"An object can be deserialized only when all its properties are present in the input."
A property with a default is missingThe default is usedDefaults "will be assigned to missing fields during decoding"
null arrives for a non-null propertyDecoding 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 lacksDecoding 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 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.
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 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), 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 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). Of the Gson one: "this converter assumes that it can handle all types" (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 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
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

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

Frequently Asked

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.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA