In the previous tutorial, you learned about Kotlin DSLs. Now let’s learn about Kotlin Serialization. Serialization is the process of converting objects to a format like JSON, and deserialization is the reverse. Kotlin Serialization is the official library for this.

In this tutorial, you will learn:

  • Setting up Kotlin Serialization
  • @Serializable and basic encoding/decoding
  • Default values and optional fields
  • @Transient for ignoring fields
  • @SerialName for custom field names
  • Nested objects and collections
  • Enum serialization
  • Json configuration options
  • Polymorphic serialization
  • JsonElement API for raw JSON
  • Practical examples

Setting Up

Add the serialization plugin and dependency to your build.gradle.kts:

plugins {
    kotlin("jvm") version "2.3.0"
    kotlin("plugin.serialization") version "2.3.0"
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
}

Basic Serialization

Mark a class with @Serializable. The compiler generates a serializer automatically.

@Serializable
data class User(
    val name: String,
    val age: Int,
    val email: String
)

Encoding (Object to JSON)

val user = User("Alex", 28, "alex@example.com")
val json = Json.encodeToString(user)
println(json)
// {"name":"Alex","age":28,"email":"alex@example.com"}

Decoding (JSON to Object)

val json = """{"name":"Alex","age":28,"email":"alex@example.com"}"""
val user = Json.decodeFromString<User>(json)
println(user) // User(name=Alex, age=28, email=alex@example.com)

Round Trip

Encoding and decoding should always give you back the same object:

val original = User("Alex", 28, "alex@example.com")
val json = Json.encodeToString(original)
val decoded = Json.decodeFromString<User>(json)
println(original == decoded) // true

Default Values

Properties with default values are optional during deserialization. If the JSON field is missing, the default value is used.

@Serializable
data class Settings(
    val theme: String = "light",
    val fontSize: Int = 14,
    val notifications: Boolean = true,
    val language: String = "en"
)

Decoding with only one field:

val json = """{"theme":"dark"}"""
val settings = Json.decodeFromString<Settings>(json)
println(settings)
// Settings(theme=dark, fontSize=14, notifications=true, language=en)

By default, fields with default values are not included in the JSON output. To include them, use encodeDefaults:

val json = Json { encodeDefaults = true }
println(json.encodeToString(Settings()))
// {"theme":"light","fontSize":14,"notifications":true,"language":"en"}

Ignoring Fields with @Transient

@Transient marks a field that should never be serialized. Transient fields must have a default value.

@Serializable
data class Account(
    val username: String,
    val email: String,
    @Transient val password: String = "" // Never in JSON
)

val account = Account("alex", "alex@example.com", "secret123")
val json = Json.encodeToString(account)
println(json)
// {"username":"alex","email":"alex@example.com"}
// No password field!

Use @Transient for sensitive data (passwords, tokens), computed properties, or internal state.

Custom Field Names with @SerialName

@SerialName changes the name used in JSON. This is useful when the API uses snake_case but your Kotlin code uses camelCase.

@Serializable
data class Product(
    @SerialName("product_name") val name: String,
    @SerialName("unit_price") val price: Double,
    @SerialName("in_stock") val inStock: Boolean = true
)

val product = Product("Laptop", 999.99)
val json = Json.encodeToString(product)
println(json)
// {"product_name":"Laptop","unit_price":999.99,"in_stock":true}

The Kotlin property is name, but in JSON it becomes product_name.

Nested Objects

Nested @Serializable objects are handled automatically:

@Serializable
data class Address(
    val street: String,
    val city: String,
    val country: String
)

@Serializable
data class Customer(
    val name: String,
    val address: Address,
    val orders: List<String> = emptyList()
)

val customer = Customer(
    name = "Alex",
    address = Address("123 Main St", "Berlin", "Germany"),
    orders = listOf("Order-1", "Order-2")
)

println(Json.encodeToString(customer))

Output:

{
  "name": "Alex",
  "address": {
    "street": "123 Main St",
    "city": "Berlin",
    "country": "Germany"
  },
  "orders": ["Order-1", "Order-2"]
}

Collections

Lists, Sets, and Maps are serialized automatically:

@Serializable
data class Team(
    val name: String,
    val members: List<String>,
    val scores: Map<String, Int> = emptyMap()
)

