Kotlin JSON: kotlinx.serialization, Moshi, Gson & Ktor Integration
Last updated:
Kotlin JSON serialization uses kotlinx.serialization — add @Serializable to a data class and call Json.decodeFromString<MyClass>(jsonStr) or Json.encodeToString(obj) for type-safe JSON handling with compile-time code generation and no runtime reflection. kotlinx.serialization generates serializer code at compile time via a Kotlin compiler plugin — decoding a 10 KB JSON object takes ~0.5 ms on Android, 3× faster than Gson which uses reflection; the generated serializers also work in Kotlin Multiplatform (KMP) for iOS, desktop, and web targets. This guide covers @Serializable data classes, @SerialName for field name mapping, Json configuration (ignoreUnknownKeys, prettyPrint), JsonElement for dynamic JSON, custom serializers, Moshi with KotlinJsonAdapterFactory, and Ktor HTTP client JSON integration.
@Serializable and kotlinx.serialization Basics
kotlinx.serialization requires two Gradle additions: the kotlin("plugin.serialization") compiler plugin and the kotlinx-serialization-json runtime dependency. Annotate any data class with @Serializable and the plugin generates a companion serializer() at compile time — no runtime reflection. Call Json.encodeToString(obj) to serialize and Json.decodeFromString<T>(jsonStr) to deserialize. Both throw SerializationException on malformed input, which you should catch in production code. The generated serializers are compatible with Kotlin Multiplatform — the same @Serializable class compiles for Android, iOS (via Kotlin/Native), and Kotlin/JS without any changes.
// build.gradle.kts — Gradle plugin and dependency setup
plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.serialization") version "2.0.0" // compiler plugin
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
}
// ── Basic @Serializable data class ────────────────────────────
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
@Serializable
data class User(
val id: Int,
val name: String,
val email: String,
val active: Boolean = true, // default value — used when field is missing
)
// Serialize to JSON string
val user = User(id = 1, name = "Alice", email = "alice@example.com")
val jsonStr: String = Json.encodeToString(user)
// Result: {"id":1,"name":"Alice","email":"alice@example.com","active":true}
// Deserialize from JSON string
val decoded: User = Json.decodeFromString<User>(jsonStr)
// decoded.name == "Alice", decoded.active == true
// ── Error handling ─────────────────────────────────────────────
import kotlinx.serialization.SerializationException
try {
val bad: User = Json.decodeFromString<User>("{invalid json}")
} catch (e: SerializationException) {
println("JSON parse failed: ${e.message}")
} catch (e: IllegalArgumentException) {
println("Illegal argument: ${e.message}")
}
// ── Serialize a list ───────────────────────────────────────────
val users = listOf(User(1, "Alice", "alice@example.com"), User(2, "Bob", "bob@example.com"))
val listJson: String = Json.encodeToString(users)
// [{"id":1,"name":"Alice","email":"alice@example.com","active":true},...]
val decodedList: List<User> = Json.decodeFromString<List<User>>(listJson)
// ── Kotlin Multiplatform — same class, all targets ─────────────
// In commonMain/kotlin:
@Serializable
data class ApiResponse<T>(val data: T, val status: String)
// Works identically on:
// androidMain (JVM bytecode)
// iosMain (Kotlin/Native)
// jsMain (Kotlin/JS)Missing JSON fields use the Kotlin default value, not null — val active: Boolean = true in the data class means {"id":1,"name":"Alice","email":"a@b.com"} decodes to User(active = true). This is a key advantage over Gson, which bypasses the Kotlin constructor and sets missing fields to null or zero regardless of declared defaults. For nullable fields (val bio: String? = null), a missing JSON field also correctly applies the null default rather than throwing.
@SerialName and Json Configuration
@SerialName overrides the JSON key for a field or class — use it when the JSON API uses a different naming convention than your Kotlin code. The Json builder configures serialization behavior globally for a Json instance: ignoreUnknownKeys for forward compatibility, prettyPrint for human-readable output, isLenient for relaxed parsing, and coerceInputValues for graceful enum fallback. Create a shared Json instance rather than using the default Json companion object so you can configure it consistently across your application.
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
// ── @SerialName for field name mapping ─────────────────────────
@Serializable
data class UserProfile(
@SerialName("user_id") val userId: Int, // JSON key: "user_id"
@SerialName("full_name") val fullName: String, // JSON key: "full_name"
@SerialName("is_active") val isActive: Boolean,
val email: String, // no annotation → key: "email"
)
// JSON: {"user_id":1,"full_name":"Alice Smith","is_active":true,"email":"a@b.com"}
// Kotlin: UserProfile(userId=1, fullName="Alice Smith", isActive=true, email="a@b.com")
// ── Naming strategy (kotlinx.serialization 1.5+) ───────────────
// Alternative to per-field @SerialName for consistent snake_case conversion
val snakeCaseJson = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
}
// userId → "user_id", isActive → "is_active" automatically
// ── Production-safe Json configuration ────────────────────────
val AppJson = Json {
ignoreUnknownKeys = true // don't throw on extra JSON fields
prettyPrint = false // compact output (set true for debugging)
isLenient = false // strict parsing by default
coerceInputValues = true // invalid enum → default value (not exception)
encodeDefaults = false // omit fields equal to their default (smaller JSON)
}
// ── isLenient — accept single-quoted and unquoted JSON ─────────
val lenientJson = Json { isLenient = true }
val result = lenientJson.decodeFromString<Map<String, String>>("{'key': 'value'}")
// Useful for parsing JavaScript object literals or malformed API responses
// ── encodeDefaults — include or exclude default values ─────────
@Serializable
data class Config(
val timeout: Int = 30,
val retries: Int = 3,
val debug: Boolean = false,
)
val defaultConfig = Config()
println(Json.encodeToString(defaultConfig))
// Default Json: {"timeout":30,"retries":3,"debug":false} (encodeDefaults = true by default)
val compactJson = Json { encodeDefaults = false }
println(compactJson.encodeToString(defaultConfig))
// {} — omits all fields equal to default values
// ── @SerialName on enum values ─────────────────────────────────
@Serializable
enum class Status {
@SerialName("active") ACTIVE,
@SerialName("inactive") INACTIVE,
@SerialName("pending") PENDING,
}
// JSON: "active" → Status.ACTIVE (not "ACTIVE")
// coerceInputValues handles unknown enum values by falling back to default
@Serializable
data class UserStatus(val status: Status = Status.ACTIVE)
val json = Json { coerceInputValues = true }
val decoded = json.decodeFromString<UserStatus>("""{"status":"unknown_future_value"}""")
// decoded.status == Status.ACTIVE (the default), no exceptionThe encodeDefaults = false setting produces smaller JSON by omitting fields that equal their default values — useful for network payloads. Set encodeDefaults = true (the default) when the receiving system requires all fields to be present. coerceInputValues = true is the safer choice for API consumers: unknown enum values fall back to the property default rather than throwing SerializationException, which prevents crashes when the API adds new enum values.
JsonElement and Dynamic JSON
JsonElement is the base type for dynamic, schema-free JSON access in kotlinx.serialization. The hierarchy has four concrete types: JsonObject (a JSON object, implements Map<String, JsonElement>), JsonArray (implements List<JsonElement>), JsonPrimitive (string, number, or boolean), and JsonNull. Use Json.parseToJsonElement() to parse JSON without a target class, navigate with safe casts and smart-cast properties, and build dynamic JSON with the buildJsonObject / buildJsonArray DSL. JsonElement can also appear as a typed field in a @Serializable class to capture a variable-schema portion of a response.
import kotlinx.serialization.json.*
val rawJson = """
{
"id": 1,
"name": "Alice",
"score": 98.5,
"active": true,
"tags": ["admin", "editor"],
"address": { "city": "London", "zip": "EC1A" },
"extra": null
}
"""
// ── Parse to JsonElement ───────────────────────────────────────
val element: JsonElement = Json.parseToJsonElement(rawJson)
val obj: JsonObject = element.jsonObject
// Access fields with safe navigation
val id: Int? = obj["id"]?.jsonPrimitive?.int
val name: String? = obj["name"]?.jsonPrimitive?.content
val score: Double? = obj["score"]?.jsonPrimitive?.double
val active: Boolean? = obj["active"]?.jsonPrimitive?.boolean
// Nested object
val city: String? = obj["address"]?.jsonObject?.get("city")?.jsonPrimitive?.content
// Array access
val tags: List<String> = obj["tags"]?.jsonArray
?.map { it.jsonPrimitive.content }
?: emptyList()
// Check for null
val extra: JsonElement? = obj["extra"]
val isNull: Boolean = extra is JsonNull // true
// ── buildJsonObject DSL ────────────────────────────────────────
val built: JsonObject = buildJsonObject {
put("id", 42)
put("name", "Bob")
put("active", true)
putJsonArray("roles") {
add("reader")
add("writer")
}
putJsonObject("meta") {
put("version", 2)
put("source", "api")
}
}
// Result: {"id":42,"name":"Bob","active":true,"roles":["reader","writer"],"meta":{...}}
// ── JsonElement as a data class field ─────────────────────────
// Use when a field's schema varies per response
@Serializable
data class ApiResponse(
val status: String,
val data: JsonElement, // could be an object or array
val metadata: JsonObject? = null,
)
val response = Json.decodeFromString<ApiResponse>("""
{
"status": "ok",
"data": [{"id":1},{"id":2}],
"metadata": {"page": 1, "total": 50}
}
""")
val items: JsonArray = response.data.jsonArray
// ── Convert JsonObject to Map<String, String> ──────────────────
val flatMap: Map<String, String> = obj
.filterValues { it is JsonPrimitive }
.mapValues { (_, v) -> v.jsonPrimitive.content }
// {"id":"1","name":"Alice","score":"98.5","active":"true"}The jsonPrimitive, jsonObject, and jsonArray extension properties throw IllegalArgumentException if the element is not of the expected type — use the safe cast variants jsonPrimitiveOrNull, jsonObjectOrNull, and jsonArrayOrNull (or null-safe navigation with ?.) to avoid crashes on unexpected data shapes. JsonElement as a field type is particularly useful for API wrappers where the outer envelope is stable but the inner data field varies by endpoint.
Custom Serializers and SerializationStrategy
Implement KSerializer<T> when no automatic serialization is possible — most commonly for platform-specific types like LocalDate, UUID, BigDecimal, or third-party value classes. The interface requires three members: descriptor (describes the encoded form), serialize() (encodes to an Encoder), and deserialize() (decodes from a Decoder). Attach a custom serializer to a field with @Serializable(with = ...) or register it globally for all occurrences of a type using SerializersModule.
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import java.time.LocalDate
import java.util.UUID
// ── Custom serializer for LocalDate (encodes as ISO 8601 string) ─
object LocalDateSerializer : KSerializer<LocalDate> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDate) {
encoder.encodeString(value.toString()) // "2026-05-20"
}
override fun deserialize(decoder: Decoder): LocalDate {
return LocalDate.parse(decoder.decodeString())
}
}
// ── Custom serializer for UUID ─────────────────────────────────
object UUIDSerializer : KSerializer<UUID> {
override val descriptor =
PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): UUID =
UUID.fromString(decoder.decodeString())
}
// ── @Serializable(with = ...) — apply to a specific field ──────
@Serializable
data class Event(
val id: Int,
@Serializable(with = LocalDateSerializer::class)
val date: LocalDate,
@Serializable(with = UUIDSerializer::class)
val traceId: UUID,
)
val event = Event(1, LocalDate.of(2026, 5, 20), UUID.randomUUID())
val json = Json.encodeToString(event)
// {"id":1,"date":"2026-05-20","traceId":"550e8400-e29b-41d4-a716-446655440000"}
// ── Global contextual registration via SerializersModule ────────
val module = SerializersModule {
contextual(LocalDate::class, LocalDateSerializer)
contextual(UUID::class, UUIDSerializer)
}
val AppJson = Json { serializersModule = module }
// Now LocalDate and UUID fields are handled automatically without @Serializable(with = ...)
@Serializable
data class Meeting(@Contextual val scheduledOn: LocalDate, val title: String)
val m = AppJson.encodeToString(Meeting(LocalDate.now(), "Sprint Review"))
// ── Composite serializer for a wrapper class ───────────────────
@Serializable(with = MoneySerializer::class)
data class Money(val amount: Long, val currency: String) // encodes as "1099 USD"
object MoneySerializer : KSerializer<Money> {
override val descriptor = PrimitiveSerialDescriptor("Money", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Money) {
encoder.encodeString("${value.amount} ${value.currency}")
}
override fun deserialize(decoder: Decoder): Money {
val parts = decoder.decodeString().split(" ")
return Money(parts[0].toLong(), parts[1])
}
}For complex types that encode as JSON objects (not primitives), use buildClassSerialDescriptor in the descriptor and call encoder.beginStructure(descriptor) / encodeStringElement / endStructure() in serialize(). The @Contextual annotation on a field tells kotlinx.serialization to look up the serializer from the SerializersModule at runtime — required when registering serializers globally via contextual().
Polymorphic Serialization: sealed classes and @JsonClassDiscriminator
kotlinx.serialization handles sealed class hierarchies with a type discriminator field injected into the JSON. All subclasses must also be annotated with @Serializable; the @SerialName annotation controls the discriminator value for each subclass. By default the discriminator field is named "type" and contains the fully qualified class name — override the field name with @JsonClassDiscriminator on the sealed class and the value with @SerialName on each subclass.
import kotlinx.serialization.*
import kotlinx.serialization.json.*
// ── Sealed class hierarchy with custom discriminator ───────────
@Serializable
@JsonClassDiscriminator("shape") // discriminator field name (default: "type")
sealed class Shape
@Serializable
@SerialName("circle") // discriminator value in JSON
data class Circle(val radius: Double) : Shape()
@Serializable
@SerialName("rectangle")
data class Rectangle(val width: Double, val height: Double) : Shape()
@Serializable
@SerialName("triangle")
data class Triangle(val base: Double, val height: Double) : Shape()
// Serialize a sealed subtype
val shape: Shape = Circle(5.0)
val json = Json.encodeToString(shape)
// {"shape":"circle","radius":5.0}
// Deserialize — discriminator field determines which subclass to instantiate
val decoded: Shape = Json.decodeFromString<Shape>("""{"shape":"rectangle","width":10.0,"height":6.0}""")
// decoded is Rectangle(width=10.0, height=6.0)
// Serialize a list of mixed subtypes
val shapes = listOf<Shape>(Circle(3.0), Rectangle(4.0, 5.0), Triangle(6.0, 7.0))
val shapesJson = Json.encodeToString(shapes)
// [{"shape":"circle","radius":3.0},{"shape":"rectangle",...},{"shape":"triangle",...}]
// ── Open class hierarchy — requires SerializersModule ──────────
// For non-sealed parent classes, register subclasses explicitly
@Serializable
abstract class Event {
abstract val timestamp: Long
}
@Serializable
@SerialName("click")
data class ClickEvent(override val timestamp: Long, val x: Int, val y: Int) : Event()
@Serializable
@SerialName("scroll")
data class ScrollEvent(override val timestamp: Long, val delta: Int) : Event()
val eventModule = SerializersModule {
polymorphic(Event::class) {
subclass(ClickEvent::class)
subclass(ScrollEvent::class)
}
}
val eventJson = Json { serializersModule = eventModule }
val event: Event = eventJson.decodeFromString<Event>("""{"type":"click","timestamp":1716211200,"x":100,"y":200}""")
// ── Handling unknown discriminant values ───────────────────────
// Use a fallback subclass for unknown "type" values
@Serializable
@SerialName("unknown")
data class UnknownShape(val raw: JsonObject? = null) : Shape()
// With coerceInputValues the unknown type coerces to the default, not throwSealed class polymorphism is preferred over open class polymorphism because the Kotlin compiler enforces that all subclasses are known at compile time — no runtime SerializersModule registration is needed. For REST APIs that may add new event types in future, add an @Serializable @SerialName("unknown") catch-all subclass and register it last in the sealed hierarchy to gracefully handle unrecognized discriminant values.
Moshi with Kotlin: KotlinJsonAdapterFactory vs Code Generation
Moshi is a popular JSON library for Android that provides stronger null safety than Gson. For Kotlin data classes, Moshi offers two approaches: KotlinJsonAdapterFactory (reflection-based, requires the kotlin-reflect 2.5 MB artifact) and the Moshi Kotlin code generation plugin (moshi-kotlin-codegen, generates JsonAdapter classes at compile time, no reflection). Use code generation for production Android builds to avoid the reflection overhead and reduce APK size by approximately 2 MB. Moshi correctly calls the Kotlin constructor, respecting default values and non-null enforcement — unlike Gson.
// build.gradle.kts — Moshi dependency options
// Option A: Reflection-based (simpler setup, larger binary)
dependencies {
implementation("com.squareup.moshi:moshi:1.15.1")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1") // includes kotlin-reflect
}
// Option B: Code generation (recommended for Android — no kotlin-reflect)
plugins {
kotlin("kapt") // or use KSP plugin for faster builds
}
dependencies {
implementation("com.squareup.moshi:moshi:1.15.1")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
}
// ── Moshi data class setup ─────────────────────────────────────
import com.squareup.moshi.*
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
// Option A: reflection — add KotlinJsonAdapterFactory
val moshi: Moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory()) // must be last
.build()
// Option B: code generation — no factory needed, adapter auto-generated
// Just use @JsonClass(generateAdapter = true) on the data class
// ── Data class with Moshi annotations ─────────────────────────
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) // required for code generation
data class Product(
val id: Int,
@Json(name = "product_name") val name: String, // field rename
val price: Double,
val inStock: Boolean = true, // default value respected
val tags: List<String> = emptyList(),
)
// ── Serialize and deserialize ──────────────────────────────────
val adapter: JsonAdapter<Product> = moshi.adapter(Product::class.java)
// Deserialize from JSON string
val json = """{"id":1,"product_name":"Widget","price":9.99,"in_stock":true}"""
val product: Product? = adapter.fromJson(json)
// product?.name == "Widget", product?.inStock == true
// Serialize to JSON string
val jsonOut: String = adapter.toJson(Product(2, "Gadget", 19.99))
// {"id":2,"product_name":"Gadget","price":19.99,"inStock":true}
// ── Null safety with Moshi ─────────────────────────────────────
// Moshi with KotlinJsonAdapterFactory enforces non-null:
// val required: String (non-null) missing from JSON → JsonDataException
// val optional: String? (nullable) missing from JSON → null (correct)
// Gson would set required to null silently — Moshi throws immediately
// ── Adapter for a List type ────────────────────────────────────
import com.squareup.moshi.Types
import java.lang.reflect.Type
val listType: Type = Types.newParameterizedType(List::class.java, Product::class.java)
val listAdapter: JsonAdapter<List<Product>> = moshi.adapter(listType)
val products: List<Product>? = listAdapter.fromJson("""[{"id":1,"product_name":"A","price":1.0}]""")
// ── lenient() — skip unknown fields ───────────────────────────
val lenientAdapter = adapter.lenient() // ignore extra JSON fields
val parsed = lenientAdapter.fromJson("""{"id":1,"product_name":"B","price":2.0,"unknown":"x"}""")
// parsed is not null — "unknown" field is ignoredThe Moshi code generation plugin generates a ProductJsonAdapter.kt file at compile time — the adapter is registered automatically and no factory is needed in the Moshi.Builder. This reduces APK size by ~2 MB (no kotlin-reflect) and improves deserialization speed by 20-40% compared to the reflection-based approach. For new Android projects, prefer kotlinx.serialization over Moshi if you need Kotlin Multiplatform support; choose Moshi if you are already using OkHttp/Retrofit and want a mature, well-tested Android JSON library. See the Java JSON with Jackson guide for a JVM-focused comparison.
Ktor HTTP Client JSON Integration
Ktor is JetBrains' multiplatform HTTP client — the same client code compiles for Android (OkHttp engine), iOS (Darwin engine), and JVM/desktop (CIO or Apache engine). The ContentNegotiation plugin with kotlinx-serialization handles automatic JSON encoding and decoding of request and response bodies. Install the plugin on the HttpClient, then call client.get<T>(url).body() for GET and client.post(url) { setBody(obj) }.body<T>() for POST — no manual JSON parsing required.
// build.gradle.kts — Ktor dependencies
dependencies {
// Core + engine (choose one engine per target)
implementation("io.ktor:ktor-client-core:2.3.12")
implementation("io.ktor:ktor-client-cio:2.3.12") // JVM/desktop
// implementation("io.ktor:ktor-client-okhttp:2.3.12") // Android
// implementation("io.ktor:ktor-client-darwin:2.3.12") // iOS (in iosMain)
// JSON support
implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
}
// ── Client setup ───────────────────────────────────────────────
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.client.request.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.http.*
import kotlinx.serialization.json.Json
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true // production-safe
prettyPrint = false
})
}
// Default request configuration
defaultRequest {
url("https://api.example.com")
header("Authorization", "Bearer ${getToken()}")
}
}
// ── GET request — deserialize response body automatically ──────
@Serializable
data class User(val id: Int, val name: String, val email: String)
@Serializable
data class UsersResponse(val users: List<User>, val total: Int)
suspend fun fetchUser(id: Int): User {
return client.get("/users/$id").body()
}
suspend fun fetchAllUsers(): List<User> {
val response: UsersResponse = client.get("/users") {
parameter("limit", 50)
parameter("offset", 0)
}.body()
return response.users
}
// ── POST request — serialize request body automatically ────────
@Serializable
data class CreateUserRequest(val name: String, val email: String)
@Serializable
data class CreateUserResponse(val id: Int, val name: String)
suspend fun createUser(name: String, email: String): CreateUserResponse {
return client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest(name, email))
}.body()
}
// ── Error handling with ResponseException ──────────────────────
import io.ktor.client.plugins.*
suspend fun fetchUserSafe(id: Int): User? {
return try {
client.get("/users/$id").body()
} catch (e: ClientRequestException) {
// 4xx errors — e.response.status.value for the HTTP code
println("Client error ${e.response.status.value}: ${e.message}")
null
} catch (e: ServerResponseException) {
// 5xx errors
println("Server error ${e.response.status.value}: ${e.message}")
null
}
}
// ── Multiplatform HTTP client ──────────────────────────────────
// In commonMain — platform-agnostic code:
// expect fun createHttpClient(): HttpClient
// In androidMain: actual fun createHttpClient() = HttpClient(OkHttp) { ... }
// In iosMain: actual fun createHttpClient() = HttpClient(Darwin) { ... }
// ContentNegotiation + kotlinx-serialization works identically on all platformsAlways close the HttpClient when it is no longer needed by calling client.close() — in Android, close it in onDestroy() or use a dependency injection scope that manages the lifecycle. For Kotlin Multiplatform projects, create the HttpClient with platform-specific engine using expect/actual declarations but share all networking logic in commonMain. Ktor's Timeout plugin (install(HttpTimeout) { requestTimeoutMillis = 10_000 }) prevents hanging requests on slow connections.
Key Terms
- @Serializable
- A kotlinx.serialization annotation that instructs the Kotlin compiler plugin to generate a
KSerializerfor the annotated class at compile time. Applying@Serializableto a data class enablesJson.encodeToString()andJson.decodeFromString()without any runtime reflection. The generated serializer is a companion object extension that handles field enumeration, default value application, and type mapping. All fields in a@Serializableclass must themselves be serializable — either primitives, other@Serializableclasses, or types with a registered serializer. The annotation can also be applied toobject,enum class, andsealed classdeclarations to enable serialization of those type hierarchies. - @SerialName
- A kotlinx.serialization annotation that overrides the default JSON key (which is the Kotlin property name) for a field, enum constant, or sealed class subtype.
@SerialName("user_id") val userId: Intcauses the field to be read from and written to the JSON key"user_id"rather than"userId". For enum values,@SerialName("active") ACTIVEcauses the enum to serialize to the string"active"rather than"ACTIVE". For sealed class subtypes,@SerialName("circle")onclass Circlesets the discriminator value to"circle"rather than the fully qualified class name. An alternative for global naming convention conversion isJsonNamingStrategy.SnakeCasein theJsonbuilder (available in kotlinx.serialization 1.5+). - JsonElement
- The abstract base class of the kotlinx.serialization JSON tree model, representing any JSON value. The four concrete subtypes are:
JsonObject(implementsMap<String, JsonElement>— represents a JSON object),JsonArray(implementsList<JsonElement>— represents a JSON array),JsonPrimitive(represents a JSON string, number, or boolean — access via.content,.int,.double,.boolean), andJsonNull(the singleton representing JSONnull). Parse JSON to aJsonElementwithJson.parseToJsonElement(); build dynamic JSON withbuildJsonObjectandbuildJsonArray. UseJsonElementas a field type in@Serializableclasses to capture variable-schema portions of API responses. - KSerializer
- The interface that all kotlinx.serialization serializers must implement, defined as
KSerializer<T>whereTis the type being serialized. It has three required members:descriptor: SerialDescriptor(describes the encoded structure — usePrimitiveSerialDescriptorfor types that encode as a single JSON value, orbuildClassSerialDescriptorfor types that encode as JSON objects),serialize(encoder: Encoder, value: T)(writes the value to anEncoder), anddeserialize(decoder: Decoder): T(reads a value from aDecoder).@Serializable-annotated classes have theirKSerializergenerated automatically; custom types require a hand-written implementation. - sealed class
- A Kotlin class modifier that restricts the set of direct subclasses to those declared within the same compilation unit (module). Sealed classes are the recommended way to model algebraic data types (ADTs) and sum types in Kotlin — for example,
sealed class Result<T>withdata class Success(val data: T)anddata class Error(val message: String)subclasses. In kotlinx.serialization, sealed classes receive automatic polymorphic serialization support: the compiler knows all subclasses at compile time, so no runtime registration is needed. Each subclass's@SerialNamevalue becomes the discriminator field value in the JSON. The@JsonClassDiscriminatorannotation on the sealed class controls the discriminator field name (default:"type"). - ClassDiscriminator
- The field name injected into JSON when serializing polymorphic types with kotlinx.serialization to identify which concrete subclass to deserialize into. By default, the discriminator field is named
"type"and its value is the fully qualified class name (e.g.,"com.example.Circle"). The@JsonClassDiscriminator("shape")annotation on a sealed class changes the field name to"shape"; the@SerialName("circle")annotation on the subclass sets the value to"circle". You can also configure a global default discriminator field name withJson { classDiscriminator = "kind" }. The discriminator field is included in the serialized JSON and must be present during deserialization — clients consuming this JSON must handle the discriminator field to reconstruct the correct type.
FAQ
How do I serialize a Kotlin data class to JSON?
Add the kotlin("plugin.serialization") Gradle plugin and the kotlinx-serialization-json dependency, annotate the data class with @Serializable, then call Json.encodeToString(obj) to serialize and Json.decodeFromString<MyClass>(jsonStr) to deserialize. The compiler plugin generates a type-safe serializer at compile time — no runtime reflection is needed. Default parameter values are respected: missing JSON fields use the declared Kotlin default rather than null. For custom configuration such as ignoring unknown keys or pretty printing, create a Json instance with a builder: val json = Json { ignoreUnknownKeys = true; prettyPrint = true } and use that instance instead of the default Json companion object. Wrap decoding calls in try/catch for SerializationException to handle malformed JSON or type mismatches safely in production code.
How do I handle unknown JSON fields in Kotlin kotlinx.serialization?
Configure ignoreUnknownKeys = true in the Json builder: val json = Json { ignoreUnknownKeys = true }. By default, kotlinx.serialization throws SerializationException when the JSON contains a key not present in the data class — this strict default catches typos during development but causes crashes in production when the API adds new fields. Setting ignoreUnknownKeys = true silently discards unrecognized keys, making your client forward-compatible with API additions. For capturing unknown fields dynamically rather than discarding them, use a Map<String, JsonElement> field in your data class or parse the entire response to JsonElement first and extract known fields manually. The coerceInputValues = true setting additionally handles unknown enum values and invalid null assignments by coercing to defaults rather than throwing.
What is @SerialName and when do I need it?
@SerialName overrides the JSON key used for a field or the discriminator value used for a class. By default, kotlinx.serialization uses the Kotlin property name as the JSON key — val userId: Int maps to "userId". When the JSON API uses snake_case ("user_id") but you want camelCase Kotlin properties, annotate the field: @SerialName("user_id") val userId: Int. Use @SerialName when: the JSON key contains characters not valid in Kotlin identifiers; the API uses a naming convention different from your Kotlin code; you rename a Kotlin property but need backward compatibility with existing JSON; or you want to set the discriminator value for a sealed class subtype. For a global naming strategy, configure JsonNamingStrategy.SnakeCase in the Json builder (available since kotlinx.serialization 1.5) to automatically convert camelCase Kotlin names to snake_case JSON keys without per-field annotations.
Why is Gson problematic with Kotlin data classes?
Gson has three critical issues with Kotlin: (1) Null safety bypass — Gson uses Java reflection to set fields directly, bypassing the Kotlin constructor. A missing or null JSON field sets a non-null Kotlin property to null without any exception, silently corrupting Kotlin's null safety and causing NullPointerException at runtime when the property is accessed. (2) Default values ignored — because Gson bypasses the constructor, default parameter values (val status: String = "active") are never applied; the field gets null or zero regardless. (3) No sealed class support — Gson cannot serialize or deserialize sealed class hierarchies without a custom TypeAdapter. kotlinx.serialization fixes all three: it calls the Kotlin constructor (applying defaults and enforcing non-null types), generates code at compile time (no reflection), and handles sealed classes natively. If you must use Gson, register a TypeAdapter for every Kotlin class and add a null check in each getter.
How do I decode dynamic JSON with JsonElement in Kotlin?
Parse JSON without a target class using Json.parseToJsonElement(jsonStr), which returns a JsonElement. Navigate the tree: element.jsonObject["key"]?.jsonPrimitive?.content for strings, ?.int for integers, ?.boolean for booleans. The four element types are JsonObject (acts like Map), JsonArray (acts like List), JsonPrimitive (scalar values), and JsonNull. Build dynamic JSON with buildJsonObject { put("name", "Alice"); put("age", 30) }. Use JsonElement as a field type in a @Serializable data class to capture a variable-schema portion of a response while keeping the outer structure typed: @Serializable data class ApiResponse(val data: JsonElement). Use the safe variants jsonPrimitiveOrNull, jsonObjectOrNull to avoid IllegalArgumentException when the element type does not match expectations.
How do I implement a custom serializer in Kotlin?
Implement KSerializer<T> with three members: descriptor (use PrimitiveSerialDescriptor("Name", PrimitiveKind.STRING) for types that encode as a JSON string), serialize(encoder, value) (call encoder.encodeString(value.toString())), and deserialize(decoder) (call MyType.parse(decoder.decodeString())). A common example is a LocalDate serializer that encodes as an ISO 8601 string. Attach it to a specific field with @Serializable(with = MySerializer::class), or register it globally for all occurrences of the type using SerializersModule { contextual(MyType::class, MySerializer) } and configuring Json { serializersModule = module }. Fields using a globally registered serializer must be annotated with @Contextual. For types that encode as JSON objects rather than primitives, use buildClassSerialDescriptor and encoder.beginStructure() / endStructure().
How do I serialize sealed classes as polymorphic JSON in Kotlin?
Annotate the sealed class and all subclasses with @Serializable. Add @SerialName("value") on each subclass to set the discriminator value in JSON (otherwise the fully qualified class name is used). Add @JsonClassDiscriminator("type") on the sealed class to control the discriminator field name. Example: @Serializable @JsonClassDiscriminator("shape") sealed class Shape; @Serializable @SerialName("circle") data class Circle(val radius: Double) : Shape(). Then Json.encodeToString<Shape> (Circle(5.0)) produces {"shape":"circle","radius":5.0} and Json.decodeFromString<Shape> reads the "shape" field to choose the correct subclass. For open class hierarchies, register subclasses with SerializersModule { polymorphic(Base::class) { subclass(Sub::class) } }.
How do I use JSON with the Ktor HTTP client?
Add ktor-client-content-negotiation and ktor-serialization-kotlinx-json dependencies, then install the ContentNegotiation plugin on the HttpClient: install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }. For GET requests, call client.get(url).body<MyType>() — Ktor deserializes the response JSON automatically. For POST requests, call client.post(url) { contentType(ContentType.Application.Json); setBody(requestObj) }.body<ResponseType>(). Handle HTTP errors by catching ClientRequestException (4xx) and ServerResponseException (5xx). Ktor is the recommended HTTP client for Kotlin Multiplatform — the same ContentNegotiation + kotlinx-serialization setup works on Android (OkHttp engine) and iOS (Darwin engine) sharing all networking code in commonMain. Always close the client with client.close() when done.
Further reading and primary sources
- kotlinx.serialization Guide — Official Kotlin documentation for kotlinx.serialization, @Serializable, and Json configuration
- kotlinx.serialization GitHub — Source code, changelog, and advanced usage examples for kotlinx.serialization
- Moshi: A modern JSON library for Android — Moshi documentation covering KotlinJsonAdapterFactory and code generation plugin
- Ktor HTTP Client — Content Negotiation — Ktor client ContentNegotiation plugin setup with kotlinx-serialization for JSON
- Kotlin Multiplatform: Serialization — Ktor and kotlinx.serialization in a Kotlin Multiplatform (KMP) project for Android and iOS