skip to content

Serialization Formats

The concrete formats — JSON, binary encodings — plus custom serializers, polymorphic encoding, and evolving a schema without breaking old clients. Compatibility is the part interviewers actually care about.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

explore

questions

page 1 of 2

In kotlinx.serialization, what is a custom KSerializer and when would you write one by hand instead of relying on the @Serializable-generated serializer?

level: juniorimportance: must knowfreq 60%

answer

  1. descriptor + serialize + deserialize = the three members
  2. @Serializable generates one; hand-write when default is wrong
  3. third-party type / different wire shape / validation
  4. @Serializable(with=...) or @file:UseSerializers
  5. format-agnostic: works for JSON, ProtoBuf, CBOR

basics

~20 s

It is a small class that tells the library exactly how to turn one type into data and back. You write one when the default behavior is wrong, for types you cannot annotate, or when the wire format must differ from your class.

solid answer

~40 s

A KSerializer<T> is an object implementing three things: a descriptor (the type's shape/name for the format), serialize(encoder, value), and deserialize(decoder). For a plain @Serializable class the compiler plugin generates one automatically. You hand-write one when: the class comes from a third-party library you cannot annotate; the JSON/binary shape must differ from the Kotlin class (e.g. a date stored as an epoch Long but modeled as Instant); or you need to validate/transform during (de)serialization. You attach it with @Serializable(with = MySerializer::class) on the property or class, or register it at file level via @file:UseSerializers(MySerializer::class). The serializer is format-agnostic: the same KSerializer works for JSON, ProtoBuf, CBOR.

code

kotlin · 8 lines
kotlin
object InstantSerializer : KSerializer<Instant> {
    override val descriptor =
        PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG)
    override fun serialize(encoder: Encoder, value: Instant) =
        encoder.encodeLong(value.epochSecond)
    override fun deserialize(decoder: Decoder): Instant =
        Instant.ofEpochSecond(decoder.decodeLong())
}

go deeper

for a junior

Knows a KSerializer has descriptor, serialize, deserialize and that @Serializable usually generates it.

for a middle

Can list concrete reasons to hand-write one (third-party type, different shape, validation) and wire it via @Serializable(with=...).

for a senior

Stresses format-agnosticism and decoupling the wire contract from the model; reaches for @file:UseSerializers at scale.

for a principal

Frames custom serializers as a stable API boundary and weighs them against schema-evolution and polymorphism strategies.

## What a KSerializer is kotlinx.serialization splits work into two halves: a **format** (Json, ProtoBuf, Cbor) that knows how to write bytes, and a **serializer** that knows the *structure* of a specific type. A `KSerializer<T>` is the per-type strategy. The interface has three members: - `descriptor: SerialDescriptor` — metadata describing the type's shape (kind, name, element names). Formats read this to know how many fields exist and what to call them. - `serialize(encoder: Encoder, value: T)` — writes `value` into the supplied `Encoder`. - `deserialize(decoder: Decoder): T` — reads a `T` back from the `Decoder`. ## Why the compiler usually does it for you When you put `@Serializable` on a class, the **compiler plugin** generates a nested serializer (`MyClass.serializer()`) plus the descriptor automatically. You almost never see it. ## When to hand-write one You write a custom `KSerializer` when the generated one is unavailable or wrong: - **Third-party type** you can't annotate (e.g. `java.time.Instant`, a class from a dependency). - **Format differs from the model** — your class is `Color(r,g,b)` but the JSON must be the string `"#ff0000"`. - **Validation/normalization** — reject out-of-range values, trim strings, enforce invariants while decoding. - **Stable wire contract** — you want the serialized form decoupled from internal refactors. ## Wiring it in ```kotlin // Per-use, on a property or the class itself: @Serializable data class Event( @Serializable(with = InstantSerializer::class) val at: Instant, ) // File-wide, so every Instant in the file uses it: @file:UseSerializers(InstantSerializer::class) ``` A hand-written serializer is **format-agnostic** — the same `InstantSerializer` is used whether the format is JSON, ProtoBuf or CBOR, because it talks to the abstract `Encoder`/`Decoder`, not to JSON directly.

  • Does a custom KSerializer only work for JSON?
    No. It talks to the abstract Encoder/Decoder, so the same serializer works for any format — JSON, ProtoBuf, CBOR, etc.
  • What's the difference between @Serializable(with=...) and @file:UseSerializers?
    @Serializable(with=...) applies to a single class or property; @file:UseSerializers registers serializers for all occurrences of those types within the file.

