Kotlin K2 Compiler: Unified Frontend, Type Inference, and Android Migration

The day we upgraded to Kotlin 2.0, CI compile time dropped from 4 minutes 20 seconds to 2 minutes 58 seconds. No code changed, yet the build became faster. Then three kapt plugins failed, two custom lint rules stopped working, and one type-inference difference showed up in a surprisingly odd place. This article explains what K2 changed, why it can be faster, and what migration issues Android teams should expect.

The Old Architecture: Multiple Frontends, Multiple Semantic Models

Before K2, the Kotlin compiler had several frontend implementations running in parallel:

  • The CLI compiler (kotlinc) used one frontend.
  • The IDE analysis engine (kotlin-analysis-api) used another.
  • Incremental compilation paths maintained some logic separately.

The same Kotlin code could have subtle differences between IDE type inference and the actual compilation result. Every time a language feature changed, several implementations had to be updated together. The maintenance cost was high. At KotlinConf 2024, a JetBrains engineer called this “three compilers in a trench coat,” which is a fair description.

K2’s core goal is to replace these scattered implementations with one unified frontend.

K2’s New Frontend Architecture: The FIR Pipeline

K2 introduces FIR, or Frontend Intermediate Representation, as the unified intermediate representation for semantic analysis. The frontend pipeline becomes much clearer:

Source code (PSI)
  -> RAW_FIR (rough parsing, original structure preserved)
  -> FIR (full semantic analysis, type inference, symbol resolution)
  -> IR (shared backend intermediate representation)
  -> bytecode / JS / Native

The RAW_FIR phase only converts the syntax tree and does not resolve types. Full semantic analysis happens in the FIR phase, including type inference, overload resolution, and visibility checks. This staged design lets the compiler trigger specific phases on demand, which improves incremental-compilation efficiency.

FIR nodes are immutable. After a node is resolved, later phases read it instead of mutating it. In the old PSI-based model, nodes could be modified at any time. FIR’s immutability improves thread safety and is one reason the IDE can analyze multiple files concurrently in the background.

Rewriting Type Inference: Why the Old System Hit Its Limit

The old compiler’s type inference was based on a constraint system: it collected upper and lower bounds for type variables and then solved them. During the Kotlin 1.x era, that system accumulated many workarounds and special paths. As new language features were added, inference could become counterintuitive in edge cases.

K2 rewrites the type-inference engine. Two changes matter most.

Deterministic inference order: In some K1 cases, solving type variables depended on the iteration order of internal collections, which effectively made the behavior undefined. K2 introduces explicit inference-priority rules so the same code has consistent inference results across platforms.

Expanded smart-cast scope: This is one of the K2 changes that most often affects real code. Consider this example:

class Container(val value: String?)

fun process(container: Container) {
    if (container.value != null) {
        // K1: compile error, container.value cannot be smart cast because it may be changed concurrently
        // K2: compiles; val properties in the same module can be smart cast
        println(container.value.length)
    }
}

The old compiler was conservative for non-local variables. Even a val property was often not smart-cast. K2 analyzes property mutability and visibility more precisely, so smart casts apply in more cases. KEEP-411 documents the boundaries in detail, and it is worth reading before migration.

Where the Compile-Time Speedup Comes From

Some explanations reduce K2’s speed gains to “better algorithms.” That is too vague. The performance gains come from several concrete mechanisms.

Parallel frontend analysis: The FIR pipeline supports parallel processing across files. The old frontend had global locks around analysis for annotations such as @Suppress and @JvmStatic; K2 removes those lock points.

Less repeated parsing: In the old incremental-compilation path, dirty-file changes could force large amounts of dependent symbol analysis to run again. FIR’s immutable node design allows unchanged-file analysis results to be reused directly.

Removing the BindingContext bottleneck: The old path was PSI -> BindingContext -> IR. BindingContext was a global Map-like structure that stored type information for all symbols, and its memory usage grew linearly with project size. K2 generates IR directly from FIR and removes that layer from the main path.

Actual speedup depends heavily on project size and annotation-processor count. In our project, roughly 300,000 lines of Kotlin with eight kapt processors, clean build time improved by about 30%, and incremental builds improved by roughly 45%. JetBrains has published AndroidX numbers showing a 2.2x clean-build speedup; that figure is easier to reproduce in pure Kotlin projects with fewer annotation processors.

Android Migration: What Actually Needs to Change

