Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 088 / 100

Kotlin serialization: a practical Android & Kotlin guide

JSON without reflection.

Expona Admin6 min read

Why Kotlin serialization matters for modern Android

JSON parsing is one of the most common tasks in mobile development, yet many teams still rely on reflection-based libraries that add runtime overhead and increase APK size. Kotlin serialization: a practical Android & Kotlin guide walks you through a compile-time approach that generates serializers ahead of time, eliminating reflection entirely. We've seen teams reduce build times by 15% and APK sizes by 2–3 MB after switching from reflection-based solutions.

Over 88 days of building production Android apps, you've learned to think about performance, maintainability, and correctness. Kotlin serialization aligns with all three by enforcing type safety at compile time, requiring explicit opt-in for each data class, and providing a predictable, controlled serialization pipeline.

@Serializable on data classes

The foundation of Kotlin serialization is the @Serializable annotation. Add it to any data class, and the Kotlin compiler plugin generates a serializer automatically. No interfaces to implement, no boilerplate—just the annotation.

kotlin
import kotlinx.serialization.Serializable

@Serializable
data class User(
    val id: Long,
    val name: String,
    val email: String,
    val isActive: Boolean = true
)

@Serializable
data class ApiResponse(
    val status: String,
    val data: List,
    val timestamp: Long
)

When you compile this code, the Kotlin compiler plugin generates a serializer for each class. You then use the Json object to parse or produce JSON:

kotlin
import kotlinx.serialization.json.Json

// Parse JSON string to Kotlin object
val jsonString = """{"id":1,"name":"Alice","email":"alice@example.com","isActive":true}"""
val user = Json.decodeFromString(jsonString)

// Encode Kotlin object to JSON string
val response = ApiResponse(
    status = "success",
    data = listOf(user),
    timestamp = System.currentTimeMillis()
)
val jsonOutput = Json.encodeToString(response)

One critical advantage: since serialization happens at compile time, you catch type mismatches immediately. If your API changes and returns a field you didn't expect, Kotlin serialization won't silently skip it—you control exactly what happens.

In production e-commerce systems, we often work with hundreds of API models. Using @Serializable makes it trivial to add new endpoints because the compiler enforces consistency. No more debugging JSON parsing failures in production; errors surface during the build.

Json { ignoreUnknownKeys = true }

Real-world APIs evolve. A backend team might add a new field to the response while you're still on an older version of the client. Without proper configuration, parsing would fail.

Kotlin serialization provides a flexible Json builder that lets you customize behavior:

kotlin
val json = Json {
    ignoreUnknownKeys = true
    prettyPrint = true
    encodeDefaults = true
}

// Now parsing will succeed even if the JSON has extra fields
val user = json.decodeFromString(jsonString)

The ignoreUnknownKeys = true setting is essential for robust mobile apps. When the backend adds a field that your client doesn't yet know about, the parser simply skips it instead of throwing an exception. This makes rolling out new API versions far less risky.

Other useful configurations:

  • prettyPrint: formats JSON with indentation and newlines, useful for logging and debugging.
  • encodeDefaults: includes fields with default values in the output JSON, which some APIs require.
  • coerceInputValues: automatically converts null to the default value if the field is missing.
  • classDiscriminator: specifies which field identifies polymorphic types.

In a travel app we worked on, the backend would occasionally add new search filters or booking statuses. By setting ignoreUnknownKeys = true and using sensible defaults in our data classes, old client versions could still function while new versions took advantage of the new fields.

Works on Kotlin Multiplatform

If you're building on Kotlin Multiplatform (KMP), Kotlin serialization becomes even more valuable. You write your models once in the common module, and both Android and iOS clients share identical serialization logic.

kotlin
// commonMain/kotlin/models/Product.kt
@Serializable
data class Product(
    val id: String,
    val title: String,
    val price: Double,
    val imageUrl: String,
    val inStock: Boolean
)

@Serializable
data class Cart(
    val items: List,
    val totalPrice: Double,
    val currency: String = "USD"
)

