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
@Serializableand basic encoding/decoding- Default values and optional fields
@Transientfor ignoring fields@SerialNamefor custom field names- Nested objects and collections
- Enum serialization
- Json configuration options
- Polymorphic serialization
JsonElementAPI 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:
| Setting | Default | Description |
|---|---|---|
prettyPrint | false | Format JSON with indentation |
prettyPrintIndent | " " | Indent string |
encodeDefaults | false | Include default values in output |
ignoreUnknownKeys | false | Skip unknown fields during parsing |
isLenient | false | Allow unquoted strings and trailing commas |
coerceInputValues | false | Use defaults for null or invalid values |
allowSpecialFloatingPointValues | false | Allow 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
| Feature | Kotlin Serialization | Gson | Moshi |
|---|---|---|---|
| Compile-time safety | Yes | No | Partial |
| Kotlin support | Native | Limited | Good |
| Code generation | Compiler plugin | Reflection | Annotation processor |
| Null safety | Respects Kotlin types | Ignores | Respects |
| Default values | Works correctly | Ignores | Works |
| Multiplatform | Yes (KMP) | No (JVM only) | No (JVM only) |
| Performance | Fast | Medium | Fast |
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
| Feature | Description |
|---|---|
@Serializable | Marks a class for serialization |
Json.encodeToString | Object to JSON string |
Json.decodeFromString | JSON string to object |
| Default values | Optional fields with defaults |
@Transient | Exclude a field from serialization |
@SerialName | Custom JSON field name |
ignoreUnknownKeys | Skip unknown JSON fields |
encodeDefaults | Include default values in output |
| Polymorphic | Handle sealed class hierarchies |
JsonElement | Work 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.