@Serializable Basics
compile-time codegen ยท no reflectionkotlinx.serialization is the official library for converting Kotlin objects to and from formats like JSON. Its defining design choice, and the reason it needs its own compiler plugin: annotating a class @Serializable generates a real serializer at compile time, rather than inspecting the class through reflection at runtime the way Jackson or Gson do. That means serialization works on Kotlin/JS and Kotlin/Native (where JVM reflection doesn't exist at all), and mistakes โ a field type serialization can't handle โ surface as compile errors instead of runtime surprises.
import kotlinx.serialization.Serializable @Serializable data class User( val name: String, val age: Int, val email: String? ) // The compiler plugin generates User.serializer() at compile time โ // no runtime class inspection needed to encode or decode this type
@Serializable class must itself be a serializable type โ a primitive, String, another @Serializable class, or a standard collection of those. A property whose type kotlinx.serialization doesn't know how to handle is a compile error, not something you discover the first time you try to encode an instance.Json.encodeToString & decodeFromString
reified generics ยท one-line conversionThe Json format object is the entry point for converting between a Kotlin object and its JSON text representation. Both functions are generic and reified (see Generics) โ the type parameter is inferred from context, so there's no separate Class<T> token to pass in the way older Java JSON libraries required.
import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.decodeFromString @Serializable data class User(val name: String, val age: Int) val user = User("Ada", 36) // encodeToString: T inferred from the argument val json = Json.encodeToString(user) println(json) // {"name":"Ada","age":36} // decodeFromString: T inferred from the target type val decoded: User = Json.decodeFromString(json) println(decoded) // User(name=Ada, age=36) // Lists and nested objects work the same way, no extra ceremony val users = listOf(User("Ada", 36), User("Grace", 85)) val usersJson = Json.encodeToString(users) println(usersJson) // [{"name":"Ada","age":36},{"name":"Grace","age":85}]
Custom Names: @SerialName
Kotlin name โ JSON keyExternal APIs frequently use JSON key names that aren't valid or idiomatic Kotlin identifiers โ snake_case fields, reserved words, or names you simply want to present differently in your own model. @SerialName decouples the Kotlin property name from the JSON key it maps to, so your Kotlin code stays idiomatic while still matching the wire format exactly.
import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName @Serializable data class ApiUser( val name: String, @SerialName("created_at") val createdAt: String, @SerialName("is_active") val isActive: Boolean ) val json = """{"name":"Ada","created_at":"2024-01-01","is_active":true}""" val user = Json.decodeFromString<ApiUser>(json) println(user.createdAt) // "2024-01-01" โ read from the "created_at" JSON key
Default Values
missing field โ default, not an errorAn ordinary Kotlin default parameter value (see Basics) also governs decoding: if a JSON payload is missing a field entirely, and the corresponding property has a default, decoding succeeds using that default instead of failing. This is what lets a data model evolve โ adding a new optional field โ without every previously-stored payload suddenly becoming unparseable.
@Serializable data class Settings( val theme: String = "light", val notificationsEnabled: Boolean = true ) // Old payload, predating the notificationsEnabled field entirely val oldJson = """{"theme":"dark"}""" val settings = Json.decodeFromString<Settings>(oldJson) println(settings) // Settings(theme=dark, notificationsEnabled=true) โ default filled in // Without a default, a missing field is a decoding ERROR, not null: // SerializationException: Field 'notificationsEnabled' is required
Polymorphism
sealed class ยท type discriminator ยท "type" keySerializing a value through an interface or abstract base type needs a way to record, in the JSON itself, which concrete subtype it actually was โ otherwise decoding wouldn't know what to reconstruct. kotlinx.serialization handles this automatically for a sealed hierarchy (see Classes & Objects): because every subtype is known at compile time, the plugin can generate a type discriminator without any extra annotations.
@Serializable sealed class Shape @Serializable @SerialName("circle") data class Circle(val radius: Double) : Shape() @Serializable @SerialName("rectangle") data class Rectangle(val width: Double, val height: Double) : Shape() val shapes: List<Shape> = listOf(Circle(2.0), Rectangle(3.0, 4.0)) val json = Json.encodeToString(shapes) println(json) // [{"type":"circle","radius":2.0},{"type":"rectangle","width":3.0,"height":4.0}] // Decoding reconstructs the correct concrete subtype using that "type" key val decoded: List<Shape> = Json.decodeFromString(json) println(decoded.first() is Circle) // true
"type" discriminator key defaults to the class's fully-qualified name unless @SerialName overrides it โ always set it explicitly on each subtype in a serialized hierarchy, since the fully-qualified default breaks the moment you rename or move the class.Lenient Parsing
Json { } configuration ยท unknown keysThe default Json instance is strict: unrecognized keys throw, malformed literals throw. A custom-configured Json instance can relax specific rules โ ignoring keys your model doesn't declare, coercing malformed values instead of failing, or accepting non-standard JSON โ which matters when parsing real-world APIs that don't always produce textbook-clean JSON.
import kotlinx.serialization.json.Json val lenientJson = Json { ignoreUnknownKeys = true // don't fail on JSON fields your model doesn't declare isLenient = true // accept minor deviations from strict JSON syntax coerceInputValues = true // use a property's default instead of failing on a null/wrong-type value } @Serializable data class User(val name: String) // "age" isn't declared on User at all โ default Json would throw, // lenientJson silently ignores it instead val json = """{"name":"Ada","age":36,"extra":"field"}""" val user = lenientJson.decodeFromString<User>(json) println(user) // User(name=Ada)
Quick Reference
Syntax cheat-sheet| Concept | Syntax | Notes |
|---|---|---|
| Mark a class serializable | @Serializable data class X(...) | Generates a serializer at compile time |
| Object โ JSON | Json.encodeToString(obj) | Type inferred via reified generics |
| JSON โ object | Json.decodeFromString<T>(json) | No Class<T> token needed |
| Custom JSON key | @SerialName("json_key") | Decouples Kotlin property name from wire format |
| Tolerate a missing field | val x: T = default | Ordinary Kotlin default; used when the JSON omits the field |
| Polymorphic hierarchy | @Serializable sealed class Base | Automatic type discriminator for known subtypes |
| Subtype's discriminator value | @SerialName("circle") on the subtype | Always set explicitly; default is the FQN |
| Ignore unrecognized fields | Json { ignoreUnknownKeys = true } | Default Json throws on unknown keys |
| Accept minor syntax deviations | Json { isLenient = true } | For real-world, not-quite-strict JSON |
| Fall back to defaults on bad values | Json { coerceInputValues = true } | Use sparingly; can mask real schema issues |