Custom serialization formats
JSON is currently the only stable format in Kotlin serialization. Kotlin serialization also provides experimental support for the CBOR and ProtoBuf binary formats, as well as the Properties format for representing classes as flat maps with String keys.
These experimental format implementations are production-quality, but future releases may introduce changes to their default serialization behavior. They may also have format-specific limitations or restrictions on how they represent Kotlin data.
If none of these formats fit your use case, you can create a custom format to control how values and structures are encoded and decoded.
Create custom formats
To create a custom format for Kotlin serialization, implement the Encoder and Decoder interfaces. Serializers use these implementations to encode and decode values.
Serializers call the beginStructure() function on these implementations when encoding or decoding structured values, such as classes and collections. This function returns a CompositeEncoder or CompositeDecoder interface with functions that encode or decode each property or element.
The Encoder and Decoder interfaces are extensive, but you can use the AbstractEncoder and AbstractDecoder classes to simplify the process of creating custom encoder and decoder implementations. These classes implement the Encoder, Decoder, CompositeEncoder, and CompositeDecoder interfaces, so you can use the same class to handle primitive values and structured values in simple formats.
The AbstractEncoder class provides default implementations for most of the encode functions, such as encodeString(), which delegate to the encodeValue(value: Any) function. This means that by overriding the encodeValue() function, you can create a basic, functional custom format with minimal effort.
The following sections use these APIs to create a custom format that encodes values into a list, decodes values from that list, and then adds sequential decoding, collection support, and null support.
Create a basic encoder
To build a custom encoder, you can extend the AbstractEncoder class. This allows you to override the default implementations instead of implementing the Encoder and CompositeEncoder interfaces from scratch.
To create a basic encoder:
Create a class that extends
AbstractEncoderto define custom serialization logic:@ExperimentalSerializationApi class ListEncoder : AbstractEncoder() { val list = mutableListOf<Any>() override val serializersModule: SerializersModule = EmptySerializersModule() }In the
ListEncoderclass, overrideencodeValue()to define how the encoder handles each value.override fun encodeValue(value: Any) { list.add(value) }Create a function that uses the encoder to serialize a value:
@ExperimentalSerializationApi fun <T> encodeToList(serializer: SerializationStrategy<T>, value: T): List<Any> { val encoder = ListEncoder() encoder.encodeSerializableValue(serializer, value) return encoder.list }Add an inline overload that uses
serializer()with a reified type parameter, so callers don't need to pass the serializer explicitly:@ExperimentalSerializationApi inline fun <reified T> encodeToList(value: T) = encodeToList(serializer(), value)
Here's a minimal example that encodes the primitive values from an object graph into a flat list in serialization order:
The output shows that the encoder collects all serialized values into a flat list in serialization order. This can be useful when you need to process those values uniformly, for example to compute a hash or digest.
Create a basic decoder
To build a custom decoder, you can extend the AbstractDecoder class. This allows you to override the default implementations instead of implementing the Decoder and CompositeDecoder interfaces from scratch.
To create a basic decoder:
Create a class that extends
AbstractDecoderto define custom deserialization logic:@ExperimentalSerializationApi class ListDecoder(val list: ArrayDeque<Any>) : AbstractDecoder() { private var elementIndex = 0 override val serializersModule: SerializersModule = EmptySerializersModule() }In the
ListDecoderclass, override thedecodeValue()function to define how the decoder deserializes values:override fun decodeValue(): Any = list.removeFirst()Override the
decodeElementIndex()function to report which element is decoded next and returnDECODE_DONEwhen the structure is complete:override fun decodeElementIndex(descriptor: SerialDescriptor): Int { if (elementIndex == descriptor.elementsCount) return CompositeDecoder.DECODE_DONE return elementIndex++ }This format deserializes values in serialization order, so you can use a simple
elementIndexcounter to track progress. More complex formats often need additional logic to find the next decoded element.Override the
beginStructure()function to create a new decoder for each nested structure, so each recursively decoded structure keeps its ownelementIndexstate:override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = ListDecoder(list)Create a function that uses the decoder to deserialize a value:
@ExperimentalSerializationApi fun <T> decodeFromList(list: List<Any>, deserializer: DeserializationStrategy<T>): T { val decoder = ListDecoder(ArrayDeque(list)) return decoder.decodeSerializableValue(deserializer) }Add an inline overload to make deserialization easier to call:
@ExperimentalSerializationApi inline fun <reified T> decodeFromList(list: List<Any>): T = decodeFromList(list, serializer())
Here's a complete example that decodes a list of primitive values back into an object:
The output shows that the decoder reads the list back in serialization order and reconstructs the original object.
The decodeElementIndex() function reports which property comes next, and the beginStructure() function creates a new decoder state for nested objects.
Optimize with sequential decoding
If your format always stores values in declaration order and doesn't support skipping optional elements, you can optimize decoding with the decodeSequentially() function.
When this function returns true, serializers that support sequential decoding can read values in order without repeatedly requesting the next element index. This can improve performance when deserializing data stored in sequential formats. Serializers generated by the kotlinx.serialization plugin use this optimization.
Returning true from the decodeSequentially() function doesn't guarantee that serializers using your decoder use sequential decoding. Therefore, make sure your decoder also supports regular decoding with the decodeElementIndex() function.
Here's how to apply this optimization to the custom ListDecoder:
In this example, ListDecoder overrides the decodeSequentially() function to return true. This lets supported serializers read values directly in declaration order instead of querying each element index one by one.
Add collection support
A basic custom format can encode collection elements one by one, but decoding also needs to know how many elements belong to the collection.
To support collections in a custom format, encode the collection size along with its elements so the decoder knows where the collection ends:
Implement the
beginCollection()function in the encoder to handle the collection size.Return the encoder instance from the
beginCollection()function if the encoder doesn't need extra collection-specific state.Implement the
decodeCollectionSize()function in the decoder to decode and store the collection size during deserialization.Return
truefrom thedecodeSequentially()function if the format stores collection size in advance and values are read in order.
Here's a complete example that adds collection support to ListEncoder:
In this example, the encoded list includes the collection size before the collection elements, so the decoder can correctly decode collections.
Add null support
To support null values in a custom format, you need a way to distinguish null from a regular value.
This typically involves adding a "null indicator" before each nullable value that distinguishes between null values and actual data.
To add support for null values in a custom format:
Override
encodeNull()in the encoder to specify hownullvalues are marked.Override
encodeNotNullMark()in the encoder to mark non-null values.Override
decodeNotNullMark()in the decoder to check that marker before decoding the value.
Here's an example that adds null support to the ListEncoder and ListDecoder implementations:
In this example, the encoder writes !! before a non-null value and writes NULL for a null value. The decoder checks these markers to decide whether to decode a value or return null.
Create a compact binary format
Binary formats are often used for their compact representation of data, making them ideal for scenarios where minimizing the amount of data stored or transmitted is important.
Custom binary formats allow you to control how data is serialized and deserialized at a low level. This gives you the flexibility to optimize performance and maintain compatibility with other systems.
You can create a custom binary format with Kotlin serialization by implementing the java.io.DataOutput and the java.io.DataInput interfaces.
Here's an example of how to turn the ListEncoder and ListDecoder implementations into a compact binary format using DataOutput and DataInput:
Override the encode functions for each primitive type, such as
encodeInt()for integers orencodeString()for strings. These type-specific encode functions avoid boxing and let you define the binary representation for each primitive type. In this example, the values are encoded directly to theDataOutputstream:override fun encodeBoolean(value: Boolean) = output.writeByte(if (value) 1 else 0) override fun encodeByte(value: Byte) = output.writeByte(value.toInt()) override fun encodeShort(value: Short) = output.writeShort(value.toInt()) override fun encodeInt(value: Int) = output.writeInt(value) override fun encodeLong(value: Long) = output.writeLong(value) override fun encodeFloat(value: Float) = output.writeFloat(value) override fun encodeDouble(value: Double) = output.writeDouble(value) override fun encodeChar(value: Char) = output.writeChar(value.code) override fun encodeString(value: String) = output.writeUTF(value) override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) = output.writeInt(index)Implement the decode functions for each primitive type, such as
decodeInt()ordecodeString(). This lets the decoder decode values directly from theDataInputstream and reconstruct the original data structure:override fun decodeBoolean(): Boolean = input.readByte().toInt() != 0 override fun decodeByte(): Byte = input.readByte() override fun decodeShort(): Short = input.readShort() override fun decodeInt(): Int = input.readInt() override fun decodeLong(): Long = input.readLong() override fun decodeFloat(): Float = input.readFloat() override fun decodeDouble(): Double = input.readDouble() override fun decodeChar(): Char = input.readChar() override fun decodeString(): String = input.readUTF() override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = input.readInt()Use these classes to serialize and deserialize Kotlin objects in a binary format:
import kotlinx.serialization.* import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* import kotlinx.serialization.modules.* import java.io.* @ExperimentalSerializationApi class DataOutputEncoder(val output: DataOutput) : AbstractEncoder() { override val serializersModule: SerializersModule = EmptySerializersModule() // Encodes primitive values directly in binary form override fun encodeBoolean(value: Boolean) = output.writeByte(if (value) 1 else 0) override fun encodeByte(value: Byte) = output.writeByte(value.toInt()) override fun encodeShort(value: Short) = output.writeShort(value.toInt()) override fun encodeInt(value: Int) = output.writeInt(value) override fun encodeLong(value: Long) = output.writeLong(value) override fun encodeFloat(value: Float) = output.writeFloat(value) override fun encodeDouble(value: Double) = output.writeDouble(value) override fun encodeChar(value: Char) = output.writeChar(value.code) override fun encodeString(value: String) = output.writeUTF(value) override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) = output.writeInt(index) override fun beginCollection(descriptor: SerialDescriptor, collectionSize: Int): CompositeEncoder { encodeInt(collectionSize) return this } override fun encodeNull() = encodeBoolean(false) override fun encodeNotNullMark() = encodeBoolean(true) } @ExperimentalSerializationApi fun <T> encodeTo(output: DataOutput, serializer: SerializationStrategy<T>, value: T) { val encoder = DataOutputEncoder(output) encoder.encodeSerializableValue(serializer, value) } @ExperimentalSerializationApi inline fun <reified T> encodeTo(output: DataOutput, value: T) = encodeTo(output, serializer(), value) @ExperimentalSerializationApi class DataInputDecoder(val input: DataInput, var elementsCount: Int = 0) : AbstractDecoder() { private var elementIndex = 0 override val serializersModule: SerializersModule = EmptySerializersModule() // Decodes primitive values directly from binary form override fun decodeBoolean(): Boolean = input.readByte().toInt() != 0 override fun decodeByte(): Byte = input.readByte() override fun decodeShort(): Short = input.readShort() override fun decodeInt(): Int = input.readInt() override fun decodeLong(): Long = input.readLong() override fun decodeFloat(): Float = input.readFloat() override fun decodeDouble(): Double = input.readDouble() override fun decodeChar(): Char = input.readChar() override fun decodeString(): String = input.readUTF() override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = input.readInt() override fun decodeElementIndex(descriptor: SerialDescriptor): Int { if (elementIndex == elementsCount) return CompositeDecoder.DECODE_DONE return elementIndex++ } override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = DataInputDecoder(input, descriptor.elementsCount) override fun decodeSequentially(): Boolean = true override fun decodeCollectionSize(descriptor: SerialDescriptor): Int = decodeInt().also { elementsCount = it } override fun decodeNotNullMark(): Boolean = decodeBoolean() } @ExperimentalSerializationApi fun <T> decodeFrom(input: DataInput, deserializer: DeserializationStrategy<T>): T { val decoder = DataInputDecoder(input) return decoder.decodeSerializableValue(deserializer) } @ExperimentalSerializationApi inline fun <reified T> decodeFrom(input: DataInput): T = decodeFrom(input, serializer()) fun ByteArray.toAsciiHexString() = joinToString("") { if (it in 32..127) it.toInt().toChar().toString() else "{${it.toUByte().toString(16).padStart(2, '0').uppercase()}}" } //sampleStart @Serializable data class Project(val name: String, val language: String) @OptIn(ExperimentalSerializationApi::class) fun main() { val data = Project("kotlinx.serialization", "Kotlin") // Encodes the object to the custom binary format val output = ByteArrayOutputStream() encodeTo(DataOutputStream(output), data) val bytes = output.toByteArray() println(bytes.toAsciiHexString()) // {00}{15}kotlinx.serialization{00}{06}Kotlin // Decodes the object from the custom binary format val input = ByteArrayInputStream(bytes) val obj = decodeFrom<Project>(DataInputStream(input)) println(obj) // Project(name=kotlinx.serialization, language=Kotlin) } //sampleEnd
In this example, the custom format encodes only the serialized values without keys or property names in binary form and decodes them back into a Project object. This makes it easier to adapt the format for cases where you need a compact representation and precise control over the binary encoding.
Add support for format-specific types
A custom format can support types that don't map directly to the standard primitive encoding functions.
To support such types, override the encodeSerializableValue() function in the encoder and the decodeSerializableValue() function in the decoder. This lets you define custom serialization logic for format-specific types, while maintaining efficient handling and flexibility for non-standard data representations.
To detect a type correctly, compare the serializer.descriptor property with the descriptor of the serializer for that type instead of checking the runtime type of the value. This preserves the declared serialized form, even when a type uses a custom serializer or shares the same underlying representation as another type.
Let's look at an example of how to extend the compact binary format example with specialized support for the type ByteArray:
Obtain a serializer for the format-specific type, so the encoder can detect it by descriptor:
private val byteArraySerializer = serializer<ByteArray>()Override the
encodeSerializableValue()function in the encoder to detectByteArrayvalues by descriptor and use a specialized encoding path. Then define helper functions to encode the byte array:override fun <T> encodeSerializableValue(serializer: SerializationStrategy<T>, value: T) { if (serializer.descriptor == byteArraySerializer.descriptor) encodeByteArray(value as ByteArray) else super.encodeSerializableValue(serializer, value) } // Encodes a ByteArray using a compact representation for its size private fun encodeByteArray(bytes: ByteArray) { encodeCompactSize(bytes.size) output.write(bytes) } // Encodes sizes up to 254 in a single byte private fun encodeCompactSize(value: Int) { if (value < 0xff) { output.writeByte(value) } else { output.writeByte(0xff) output.writeInt(value) } }Override the
decodeSerializableValue()function in the decoder to detectByteArrayvalues by descriptor and deserialize them with the matching compact size format:@Suppress("UNCHECKED_CAST") override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>, previousValue: T?): T = if (deserializer.descriptor == byteArraySerializer.descriptor) decodeByteArray() as T else super.decodeSerializableValue(deserializer, previousValue) // Decodes ByteArray data private fun decodeByteArray(): ByteArray { val bytes = ByteArray(decodeCompactSize()) input.readFully(bytes) return bytes } // Decodes size efficiently using a compact format private fun decodeCompactSize(): Int { val byte = input.readByte().toInt() and 0xff if (byte < 0xff) return byte return input.readInt() }Serialize and deserialize objects with the embedded
ByteArraydata:@Serializable data class Project(val name: String, val attachment: ByteArray) @OptIn(ExperimentalSerializationApi::class) fun main() { val data = Project("kotlinx.serialization", byteArrayOf(0x0A, 0x0B, 0x0C, 0x0D)) val output = ByteArrayOutputStream() encodeTo(DataOutputStream(output), data) val bytes = output.toByteArray() println(bytes.toAsciiHexString()) // {00}{15}kotlinx.serialization{04}{0A}{0B}{0C}{0D} val input = ByteArrayInputStream(bytes) val obj = decodeFrom<Project>(DataInputStream(input)) println(obj) // Project(name=kotlinx.serialization, attachment=[10, 11, 12, 13]) }
Here's the complete example that serializes and deserializes a class with a ByteArray property using the specialized encoding and decoding path:
Define a format-specific @Serializable annotation
You can use the @MetaSerializable annotation to define a format-specific @Serializable annotation. Add the format-specific annotation to a class instead of @Serializable to make the class serializable and include the annotation data in the generated serial descriptor.
Here's an example that defines a @BinarySerializable annotation:
What's next
Learn how to serialize data in CBOR format.
Explore Protocol Buffers serialization in ProtoBuf format.