Both your Android and iOS apps can then use these models with zero duplication:

kotlin
// androidMain or iosMain
val json = Json { ignoreUnknownKeys = true }
val cart = json.decodeFromString(jsonResponse)

This eliminates a major source of inconsistency. In our ride-hailing platform, Android and iOS clients used to parse the same API differently, leading to subtle bugs where one platform handled edge cases better than the other. Moving to KMP with shared models eliminated that entire category of defects.

Kotlin serialization also supports the entire common/android/ios module structure without any platform-specific tweaks needed for JSON parsing.

Custom serializers for special types

Not every type fits the standard model. Timestamps, UUIDs, custom enums, or third-party classes often need special handling. Kotlin serialization lets you write custom serializers.

A common case: your API returns timestamps as Unix milliseconds, but you want to work with Instant in your code:

kotlin
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant

@Serializer(forClass = Instant::class)
object InstantSerializer : KSerializer {
    override val descriptor = PrimitiveSerialDescriptor(
        "Instant",
        PrimitiveKind.LONG
    )

    override fun serialize(encoder: Encoder, value: Instant) {
        encoder.encodeLong(value.toEpochMilli())
    }

    override fun deserialize(decoder: Decoder): Instant {
        return Instant.ofEpochMilli(decoder.decodeLong())
    }
}

@Serializable
data class Event(
    val id: String,
    val title: String,
    @Serializable(with = InstantSerializer::class)
    val createdAt: Instant
)

Now when you parse JSON with a timestamp field, it automatically becomes an Instant object without any manual conversion:

kotlin
val json = Json { ignoreUnknownKeys = true }
val event = json.decodeFromString("""
{
    "id": "evt-001",
    "title": "User signup",
    "createdAt": 1704067200000
}
""".trimIndent())

println(event.createdAt) // 2024-01-01T00:00:00Z

Custom serializers are also useful for enum variants that don't map directly to field names:

kotlin
@Serializable
enum class OrderStatus {
    @SerialName("pending_payment")
    PENDING_PAYMENT,
    
    @SerialName("processing")
    PROCESSING,
    
    @SerialName("completed")
    COMPLETED,
    
    @SerialName("failed")
    FAILED
}

@Serializable
data class Order(
    val id: String,
    val status: OrderStatus
)

The @SerialName annotation maps the JSON value to the Kotlin enum member. In trading systems and CRM platforms, this becomes critical because external systems use different naming conventions than your code.

Common mistakes

Forgetting the annotation: If you annotate a data class with @Serializable but fail to annotate nested classes, you'll get a compile error. Make sure the entire object graph is annotated.

Mutable default values: Avoid mutable defaults in your data classes. Use emptyList() or emptyMap() instead of mutable collections. Kotlin serialization expects immutability.

Over-permissive configuration: While ignoreUnknownKeys = true is useful, applying it globally to every Json instance can hide real bugs. Consider using it only where you need version compatibility.

Not handling null: By default, nullable properties must be present in the JSON or serialization fails. Use coerceInputValues = true if you want missing nullable fields to deserialize as null.

Forgetting serialization format choices: Kotlin serialization supports multiple formats—JSON, Protobuf, CBOR—via different libraries. Don't assume you're stuck with JSON if a different format would be more efficient.

Key takeaways

  • Add @Serializable to your data classes and let the compiler generate serializers automatically—no reflection overhead.
  • Use Json { ignoreUnknownKeys = true }
  • Use Json { ignoreUnknownKeys = true } to handle API evolution gracefully and safely.
  • Kotlin serialization works seamlessly across Kotlin Multiplatform modules, enabling true code sharing between Android and iOS.
  • Write custom serializers for non-standard types like Instant, UUIDs, or fields with special naming conventions.
  • Compile-time safety catches type mismatches early, preventing runtime JSON parsing failures in production.
  • Keep your data classes immutable and ensure the entire object graph is annotated to avoid surprises at build time.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles