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