JSON #
JSON (JavaScript Object Notation) is the most common data exchange format in the modern web — used for REST APIs, configuration, data storage, and inter-service communication. In Kotlin there are three popular libraries: kotlinx.serialization (official from JetBrains, Kotlin-first, compile-time safe), Gson (from Google, mature and simple, but reflection-based), and Moshi (from Square, more modern than Gson, supports Kotlin well). This article covers all three in depth with the main focus on kotlinx.serialization as the idiomatic choice for modern Kotlin projects.
Choosing a JSON Library #
flowchart TD
A{New project\nKotlin-first?} -- Yes --> B["kotlinx.serialization\nOfficial JetBrains, compile-time safe\nMultiplatform, no reflection"]
A -- No --> C{Already have\nJava code?}
C -- Yes --> D["Gson\nMature, zero config, easy interop"]
C -- No --> E{Need high\nperformance?}
E -- Yes --> F["Moshi with\ncodegen (kapt/ksp)"]
E -- No --> B| Aspect | kotlinx.serialization | Gson | Moshi |
|---|---|---|---|
| Approach | Compile-time (plugin) | Runtime reflection | Reflection + codegen |
| Kotlin-first | ✓ Completely | Partial | ✓ Good |
| Null safety | ✓ Enforced | Partial | ✓ Good |
| Default values | ✓ Native | ✗ No | ✓ With adapter |
| Multiplatform | ✓ KMP ready | ✗ JVM only | ✗ JVM only |
| Performance | Very good | Moderate | Good |
| Configuration | Minimal | Minimal | Needs adapter |
kotlinx.serialization — The Primary Choice #
Setup #
// build.gradle.kts
plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.serialization") version "2.0.0" // required plugin
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
Basic Encode and Decode #
import kotlinx.serialization.*
import kotlinx.serialization.json.*
@Serializable
data class User(
val id: Int,
val name: String,
val email: String
)
fun main() {
val user = User(1, "Budi Santoso", "[email protected]")
// Encode: object → JSON string
val json = Json.encodeToString(user)
println(json)
// {"id":1,"name":"Budi Santoso","email":"[email protected]"}
// Decode: JSON string → object
val back = Json.decodeFromString<User>(json)
println(back.name) // Budi Santoso
// Encode a list
val list = listOf(
User(1, "Budi", "[email protected]"),
User(2, "Sari", "[email protected]")
)
val jsonList = Json.encodeToString(list)
println(jsonList)
// [{"id":1,"name":"Budi","email":"[email protected]"},{"id":2,...}]
// Decode a list
val backList = Json.decodeFromString<List<User>>(jsonList)
println(backList.size) // 2
}
Json Configuration
#
Create a custom Json instance to control serialization behavior:
// Custom instance — create once, use many times
val prettyJson = Json {
prettyPrint = true // format the output with indentation
ignoreUnknownKeys = true // ignore JSON fields that don't exist in the class
isLenient = true // allow non-standard JSON (trailing commas, etc.)
encodeDefaults = false // don't encode fields with default values
explicitNulls = false // don't encode null fields
coerceInputValues = true // convert types when possible (e.g., string → enum)
}
@Serializable
data class Product(
val id: Int,
val name: String,
val price: Double,
val category: String = "General", // default value
val description: String? = null // nullable with a null default
)
val product = Product(1, "Laptop", 15_000_000.0)
// With encodeDefaults = false: doesn't encode 'category' and 'description'
val json1 = Json { encodeDefaults = false }.encodeToString(product)
println(json1)
// {"id":1,"name":"Laptop","price":1.5E7}
// With encodeDefaults = true: encodes all fields including defaults
val json2 = Json { encodeDefaults = true }.encodeToString(product)
println(json2)
// {"id":1,"name":"Laptop","price":1.5E7,"category":"General","description":null}
// pretty print
println(prettyJson.encodeToString(product))
// {
// "id": 1,
// "name": "Laptop",
// "price": 1.5E7
// }
Serialization Annotations #
@SerialName — Change the Field Name in JSON
#
@Serializable
data class APIResponse(
@SerialName("user_id") val userId: Int,
@SerialName("full_name") val fullName: String,
@SerialName("created_at") val createdAt: String,
@SerialName("is_active") val isActive: Boolean
)
// JSON: {"user_id":1,"full_name":"Budi","created_at":"2024-08-17","is_active":true}
// Kotlin: APIResponse(userId=1, fullName="Budi", createdAt="2024-08-17", isActive=true)
val json = """{"user_id":42,"full_name":"Sari Dewi","created_at":"2024-01-15","is_active":true}"""
val response = Json.decodeFromString<APIResponse>(json)
println(response.fullName) // Sari Dewi
@Transient — Exclude from JSON
#
@Serializable
data class User(
val id: Int,
val name: String,
val email: String,
@Transient val passwordHash: String = "", // not serialized, must have a default
@Transient val sessionToken: String = "" // doesn't appear in JSON at all
)
val user = User(1, "Budi", "[email protected]", "hash123", "token456")
val json = Json.encodeToString(user)
println(json)
// {"id":1,"name":"Budi","email":"[email protected]"}
// passwordHash and sessionToken are not in the output!
Nullable and Default Values #
@Serializable
data class Profile(
val id: Int,
val name: String,
val bio: String? = null, // nullable, default null
val website: String? = null,
val followers: Int = 0, // default value
val verified: Boolean = false
)
// Minimal JSON — fields with defaults aren't required
val minimalJson = """{"id":1,"name":"Budi"}"""
val profile = Json { ignoreUnknownKeys = true }.decodeFromString<Profile>(minimalJson)
println(profile)
// Profile(id=1, name=Budi, bio=null, website=null, followers=0, verified=false)
// Full JSON
val fullJson = """
{
"id": 2,
"name": "Sari",
"bio": "Developer & Writer",
"followers": 1500,
"verified": true
}
""".trimIndent()
val fullProfile = Json { ignoreUnknownKeys = true }.decodeFromString<Profile>(fullJson)
println(fullProfile.followers) // 1500
Nested JSON Structures #
@Serializable
data class Address(
val street: String,
val city: String,
val province: String,
@SerialName("postal_code") val postalCode: String
)
@Serializable
data class Order(
val id: String,
val user: User,
val shippingAddress: Address,
val products: List<OrderItem>,
val total: Double
)
@Serializable
data class OrderItem(
val productId: Int,
val name: String,
val price: Double,
val quantity: Int
) {
val subtotal: Double
@Transient get() = price * quantity // computed property — not serialized
}
val order = Order(
id = "ORD-001",
user = User(1, "Budi", "[email protected]"),
shippingAddress = Address("Jl. Merdeka No. 1", "Jakarta", "DKI Jakarta", "10110"),
products = listOf(
OrderItem(1, "Laptop", 15_000_000.0, 1),
OrderItem(2, "Mouse", 250_000.0, 2)
),
total = 15_500_000.0
)
println(Json { prettyPrint = true }.encodeToString(order))
Enum Serialization #
@Serializable
enum class OrderStatus {
@SerialName("pending") PENDING,
@SerialName("processing") PROCESSING,
@SerialName("shipped") SHIPPED,
@SerialName("delivered") DELIVERED,
@SerialName("cancelled") CANCELLED
}
@Serializable
data class Order2(
val id: String,
val status: OrderStatus
)
val order = Order2("ORD-001", OrderStatus.SHIPPED)
println(Json.encodeToString(order))
// {"id":"ORD-001","status":"shipped"} ← uses SerialName
val from = Json.decodeFromString<Order2>("""{"id":"ORD-002","status":"delivered"}""")
println(from.status) // DELIVERED
Polymorphism and Sealed Classes #
kotlinx.serialization supports polymorphism for sealed classes:
@Serializable
sealed class OperationResult {
@Serializable
@SerialName("success")
data class Success(val data: String, val code: Int = 200) : OperationResult()
@Serializable
@SerialName("error")
data class Failure(val message: String, val code: Int) : OperationResult()
@Serializable
@SerialName("loading")
object Loading : OperationResult()
}
val json = Json { classDiscriminator = "type" } // discriminator field name
val success: OperationResult = OperationResult.Success("Data fetched successfully")
val failure: OperationResult = OperationResult.Failure("Server error", 500)
val loading: OperationResult = OperationResult.Loading
println(json.encodeToString(success))
// {"type":"success","data":"Data fetched successfully","code":200}
println(json.encodeToString(failure))
// {"type":"error","message":"Server error","code":500}
// Decode to a sealed class — the discriminator determines the subtype
val decoded = json.decodeFromString<OperationResult>("""{"type":"error","message":"Not found","code":404}""")
when (decoded) {
is OperationResult.Success -> println("Success: ${decoded.data}")
is OperationResult.Failure -> println("Error ${decoded.code}: ${decoded.message}")
is OperationResult.Loading -> println("Loading...")
}
Custom Serializers #
For types not directly supported, create a custom serializer:
import java.time.LocalDate
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
// Serializer for LocalDate (not @Serializable natively)
object LocalDateSerializer : KSerializer<LocalDate> {
override val descriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDate) {
encoder.encodeString(value.toString()) // "2024-08-17"
}
override fun deserialize(decoder: Decoder): LocalDate {
return LocalDate.parse(decoder.decodeString())
}
}
@Serializable
data class Event(
val name: String,
@Serializable(with = LocalDateSerializer::class)
val date: LocalDate,
val location: String
)
val event = Event("Kotlin Conference", LocalDate.of(2024, 8, 17), "Jakarta")
val json = Json.encodeToString(event)
println(json)
// {"name":"Kotlin Conference","date":"2024-08-17","location":"Jakarta"}
val back = Json.decodeFromString<Event>(json)
println(back.date.year) // 2024
Dynamic JSON Parsing with JsonElement
#
For JSON with an unknown or changing structure:
val jsonString = """
{
"version": "2.0",
"data": {
"users": [
{"id": 1, "name": "Budi"},
{"id": 2, "name": "Sari"}
],
"total": 2
},
"metadata": {
"time": "2024-08-17T10:30:00Z",
"source": "API"
}
}
""".trimIndent()
val element = Json.parseToJsonElement(jsonString)
val jsonObj = element.jsonObject
// Access values safely
val version = jsonObj["version"]?.jsonPrimitive?.content
println(version) // 2.0
val total = jsonObj["data"]?.jsonObject?.get("total")?.jsonPrimitive?.int
println(total) // 2
val users = jsonObj["data"]?.jsonObject?.get("users")?.jsonArray
users?.forEach { item ->
val name = item.jsonObject["name"]?.jsonPrimitive?.content
println(name)
}
// Budi
// Sari
// Build JsonElement programmatically
val builtJson = buildJsonObject {
put("name", "New Product")
put("price", 99_000)
putJsonArray("tags") {
add("electronics")
add("sale")
}
putJsonObject("dimensions") {
put("width", 30)
put("height", 20)
}
}
println(builtJson.toString())
Gson — Java Interop #
Gson is the right choice when you need easy interoperability with existing Java code, or when the project already uses Gson.
// build.gradle.kts
// implementation("com.google.code.gson:gson:2.10.1")
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
data class User(val id: Int, val name: String, val email: String)
fun main() {
val gson = GsonBuilder()
.setPrettyPrinting()
.serializeNulls()
.create()
// Encode
val user = User(1, "Budi", "[email protected]")
val json = gson.toJson(user)
println(json)
// Decode
val back = gson.fromJson(json, User::class.java)
println(back.name)
// Decode a list — needs TypeToken for generics
val jsonList = """[{"id":1,"name":"A","email":"[email protected]"},{"id":2,"name":"B","email":"[email protected]"}]"""
val type = object : TypeToken<List<User>>() {}.type
val list: List<User> = gson.fromJson(jsonList, type)
println(list.size) // 2
}
Gson’s Limitations in Kotlin #
// PROBLEM 1: Gson ignores default values — fields are null even with defaults
data class Config(val host: String = "localhost", val port: Int = 5432)
val gson = Gson()
val config = gson.fromJson("{}", Config::class.java)
println(config.host) // null ← bug! should be "localhost"
// PROBLEM 2: Gson doesn't understand Kotlin nullability
data class NullableTest(val value: String?)
val test = gson.fromJson("""{"value":null}""", NullableTest::class.java)
// This is OK, but Gson can't enforce non-null at compile level
// SOLUTION: use kotlinx.serialization for new Kotlin projects
Moshi — A Modern Alternative #
Moshi from Square offers better type-safety than Gson:
// build.gradle.kts
// implementation("com.squareup.moshi:moshi:1.15.0")
// implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.squareup.moshi.Types
val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
data class Product(val id: Int, val name: String, val price: Double)
// Encode
val adapter = moshi.adapter(Product::class.java)
val product = Product(1, "Laptop", 15_000_000.0)
val json = adapter.toJson(product)
println(json) // {"id":1,"name":"Laptop","price":1.5E7}
// Decode
val back = adapter.fromJson(json)
println(back?.name) // Laptop
// List
val listType = Types.newParameterizedType(List::class.java, Product::class.java)
val listAdapter = moshi.adapter<List<Product>>(listType)
val list = listAdapter.fromJson("""[{"id":1,"name":"A","price":100}]""")
println(list?.size) // 1
// Moshi returns null if parsing fails (doesn't throw an exception like Gson)
val invalid = adapter.fromJson("invalid json")
println(invalid) // null
Summary #
- kotlinx.serialization for new Kotlin projects — compile-time safety, no reflection overhead, multiplatform ready, and integrated null safety. This is the standard JetBrains recommends for all Kotlin projects.
@SerialNamefor field name mapping — when a JSON field name differs from the Kotlin property name (e.g., the API usessnake_casebut Kotlin usescamelCase), use@SerialName("json_name").@Transientfor secret fields — fields that must not appear in JSON (passwords, tokens, internal data) should be marked@Transient. Must have a default value.ignoreUnknownKeys = truein production — the API you consume may add new fields. Without this, every new field in an API response will crash your app.encodeDefaults = falsefor smaller payloads — don’t include fields with default values in the JSON output. Useful for reducing payload size.- Sealed classes for polymorphism — use
sealed classwith@SerialNamefor response types that can be one of several subtypes. The discriminator field determines the subtype during decode.- Custom serializers for external types — for types that can’t be marked
@Serializable(likeLocalDate,UUID, classes from third-party libraries), implementKSerializer<T>.- Gson for Java interop — if the project already uses Gson or needs integration with existing Java code, Gson remains valid. But beware its limitations with Kotlin default values and nullability.