Parse JSON in Kotlin

Kotlin has 3 main JSON libraries: kotlinx.serialization (JetBrains' official multiplatform library), Gson (Google's Java library), and Moshi (Square's Kotlin-idiomatic library). kotlinx.serialization is the recommended choice for new projects — it generates serializers at compile time via a Gradle plugin (no reflection), supports all Kotlin targets (JVM, JS, Native, Wasm), and handles Kotlin's null safety by mapping JSON null to Kotlin nullable types. For Android projects using Retrofit, Gson and Moshi are popular HTTP converter options. This guide covers all three with practical examples: parsing data classes, JSON arrays, nested objects, null handling, and error handling.

Validate your JSON before parsing it in Kotlin.

Open JSON Formatter

kotlinx.serialization: the official Kotlin library

kotlinx.serialization is the official JetBrains JSON library for Kotlin. Unlike Gson, it uses a Gradle plugin to generate serializer code at compile time — no runtime reflection, full support for Kotlin Multiplatform, and tight integration with Kotlin's type system. The most important configuration option is ignoreUnknownKeys: by default, the library throws SerializationException if the JSON contains fields not present in your data class (intentionally strict to catch API drift early). Set ignoreUnknownKeys = true when consuming third-party APIs.

Gradle setup

// build.gradle.kts

plugins {
    kotlin("jvm") version "2.0.0"
    kotlin("plugin.serialization") version "2.0.0" // Gradle plugin (required)
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
}

Basic parsing and serialization

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString

// Annotate data classes with @Serializable
@Serializable
data class User(
    val id: Int,
    val name: String,
    val email: String? = null,    // nullable — maps to JSON null or absent key
    val role: String = "user",    // default value — used when key is absent
)

fun main() {
    val jsonStr = """{"id": 1, "name": "Alice", "email": "alice@example.com"}"""

    // Decode (parse) JSON → data class
    val user: User = Json.decodeFromString(jsonStr)
    println(user.name)  // Alice
    println(user.role)  // user  (default value applied)

    // Encode (serialize) data class → JSON
    val encoded = Json.encodeToString(user)
    println(encoded)
    // {"id":1,"name":"Alice","email":"alice@example.com"}
    // Note: role is NOT included because encodeDefaults = false by default
}

Configuring the Json instance

import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString

// Create a configured Json instance — do this once and reuse it
val json = Json {
    ignoreUnknownKeys = true   // do not throw on extra JSON fields (most important!)
    isLenient = true            // allow unquoted strings, relaxed JSON
    encodeDefaults = true       // include fields with default values in output
    prettyPrint = true          // human-readable indented output
    coerceInputValues = true    // coerce null to default value for non-nullable fields
}

@Serializable
data class Product(val id: Int, val name: String, val price: Double)

// Real API response may have extra fields — ignoreUnknownKeys handles this
val apiResponse = """
    {
        "id": 42,
        "name": "Widget",
        "price": 9.99,
        "createdAt": "2024-01-15",
        "internalCode": "WGT-001"
    }
""".trimIndent()

val product = json.decodeFromString<Product>(apiResponse)
println(product)  // Product(id=42, name=Widget, price=9.99)

// Parse a JSON array
val arrayJson = """[{"id":1,"name":"A","price":1.0},{"id":2,"name":"B","price":2.0}]"""
val products: List<Product> = json.decodeFromString(arrayJson)
println(products.size)  // 2
Config optionDefaultEffect
ignoreUnknownKeysfalseThrow on unknown fields (set to true for API responses)
isLenientfalseAllow unquoted strings and other relaxed JSON
encodeDefaultsfalseInclude fields with default values in output
prettyPrintfalseHuman-readable indented output
coerceInputValuesfalseCoerce nulls to default values for non-nullable types

Gson: parsing with Google's library

Gson is Google's Java JSON library that works well in Kotlin via Java interop. It uses reflection at runtime — no annotations or code generation are required for basic use. Its main drawback is that it doesn't understand Kotlin's null safety: a non-nullable String field can end up as null at runtime if the JSON contains null, bypassing the Kotlin type system. Gson is a solid choice for existing Android projects with established Retrofit setups.

// build.gradle.kts
dependencies {
    implementation("com.google.code.gson:gson:2.11.0")
}
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 = Gson()

    // ── Decode (parse) JSON → data class ────────────────────────────────────
    val jsonStr = """{"id": 1, "name": "Alice", "email": "alice@example.com"}"""
    val user: User = gson.fromJson(jsonStr, User::class.java)
    println(user.name)   // Alice

    // ── Encode (serialize) data class → JSON ────────────────────────────────
    val encoded = gson.toJson(user)
    println(encoded)
    // {"id":1,"name":"Alice","email":"alice@example.com"}

    // ── Parse a JSON array (requires TypeToken) ──────────────────────────────
    // Java erases generics at runtime, so Gson needs a TypeToken to know the element type
    val arrayJson = """[{"id":1,"name":"Alice","email":null},{"id":2,"name":"Bob","email":null}]"""
    val listType = object : TypeToken<List<User>>() {}.type
    val users: List<User> = gson.fromJson(arrayJson, listType)
    println(users.size)   // 2

    // ── GsonBuilder for custom configuration ─────────────────────────────────
    val prettyGson = GsonBuilder()
        .setPrettyPrinting()                    // human-readable output
        .serializeNulls()                       // include null fields in output
        .setDateFormat("yyyy-MM-dd'T'HH:mm:ss") // custom date format
        .create()

    println(prettyGson.toJson(user))
}

