Converting JSON to a Kotlin data class looks like a five-minute job. Copy the keys, make a data class, done. Then the API returns null for a field you marked non-null, or the keys are snake_case and your properties are camelCase, and now you have a SerializationException in production.
I have written this mapping by hand more times than I can count, on Android apps and Ktor backends. This is the version that actually holds up: the right library, the annotations that matter, and the two or three things that bite everyone. And because typing out a 40-field class from a sample payload is soul-crushing, I will also show you the tool I built to skip that part entirely.
How do you convert JSON to a Kotlin data class?
You convert JSON to a Kotlin data class by defining a data class whose properties match the JSON keys, marking it @Serializable, and calling Json.decodeFromString. The official library for this is kotlinx.serialization, and it is the one I reach for on every new project.
Add the plugin and dependency to your Gradle build:
plugins {
kotlin("plugin.serialization") version "2.1.0"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}Then the class and the parse call:
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class User(
val id: Int,
val name: String,
val email: String,
val active: Boolean
)
val json = """{"id":1,"name":"Rabi","email":"r@example.com","active":true}"""
val user = Json.decodeFromString<User>(json)That is the whole happy path. The @Serializable annotation triggers the compiler plugin to generate the serializer at build time, so there is no runtime reflection, which is why kotlinx.serialization works on Kotlin Multiplatform and Android without extra config. Leave the annotation off and you get a compile error, not a runtime one, which is the good kind of failure.
Why kotlinx.serialization over Gson or Moshi?
Because kotlinx.serialization is the official Kotlin library, it understands Kotlin types like non-null and default values natively, while Gson and Moshi were built for Java and fake it. If you are starting fresh, this is the one to pick.
Here is the practical difference. Gson uses reflection and will happily construct a Kotlin object with null in a non-null property, because it bypasses your constructors. That gives you a value that the Kotlin type system swears cannot be null, right up until it crashes somewhere far from the parse. Moshi with its Kotlin codegen fixes that, and it is a fine choice on existing Android projects that already use it. But it is another dependency and another annotation processor.
kotlinx.serialization sidesteps all of it. It respects nullability, it respects default values, and it fails loudly at parse time when the JSON does not match, instead of handing you a broken object. On a new Kotlin codebase I do not think the comparison is close. Reach for kotlinx.serialization and move on.
How do you map snake_case JSON keys to Kotlin properties?
Use the @SerialName annotation to bind a JSON key to a differently named Kotlin property. Most APIs return snake_case, and you do not want that leaking into idiomatic Kotlin code.
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserProfile(
val id: Int,
@SerialName("full_name") val fullName: String,
@SerialName("avatar_url") val avatarUrl: String,
@SerialName("created_at") val createdAt: String
)Now the serializer reads full_name from the JSON but your code uses fullName. This is the single most common thing people get wrong, because without it the parse throws a SerializationException complaining about a missing field, and the field is right there in the JSON. It is not missing. Its name just does not match.
If literally every key is snake_case, there is a shortcut. Configure the Json instance with a naming strategy instead of annotating every property:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
val json = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
}I still prefer explicit @SerialName on public API models, because it documents the exact wire contract at the property. But for an all-snake_case API, the global strategy saves a lot of noise.
How do you handle nullable fields, defaults, and missing keys?
Match the JSON's optionality in the Kotlin type: nullable properties for keys that can be null, default values for keys that can be absent. These are two different problems and people conflate them constantly.
A key that is present but can hold null needs a nullable type:
@Serializable
data class Account(
val id: Int,
val nickname: String?, // present, but may be null
val plan: String = "free" // may be absent entirely
)The distinction that trips people up: a nullable type (String?) handles a key whose value is null. A default value handles a key that is missing from the JSON altogether. If a key might be absent and you do not give it a default, the parse fails. If it is present as null and you did not make it nullable, the parse also fails. They look similar and fail the same way, but the fix is different.
One more flag worth knowing. By default kotlinx.serialization rejects unknown keys, which is strict and safe. Real APIs add fields all the time, so on client models I usually relax that:
val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true // treats null on a non-null with a default as the default
}ignoreUnknownKeys stops a new server field from breaking your app. coerceInputValues is the pragmatic escape hatch for APIs that send null where you expected a value with a default. Use them on client-side models. On server-side request bodies I keep strict parsing, because there I want the bad request rejected.
How do you map nested objects and arrays?
Model each nested JSON object as its own @Serializable data class, and map arrays to List. kotlinx.serialization handles the nesting automatically once the types line up.
@Serializable
data class Address(
val city: String,
@SerialName("zip_code") val zipCode: String
)
@Serializable
data class Customer(
val id: Int,
val name: String,
val address: Address, // nested object
val tags: List<String>, // array of primitives
val orders: List<Order> // array of objects
)
@Serializable
data class Order(val id: Int, val total: Double)The rule is simple: every JSON object becomes a data class, every JSON array becomes a List of whatever the elements are. There is no special config for depth. A five-level-deep payload just means five data classes, each @Serializable. This is also where doing it by hand stops being fun, and where a generator earns its keep.
Is there a faster way than writing the class by hand?
Yes. Paste the JSON into a generator and it writes the entire data class for you, nested classes and all. This is exactly the tedium I got tired of, so I built jsontodto.com to do it.
You drop in a sample payload and it produces the matching Kotlin data class, with nested objects split into separate classes and arrays typed as List. You can toggle a plain data class or add kotlinx.serialization decorators, and it infers types from the values. For a big third-party API response, this turns a 20-minute typing session into a paste. The tool also outputs Java records, TypeScript interfaces, Python dataclasses, Go structs, and C# records from the same JSON, so it is handy across a polyglot stack, not just Kotlin.
My honest workflow: generate the class from a real sample, then hand-tune it. The generator cannot know that a field is nullable if the sample happened to have a value there, and it cannot know which keys are optional across all responses. So I paste, generate, then go through and add ? where the API can return null and defaults where keys can be absent. Generation gets you 90 percent of the way in one paste. The last 10 percent is the judgment this guide is about.
Converting JSON to a Kotlin data class is one of those tasks that is trivial until the real world shows up with snake_case keys, nullable everything, and objects nested five deep. Get the annotations right and kotlinx.serialization does the rest without complaint. Generate the boilerplate, then spend your actual attention on the nullability and optionality the generator cannot infer. That is where correctness lives.
For the full API, see the kotlinx.serialization JSON documentation and the official Kotlin serialization guide.
Keep Reading
- JSON to DTO Converter: Instantly Generate Java Classes from JSON. The Java side of the same tool, for records, Lombok, and Jackson DTOs.
- Java Libraries Beyond Lombok. More ways to cut boilerplate when you are back on the JVM side of a project.