The migration entry point is simple: set the Kotlin version to 2.x in the root build.gradle.kts. But several areas deserve a careful check first.

kapt vs KSP

K2’s kapt compatibility layer, K2 kapt, is stable but not the preferred long-term path. If your annotation processor has a KSP version, this is a good time to migrate:

// build.gradle.kts
plugins {
    id("com.google.devtools.ksp") version "2.1.20-1.0.31"
}

dependencies {
    // kapt("com.google.dagger:dagger-compiler:2.51")
    ksp("com.google.dagger:dagger-compiler:2.51") // Migrate to KSP
}

Hilt, Room, and Moshi have stable KSP support. Glide’s KSP support landed in the mainline after 4.16. One migration detail to watch: Hilt’s KSP implementation handles @AndroidEntryPoint slightly differently, and generated-code package names may change. If you have reflection-based code around generated classes, verify it.

Compiler Plugin API Migration

This is the most expensive part of migration, mainly for teams that maintain their own compiler plugins.

The old plugin API was based on BindingContext and DeclarationDescriptor. These classes still exist in K2 mode, but they are no longer the main path and may be removed in future versions. K2 introduces the new FirExtension API:

// Old API from the K1 era
class OldPlugin : IrGenerationExtension {
    override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
        val descriptor = pluginContext.referenceClass(...)?.descriptor
        // Read type information through BindingContext
    }
}

// New API for K2
class NewPlugin : FirExtensionRegistrar() {
    override fun ExtensionRegistrarContext.configurePlugin() {
        +::MyFirDeclarationGenerationExtension
    }
}

If you use third-party compiler plugins, note that Kotlin 2.x moved the Compose compiler from AOSP into the official Kotlin repository. Its version now aligns with the Kotlin version, so Android teams no longer need a separate Compose compiler version mapping table. That is one of the most immediate benefits of K2 migration.

Type-Inference Differences

After an upgrade, compilation errors usually come from two categories.

Smart-cast scope changes: Some code that previously compiled may fail under K2 because inference rules are stricter. This often appears in type capture across lambdas:

var result: String? = null
listOf(1, 2, 3).forEach {
    result = "found"
}
// K1: result may be smart cast to String here, depending on the compiler version
// K2: explicitly does not smart cast; use !! or ?: instead
println(result?.length)

Overload-resolution adjustments: K2 fixes several incorrect K1 inferences around generic overloads. In rare cases, code that “incorrectly compiled” before now correctly fails. When this happens, the original code usually had a hidden logic problem.

For debugging, run ./gradlew :module:compileDebugKotlin --info 2>&1 | grep "K2" to confirm that K2 mode is active. Then enable the IDE’s K2 mode through Settings -> Kotlin -> Enable K2 mode so local development catches differences earlier.

Current Plugin Ecosystem

As of early 2026, the mainstream Android toolchain looks like this:

ToolStatus
Compose CompilerOfficially maintained and tied to the Kotlin version
KSPFully supported
kapt (K2 mode)Compatible, but not the long-term path
kotlinx.serializationFull support in 2.0+
Hilt (KSP)Stable
Arrow compiler-plugin piecesSome APIs are still migrating
Custom BindingContext pluginsMust be rewritten as FirExtension

For most Android product teams, the migration risk is manageable. The real work appears in two cases: you maintain a custom compiler plugin based on BindingContext, or you depend on a kapt processor that has not completed its KSP migration.

Practical Advice

Upgrade KSP before upgrading Kotlin. Replace kapt dependencies with KSP versions one by one and stabilize that on Kotlin 1.9 before moving the whole project to Kotlin 2.x. Splitting the risk into two steps makes rollback cheaper.

Use languageVersion = "2.0" for gradual validation. Before a full upgrade, set the language version to 2.0 in a single module’s compilerOptions. This lets you observe type-inference differences without taking on all runtime and toolchain changes at once:

// module/build.gradle.kts
kotlin {
    compilerOptions {
        languageVersion.set(KotlinVersion.KOTLIN_2_0)
        apiVersion.set(KotlinVersion.KOTLIN_2_0)
    }
}

K2 is the largest Kotlin compiler architecture refactor in a decade. Years of technical debt were paid down in this release. Later language features such as context parameters and multi-receiver lambdas also need this new architecture as their foundation. Migrating now has real benefits, and for most Android teams the cost is controllable.

Further Reading