Why does a Ktor client call to body<User>() fail without ContentNegotiation installed?
answer
- Core converts only bytes, text, channels
- Typed conversion needs a registered converter
- The exception names a missing transformation
- Client plugin, not the server one
- Content type selects the converter both ways
basics
~20 sKtor's client core only converts payloads to bytes, text and channels. Typed conversion needs a converter, supplied by the client ContentNegotiation plugin; without it body<User>() throws NoTransformationFoundException because no registered converter can produce that type.
solid answer
~40 s`HttpResponse.body<T>()` runs the payload through the client's response pipeline, which knows only a few built-in transformations — `ByteArray`, `String`, `ByteReadChannel`. Anything else needs a converter, and converters are registered by installing the **client** `ContentNegotiation` plugin: `install(ContentNegotiation) { json() }`, from `ktor-client-content-negotiation` plus `ktor-serialization-kotlinx-json`. Missing it produces `NoTransformationFoundException`, which reads like a Ktor bug but simply means "nothing here can build a `User`". Two traps follow. First, the client plugin `io.ktor.client.plugins.contentnegotiation.ContentNegotiation` is a *different class* from the server one under `io.ktor.server.plugins...`; the wrong import compiles in a project that has both and then does nothing. Second, on the sending side the converter is selected by the request's content type, so `setBody(user)` must be paired with `contentType(ContentType.Application.Json)`.
code
kotlin · 13 linesimport io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
@Serializable
data class User(val name: String, val age: Int)
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
val user: User = client.get("https://api.example.com/users/1").body()go deeper
Recall that typed responses need a converter: install ContentNegotiation with json() on the client and annotate the model @Serializable. Know that bodyAsText() works without any of that.
Explain the mechanism — the response pipeline has only low-level transformations, and the plugin registers content-type-keyed converters used in both directions. Be able to state why setBody needs contentType.
Diagnose from the symptom: NoTransformationFoundException versus a serialization error versus a wrong import are three different faults. Also justify a tolerant Json configuration so an additive server change does not break clients.
Own the contract question: which converters and which Json settings are standard across services, and how model drift between producer and consumer is prevented rather than discovered in production.
## What the plugin does The Ktor client's response pipeline turns the received bytes into whatever type you asked for. Out of the box it can produce only low-level types: `ByteArray`, `String` (that is why `bodyAsText()` always works), and streaming types such as `ByteReadChannel`. When you write `response.body<User>()`, the pipeline looks for something that can transform the payload into `User`, finds nothing, and throws `NoTransformationFoundException`. The client `ContentNegotiation` plugin is what supplies those transformations. It holds a registry of `ContentType` → converter pairs, and it works in both directions: deserializing responses and serializing request bodies. ## Wiring it up Two dependencies are involved: `ktor-client-content-negotiation` for the plugin, and a serialization artifact such as `ktor-serialization-kotlinx-json` for the converter itself. With kotlinx.serialization you also need the Kotlin serialization compiler plugin and `@Serializable` on the model. val client = HttpClient(CIO) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } The `json()` call registers the kotlinx JSON converter for `application/json`. Other converters (for example Jackson, or XML) register their own content types the same way, and several may be registered on one client. ## Receiving Once a converter is registered, `response.body<User>()` deserializes the payload. Which converter runs is decided by the **response's** `Content-Type` header. A server that answers `text/plain` with JSON-looking text will not be handled by the JSON converter, and you will get a transformation failure that looks identical to having no plugin at all — worth remembering when debugging. Customising the `Json` instance matters in practice: `ignoreUnknownKeys = true` is the difference between a client that survives a server adding a field and one that starts throwing. ## Sending On the request side, `setBody(user)` hands an arbitrary object to the pipeline. The plugin selects a converter using the request's `Content-Type`, so the content type must be set explicitly: client.post("https://api.example.com/users") { contentType(ContentType.Application.Json) setBody(User("ann", 30)) } Omitting `contentType(...)` is the single most common client-side mistake: there is no content type to match against, so no converter is chosen and the request body cannot be built. ## The Accept header A useful side effect of installing the plugin is that the client advertises what it can parse: registered content types are added to the outgoing `Accept` header. So a client with `json()` installed asks servers for JSON without you writing `accept(ContentType.Application.Json)` on every call. You can still set `accept(...)` per request when you need something narrower. ## The two ContentNegotiation classes Ktor ships two plugins with the same simple name: - `io.ktor.client.plugins.contentnegotiation.ContentNegotiation` - `io.ktor.server.plugins.contentnegotiation.ContentNegotiation` In a project that contains both a Ktor server and a Ktor client — very common — an IDE auto-import can silently pick the wrong one. The symptom is a client that still throws `NoTransformationFoundException` even though `install(ContentNegotiation)` is right there in the source. Checking the import is the first diagnostic step for that failure. ## Failure modes to recognise - `NoTransformationFoundException` on a typed `body<T>()` → no converter registered for that response content type, or the wrong plugin class imported. - A serialization exception naming an unexpected field → the server's payload does not match the model; `ignoreUnknownKeys` or a corrected model is the fix. - A request rejected by the server as an empty or malformed body → `setBody` used without `contentType(...)`. - A `@Serializable` annotation missing from the data class → a compile-time or runtime serializer error from kotlinx.serialization, not a Ktor error. ## Why it is worth knowing precisely This is the place where a Ktor client stops looking like a toy and starts looking like the transport layer of a real integration. The plugin decides how models cross the wire in both directions, and every one of the failure modes above is a debugging session someone loses an afternoon to. Knowing that the converter is selected by content type — on the response for reading, on the request for writing — explains all of them at once.
- What does the Ktor client's ContentNegotiation plugin do to outgoing requests besides serializing bodies?It advertises what the client can parse by adding the registered content types to the Accept header, so calls ask for JSON without per-request accept(...) calls. You can still override it on a single request when you need to negotiate something narrower.
- You installed ContentNegotiation yet body<User>() still throws NoTransformationFoundException. What do you check first?The import. Ktor has a client ContentNegotiation (io.ktor.client.plugins.contentnegotiation) and a server one (io.ktor.server.plugins.contentnegotiation); in a project containing both, auto-import can pick the server class, which installs nothing useful on the client. After that, check the response's Content-Type actually matches a registered converter.
- Why configure Json with ignoreUnknownKeys = true on a client?Because the server owns the payload and will add fields. With strict parsing, a new field the model does not declare fails deserialization and breaks the client on a purely additive server change. Passing a configured Json instance to json() makes the client tolerant of that growth.
saying these in an interview costs you the question
- Assumes body<T>() parses JSON with no plugin
- Imports the server ContentNegotiation into a client
- Calls setBody without setting the content type
- Forgets @Serializable on the model class
- Blames the exception on a Ktor bug