In Ktor's ContentNegotiation, how do the json() and jackson() converters differ?
answer
- one registry, two engines
- compile-time generation versus runtime reflection
- who owns the class you must serialize
- ignoreUnknownKeys versus FAIL_ON_UNKNOWN_PROPERTIES
basics
~20 sKtor's json() uses kotlinx.serialization: a compiler plugin generates serializers at build time for @Serializable types. jackson() wraps a Jackson ObjectMapper that works by runtime reflection on any class, annotated or not. Compile-time safety versus reflective flexibility.
solid answer
~50 sBoth plug into the same registry — `install(ContentNegotiation) { json() }` or `{ jackson { } }` — and both bind to `application/json` by default; the difference is how the bytes are produced. `json()` comes from `ktor-serialization-kotlinx-json` and relies on the `kotlin("plugin.serialization")` compiler plugin: every type must be `@Serializable`, serializers are generated at compile time, there is no reflection, and the config block is a kotlinx `Json { }` builder (`ignoreUnknownKeys`, `encodeDefaults`, `explicitNulls`). `jackson { }` comes from `ktor-serialization-jackson` and hands you an `ObjectMapper` receiver, so you configure it the Jackson way — `enable(SerializationFeature.INDENT_OUTPUT)`, `registerModule(JavaTimeModule())`, Jackson annotations — and it will map Java or third-party classes you cannot annotate. Pick kotlinx for a pure-Kotlin service and compile-time guarantees; pick Jackson when you must serialize types you do not own or reuse an existing Jackson setup.
code
kotlin · 10 linesinstall(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
})
}
@Serializable
data class User(val id: Long, val name: String, val nickname: String? = null)go deeper
Recall that Ktor does not pick a JSON library for you: you install ContentNegotiation and then choose json() or jackson { }, adding the matching dependency.
Explain the mechanism behind each: a compiler plugin generating serializers for @Serializable types versus a reflective ObjectMapper, and name one config knob you have actually set on each.
Argue the choice for a real service — types you do not own, an existing mapper configuration to preserve, native-image or shrinking constraints, and how unknown-field strictness interacts with rolling deploys.
Own the org-level call: a single serialization stack across services and shared DTOs, versus letting each team choose, and what that costs when contracts and error shapes must match.
## Same slot, two engines Ktor's `ContentNegotiation` is only a registry: it maps a `ContentType` to a `ContentConverter`. `json()`, `jackson { }` and `gson { }` are convenience functions that build a converter and register it, by default for `application/json`. Everything downstream — `call.receive<T>()` selecting by `Content-Type`, `call.respond(obj)` selecting by `Accept` — is identical. The choice is purely about how an object becomes bytes. ## kotlinx.serialization: `json()` Artifact: `io.ktor:ktor-serialization-kotlinx-json`, plus the Gradle plugin `kotlin("plugin.serialization")`. The compiler plugin generates a serializer for each class marked `@Serializable` at build time. Consequences: - **No reflection.** Startup and per-call cost are low, and it behaves predictably under GraalVM native image and R8/ProGuard, where reflective mappers need extra configuration. - **Compile-time failure.** If you try to serialize a type with no serializer, the build fails. You learn about the gap before deployment, not from a 500 in production. - **Strict by default.** Unknown JSON properties throw unless you set `ignoreUnknownKeys = true`; missing non-optional fields throw. Configure through the `Json { }` builder passed to `json(...)`: `ignoreUnknownKeys`, `encodeDefaults`, `explicitNulls`, `prettyPrint`, `coerceInputValues`. - **Kotlin-shaped.** Default parameter values, nullability and sealed hierarchies (with `@SerialName` and a `SerializersModule` for polymorphism) are first-class. Types you do not own — a Java library's class, a JDK type without a serializer — need a hand-written serializer or a wrapper DTO. ## Jackson: `jackson { }` Artifact: `io.ktor:ktor-serialization-jackson`. The block's receiver is a Jackson `ObjectMapper`, so anything you would do to a mapper you do here: `jackson { enable(SerializationFeature.INDENT_OUTPUT); disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); registerModule(JavaTimeModule()) }` Consequences: - **Reflective and annotation-driven.** Any class can be mapped without touching its source; Jackson annotations (`@JsonProperty`, `@JsonIgnore`, `@JsonCreator`) work as usual, which matters when you are porting an existing service or sharing DTOs with Java code. - **Module ecosystem.** Date/time, Guava, and Kotlin support arrive as modules you register. Kotlin-specific behaviour — honouring default parameter values and non-null constructor parameters — depends on the Kotlin module being present; without it a `null` can slip into a non-null property and blow up later rather than at parse time. - **Runtime discovery.** Mistakes surface at runtime, and native-image or heavy shrinking needs reflection configuration. ## Choosing For a greenfield Kotlin service, `json()` is the default choice: fewer moving parts, no reflection, failures at compile time, and the same library across Ktor server, Ktor client and multiplatform code. Reach for Jackson when you must serialize types you cannot annotate, when the team already owns a substantial `ObjectMapper` configuration you want to keep identical across services, or when you depend on Jackson-only features such as its polymorphic type handling or a specific module. ## Mixing and per-type registration Nothing forces one converter. `register(contentType, converter)` binds a converter to a media type, so a service can answer `application/json` with kotlinx and a legacy `application/xml` with a different converter. What you cannot sensibly do is register two converters for the same media type and expect a particular one to win — keep one converter per content type and make the choice explicit. ## The interview point Interviewers are checking that you know the converter is pluggable and that you can name a real consequence of each choice, not just "kotlinx is more Kotlin-y". Compile-time serializer generation versus runtime reflection is the sentence that carries it; `ignoreUnknownKeys` versus `FAIL_ON_UNKNOWN_PROPERTIES` is the detail that proves you have configured both.
- A client sends an extra JSON field your data class does not declare. What happens with each converter?With kotlinx it fails by default — unknown keys throw unless the `Json { }` builder passed to `json(...)` sets `ignoreUnknownKeys = true`. With Jackson it also fails by default via `FAIL_ON_UNKNOWN_PROPERTIES`, which you disable in the `jackson { }` block. Both are configurable; the interview point is knowing the default is strict on both sides.
- Why does kotlinx.serialization behave better under GraalVM native image?Its serializers are generated by the compiler plugin and called directly, so there is no reflective lookup for the native-image analysis to miss. Reflection-based mappers need explicit reflection configuration for every DTO, and a missed entry fails only at runtime in the native binary.
saying these in an interview costs you the question
- Claiming Ktor picks the JSON library automatically
- Thinking @Serializable is required for the Jackson converter too
- Assuming kotlinx ignores unknown JSON fields by default
- Believing you cannot register more than one converter
- Saying Jackson cannot be used with Ktor at all