Ktor Client JSON Deserialization with kotlinx.serialization: A Practical Guide
Configure Ktor HTTP client with kotlinx.serialization content negotiation to automatically deserialize JSON responses into Kotlin data classes. Covers dependencies, client setup, error handling, and version compatibility.
31 Jul 2025, 19:57 UTC

The Problem
You need to call a REST endpoint that returns JSON and have the response automatically mapped to a Kotlin data class without manual parsing. Ktor's ContentNegotiation plugin combined with kotlinx.serialization handles this, but the setup has version-sensitive moving parts that cause runtime failures when mismatched.
Desired Outcome
A single client.get<YourType>(url) call inside a coroutine returns a fully populated instance of your serializable data class when the server responds with HTTP 200 and valid JSON. Malformed JSON or non-2xx responses surface as typed exceptions you can catch and handle.
Prerequisites
- Kotlin project (JVM, Android, or multiplatform) with Gradle or Maven.
- Ktor 2.x client dependencies:
ktor-client-core,ktor-client-content-negotiation, and an engine such asktor-client-cio(multiplatform) orktor-client-okhttp(JVM/Android). - kotlinx.serialization JSON artifact:
kotlinx-serialization-jsonmatching your Kotlin version. - Kotlin serialization plugin enabled in
build.gradle.kts:id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20"(adjust to your Kotlin version).
Version compatibility matters: Ktor 2.3.x pairs with kotlinx.serialization 1.5.x–1.6.x; Ktor 3.x requires 1.6.x+. Mismatched versions produce NoSuchMethodError or missing serializer registration at runtime.
Dependency Example (Gradle Kotlin DSL)
dependencies {
implementation("io.ktor:ktor-client-core:2.3.8")
implementation("io.ktor:ktor-client-content-negotiation:2.3.8")
implementation("io.ktor:ktor-client-cio:2.3.8") // or ktor-client-okhttp
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
Run ./gradlew build (or mvn compile) to verify resolution succeeds before writing code.
Procedure
1. Define a Serializable Data Class
import kotlinx.serialization.Serializable
@Serializable
data class GitHubRepo(
val id: Long,
val name: String,
val description: String?,
val stargazersCount: Int
)
The @Serializable annotation triggers compiler-generated serializers. Property names map to JSON keys by default; use @SerialName("stargazers_count") if the API uses snake_case.
2. Configure HttpClient with ContentNegotiation
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true // tolerate extra fields from evolving APIs
// prettyPrint = true // optional, for debugging
}) {
// Register custom serializers if needed:
// SerializersModule().apply { contextual(GitHubRepo::class, MyCustomSerializer) }
}
}
}
Critical: ContentNegotiation must be installed before any request executes. Without it, client.get<GitHubRepo>(...) returns the raw HttpResponse and you must call response.body<GitHubRepo>() manually.
3. Make the Request Inside a Coroutine
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val repo: GitHubRepo = client.get("https://api.github.com/repos/ktorio/ktor")
println("${repo.name} has ${repo.stargazersCount} stars")
}
The generic type parameter <GitHubRepo> tells Ktor which serializer to use. The call suspends until the response arrives, deserializes, and returns the instance.
Expected Checks
| Scenario | Result |
|---|---|
| HTTP 200 + valid JSON matching the data class | Returns populated GitHubRepo |
| HTTP 200 + malformed JSON | Throws kotlinx.serialization.SerializationException |
| HTTP 4xx/5xx | Throws io.ktor.client.plugins.HttpResponseException (unless you install HttpResponseValidator with custom handling) |
| Extra JSON fields not in data class | Throws SerializationException unless ignoreUnknownKeys = true |
Recovery Options
Wrap with Typed Exception Handling
suspend fun fetchRepoSafe(url: String): Result {
return try {
Result.success(client.get(url))
} catch (e: HttpResponseException) {
Result.failure(e) // non-2xx status
} catch (e: SerializationException) {
Result.failure(e) // bad JSON or structure mismatch
}
}
Use Result or a sealed class to propagate success/failure without throwing across coroutine boundaries.
Fallback on Deserialization Failure
val repo = try {
client.get(url)
} catch (e: SerializationException) {
// Log, emit metric, return a default placeholder
GitHubRepo(0, "unknown", null, 0)
}
This pattern keeps the calling code simple when a stale cache or default object is acceptable.
Engine Selection & Platform Notes
- CIO (ktor-client-cio): Pure Kotlin, works on JVM, Native, JS, Wasm. Default for multiplatform.
- OkHttp (ktor-client-okhttp): JVM/Android only, richer TLS/proxy support, HTTP/2 prioritization.
- Android: Add
androidx.lifecycle:lifecycle-runtime-ktxif you launch requests fromviewModelScopeorlifecycleScope.
Do not call blocking client APIs (e.g., client.get().block()) on dispatchers that prohibit blocking (Dispatchers.Main, Dispatchers.IO with limited threads). All Ktor client calls shown here are suspending and safe on any dispatcher.
Verification Steps
- Unit test with a test server – Use
ktor-server-test-hostto spin up an embedded server returning fixed JSON, then assert the deserialized object equals the expected instance. - Negative test – Configure the test server to return invalid JSON (
{not: valid}) and confirmSerializationExceptionis thrown. - Pipeline inspection – Add a breakpoint or log after
HttpClientcreation:client.plugins[ContentNegotiation]should be non-null. - Dependency check – Run
./gradlew dependencies --configuration compileClasspath | grep -E 'ktor|serialization'to verify versions align.
Limitations & Gotchas
- Default strictness:
ignoreUnknownKeys = falseby default. APIs that add fields over time will break deserialization unless you enable it. - No automatic snake_case ↔ camelCase conversion: Use
@SerialNameor a customJsonconfiguration withuseArrayPolymorphism = trueand a naming strategy if needed. - Sealed class hierarchies: Require explicit
typediscriminator field or custom polymorphic serializer; not automatic. - Large payloads: The entire response body is buffered in memory before deserialization. For multi-MB JSON, consider streaming parsers or pagination.
Quick Checklist Before Shipping
- [ ]
kotlinx-serialization-jsonversion matches Ktor release notes. - [ ]
ContentNegotiationinstalled before first request. - [ ]
ignoreUnknownKeys = trueif the upstream API evolves independently. - [ ] All calls wrapped in
try/catchforHttpResponseExceptionandSerializationException. - [ ] Tested with both valid and malformed JSON responses.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.