Kotlin Serialization Guide: Complete JSON Handling for Android

Master Kotlin Serialization with custom serializers, polymorphic types, and production configurations.

Introduction

Kotlin Serialization provides type-safe, efficient JSON handling without reflection. This advanced guide covers custom serializers, polymorphic serialization, and production-ready configurations.

Advanced Serializer Customization

Create custom KSerializer for complex types. Handle date formats with custom serializers. Serialize sealed classes polymorphically. Use contextual serialization for runtime types.

Polymorphic Serialization

Use @Polymorphic annotation for interface serialization. Register serializers with SerializersModule. Specify class discriminator. Handle unknown types gracefully.

Null Handling and Defaults

Configure encodeDefaults behavior. Handle null values with encodeNulls. Use default values for missing fields. Implement custom default handling.

Performance Optimization

Reuse Json instances. Pre-compile serializers. Use streaming API for large payloads. Profile serialization performance.

Frequently Asked Questions

How do I handle API versioning?

Use @Deprecated for old fields. Add new fields with defaults. Implement custom serializers for version-specific behavior. Never remove fields without API version coordination.

Can I use multiple JSON formats?

Yes. Create multiple Json instances with different configurations. Use named configurations for different APIs. Share serializers across configurations.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:20
UP00:01
MOD · ARTICLE · KOTLINS/N · AX-KOTLINPUBLISHED
KotlinMar 27, 202614 MIN

Kotlin Serialization: The Definitive Guide to JSON Parsing on Android

Replace Gson and Moshi with Kotlin Serialization for compile-time safe, multiplatform-ready JSON parsing with zero reflection and first-class Kotlin support.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why Kotlin Serialization Is Replacing Gson and Moshi

For nearly a decade, Android developers defaulted to Gson or Moshi for JSON parsing. Both libraries work, but they share a fundamental mismatch with modern Kotlin: they rely on runtime reflection or code generation that doesn't understand Kotlin's type system natively. Gson silently coerces nulls into non-null properties. Moshi requires separate adapters for sealed classes. Neither understands default parameter values. Kotlin Serialization is built by JetBrains as a first-party compiler plugin. It processes `@Serializable` annotations at compile time, generating efficient serializers that respect nullability, default values, and sealed hierarchies out of the box. There is no reflection at runtime, which means faster parsing, smaller APK size, and R8-friendly code that doesn't need keep rules. And because it's a Kotlin multiplatform library, the same models work on Android, iOS, desktop, and server.

Setup and Gradle Configuration

Kotlin Serialization requires two components: the compiler plugin (which generates serializers) and the runtime library (which provides the JSON format). The compiler plugin is applied in your module-level `build.gradle.kts`, and the runtime is added as a dependency. Note that the plugin version must match your Kotlin version exactly. If you're using the Kotlin Gradle plugin 2.0+, the serialization plugin version is managed automatically.
kotlin
// build.gradle.kts (project-level)
plugins {
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.serialization) apply false
}

// build.gradle.kts (module-level)
plugins {
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.plugin.serialization")
}

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

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

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

// libs.versions.toml
[versions]
kotlin = "2.1.0"
kotlinx-serialization = "1.7.3"

[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Defining Serializable Models

The `@Serializable` annotation tells the compiler to generate a serializer for a class. Unlike Gson, Kotlin Serialization enforces your type declarations at parse time -- if the JSON contains `null` for a non-null field, it throws immediately instead of silently corrupting your data. Default parameter values work exactly as you'd expect: if the JSON doesn't include a field, the default value is used. This eliminates entire categories of "field was unexpectedly null" crashes that plague Gson-based codebases.
kotlin
@Serializable
data class User(
    val id: Long,
    val email: String,
    val name: String,
    val role: UserRole = UserRole.VIEWER,          // Default if missing from JSON
    @SerialName("avatar_url") val avatarUrl: String? = null,  // Renamed + nullable
    @Transient val localTimestamp: Long = 0L        // Excluded from serialization
)

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

// Parsing -- null-safe, type-checked at compile time
val json = Json { ignoreUnknownKeys = true }
val user: User = json.decodeFromString("""
    {"id": 42, "email": "[email protected]", "name": "Rocky"}
""")
// user.role == UserRole.VIEWER (default applied)
// user.avatarUrl == null (nullable, missing from JSON)

// Encoding
val jsonString: String = json.encodeToString(user)

Sealed Classes and Polymorphic Serialization

Sealed class hierarchies are a core Kotlin pattern for modeling domain state, and Kotlin Serialization handles them natively. The serializer uses a discriminator field (default: `"type"`) to determine which subclass to deserialize into. This is critical for APIs that return different response shapes in the same array -- think notification types, feed items, or event streams. With Gson, you'd need a custom `TypeAdapterFactory`. With Moshi, a `PolymorphicJsonAdapterFactory`. With Kotlin Serialization, you annotate the sealed class and it just works.
kotlin
@Serializable
sealed interface NetworkResult<out T> {
    @Serializable
    @SerialName("success")
    data class Success<T>(val data: T) : NetworkResult<T>

    @Serializable
    @SerialName("error")
    data class Error(
        val code: Int,
        val message: String
    ) : NetworkResult<Nothing>
}

// Polymorphic notifications from a WebSocket
@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()
}