For Gson, the TypeToken pattern is the required boilerplate for any generic collection type. You write it once per type. With kotlinx.serialization, this is unnecessary because Kotlin's reified generics carry type information at the call site. See the parse JSON in Java guide for more Gson patterns shared with the Java ecosystem.

Moshi: Kotlin-idiomatic JSON

Moshi is Square's JSON library designed to be Kotlin-idiomatic. It supports both reflection (via KotlinJsonAdapterFactory) and compile-time code generation (via KSP with @JsonClass(generateAdapter = true)). Unlike Gson, Moshi respects Kotlin's null safety — it throws a JsonDataException if a non-nullable field receives null. Moshi is JVM/Android only (no Kotlin Multiplatform support).

// build.gradle.kts
plugins {
    id("com.google.devtools.ksp") version "2.0.0-1.0.21" // for code generation (optional)
}

dependencies {
    implementation("com.squareup.moshi:moshi:1.15.1")
    implementation("com.squareup.moshi:moshi-kotlin:1.15.1") // KotlinJsonAdapterFactory
    ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")    // KSP code gen (optional)
}
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory

// @JsonClass(generateAdapter = true) triggers KSP code generation (preferred for production)
// Without it, Moshi falls back to reflection via KotlinJsonAdapterFactory
@JsonClass(generateAdapter = true)
data class User(val id: Int, val name: String, val email: String?)

fun main() {
    // Build Moshi instance — KotlinJsonAdapterFactory handles Kotlin data classes via reflection
    val moshi = Moshi.Builder()
        .addLast(KotlinJsonAdapterFactory())
        .build()

    val adapter = moshi.adapter(User::class.java)

    // ── Decode (parse) JSON → data class ────────────────────────────────────
    val jsonStr = """{"id": 1, "name": "Alice", "email": "alice@example.com"}"""
    val user: User? = adapter.fromJson(jsonStr)
    println(user?.name)   // Alice

    // ── Encode (serialize) data class → JSON ────────────────────────────────
    val encoded = adapter.toJson(user)
    println(encoded)
    // {"id":1,"name":"Alice","email":"alice@example.com"}

    // ── Parse a JSON array ───────────────────────────────────────────────────
    val listAdapter = moshi.adapter<List<User>>(
        com.squareup.moshi.Types.newParameterizedType(List::class.java, User::class.java)
    )
    val arrayJson = """[{"id":1,"name":"Alice","email":null}]"""
    val users: List<User>? = listAdapter.fromJson(arrayJson)
    println(users?.size)  // 1
}
FeatureGsonMoshikotlinx.serialization
Kotlin null safetyNo (bypasses it)YesYes
Reflection-free optionNoYes (KSP)Yes (always)
Kotlin MultiplatformNoNoYes
Annotations requiredNoOptionalYes (@Serializable)
Retrofit converterGsonConverterFactoryMoshiConverterFactoryasConverterFactory()