A KSerializer is a translator hired for one specific language pair; the format is the messenger who carries whatever the translator produces.

saying these in an interview costs you the question

  • Thinking a custom serializer is JSON-specific
  • Believing you must annotate every class with @Serializable to serialize it
  • Confusing the format (Json) with the serializer (per-type strategy)
  • Not knowing the three members descriptor/serialize/deserialize
  • Claiming reflection is used at runtime (it's compiler-generated)

context

open as a page

How and why do you enforce domain invariants on a @Serializable class so that an invalid instance can never be deserialized?

level: juniorimportance: must knowfreq 55%

basics

~10 s

Put checks in the class's init {} block using require(). Deserialization calls the real constructor, so the checks run too. Bad input throws instead of producing a broken object.

open as a page

How do you turn a Kotlin object into a JSON string and back using kotlinx.serialization, and what is the role of the Json object?

level: juniorimportance: must knowfreq 80%

basics

~10 s

Mark the class with @Serializable, then call Json.encodeToString(obj) to get JSON text and Json.decodeFromString(text) to read it back. Json is the format instance that does the conversion.

open as a page

In kotlinx.serialization, what is JsonElement and what are its main subtypes? When would you use it instead of a typed @Serializable data class?

level: juniorimportance: must knowfreq 70%

basics

~20 s

JsonElement is a flexible in-memory tree for JSON when you don't have a fixed class. Its parts are JsonObject (key-value), JsonArray (list), and JsonPrimitive (string, number, boolean, or null). Use it when the JSON shape is unknown or changes.

open as a page

You add a new property to a kotlinx.serialization @Serializable class, but old JSON payloads don't contain it. How do you make deserialization of those old payloads still succeed?

level: juniorimportance: must knowfreq 70%

basics

~10 s

Give the new property a default value. When the field is missing from the JSON, kotlinx.serialization uses the default, so old messages still load without errors.

open as a page

With kotlinx.serialization, what happens when you mark a sealed class @Serializable and serialize one of its subclasses to JSON? What does the output look like?

level: juniorimportance: must knowfreq 70%

basics

~10 s

kotlinx.serialization adds a special field (by default named "type") to the JSON that records which subclass it is. When reading back, that field tells the library which subclass to build.

open as a page

How does @ProtoNumber work in kotlinx.serialization ProtoBuf, and what happens if you omit it?

level: middleimportance: must knowfreq 55%

basics

~10 s

@ProtoNumber sets the field's tag number on the wire. ProtoBuf stores fields by number, not name. If you omit it, numbers are assigned automatically starting at 1 in declaration order.

open as a page

Write a custom KSerializer for a value type using PrimitiveSerialDescriptor. Why must the descriptor's serialName be unique, and what determines the PrimitiveKind?

level: middleimportance: must knowfreq 55%

basics

~20 s

Use PrimitiveSerialDescriptor with a unique name and the basic kind (string, long, etc.) your wire value has. The name must be unique so the format can tell types apart; the kind matches the encode/decode call you actually use.

open as a page

When decoding untrusted input with kotlinx.serialization, what exceptions can be thrown and how should you handle them at a trust boundary?

level: middleimportance: must knowfreq 50%

basics

~10 s

Bad or malformed input makes decodeFromString throw. Catch SerializationException (and IllegalArgumentException from your own checks), turn it into a safe error response, and never leak the raw message or stack trace to the caller.

open as a page

Why is kotlinx.serialization's @Serializable generally considered safer against arbitrary-gadget deserialization attacks than reflection-based deserializers like Java's ObjectInputStream or Jackson default typing?

level: middleimportance: must knowfreq 60%

basics

~20 s

Kotlin generates serialization code at compile time. It only fills fields of types you declared, so attacker JSON cannot make it build a random object or run hidden code the way classic Java deserialization gadgets do.

open as a page

What do the ignoreUnknownKeys and isLenient flags on the Json { } builder do, and when would you enable each?

level: middleimportance: must knowfreq 70%

basics

~10 s

ignoreUnknownKeys lets decoding skip JSON keys your class doesn't have instead of failing. isLenient relaxes strict JSON parsing, allowing unquoted keys/values. Enable them mainly when consuming flexible third-party JSON.

open as a page

Show how to construct ad-hoc JSON with the buildJsonObject {} and buildJsonArray {} DSLs. What builder functions are available inside the block?

level: middleimportance: must knowfreq 60%

basics

~10 s

buildJsonObject { } lets you assemble a JSON object by calling put("key", value) for primitives, and putJsonObject/putJsonArray for nested structures. buildJsonArray { } builds arrays with add(...). They return immutable JsonObject/JsonArray.

open as a page

An old service deserializes JSON produced by a newer service that added extra fields. By default kotlinx.serialization throws. How do you handle the extra keys, and what is the tradeoff?

level: middleimportance: must knowfreq 65%

basics

~10 s

Configure the Json instance with ignoreUnknownKeys = true. Then unknown extra keys are skipped instead of causing an error, letting old code read newer data.

open as a page

Why does a @Serializable sealed hierarchy work out of the box, while making an open/abstract base polymorphic requires a SerializersModule? Walk through registering the open case.

level: middleimportance: must knowfreq 60%

basics

~20 s

For sealed types the compiler knows every subclass, so it registers them for you. For open or abstract types, subclasses can live anywhere, so you must list them yourself in a SerializersModule with polymorphic { subclass(...) }.

open as a page

What are the binary serialization formats in kotlinx.serialization (ProtoBuf, CBOR), and why would you choose them over JSON?

level: juniorimportance: should knowfreq 45%

basics

~20 s

ProtoBuf and CBOR encode your data into compact bytes instead of text like JSON. You use them when you want smaller, faster messages, for example over a network. Mark the class @Serializable and call encodeToByteArray.

open as a page

What does the prettyPrint flag do, and what are the practical considerations for using it (output, performance, configurability)?

level: juniorimportance: should knowfreq 45%

basics

~10 s

prettyPrint = true makes the JSON output human-readable with indentation and line breaks instead of one compact line. It's for logs and debugging; compact output is better for the wire.

open as a page

Show how to round-trip a value through ProtoBuf, and name common pitfalls (nullability, defaults, byte handling) when using binary formats.

level: middleimportance: should knowfreq 38%

basics

~10 s

Encode with ProtoBuf.encodeToByteArray and decode with decodeFromByteArray of the same type. Common mistakes: treating bytes as a String, mismatched field numbers, and assuming missing optional fields will fail instead of using defaults.

open as a page

Explain the surrogate (delegating) serializer pattern. How do you implement a KSerializer for an unannotated class by delegating to another @Serializable type?

level: middleimportance: should knowfreq 45%

basics

~20 s

Make a small @Serializable helper class that has the exact fields you want on the wire. Your custom serializer borrows that helper's generated serializer, converting your real type to and from the helper. The helper does all the encoding.

open as a page

Explain encodeDefaults and explicitNulls in the Json { } builder. How do they affect the JSON output for properties that equal their default or are null?

level: middleimportance: should knowfreq 55%

basics

~10 s

encodeDefaults decides whether properties equal to their default value are written out. explicitNulls decides whether nullable properties that are null are written as "key": null or omitted entirely.

open as a page

Given an unknown JSON string, how do you parse it with parseToJsonElement and safely navigate optional/nested fields? Contrast the throwing accessors with the *OrNull variants.

level: middleimportance: should knowfreq 55%

basics

~20 s

Call Json.parseToJsonElement(text) to get a tree, then drill in with obj["key"]. For safety use the ...OrNull helpers (jsonObject vs the safe path, intOrNull, contentOrNull) plus Kotlin's ?. so a missing or wrong-typed field gives null instead of throwing.

open as a page

When evolving a schema, when should you model an optional field as a nullable type with `= null` versus a non-null type with a concrete default? How do they differ on encode and decode?

level: middleimportance: should knowfreq 40%

basics

~10 s

Use a concrete default when there's a sensible fallback value. Use nullable = null when you need to tell 'not provided' apart from any real value. Both let old payloads decode.

open as a page

You ship a sealed @Serializable hierarchy as a public JSON API. A teammate renames a subclass or refactors its package. Why might that break existing clients, and how do you prevent it?

level: middleimportance: should knowfreq 45%

basics

~20 s

By default the discriminator value is the class's full name. Renaming the class or moving its package changes that value, so old JSON no longer matches. Pin a stable value with @SerialName on every subclass.

open as a page

What does the @ExperimentalSerializationApi status of ProtoBuf/CBOR mean in practice, and how do you handle it in production code?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Experimental means the API can change between versions and the compiler warns unless you opt in. In production you opt in deliberately, pin the library version, and wrap usage so a future change touches one place.

open as a page

Compare ProtoBuf and CBOR in kotlinx.serialization: wire format, size, and when to choose each.

level: seniorimportance: should knowfreq 40%

basics

~20 s

ProtoBuf stores only field numbers and values, so it is very compact but both sides need the same schema. CBOR also stores the keys, so it is a bit bigger but more self-describing, like a binary JSON.

open as a page

Hand-write serialize/deserialize for a structured type using beginStructure/endStructure and decodeElementIndex. Why is the decodeElementIndex loop necessary and how do you handle CompositeDecoder.DECODE_DONE?

level: seniorimportance: should knowfreq 40%

basics

~20 s

You open a structure, write each field with its index, then close it. To read, you loop asking the decoder which field comes next until it says done. The loop is needed because formats may send fields in any order or skip optional ones.

open as a page

Compare the ways to wire a custom serializer: @Serializable(with=...), @file:UseSerializers, @Contextual + SerializersModule, and KSerializer<T> by ... delegation. When is each appropriate?

level: seniorimportance: should knowfreq 42%

basics

~10 s

Annotate one spot with @Serializable(with=...). Cover a whole file with @file:UseSerializers. Register globally (especially for third-party types) with @Contextual plus a SerializersModule. Use 'by' delegation to reuse another serializer's logic while tweaking the type.

open as a page

Why must you distrust the polymorphic type discriminator in JSON, and how does sealed-class polymorphism plus a controlled SerializersModule keep deserialization safe?

level: seniorimportance: should knowfreq 45%

basics

~20 s

A JSON 'type' field tells the decoder which subtype to build. If attackers can pick any type, that is dangerous. With sealed classes the choices are a fixed, known list, so an unknown type just fails safely.

open as a page

What is the classDiscriminator setting in Json { }, and how does it relate to polymorphic serialization? When would you change it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

classDiscriminator is the JSON key (default "type") that records which concrete subclass a polymorphic object is, so the decoder can pick the right type. You change it to avoid clashing with a real "type" field.

open as a page

How do you convert between a typed @Serializable object and a JsonElement tree, and back? Explain encodeToJsonElement and decodeFromJsonElement and a use case for round-tripping through the tree.

level: seniorimportance: should knowfreq 45%

basics

~10 s

Json.encodeToJsonElement(value) turns a typed object into a JsonElement tree, and Json.decodeFromJsonElement<T>(element) turns a tree back into a typed object. Going through the tree lets you tweak, inspect, or merge fields between the two worlds.

open as a page

By default kotlinx.serialization omits properties equal to their default during encoding. How do you control that with @EncodeDefault, and what are the Mode tradeoffs for schema evolution?

level: seniorimportance: should knowfreq 45%

basics

~10 s

Use @EncodeDefault to force a property to be written even when it equals its default, or to never write it. Mode.ALWAYS always emits it; Mode.NEVER always omits it.

open as a page

showing 1–30 of 37