// Deserialize mixed array automatically
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 major HTTP clients support Kotlin Serialization as a first-class converter. For Retrofit, the official `converter-kotlinx-serialization` adapter slots in where you previously used `converter-gson` or `converter-moshi`. For Ktor, the `ContentNegotiation` plugin handles it natively. The key advantage over Gson with Retrofit: your API response models are validated at compile time. If you add a non-null field to a response model but the API doesn't always return it, the compiler forces you to make it nullable or provide a default. With Gson, this compiles fine and crashes at runtime.
kotlin
// Retrofit setup
val contentType = "application/json".toMediaType()
val jsonConfig = Json {
    ignoreUnknownKeys = true
    isLenient = true
    encodeDefaults = false        // Don't send default values in requests
    explicitNulls = false         // Omit null fields from requests
}

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

    @GET("users")
    suspend fun listUsers(
        @Query("page") page: Int = 1,
        @Query("limit") limit: Int = 20
    ): PaginatedResponse<User>

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

@Serializable
data class PaginatedResponse<T>(
    val data: List<T>,
    val page: Int,
    val totalPages: Int,
    @SerialName("has_more") val hasMore: Boolean
)

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

Custom Serializers and Migration Strategies

Sometimes you need custom parsing logic -- a date string to `Instant`, a CSV field to a `List`, or a legacy API that wraps everything in an extra envelope. Custom serializers handle these cases without polluting your model classes. For teams migrating from Gson or Moshi, the transition can be gradual. Both converters can coexist on the same Retrofit instance (Retrofit tries converters in order). Migrate one API interface at a time, validate with integration tests, and remove the old converter once everything is ported.
kotlin
// Custom serializer for java.time.Instant
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 {
        return Instant.parse(decoder.decodeString())
    }
}

// Usage: apply once in the model
@Serializable
data class Event(
    val id: String,
    val name: String,
    @Serializable(with = InstantSerializer::class)
    val createdAt: Instant,
    @Serializable(with = InstantSerializer::class)
    val updatedAt: Instant
)

// Or register globally in the Json configuration
val json = Json {
    ignoreUnknownKeys = true
    serializersModule = SerializersModule {
        contextual(Instant::class, InstantSerializer)
    }
}

// Migration: both converters on the same Retrofit instance
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/v1/")
    .addConverterFactory(jsonConfig.asConverterFactory(contentType)) // Tries first
    .addConverterFactory(GsonConverterFactory.create())                // Fallback
    .build()
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Kotlin Serialization validates nullability and types at compile time, eliminating an entire class of runtime crashes caused by Gson's silent null coercion.
  2. 2Sealed class polymorphism works natively with a discriminator field -- no custom adapters or factory classes required.
  3. 3Zero reflection means no ProGuard/R8 keep rules, faster parsing, and smaller APK size compared to Gson.
  4. 4Default parameter values are honored during deserialization, making API evolution safe without breaking existing clients.
  5. 5Migration from Gson or Moshi can be gradual -- both converters coexist on the same Retrofit instance during transition.
  6. 6As a Kotlin Multiplatform library, your serialization models work unchanged on Android, iOS, desktop, and backend targets.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

How do I handle API versioning?

Use @Deprecated for old fields. Add new fields with defaults. Implement custom serializers for version-specific behavior. Never remove fields without API version coordination.

Can I use multiple JSON formats?

Yes. Create multiple Json instances with different configurations. Use named configurations for different APIs. Share serializers across configurations.

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