Handling nulls, errors, and nested objects

Kotlin's null safety system maps cleanly to JSON's optional and null values when using kotlinx.serialization. Understanding this mapping is key to writing robust parsing code. Always validate your JSON with the JSON formatter before debugging deserialization failures.

import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.SerializationException

// ── Null safety ──────────────────────────────────────────────────────────────
@Serializable
data class UserProfile(
    val id: Int,
    val name: String,
    val email: String?,           // nullable: JSON null or absent field → Kotlin null
    val bio: String? = null,      // nullable with default: absent field also gives null
    val age: Int = 0,             // non-nullable with default: absent field gives 0
)

// ── @SerialName: map different JSON key names to Kotlin fields ───────────────
@Serializable
data class ApiUser(
    @SerialName("user_id") val id: Int,           // JSON "user_id" → Kotlin id
    @SerialName("full_name") val name: String,    // JSON "full_name" → Kotlin name
    @SerialName("created_at") val createdAt: String,
)

// ── Nested objects: annotate nested classes too ──────────────────────────────
@Serializable
data class Address(val city: String, val country: String)

@Serializable
data class Person(val name: String, val address: Address)

// ── Error handling ───────────────────────────────────────────────────────────
val lenientJson = Json { ignoreUnknownKeys = true }

fun parseUser(jsonStr: String): UserProfile? {
    return try {
        lenientJson.decodeFromString<UserProfile>(jsonStr)
    } catch (e: SerializationException) {
        // Malformed JSON or type mismatch (e.g. string where int expected)
        println("Serialization error: " + e.message)
        null
    } catch (e: IllegalArgumentException) {
        // Unexpected structural issue
        println("Invalid argument: " + e.message)
        null
    }
}

fun main() {
    // Nested object parsing
    val personJson = """{"name":"Alice","address":{"city":"Berlin","country":"DE"}}"""
    val person = lenientJson.decodeFromString<Person>(personJson)
    println(person.address.city)  // Berlin

    // @SerialName example
    val apiJson = """{"user_id":1,"full_name":"Bob","created_at":"2024-01-01"}"""
    val apiUser = lenientJson.decodeFromString<ApiUser>(apiJson)
    println(apiUser.name)  // Bob

    // Error handling
    val bad = parseUser("""{"id": "not-a-number", "name": "Alice"}""")
    // Serialization error: Expected int, but found string at path: $.id
}

Parsing JSON on Android with Retrofit

Retrofit is the standard HTTP client for Android. It uses converter factories to automatically parse JSON response bodies into your data classes. Each JSON library has a corresponding Retrofit converter. Never parse JSON on the main thread — it blocks the UI and causes ANR (Application Not Responding) errors. Use Kotlin coroutines with suspend functions; Retrofit handles thread dispatching automatically when you use its coroutine adapter.

Retrofit + kotlinx.serialization (recommended)

// build.gradle.kts
dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

// ── Setup ────────────────────────────────────────────────────────────────────
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.http.GET
import retrofit2.http.Path

val networkJson = Json { ignoreUnknownKeys = true }

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(networkJson.asConverterFactory("application/json".toMediaType()))
    .build()

// ── Define API interface ─────────────────────────────────────────────────────
interface UserApi {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: Int): User  // suspend = coroutine-friendly
}

// ── Use in a ViewModel (never parse on main thread!) ────────────────────────
class UserViewModel : ViewModel() {
    private val api = retrofit.create(UserApi::class.java)

