JSON Parsing in Kotlin — Parsing with Moshi
Parse a complex JSON response using Moshi with Kotlin data classes, codegen, and proper null safety. The recommended JSON library for modern Android.
This is Part 3 of the JSON Parsing in Kotlin series. In Part 1, we parsed everything by hand. In Part 2, we let Gson handle the mapping — but we ran into Kotlin null safety issues. Now let’s use Moshi, a library that was built with Kotlin in mind.
Looking for the Java version? Read it here.
The JSON
Same JSON as the previous posts — a SaaS API response with users, subscriptions, teams, and pagination.
{
"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
Moshi has two Kotlin integration options: reflection-based (moshi-kotlin) and codegen (moshi-kotlin-codegen). We’ll use codegen — it generates adapters at compile time, so there’s no reflection overhead at runtime.
Maven:
<dependency>
<groupId>com.squareup.moshi</groupId>
<artifactId>moshi</artifactId>
<version>1.15.1</version>
</dependency>
<dependency>
<groupId>com.squareup.moshi</groupId>
<artifactId>moshi-kotlin-codegen</artifactId>
<version>1.15.1</version>
<scope>provided</scope>
</dependency>
Gradle (with KSP):
plugins {
id 'com.google.devtools.ksp' version '2.1.10-1.0.29'
}
dependencies {
implementation 'com.squareup.moshi:moshi:1.15.1'
ksp 'com.squareup.moshi:moshi-kotlin-codegen:1.15.1'
}
Note: Moshi codegen uses KSP (Kotlin Symbol Processing) to generate adapter classes at compile time. This means zero reflection at runtime — fast and ProGuard-friendly.
Model Classes
Each data class gets @JsonClass(generateAdapter = true), which tells Moshi’s codegen to generate a JsonAdapter for it. Use @Json(name = "...") where the JSON key doesn’t match the property name.
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.time.Instant
@JsonClass(generateAdapter = true)
data class Social(
val github: String?,
val twitter: String?,
val linkedin: String?
)
@JsonClass(generateAdapter = true)
data class Profile(
val avatar: String?,
val bio: String?,
val social: Social
)
@JsonClass(generateAdapter = true)
data class Subscription(
val plan: String,
@Json(name = "billingCycle")
val billingCycle: String?,
val price: Double,
val features: List<String>,
@Json(name = "trialEndsAt")
val trialEndsAt: Instant?
)
@JsonClass(generateAdapter = true)
data class Team(
val id: String,
val name: String,
val role: String,
@Json(name = "memberCount")
val memberCount: Int
)
@JsonClass(generateAdapter = true)
data class User(
val id: String,
val name: String,
val email: String,
val role: String,
val verified: Boolean,
@Json(name = "createdAt")
val createdAt: Instant,
val profile: Profile,
val subscription: Subscription,
val teams: List<Team>,
@Json(name = "lastLoginAt")
val lastLoginAt: Instant?
)
@JsonClass(generateAdapter = true)
data class Pagination(
val page: Int,
@Json(name = "perPage")
val perPage: Int,
@Json(name = "totalItems")
val totalItems: Int,
@Json(name = "totalPages")
val totalPages: Int,
@Json(name = "hasNext")
val hasNext: Boolean
)
@JsonClass(generateAdapter = true)
data class Meta(
@Json(name = "requestId")
val requestId: String,
val timestamp: Instant,
@Json(name = "apiVersion")
val apiVersion: String
)
@JsonClass(generateAdapter = true)
data class Data(
val users: List<User>,
val pagination: Pagination
)
@JsonClass(generateAdapter = true)
data class ApiResponse(
val status: String,
val data: Data,
val meta: Meta
)
The big difference from Gson: Moshi’s codegen generates adapters that use Kotlin’s constructor to create objects. This means:
- If the JSON has
nullfor a non-nullable property, Moshi throws aJsonDataExceptionat parse time — not a randomNullPointerExceptionlater - Default values in the constructor actually work
- You get proper Kotlin null safety, not just at compile time but at runtime too
Custom Adapter for Instant
Like Gson, Moshi doesn’t handle java.time.Instant out of the box. We write a custom adapter.
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import java.time.Instant
object InstantAdapter {
@FromJson
fun fromJson(value: String?): Instant? =
value?.let { Instant.parse(it) }
@ToJson
fun toJson(value: Instant?): String? =
value?.toString()
}
Notice how much simpler this is compared to Gson’s TypeAdapter. No JsonReader/JsonWriter, no peek() for null checks — just annotated functions. Moshi handles null delegation automatically.
Parsing
import com.squareup.moshi.Moshi
object MoshiParser {
private val moshi: Moshi = Moshi.Builder()
.add(InstantAdapter)
.build()
private val adapter = moshi.adapter(ApiResponse::class.java)
fun parse(jsonString: String): ApiResponse {
return adapter.fromJson(jsonString)
?: throw IllegalStateException("Failed to parse JSON")
}
}
The adapter.fromJson() returns ApiResponse? (nullable), so we handle the null case explicitly. In practice, this only returns null if the entire JSON input is the literal null.
Putting It Together
import java.nio.file.Path
import kotlin.io.path.readText
fun main() {
val json = Path.of("response.json").readText()
val response = MoshiParser.parse(json)
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 Moshi Over Gson for Kotlin
| Feature | Gson | Moshi (codegen) |
|---|---|---|
| Kotlin null safety | Bypasses it (uses Unsafe) | Respects it (uses constructor) |
| Default values | Ignored | Supported |
| Code generation | No | Yes (via KSP) |
| Reflection at runtime | Yes | No (with codegen) |
| Custom adapters | Verbose TypeAdapter | Simple @FromJson/@ToJson |
| Fail-fast on bad data | No (silently null) | Yes (JsonDataException) |
| ProGuard/R8 friendly | Needs rules | Codegen = no reflection = no rules |
The bottom line: Moshi was designed for Kotlin. Gson was designed for Java and works with Kotlin — but the cracks show around null safety and default values.
Comparison Across All Three
| What | org.json | Gson | Moshi |
|---|---|---|---|
| Parsing code | ~80 lines | ~5 lines | ~5 lines |
| Null handling | Manual isNull() | Automatic (but unsafe) | Automatic + null safe |
| Date handling | Manual | Custom TypeAdapter | Custom adapter (simpler) |
| Reflection | None | Yes | No (with codegen) |
| Android recommended | Built-in but verbose | Popular but aging | Recommended |
Using in Android
Moshi is the recommended JSON library for modern Android development. It pairs perfectly with Retrofit and OkHttp.
plugins {
id 'com.google.devtools.ksp' version '2.1.10-1.0.29'
}
dependencies {
implementation 'com.squareup.moshi:moshi:1.15.1'
ksp 'com.squareup.moshi:moshi-kotlin-codegen:1.15.1'
implementation 'com.squareup.retrofit2:converter-moshi:2.11.0'
}
// With Retrofit + Moshi converter
val moshi = Moshi.Builder()
.add(InstantAdapter)
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
Since Moshi codegen produces adapters at compile time, there’s no reflection at runtime — which means faster startup, smaller APK (no kotlin-reflect), and no ProGuard rules needed for your model classes.
What’s Next
This wraps up the JSON Parsing in Kotlin series. We started with the pain of manual parsing (org.json), moved to automatic mapping with Gson (convenient but leaky null safety), and landed on Moshi (Kotlin-native, null safe, codegen). If you’re building a Kotlin or Android project today, Moshi with codegen is the way to go.
Want to see how these same approaches look in Java? Check out the JSON Parsing in Java series.