val team = Team("Dev", listOf("Alex", "Sam"), mapOf("Alex" to 95, "Sam" to 87))
println(Json.encodeToString(team))
// {"name":"Dev","members":["Alex","Sam"],"scores":{"Alex":95,"Sam":87}}

Enum Serialization

Enums are serialized by their name. Use @SerialName to customize:

@Serializable
enum class Priority {
    @SerialName("low") LOW,
    @SerialName("medium") MEDIUM,
    @SerialName("high") HIGH
}

@Serializable
data class Task(
    val title: String,
    val priority: Priority,
    val done: Boolean = false
)

val task = Task("Fix bug", Priority.HIGH)
println(Json.encodeToString(task))
// {"title":"Fix bug","priority":"high","done":false}

Json Configuration

You can create custom Json instances with different settings:

// Pretty printing
val prettyJson = Json {
    prettyPrint = true
    prettyPrintIndent = "  "
}

// Lenient parsing
val lenientJson = Json {
    ignoreUnknownKeys = true   // Skip fields not in the class
    isLenient = true           // Allow unquoted strings
    coerceInputValues = true   // Use defaults for invalid values
}

// Include defaults
val encodingJson = Json {
    encodeDefaults = true      // Include fields with default values
}

ignoreUnknownKeys

This is the most important setting for working with APIs. APIs often return fields your class does not have:

val lenientJson = Json { ignoreUnknownKeys = true }

// JSON has "unknown_field" but User class doesn't
val json = """{"name":"Alex","age":28,"email":"a@b.com","unknown_field":"value"}"""
val user = lenientJson.decodeFromString<User>(json)
// Works! unknown_field is ignored

Without ignoreUnknownKeys, this would throw an exception.

All Json Settings

Here is a complete list of the most useful settings:

SettingDefaultDescription
prettyPrintfalseFormat JSON with indentation
prettyPrintIndent" "Indent string
encodeDefaultsfalseInclude default values in output
ignoreUnknownKeysfalseSkip unknown fields during parsing
isLenientfalseAllow unquoted strings and trailing commas
coerceInputValuesfalseUse defaults for null or invalid values
allowSpecialFloatingPointValuesfalseAllow NaN and Infinity
classDiscriminator“type”Field name for polymorphic types

Creating a Reusable Json Instance

Create one Json instance and reuse it throughout your app:

val appJson = Json {
    prettyPrint = false
    ignoreUnknownKeys = true
    encodeDefaults = false
    coerceInputValues = true
}

// Use everywhere
val user = appJson.decodeFromString<User>(jsonString)
val output = appJson.encodeToString(user)

Do not create a new Json instance every time you serialize. Creating the instance has a cost because it compiles the serializers.

Kotlin Serialization vs Gson vs Moshi

FeatureKotlin SerializationGsonMoshi
Compile-time safetyYesNoPartial
Kotlin supportNativeLimitedGood
Code generationCompiler pluginReflectionAnnotation processor
Null safetyRespects Kotlin typesIgnoresRespects
Default valuesWorks correctlyIgnoresWorks
MultiplatformYes (KMP)No (JVM only)No (JVM only)
PerformanceFastMediumFast

Kotlin Serialization is the best choice for new Kotlin projects because:

  • It understands Kotlin types (null safety, default values)
  • It works at compile time (no reflection overhead)
  • It supports Kotlin Multiplatform
  • It is the official Kotlin library

Polymorphic Serialization

Polymorphic serialization handles inheritance. Use a sealed class as the base type:

@Serializable
sealed class Shape {
    abstract fun area(): Double
}

@Serializable
@SerialName("circle")
data class Circle(val radius: Double) : Shape() {
    override fun area() = Math.PI * radius * radius
}

@Serializable
@SerialName("rectangle")
data class Rectangle(val width: Double, val height: Double) : Shape() {
    override fun area() = width * height
}

Encoding adds a type discriminator:

val shape: Shape = Circle(5.0)
val json = Json.encodeToString<Shape>(shape)
println(json)
// {"type":"circle","radius":5.0}

Decoding reads the type to know which class to create:

val json = """{"type":"rectangle","width":3.0,"height":4.0}"""
val shape = Json.decodeFromString<Shape>(json)
println(shape.area()) // 12.0

Lists of Polymorphic Objects

val shapes: List<Shape> = listOf(
    Circle(5.0),
    Rectangle(3.0, 4.0)
)
val json = Json.encodeToString(shapes)
val decoded = Json.decodeFromString<List<Shape>>(json)
decoded.forEach { println("${it::class.simpleName}: ${it.area()}") }

