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
Aspectkotlinx.serializationGsonMoshi
ApproachCompile-time (plugin)Runtime reflectionReflection + codegen
Kotlin-first✓ CompletelyPartial✓ Good
Null safety✓ EnforcedPartial✓ Good
Default values✓ Native✗ No✓ With adapter
Multiplatform✓ KMP ready✗ JVM only✗ JVM only
PerformanceVery goodModerateGood
ConfigurationMinimalMinimalNeeds 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.
  • @SerialName for field name mapping — when a JSON field name differs from the Kotlin property name (e.g., the API uses snake_case but Kotlin uses camelCase), use @SerialName("json_name").
  • @Transient for secret fields — fields that must not appear in JSON (passwords, tokens, internal data) should be marked @Transient. Must have a default value.
  • ignoreUnknownKeys = true in production — the API you consume may add new fields. Without this, every new field in an API response will crash your app.
  • encodeDefaults = false for smaller payloads — don’t include fields with default values in the JSON output. Useful for reducing payload size.
  • Sealed classes for polymorphism — use sealed class with @SerialName for 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 (like LocalDate, UUID, classes from third-party libraries), implement KSerializer<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.

← Previous: Mocking   Next: YAML →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact