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.jsonis built into the Android SDK — no dependency needed. Just useimport 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:
| What | Count |
|---|---|
| Data classes | 8 |
| Parse functions | 8 |
| Extension functions | 4 |
Explicit getString/getInt/getBoolean calls | ~25 |
| Null checks with extensions | 7 |
| 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 wroteoptStringOrNull()- 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).