JsonElement API

Sometimes you need to work with raw JSON without defining classes. Use JsonElement:

val json = """{"name":"Alex","age":28,"city":"Berlin"}"""
val element = Json.parseToJsonElement(json)

// Access fields
val name = element.jsonObject["name"]?.jsonPrimitive?.content
println(name) // "Alex"

val age = element.jsonObject["age"]?.jsonPrimitive?.int
println(age) // 28

This is useful when:

  • The JSON structure is unknown or dynamic
  • You only need a few fields from a large JSON
  • You are exploring an API response

Building JsonElements

val json = buildJsonObject {
    put("name", "Alex")
    put("age", 28)
    putJsonArray("hobbies") {
        add("coding")
        add("reading")
    }
}
println(json) // {"name":"Alex","age":28,"hobbies":["coding","reading"]}

Practical Example: API Response

A common pattern for REST API responses:

@Serializable
data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val error: String? = null
)

// Success response
val success = ApiResponse(success = true, data = "User created")
println(Json.encodeToString(success))
// {"success":true,"data":"User created"}

// Error response
val error = ApiResponse<String>(success = false, error = "Not found")
println(Json.encodeToString(error))
// {"success":false,"error":"Not found"}

Practical Example: Configuration File

Read and write application configuration:

@Serializable
data class AppConfig(
    val appName: String,
    val version: String,
    val debug: Boolean = false,
    val features: List<String> = emptyList(),
    val limits: Map<String, Int> = emptyMap()
)

val prettyJson = Json { prettyPrint = true }
val lenientJson = Json { ignoreUnknownKeys = true }

fun saveConfig(config: AppConfig): String {
    return prettyJson.encodeToString(config)
}

fun loadConfig(json: String): AppConfig {
    return lenientJson.decodeFromString(json)
}

Usage:

val config = AppConfig(
    appName = "MyApp",
    version = "1.0",
    features = listOf("dark-mode", "notifications"),
    limits = mapOf("max-users" to 100)
)

val json = saveConfig(config)
// Save to file...

val loaded = loadConfig(json)
// Use config...

Practical Example: Data Transfer Objects

When working with APIs, you often need separate classes for the request, response, and database model:

// What the API sends
@Serializable
data class CreateUserRequest(
    val name: String,
    val email: String
)

// What the API returns
@Serializable
data class UserResponse(
    val id: Int,
    val name: String,
    val email: String,
    val createdAt: String
)

// Internal model (not serializable)
data class UserEntity(
    val id: Int,
    val name: String,
    val email: String,
    val passwordHash: String,
    val createdAt: Long
)

// Conversion functions
fun UserEntity.toResponse() = UserResponse(
    id = id,
    name = name,
    email = email,
    createdAt = java.time.Instant.ofEpochMilli(createdAt).toString()
)

This separation keeps sensitive data (like passwordHash) out of the API response.

Common Mistakes

Mistake 1: Forgetting @Serializable

// Does NOT compile
data class User(val name: String)
Json.encodeToString(User("Alex")) // Error!

// Must add @Serializable
@Serializable
data class User(val name: String)

Mistake 2: Not Using ignoreUnknownKeys

// Crashes when API adds new fields
val json = """{"name":"Alex","newField":"value"}"""
Json.decodeFromString<User>(json) // Exception!

// Fix: use ignoreUnknownKeys
val lenient = Json { ignoreUnknownKeys = true }
lenient.decodeFromString<User>(json) // Works

Mistake 3: Serializing Sensitive Data

// BAD — password is in the JSON
@Serializable
data class Account(val username: String, val password: String)

// GOOD — use @Transient
@Serializable
data class Account(
    val username: String,
    @Transient val password: String = ""
)

Summary

FeatureDescription
@SerializableMarks a class for serialization
Json.encodeToStringObject to JSON string
Json.decodeFromStringJSON string to object
Default valuesOptional fields with defaults
@TransientExclude a field from serialization
@SerialNameCustom JSON field name
ignoreUnknownKeysSkip unknown JSON fields
encodeDefaultsInclude default values in output
PolymorphicHandle sealed class hierarchies
JsonElementWork with raw JSON

Source Code

You can find the source code for this tutorial on GitHub: tutorial-22-serialization

What’s Next?

In the next tutorial, you will build a real CLI tool with Kotlin using everything you have learned so far.