KSP vs KAPT, Code Generation & Serialization
Understand annotation processing, build performance, and serialization strategies in modern Android
Open interactive version (quiz + challenge)Real-world analogy
What is it?
This lesson covers the annotation processing and serialization landscape in modern Android development. KAPT processes Kotlin via Java stubs (slow), while KSP processes Kotlin symbols directly (2x faster). For serialization, Gson uses runtime reflection, Moshi offers Kotlin-aware codegen, and kotlinx.serialization uses a compiler plugin for zero-reflection, compile-time serialization. Understanding these tools and their tradeoffs is essential for build performance, runtime performance, and architecture decisions.
Real-world relevance
Every production Android app uses annotation processing (Room, Hilt, Moshi/serialization) and JSON serialization (API responses). Companies like Uber and Airbnb reported 20-30% build time improvements migrating from KAPT to KSP across hundreds of modules. Square created Moshi specifically because Gson's lack of Kotlin null-safety caused production crashes. Netflix uses kotlinx.serialization in their Kotlin Multiplatform SDK that powers Android, iOS, and TV apps with shared serialization logic.
Key points
- KAPT Mechanics — KAPT (Kotlin Annotation Processing Tool) works by generating Java stubs from Kotlin code, then running standard Java annotation processors (javax.annotation.processing) against those stubs. This stub generation is expensive — it requires a full Kotlin compilation pass before annotation processing even begins. KAPT processes Java's Element API and cannot access Kotlin-specific features like inline classes, sealed hierarchies, or default parameter values directly.
- KSP Architecture — KSP (Kotlin Symbol Processing) operates directly on Kotlin's compiler symbols without generating Java stubs. It provides a Kotlin-native API (KSNode, KSClassDeclaration, KSFunctionDeclaration) that understands all Kotlin features: suspend functions, value classes, sealed interfaces, type aliases, and multiplatform expect/actual. KSP runs as a compiler plugin, making it fundamentally faster than KAPT.
- KSP vs KAPT Performance — KSP is typically 2x faster than KAPT because it eliminates the stub generation phase. In benchmarks: a project with Room + Hilt + Moshi saw build times drop from 45s to 28s after migrating to KSP. KSP also supports incremental processing out of the box, whereas KAPT's incremental support is limited and often disabled. Memory usage is also lower since no Java stubs are generated.
- Migration from KAPT to KSP — Migration steps: (1) Replace `id 'kotlin-kapt'` with `id 'com.google.devtools.ksp'` in plugins block. (2) Change `kapt 'lib:compiler'` to `ksp 'lib:compiler'` in dependencies. (3) Replace `@JvmStatic` workarounds needed for KAPT. Not all libraries support KSP yet — check each library. Room, Hilt (via dagger-hilt-compiler), Moshi, and Glide all support KSP. You can run KAPT and KSP side-by-side during migration.
- kotlinx.serialization — kotlinx.serialization is a Kotlin-first serialization framework using a compiler plugin (not annotation processing). Annotate classes with `@Serializable` and the compiler generates serializers at compile time — no reflection. Supports JSON (kotlinx.serialization.json), Protobuf, CBOR, Properties. Handles Kotlin features natively: default values, nullable types, sealed classes, inline classes. Use `Json.encodeToString()` and `Json.decodeFromString()`.
- Gson vs Moshi vs kotlinx.serialization — Gson: Java-based, uses reflection, no Kotlin null-safety (can create objects with null non-nullable fields), largest community. Moshi: Kotlin-aware with codegen option (KSP), respects nullability, supports adapters. kotlinx.serialization: compile-time, no reflection, smallest APK impact, handles all Kotlin types, multiplatform. For new projects, kotlinx.serialization is recommended. For existing Gson projects, Moshi is the easiest migration path.
- Custom KSP Processor Basics — Create a KSP processor by implementing SymbolProcessorProvider and SymbolProcessor. Override `process(resolver: Resolver)` to find annotated symbols via `resolver.getSymbolsWithAnnotation()`. Generate code using `CodeGenerator.createNewFile()` with Dependencies for incremental processing. Return unprocessed symbols (deferred) from process() for multi-round processing. Package as a separate module with META-INF/services registration.
- kotlinx.serialization Advanced Features — Custom serializers: implement KSerializer with descriptor, serialize(), and deserialize(). `@Contextual` enables runtime serializer registration via SerializersModule. `@SerialName` customizes JSON key names. `Json { ignoreUnknownKeys = true; coerceInputValues = true; encodeDefaults = false }` configures parsing behavior. Polymorphic serialization handles sealed hierarchies with `classDiscriminator`.
- Room, Hilt, Moshi with KSP — Room KSP: use `ksp 'androidx.room:room-compiler:version'` — generates DAO implementations and schema validation. Hilt KSP: use `ksp 'com.google.dagger:hilt-compiler:version'` — generates dependency injection code. Moshi KSP: use `ksp 'com.squareup.moshi:moshi-kotlin-codegen:version'` — generates JsonAdapter factories. All three see significant build time improvements with KSP over KAPT.
- Build Configuration & Debugging — Configure KSP arguments via `ksp { arg('key', 'value') }` in build.gradle. Room's schema export: `arg('room.schemaLocation', '$projectDir/schemas')`. View generated code in `build/generated/ksp/`. For KAPT, generated code is in `build/generated/source/kapt/`. Use `./gradlew kspDebugKotlin --info` for detailed processing logs. Enable KSP's incremental processing with `ksp.incremental=true` in gradle.properties.
- Serialization Performance Comparison — Benchmarks on a typical Android data class: kotlinx.serialization JSON is 2-3x faster than Gson for both serialization and deserialization. Moshi with codegen is comparable to kotlinx.serialization. Gson with reflection is the slowest. For large lists (1000+ items), the difference is dramatic. kotlinx.serialization also has the smallest APK impact since no reflection library is bundled. Protobuf format is 3-5x faster than JSON for all libraries.
- Multiplatform Serialization — kotlinx.serialization is the only option that works across Kotlin Multiplatform (Android, iOS, JS, Desktop, Server). The same @Serializable data classes and Json configuration work on all platforms. This is a major advantage for KMP projects. Gson and Moshi are JVM-only. When planning a KMP migration, standardizing on kotlinx.serialization early avoids a painful serialization layer rewrite later.
Code example
// === kotlinx.serialization Setup & Usage ===
// build.gradle.kts
// plugins { kotlin("plugin.serialization") version "1.9.22" }
// dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") }
@Serializable
data class ApiResponse<T : @Serializable Any>(
@SerialName("status_code")
val statusCode: Int,
val message: String,
val data: T,
val timestamp: Long = System.currentTimeMillis()
)
@Serializable
data class User(
val id: Long,
val name: String,
val email: String,
@SerialName("avatar_url")
val avatarUrl: String? = null,
val role: UserRole = UserRole.MEMBER
)
@Serializable
enum class UserRole {
@SerialName("admin") ADMIN,
@SerialName("member") MEMBER,
@SerialName("guest") GUEST
}
// Configured Json instance (reuse this!)
val appJson = Json {
ignoreUnknownKeys = true
coerceInputValues = true
encodeDefaults = false
prettyPrint = false
isLenient = false
explicitNulls = false
}
// Usage
fun parseUser(jsonString: String): User {
return appJson.decodeFromString<User>(jsonString)
}
fun serializeUser(user: User): String {
return appJson.encodeToString(user)
}
// === Sealed Class Polymorphic Serialization ===
@Serializable
sealed interface NetworkResult<out T> {
@Serializable
@SerialName("success")
data class Success<T : @Serializable Any>(
val data: T
) : NetworkResult<T>
@Serializable
@SerialName("error")
data class Error(
val code: Int,
val message: String
) : NetworkResult<Nothing>
}
// === Custom Serializer ===
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor(
"Instant",
PrimitiveKind.LONG
)
override fun serialize(
encoder: Encoder,
value: Instant
) {
encoder.encodeLong(value.toEpochMilliseconds())
}
override fun deserialize(decoder: Decoder): Instant {
return Instant.fromEpochMilliseconds(
decoder.decodeLong()
)
}
}
@Serializable
data class Event(
val name: String,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant
)
// === Retrofit + kotlinx.serialization ===
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(
appJson.asConverterFactory(
"application/json".toMediaType()
)
)
.build()
// === KSP Processor Example ===
// In a separate :processor module
class AutoFactoryProcessorProvider : SymbolProcessorProvider {
override fun create(
environment: SymbolProcessorEnvironment
): SymbolProcessor = AutoFactoryProcessor(environment)
}
class AutoFactoryProcessor(
private val environment: SymbolProcessorEnvironment
) : SymbolProcessor {
override fun process(
resolver: Resolver
): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(
"com.example.AutoFactory"
)
val unprocessed = mutableListOf<KSAnnotated>()
symbols.forEach { symbol ->
if (symbol !is KSClassDeclaration) {
environment.logger.error(
"@AutoFactory must target a class",
symbol
)
return@forEach
}
if (!symbol.validate()) {
unprocessed.add(symbol)
return@forEach
}
generateFactory(symbol)
}
return unprocessed
}
private fun generateFactory(
classDecl: KSClassDeclaration
) {
val packageName = classDecl.packageName.asString()
val className = classDecl.simpleName.asString()
val factoryName = "${className}Factory"
val file = environment.codeGenerator.createNewFile(
dependencies = Dependencies(
aggregating = false,
classDecl.containingFile!!
),
packageName = packageName,
fileName = factoryName
)
val constructor = classDecl
.primaryConstructor
?: return
val params = constructor.parameters.joinToString(
separator = ",
"
) { param ->
val name = param.name?.asString() ?: return
val type = param.type.resolve()
.declaration.qualifiedName?.asString()
"$name: $type"
}
file.writer().use { writer ->
writer.write(
"""
|package $packageName
|
|object $factoryName {
| fun create(
| $params
| ): $className = $className(
| ${constructor.parameters.joinToString {
it.name?.asString() ?: ""
}}
| )
|}
""".trimMargin()
)
}
}
}
// === build.gradle.kts Migration Example ===
// BEFORE (KAPT):
// plugins { id("kotlin-kapt") }
// dependencies {
// kapt("androidx.room:room-compiler:2.6.1")
// kapt("com.google.dagger:hilt-compiler:2.50")
// kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
// }
// AFTER (KSP):
// plugins { id("com.google.devtools.ksp") version "1.9.22-1.0.17" }
// dependencies {
// ksp("androidx.room:room-compiler:2.6.1")
// ksp("com.google.dagger:hilt-compiler:2.50")
// ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
// }
// Note: KSP version must match Kotlin version!
// === Room KSP Configuration ===
// ksp {
// arg("room.schemaLocation", "$projectDir/schemas")
// arg("room.incremental", "true")
// arg("room.generateKotlin", "true")
// }Line-by-line walkthrough
- 1. The @Serializable annotation triggers the kotlinx.serialization compiler plugin to generate a serializer for each class at compile time.
- 2. @SerialName('status_code') maps the Kotlin property statusCode to the JSON key 'status_code' — no runtime reflection needed.
- 3. Default values (avatarUrl = null, role = MEMBER) are handled natively — Gson would ignore these and set null/first-enum.
- 4. The appJson instance configures parsing behavior. ignoreUnknownKeys prevents crashes when the API adds new fields.
- 5. explicitNulls = false omits null fields from serialized output, reducing payload size.
- 6. Sealed interface NetworkResult with @SerialName on subtypes enables type-safe polymorphic JSON parsing with a discriminator.
- 7. InstantSerializer demonstrates custom serialization — implementing KSerializer with descriptor, serialize, and deserialize.
- 8. The KSP processor's SymbolProcessorProvider is the entry point, registered via META-INF/services.
- 9. resolver.getSymbolsWithAnnotation() finds all @AutoFactory-annotated classes. validate() checks if the symbol is fully resolved.
- 10. CodeGenerator.createNewFile with Dependencies enables incremental processing — KSP only re-processes changed files.
- 11. The KAPT-to-KSP migration is a dependency-level change: swap 'kapt' for 'ksp' and update the plugin. Library APIs remain identical.
- 12. Room KSP args like room.generateKotlin = true enable Kotlin code generation instead of Java, improving null-safety and readability.
Spot the bug
// Gson deserialization
data class UserProfile(
val id: Long,
val name: String,
val email: String,
val bio: String = "No bio provided"
)
val json = """{"id": 1, "name": "Alice", "email": null}"""
val user = Gson().fromJson(json, UserProfile::class.java)
println(user.email) // What happens here?
println(user.bio) // And here?Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- KSP Official Documentation (Kotlin Documentation)
- kotlinx.serialization Guide (Kotlin Documentation)
- Migrating from KAPT to KSP (Android Developers)
- KSP: Kotlin Symbol Processing (Android Developers YouTube)