PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 3 min read

JSON Parsing in Kotlin — Parsing with Kotlin Serialization

Parse a complex JSON response using Kotlin Serialization — the Kotlin-native, compile-time safe approach with no reflection.


This is Part 4 of the JSON Parsing in Kotlin series. This is the one I’ve been building up to — Kotlin Serialization is the Kotlin-native approach to JSON parsing. No Java legacy, no reflection, compile-time safety. If you’re writing Kotlin, this is the library that was built for you.

The JSON

Throughout this series, we’re parsing the same JSON — a typical SaaS API response with users, subscriptions, teams, and pagination. It has nested objects, arrays, nullable fields, enums, dates, and decimals.

{
  "status": "success",
  "data": {
    "users": [
      {
        "id": "usr_a1b2c3d4",
        "name": "Navkrishna",
        "email": "[email protected]",
        "role": "ADMIN",
        "verified": true,
        "createdAt": "2026-01-15T10:30:00Z",
        "profile": {
          "avatar": "https://cdn.example.com/avatars/nav.jpg",
          "bio": "Full-stack developer",
          "social": {
            "github": "krrishnaaaa",
            "twitter": null,
            "linkedin": "navkrishna"
          }
        },
        "subscription": {
          "plan": "PRO",
          "billingCycle": "YEARLY",
          "price": 199.99,
          "features": ["analytics", "api_access", "priority_support"],
          "trialEndsAt": null
        },
        "teams": [
          {
            "id": "team_x1y2",
            "name": "Backend",
            "role": "OWNER",
            "memberCount": 5
          },
          {
            "id": "team_z3w4",
            "name": "Mobile",
            "role": "MEMBER",
            "memberCount": 3
          }
        ],
        "lastLoginAt": "2026-03-14T18:45:00Z"
      },
      {
        "id": "usr_e5f6g7h8",
        "name": "Priya Sharma",
        "email": "[email protected]",
        "role": "MEMBER",
        "verified": false,
        "createdAt": "2026-03-01T14:00:00Z",
        "profile": {
          "avatar": null,
          "bio": null,
          "social": {
            "github": "priya-dev",
            "twitter": "priyacodes",
            "linkedin": null
          }
        },
        "subscription": {
          "plan": "TRIAL",
          "billingCycle": null,
          "price": 0.0,
          "features": ["analytics"],
          "trialEndsAt": "2026-03-31T23:59:59Z"
        },
        "teams": [],
        "lastLoginAt": null
      }
    ],
    "pagination": {
      "page": 1,
      "perPage": 20,
      "totalItems": 2,
      "totalPages": 1,
      "hasNext": false
    }
  },
  "meta": {
    "requestId": "req_9k8j7h6g",
    "timestamp": "2026-03-15T00:00:00Z",
    "apiVersion": "v2"
  }
}

Setup

Kotlin Serialization requires both a Gradle plugin and a runtime library. In your build.gradle.kts:

plugins {
  kotlin("jvm") version "2.1.10"
  kotlin("plugin.serialization") version "2.1.10"
}

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

The plugin runs at compile time and generates serializers for every class annotated with @Serializable. No reflection, no runtime surprises — if it compiles, it serializes.

Model Classes

Here’s where Kotlin Serialization shines. You annotate your data classes with @Serializable, and the compiler does the rest. For fields where the JSON key doesn’t match the Kotlin property name, use @SerialName.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class Social(
  val github: String?,
  val twitter: String?,
  val linkedin: String?
)

@Serializable
data class Profile(
  val avatar: String?,
  val bio: String?,
  val social: Social
)

@Serializable
data class Subscription(
  val plan: String,
  val billingCycle: String?,
  val price: Double,
  val features: List<String>,
  val trialEndsAt: String?
)

@Serializable
data class Team(
  val id: String,
  val name: String,
  val role: String,
  val memberCount: Int
)

@Serializable
data class User(
  val id: String,
  val name: String,
  val email: String,
  val role: String,
  val verified: Boolean,
  val createdAt: String,
  val profile: Profile,
  val subscription: Subscription,
  val teams: List<Team>,
  val lastLoginAt: String?
)

@Serializable
data class Pagination(
  val page: Int,
  val perPage: Int,
  val totalItems: Int,
  val totalPages: Int,
  val hasNext: Boolean
)

@Serializable
data class Meta(
  val requestId: String,
  val timestamp: String,
  val apiVersion: String
)

@Serializable
data class Data(
  val users: List<User>,
  val pagination: Pagination
)

@Serializable
data class ApiResponse(
  val status: String,
  val data: Data,
  val meta: Meta
)

Notice: every nullable JSON field is String? in Kotlin. The serializer handles null automatically — no optString(), no isNull() checks, no surprises.

Handling Dates with a Custom Serializer

The model above keeps dates as String. That works, but we can do better. Let’s write a custom serializer for java.time.Instant:

import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant

object InstantSerializer : KSerializer<Instant> {
  override val descriptor: SerialDescriptor =
    PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)

  override fun serialize(encoder: Encoder, value: Instant) {
    encoder.encodeString(value.toString())
  }

  override fun deserialize(decoder: Decoder): Instant {
    return Instant.parse(decoder.decodeString())
  }
}