    fun loadUser(id: Int) {
        viewModelScope.launch {
            // Retrofit automatically dispatches IO work off the main thread
            val user = api.getUser(id)
            // Update UI state here — already back on main thread
        }
    }
}

Retrofit + Gson and Moshi converters

// Retrofit + Gson
import retrofit2.converter.gson.GsonConverterFactory

val retrofitGson = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .build()

// Retrofit + Moshi
import retrofit2.converter.moshi.MoshiConverterFactory
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory

val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()

val retrofitMoshi = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(MoshiConverterFactory.create(moshi))
    .build()
LibraryCoroutine supportMultiplatformReflection-freeCode gen required
kotlinx.serializationYesYesYesYes (@Serializable)
GsonYes (via Retrofit)NoNoNo
MoshiYes (via Retrofit)NoOptional (KSP)Optional

For background on JSON parsing patterns shared with Android's Java roots, see the parse JSON in Java guide. For typed schema validation before parsing, see the JSON Schema validation guide.

Frequently asked questions

Which JSON library should I use in Kotlin?

For new Kotlin projects, use kotlinx.serialization — it's the official JetBrains library, supports all Kotlin targets (JVM, JS, Native, Wasm), generates serializers at compile time (no runtime reflection), and handles Kotlin null safety correctly. For existing Android projects that already use Retrofit with Gson, switching has limited benefit unless you need Kotlin Multiplatform. Moshi is a good middle ground — Kotlin-idiomatic with optional code generation, but limited to JVM/Android. Choose kotlinx.serialization for new projects, Gson/Moshi for Android projects with existing Retrofit setups.

How do I parse a JSON array in Kotlin with kotlinx.serialization?

Use Json.decodeFromString<List<User>>(jsonStr). The generic type parameter is reified, so no TypeToken or explicit class token is needed. For custom configuration (like ignoring unknown keys), create a Json instance first: val json = Json { ignoreUnknownKeys = true }; val users = json.decodeFromString<List<User>>(jsonStr). The returned List<User> is a standard Kotlin immutable list.

How do I handle unknown JSON fields in kotlinx.serialization?

By default, kotlinx.serialization throws SerializationException: Encountered an unknown key 'extraField' when the JSON contains fields not in your data class. This is intentional — strict deserialization catches API changes early. To allow extra fields (common for real-world APIs), configure: val json = Json { ignoreUnknownKeys = true }. You can also annotate a field with @JsonNames to map multiple JSON key names to one Kotlin field.

How do I serialize a data class to JSON in Kotlin?

With kotlinx.serialization, annotate the class with @Serializable and call Json.encodeToString(user). Fields with default values are excluded unless encodeDefaults = true. For pretty-printed output: val json = Json { prettyPrint = true }; json.encodeToString(user). With Gson: Gson().toJson(user) — no annotations needed. With Moshi: moshi.adapter(User::class.java).toJson(user).

What is the difference between Gson TypeToken and kotlinx.serialization for lists?

Gson requires a TypeToken workaround for generic types because Java erases generics at runtime: val type = object: TypeToken<List<User>>() {}.type. kotlinx.serialization uses Kotlin's reified generics, so Json.decodeFromString<List<User>>(str) works directly with no token. The TypeToken pattern is boilerplate you write once per collection type with Gson; with kotlinx.serialization, the generic parameter is inferred at the call site. For more on this pattern, see the parse JSON in C# and parse JSON in Go guides for language comparisons.

How do I handle null JSON values in Kotlin?

kotlinx.serialization maps JSON null to Kotlin nullable types. If your data class has val email: String?, a JSON "email": null or a missing "email" field (with a default of null) will deserialize correctly to Kotlin null. If the field is non-nullable (val email: String) and the JSON contains null, kotlinx.serialization throws a SerializationException. To handle this gracefully, either make the field nullable or set coerceInputValues = true in your Json configuration to substitute the default value for null.

Validate your JSON

Paste your JSON into Jsonic to catch syntax errors before parsing in Kotlin.

Open JSON Formatter