Ktor Content Negotiation with kotlinx.serialization: Multi-Format Request/Response Handling
Configure Ktor's Content Negotiation plugin with kotlinx.serialization for automatic JSON, XML, and Protobuf request/response handling. Covers plugin setup, format selection, worked example, and pitfalls like missing @Serializable, strict unknown-key handling, and memory buffering.
10 Feb 2026, 22:40 UTC

The Problem: Handling Multiple Serialization Formats Without Boilerplate
Server applications often need to accept and return data in JSON, XML, or Protobuf depending on client capabilities. Writing manual serialization logic for each endpoint duplicates code and introduces inconsistencies. Ktor's Content Negotiation plugin solves this by automatically selecting the right serializer based on HTTP headers, using kotlinx.serialization as the default engine since Ktor 2.0.
Quick Setup: Install the Plugin and Register Formats
Add the required dependencies to build.gradle.kts:
dependencies {
implementation("io.ktor:ktor-server-content-negotiation:2.3.8")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
// Optional: for XML or Protobuf
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-xml:1.6.0")
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.6.0")
}
Enable the kotlinx.serialization compiler plugin:
plugins {
kotlin("plugin.serialization") version "1.9.22"
}
Install the plugin in your application module and register the formats you need:
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json
import io.ktor.serialization.kotlinx.xml
import io.ktor.serialization.kotlinx.protobuf
fun Application.module() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
prettyPrint = true
})
xml()
// protobuf()
}
// routing { ... }
}
The json(), xml(), and protobuf() functions each register a serializer for their respective media types. The lambda passed to json() configures the underlying kotlinx.serialization.json.Json instance — here we enable ignoreUnknownKeys for forward compatibility and coerceInputValues to handle numeric strings.
Worked Example: A User Endpoint
Define serializable data classes with the @Serializable annotation:
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
@Serializable
@SerialName("user")
data class User(
@SerialName("id") val id: String,
val name: String,
val email: String
)
@Serializable
data class UserList(val users: List)
Now implement the routes. The plugin handles (de)serialization transparently:
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.module() {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
xml()
}
routing {
get("/users") {
val users = listOf(
User("1", "Alice", "[contact removed]"),
User("2", "Bob", "[contact removed]")
)
call.respond(UserList(users))
}
post("/users") {
val user = call.receive()
// Business logic here
call.respond(user)
}
}
}
When a client sends GET /users with Accept: application/json, the response is JSON with Content-Type: application/json. If the client sends Accept: application/xml, the same handler returns XML. For POST /users, the request must include Content-Type: application/json (or XML) matching a registered format; otherwise the plugin responds 415 Unsupported Media Type.
How Format Selection Works
The plugin follows standard HTTP content negotiation:
- Response serialization: The server inspects the request's
Acceptheader. It picks the first registered format whose media type matches (respecting q-values). If no match exists, it returns406 Not Acceptableunless afallbackFormatis configured. - Request deserialization: The server reads the
Content-Typeheader and selects the matching registered format. Missing or unsupportedContent-Typeyields415 Unsupported Media Type.
Configure a fallback for responses (use sparingly):
install(ContentNegotiation) {
json()
fallbackFormat = ContentType.Application.Json
}
Caution: fallbackFormat can mask client misconfiguration. During development, prefer explicit 406 responses to catch Accept-header bugs early.
Common Pitfalls and How to Avoid Them
1. Missing @Serializable or Compiler Plugin
Without @Serializable on data classes or the Kotlin serialization compiler plugin in build.gradle.kts, you'll get a runtime SerializerNotFoundException. Verify both are present.
2. Strict Unknown Key Handling
kotlinx.serialization defaults to ignoreUnknownKeys = false. Extra JSON fields in requests cause 400 Bad Request. Set ignoreUnknownKeys = true in the Json config if you need forward compatibility:
json(Json { ignoreUnknownKeys = true })
3. Large Request Bodies Buffer Fully in Memory
call.receive<T>() buffers the entire request body before deserialization. For large payloads or streaming, use call.receiveStream() and parse manually:
val input = call.receiveStream()
val reader = input.reader()
// Use Json.decodeFromStringElement or streaming parser
4. Registering Unused Formats Increases Attack Surface
Each registered format adds parsing code. Disable XML, Protobuf, or formUrlEncoded in production if not needed:
install(ContentNegotiation) {
json()
// xml() // remove if unused
// protobuf()
}
5. Version-Specific Imports
Ktor 2.x uses io.ktor.serialization.kotlinx.json(). Ktor 1.x used io.ktor.gson.gson() or io.ktor.jackson.jackson(). Migration requires updating imports and serializer setup — the old APIs are not compatible.
Verification Checklist
After implementing, test these scenarios:
| Test | Command | Expected |
|---|---|---|
| JSON response | curl -H "Accept: application/json" http://localhost:8080/users | 200, JSON body, Content-Type: application/json |
| XML response | curl -H "Accept: application/xml" http://localhost:8080/users | 200, XML body, Content-Type: application/xml |
| JSON request | curl -H "Content-Type: application/json" -d '{"id":"3","name":"Carol","email":"[contact removed]"}' http://localhost:8080/users | 200, deserialized User object in handler |
| Missing Accept | curl -H "Accept: application/yaml" http://localhost:8080/users | 406 Not Acceptable (unless fallbackFormat set) |
| Missing Content-Type | curl -d '{"id":"3"}' http://localhost:8080/users | 415 Unsupported Media Type |
Limitations
- No schema validation: Content Negotiation only handles (de)serialization. Malformed data that passes kotlinx.serialization decoding may still violate business rules — add explicit validation in handlers.
- No streaming deserialization: The plugin buffers full request bodies. For large files or streams, bypass the plugin and use
call.receiveStream(). - Single format per request/response: You cannot mix formats in a single request (e.g., JSON body with XML response) without custom logic.
When to Use This Approach
Content Negotiation with kotlinx.serialization is ideal when:
- You need multi-format support with minimal code
- Your data models are Kotlin data classes
- Request/response sizes fit comfortably in memory
- You want compile-time serialization safety via @Serializable
For high-throughput streaming, binary-only protocols, or schema-driven validation, consider manual serialization or dedicated frameworks like gRPC.