PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 3 min read

JSON Parsing in Kotlin — Manual Parsing with org.json

Parse a complex, real-world JSON response manually using org.json in Kotlin. Extension functions help, but the verbosity is still real.


This is Part 1 of the JSON Parsing in Kotlin series. Just like the Java series, we start with the hardest approach — manual parsing with org.json. Kotlin makes it slightly less painful with extension functions and data classes, but the core problem remains: you’re hand-wiring every single field.

Looking for the Java version? Read it here.

The JSON

Throughout this series, we’ll parse 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

Add the org.json dependency. If you’re using Maven:

<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>20240303</version>
</dependency>

Or Gradle:

implementation 'org.json:json:20240303'

Android note: org.json is built into the Android SDK — no dependency needed. Just use import org.json.JSONObject.

Helpful Extension Functions

Before we start parsing, let’s add a couple of extension functions to make org.json a bit less painful in Kotlin. The built-in optString() returns "" for null values, which is not what we want — we want actual null.

import org.json.JSONArray
import org.json.JSONObject
import java.time.Instant

fun JSONObject.optStringOrNull(key: String): String? =
  if (isNull(key)) null else optString(key, null)

fun JSONObject.optInstantOrNull(key: String): Instant? =
  optStringOrNull(key)?.let { Instant.parse(it) }

fun <T> JSONArray.map(transform: (JSONObject) -> T): List<T> =
  (0 until length()).map { transform(getJSONObject(it)) }

fun JSONArray.mapStrings(): List<String> =
  (0 until length()).map { getString(it) }

These four helpers save us from repeating isNull() checks and index loops everywhere. They don’t eliminate the verbosity — they just make it more Kotlin-like.

Model Classes

Kotlin data classes are more concise than Java records, but we still need one for every object in the JSON.

import java.time.Instant

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

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

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

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

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

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

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

data class ApiResponse(
  val status: String,
  val users: List<User>,
  val pagination: Pagination,
  val meta: Meta
)

That’s 8 data classes. Kotlin’s nullable types (String?, Instant?) at least make the nullability explicit in the type system — something Java records can’t do.

Parsing

Even with extension functions, the parsing is still verbose. Every field, every nested object, every array — all hand-wired.

import org.json.JSONObject

object OrgJsonParser {

  fun parse(jsonString: String): ApiResponse {
    val root = JSONObject(jsonString)
    val data = root.getJSONObject("data")

    return ApiResponse(
      status = root.getString("status"),
      users = data.getJSONArray("users").map(::parseUser),
      pagination = parsePagination(data.getJSONObject("pagination")),
      meta = parseMeta(root.getJSONObject("meta"))
    )
  }

  private fun parseUser(obj: JSONObject): User = User(
    id = obj.getString("id"),
    name = obj.getString("name"),
    email = obj.getString("email"),
    role = obj.getString("role"),
    verified = obj.getBoolean("verified"),
    createdAt = Instant.parse(obj.getString("createdAt")),
    profile = parseProfile(obj.getJSONObject("profile")),
    subscription = parseSubscription(obj.getJSONObject("subscription")),
    teams = obj.getJSONArray("teams").map(::parseTeam),
    lastLoginAt = obj.optInstantOrNull("lastLoginAt")
  )

  private fun parseProfile(obj: JSONObject): Profile = Profile(
    avatar = obj.optStringOrNull("avatar"),
    bio = obj.optStringOrNull("bio"),
    social = parseSocial(obj.getJSONObject("social"))
  )

  private fun parseSocial(obj: JSONObject): Social = Social(
    github = obj.optStringOrNull("github"),
    twitter = obj.optStringOrNull("twitter"),
    linkedin = obj.optStringOrNull("linkedin")
  )

  private fun parseSubscription(obj: JSONObject): Subscription = Subscription(
    plan = obj.getString("plan"),
    billingCycle = obj.optStringOrNull("billingCycle"),
    price = obj.getDouble("price"),
    features = obj.getJSONArray("features").mapStrings(),
    trialEndsAt = obj.optInstantOrNull("trialEndsAt")
  )

  private fun parseTeam(obj: JSONObject): Team = Team(
    id = obj.getString("id"),
    name = obj.getString("name"),
    role = obj.getString("role"),
    memberCount = obj.getInt("memberCount")
  )

  private fun parsePagination(obj: JSONObject): Pagination = Pagination(
    page = obj.getInt("page"),
    perPage = obj.getInt("perPage"),
    totalItems = obj.getInt("totalItems"),
    totalPages = obj.getInt("totalPages"),
    hasNext = obj.getBoolean("hasNext")
  )

  private fun parseMeta(obj: JSONObject): Meta = Meta(
    requestId = obj.getString("requestId"),
    timestamp = Instant.parse(obj.getString("timestamp")),
    apiVersion = obj.getString("apiVersion")
  )
}

The extension functions and named parameters do make this more readable than the Java version, but it’s still ~80 lines of parsing code. Every field is still wired by hand.

Putting It Together

import java.nio.file.Path
import kotlin.io.path.readText

fun main() {
  val json = Path.of("response.json").readText()
  val response = OrgJsonParser.parse(json)

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

  for (user in response.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

The Problems with Manual Parsing

Let’s count what we had to do:

WhatCount
Data classes8
Parse functions8
Extension functions4
Explicit getString/getInt/getBoolean calls~25
Null checks with extensions7
Lines of parsing code~80

Kotlin makes this cleaner than Java — named parameters, expression-body functions, nullable types, and extension functions all help. But the fundamental problem remains: you’re manually mapping every field, and any change to the JSON means updating both the data class and the parse function.

Common pitfalls

  • optString() returns "" for null values — that’s why we wrote optStringOrNull()
  • No type safety on key names — typo in "billingCycle" and you’ll only find out at runtime
  • No date handling — you still parse strings and call Instant.parse() yourself
  • No enum mapping"ADMIN" stays as a String unless you convert manually
  • Kotlin’s null safety doesn’t help with JSON nulls — you still need explicit checks at parse time

Using in Android

org.json is part of the Android SDK — no dependency needed. The code above works as-is in any Android project. Just replace the file reading with however you get your JSON (Retrofit, OkHttp, etc.).

// In an Android context, you'd typically get JSON from a network call:
val json = responseBody.string() // from OkHttp
val response = OrgJsonParser.parse(json)

What’s Next

In the next post, we’ll parse this exact same JSON using Gson — and watch the parsing code shrink dramatically. Gson handles the mapping automatically, and Kotlin data classes work well with it (with a few caveats around nulls).