skip to content

kapt is described as legacy. As a principal engineer, how would you decide whether and how to migrate a large multi-module project off kapt?

level: principalimportance: nice to knowfreq 30%

answer

  1. Migrate where a KSP variant exists; keep kapt otherwise
  2. kapt + KSP coexist; move processors one at a time
  3. ksp(...) replaces kapt(...); ksp { arg() } replaces kapt arguments
  4. KSP plugin version paired to Kotlin version
  5. Measure with build scans / --profile; migrate per module

basics

~10 s

Check which of your annotation processors have a faster KSP version. Migrate those module-by-module, keep kapt only for the few processors that have no KSP option, and measure build-time improvement before and after.

solid answer

~50 s

kapt is legacy mainly because of its stub-generation overhead; KSP reads Kotlin symbols directly and is usually ~2x faster. Migration strategy: (1) **inventory processors** and check each for a KSP variant — Room, Moshi-codegen, Glide, and Hilt/Dagger have KSP support in current versions, while a few niche processors do not; (2) you can run **kapt and KSP side by side** in the same module during transition, applying both the `kotlin("kapt")` and `com.google.devtools.ksp` plugins and moving processors one at a time from `kapt(...)` to `ksp(...)`; (3) for each migrated processor, swap the dependency configuration, drop any now-unneeded `correctErrorTypes`, and verify generated output and tests; (4) **measure** with Gradle build scans / `--profile` to confirm wins and watch incrementality. Keep kapt only where no KSP processor exists. Risks: subtle differences in generated code, error-type handling, and processor-arg names; do it per module behind CI.

code

kotlin · 14 lines
kotlin
plugins {
    kotlin("jvm") version "2.1.0"
    kotlin("kapt") version "2.1.0"
    id("com.google.devtools.ksp") version "2.1.0-1.0.29"
}

dependencies {
    ksp("androidx.room:room-compiler:2.6.1")        // migrated
    kapt("com.example:legacy-processor:1.0")        // no KSP variant yet
}

ksp {
    arg("room.schemaLocation", "$projectDir/schemas")
}

go deeper

for a junior

Knows KSP is the modern, faster replacement for kapt.

for a middle

Can swap a single processor from kapt(...) to ksp(...) and translate its arguments.

for a senior

Plans a side-by-side, per-module migration, handles version pairing, and removes correctErrorTypes/kapt once a module is clean.

for a principal

Drives the whole decision: inventories processors, weighs KSP availability vs. effort, measures build-time impact with scans, and de-risks via CI while accepting kapt where no KSP exists.

## Why migrate at all kapt's cost is structural: it runs a **stub-generation** pass (a near-full Kotlin front-end compile that emits Java stubs) before any processing, hurting build time and incrementality. **KSP (Kotlin Symbol Processing)** inspects Kotlin **symbols** directly via its own API — no Java stubs — and is typically about **2x faster** for the same processor. So migration is primarily a **build-performance and developer-experience** decision. ## Step 1 — inventory and feasibility List every processor on `kapt(...)` across modules and check for a **KSP variant**: - Room (`room-compiler`) — has KSP. - Moshi-codegen, Glide — have KSP. - Dagger/Hilt — KSP support exists in current versions. - Some older/niche processors — **kapt-only**; these must stay on kapt. No KSP variant = no migration for that processor (yet); that's a legitimate reason kapt remains in a codebase. ## Step 2 — incremental, side-by-side migration kapt and KSP can coexist in one module: ```kotlin plugins { kotlin("jvm") version "2.1.0" kotlin("kapt") version "2.1.0" // still here for unmigrated processors id("com.google.devtools.ksp") version "2.1.0-1.0.29" } dependencies { // migrated to KSP ksp("androidx.room:room-compiler:2.6.1") // not yet migrated — stays on kapt kapt("com.example:legacy-processor:1.0") } ``` Move processors from `kapt(...)` to `ksp(...)` **one at a time**, keeping the build green between steps. Note the **KSP plugin/version is paired to the Kotlin version** (e.g. `2.1.0-1.0.29`), so bump them together. ## Step 3 — clean up kapt-specific config - Translate processor arguments: kapt's `kapt { arguments { arg(k, v) } }` becomes `ksp { arg(k, v) }`. - Drop `correctErrorTypes` once the relevant processors are off kapt (KSP doesn't use stubs, so the error-type problem doesn't apply). - When a module's last kapt processor is migrated, remove the `kotlin("kapt")` plugin entirely to delete the stub pass. ## Step 4 — measure and de-risk - Use **Gradle build scans** / `./gradlew --profile` / `--scan` to confirm faster, more-incremental builds. - Watch for **generated-code differences**: KSP processors can emit slightly different code or have different arg names; gate behind CI and review generated output and tests. - Migrate **per module** so a regression is isolated. ## Decision summary - KSP variant exists + meaningful build-time pain → migrate. - No KSP variant → keep kapt for that processor; isolate it so other modules stay kapt-free. - Always migrate behind tests/CI and validate generated output. ## Keywords `com.google.devtools.ksp`, `ksp(...)` vs `kapt(...)`, symbols vs stubs, KSP/Kotlin version pairing, build scans/`--profile`, `correctErrorTypes` removal.

  • Can kapt and KSP run in the same module simultaneously?
    Yes. You apply both plugins and keep some processors on kapt(...) while others move to ksp(...), which enables an incremental, one-processor-at-a-time migration.
  • What is a valid reason to keep kapt in a 2026 codebase?
    A processor with no KSP variant. Since KSP reads symbols not Java stubs, only processors that ship a KSP implementation can be migrated; kapt remains the fallback for the rest.

Like swapping a slow translator (kapt) for someone who reads the original language (KSP) — but you keep the translator around for the few documents the new reader can't yet handle.

saying these in an interview costs you the question

  • Assuming every kapt processor has a KSP equivalent
  • Recommending a big-bang migration across all modules at once with no measurement
  • Forgetting that the KSP plugin version is tied to the Kotlin version
  • Claiming KSP and kapt cannot coexist in one module
  • Ignoring possible differences in generated code/processor arguments

context