Now update the date fields in the model classes to use it:

import kotlinx.serialization.Serializable

@Serializable
data class Subscription(
  val plan: String,
  val billingCycle: String?,
  val price: Double,
  val features: List<String>,
  @Serializable(with = InstantSerializer::class)
  val trialEndsAt: Instant?
)

@Serializable
data class User(
  val id: String,
  val name: String,
  val email: String,
  val role: String,
  val verified: Boolean,
  @Serializable(with = InstantSerializer::class)
  val createdAt: Instant,
  val profile: Profile,
  val subscription: Subscription,
  val teams: List<Team>,
  @Serializable(with = InstantSerializer::class)
  val lastLoginAt: Instant?
)

@Serializable
data class Meta(
  val requestId: String,
  @Serializable(with = InstantSerializer::class)
  val timestamp: Instant,
  val apiVersion: String
)

The @Serializable(with = ...) annotation tells the compiler exactly which serializer to use. No runtime registration, no global configuration — it’s all resolved at compile time.

Tip: If you’re targeting Kotlin Multiplatform, use kotlinx.datetime.Instant instead of java.time.Instant. The kotlinx-datetime library has built-in serialization support, so you don’t even need a custom serializer.

Parsing

Here’s the entire parsing code:

import kotlinx.serialization.json.Json

val json = Json {
  ignoreUnknownKeys = true
}

fun parse(jsonString: String): ApiResponse {
  return json.decodeFromString<ApiResponse>(jsonString)
}

That’s it. Three lines. The ignoreUnknownKeys = true configuration tells the parser to skip any JSON fields that don’t have a matching property in your data class. Without it, unknown fields throw an exception — which is actually a nice safety net during development.

Putting It Together

import java.io.File

fun main() {
  val jsonString = File("response.json").readText()

  val response = parse(jsonString)

  println("Status: ${response.status}")
  println("API Version: ${response.meta.apiVersion}")
  println("Total users: ${response.data.pagination.totalItems}")
  println()

  for (user in response.data.users) {
    println("--- ${user.name} ---")
    println("  Email: ${user.email}")
    println("  Role: ${user.role}")
    println("  Verified: ${user.verified}")
    println("  Plan: ${user.subscription.plan}")
    println("  Price: $${user.subscription.price}")
    println("  Features: ${user.subscription.features}")
    println("  GitHub: ${user.profile.social.github}")
    println("  Teams: ${user.teams.size}")
    for (team in user.teams) {
      println("    - ${team.name} (${team.role})")
    }
    println("  Last login: ${user.lastLoginAt}")
    println()
  }
}

Output

Status: success
API Version: v2
Total users: 2

--- Navkrishna ---
  Email: [email protected]
  Role: ADMIN
  Verified: true
  Plan: PRO
  Price: $199.99
  Features: [analytics, api_access, priority_support]
  GitHub: krrishnaaaa
  Teams: 2
    - Backend (OWNER)
    - Mobile (MEMBER)
  Last login: 2026-03-14T18:45:00Z

--- Priya Sharma ---
  Email: [email protected]
  Role: MEMBER
  Verified: false
  Plan: TRIAL
  Price: $0.0
  Features: [analytics]
  GitHub: priya-dev
  Teams: 0
  Last login: null

Why Kotlin Serialization is Different

This isn’t just “another JSON library.” Here’s what sets it apart:

FeatureKotlin SerializationReflection-based libraries
Code generationCompile-time (compiler plugin)Runtime reflection
Null safetyEnforced by Kotlin’s type systemRuntime errors
Unknown fieldsConfigurable (fail or ignore)Usually silently ignored
PerformanceNo reflection overheadReflection cost on every parse
MultiplatformJS, Native, Wasm, JVMJVM only
Proguard/R8No special rules neededNeeds keep rules

The compile-time safety is the killer feature. If you rename a field in your data class but forget to update @SerialName, the compiler catches it. If you mark a non-nullable field but the JSON sends null, you get a clear SerializationException instead of a silent null in a non-nullable slot.

Using in Android

Kotlin Serialization is the recommended approach for JSON parsing in Android, endorsed by Google. It’s lightweight, has no reflection (great for startup performance), and needs no Proguard rules.

// build.gradle.kts (app module)
plugins {
  id("com.android.application")
  kotlin("android")
  kotlin("plugin.serialization") version "2.1.10"
}

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

It also integrates cleanly with Retrofit via a converter:

// build.gradle.kts
dependencies {
  implementation("com.squareup.retrofit2:retrofit:2.11.0")
  implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
}
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit

val contentType = "application/json".toMediaType()
val json = Json { ignoreUnknownKeys = true }

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

No Proguard rules, no reflection, fast startup. This is the modern Android stack.

What’s Next

In the next post, we’ll parse this same JSON using Jackson with the Kotlin module — the heavyweight champion of server-side Java that also plays nicely with Kotlin. If you’re in a Spring Boot world, that one’s for you.