The Compose compiler version is not independent of your Kotlin version
A Kotlin bump without the matching plugin fails at a position no trace explains.
TL;DR
- A routine Kotlin
1.9.24 → 2.0.20bump failed with 17 errors in generated code, every one of them pointing at a line number in a file nobody wrote, and none of them mentioning Compose. - The Compose compiler plugin reads and rewrites Kotlin IR. IR is not a stable interface between Kotlin versions, so a plugin built against one compiler cannot be loaded by another.
- The lockstep is real and it is not symmetric: a Kotlin bump requires a plugin bump, and a plugin bump often permits several Kotlin versions. The catalog is where the pair belongs.
- What reading on buys: how to read the error, the one-line Gradle check that turns it into a
legible message, and why
kotlinCompilerExtensionVersiondisappeared from the build file.
The case this post is about
A five-target module, Kotlin bumped in the version catalog and nothing else touched:
# gradle/libs.versions.toml
[versions]
-kotlin = "1.9.24"
+kotlin = "2.0.20"
compose-compiler = "1.5.14"
// shared/build.gradle.kts — unchanged
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
$ ./gradlew :shared:compileKotlinAndroid
e: file:///shared/build/generated/.../ComposableSingletons$ProfileScreenKt.class
Unresolved reference: ComposableLambdaImpl
e: file:///shared/build/generated/.../ProfileScreenKt.class:412:18
Type mismatch: inferred type is Composer? but Composer was expected
… 17 errors
Seventeen errors in build/generated, at line numbers in files the build had just produced.
The symptom
The errors are the unhelpful part. The informative line is three hundred lines earlier in the output, and it is a warning:
w: Compose compiler plugin 1.5.14 was built against Kotlin 1.9.x and may not
be compatible with Kotlin 2.0.20. Set a matching version.
A warning, in a build that produces two hundred lines of output, followed by seventeen errors that say nothing about Compose. The errors are what you see; the warning is what happened.
The second symptom was worse because it did not fail. One module in the same build had no Compose code, compiled fine, and shipped — so the build was partially green, and the first hypothesis was that something about the failing modules was special.

Why the obvious fix failed
The first attempt was to bump the Compose compiler to its newest release and hope the pair lined up:
compose-compiler = "1.5.15"
w: Compose compiler plugin 1.5.15 was built against Kotlin 1.9.x …
Same warning. The 1.5.x line is built against Kotlin 1.9, and no patch release of it will ever
target 2.0 — the versioning does not work the way the version number suggests.
The second attempt was suppressKotlinVersionCompatibilityCheck, which is offered and is a trap:
freeCompilerArgs += listOf(
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=2.0.20"
)
That suppresses the warning and not the incompatibility. The build then proceeded further and failed differently — in the IR lowering phase rather than at compilation — with an internal compiler error and no line number at all:
e: org.jetbrains.kotlin.backend.common.CompilationException:
Back-end: Please report this problem
A worse error, arrived at deliberately. The flag exists for the narrow case where you have verified compatibility yourself against a pre-release pair, and using it to get past a legitimate mismatch converts a clear failure into an unclear one.
The third attempt was correct and only half the story: Kotlin 2.0 moved the Compose compiler into the Kotlin repository, so it is no longer a separately-versioned artifact at all.
The actual mechanism
The Compose compiler is a Kotlin compiler plugin, and it operates on Kotlin's intermediate representation.
It rewrites IR rather than source. Every @Composable function is transformed — a Composer
parameter is threaded through, a changed-bitmask is added, lambdas are lifted into singletons,
restart groups are inserted. That transformation is written against the IR classes of a specific
Kotlin version.
Kotlin's IR is not a stable published interface. It changes between versions, including minor ones, and it is explicitly not part of Kotlin's compatibility guarantee. So a plugin compiled against 1.9's IR classes encounters a different shape in 2.0.
Which is why the errors land in generated code. The plugin ran, produced output using the structure it expected, and the compiler then type-checked that output against the structure it actually has. The errors are real type errors in real generated code, which is why they look like your bug and mention nothing about plugins.
And the relationship is not symmetric. One Compose compiler release supports exactly one Kotlin minor line, and one Kotlin version may be supported by several plugin patch releases. So "bump the plugin to its latest" is not a strategy — the question is which plugin release was built for this Kotlin.
From Kotlin 2.0 the plugin ships in the Kotlin distribution, applied as a Gradle plugin rather than configured as a version string. The lockstep did not go away; it became impossible to express incorrectly, which is the better fix and is why the migration is worth doing rather than versioning around.

The fix
Apply the Compose compiler as a Gradle plugin from the Kotlin version, and delete the version string that can disagree.
# gradle/libs.versions.toml
[versions]
kotlin = "2.0.20"
# compose-compiler — DELETED. From Kotlin 2.0 the plugin ships with the compiler, so there is
# no second version to keep in step and therefore no way to get the pair wrong.
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
# ↑ version.ref = kotlin, not its own version. The lockstep as a
# declaration rather than as a thing to remember.
// shared/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.compiler) // replaces composeOptions entirely
}
// composeOptions { kotlinCompilerExtensionVersion = … } — DELETED.
And the guard for the pre-2.0 half of a fleet, because a monorepo migrates module by module and the mismatch is silent in the modules that have not moved yet:
// build-logic/src/main/kotlin/ComposeLockstepCheck.kt
//
// Fails CONFIGURATION with a message that names the mechanism, rather than letting the build
// reach IR lowering and emit type errors in generated code. The whole value here is the error
// text: the real failure is legible only if you already know the plugin rewrites IR.
abstract class ComposeLockstepCheck : DefaultTask() {
@get:Input abstract val kotlinVersion: Property<String>
/** Absent once the module has migrated to the bundled plugin — which is the goal. */
@get:Input @get:Optional abstract val extensionVersion: Property<String>
@TaskAction
fun check() {
val ext = extensionVersion.orNull ?: return // migrated: nothing to disagree
val kotlin = kotlinVersion.get()
// The pairing is published per release; it is not derivable from the numbers, which is
// itself the point — 1.5.15 looks newer than 1.5.14 and targets the same Kotlin line.
val supported = COMPOSE_TO_KOTLIN[ext]
?: throw GradleException(
"Compose compiler $ext is not in the known compatibility table.\n" +
" Either it is newer than this check, or it does not exist.\n" +
" Preferred fix: delete kotlinCompilerExtensionVersion and apply\n" +
" org.jetbrains.kotlin.plugin.compose at version $kotlin."
)
val kotlinMinor = kotlin.substringBeforeLast('.')
if (kotlinMinor !in supported) {
throw GradleException(
"Compose compiler $ext targets Kotlin ${supported.joinToString("/")}, " +
"but this build uses $kotlin.\n" +
" The plugin rewrites Kotlin IR, which is not stable across versions, so the\n" +
" failure surfaces as type errors in build/generated rather than here.\n" +
" Fix: apply org.jetbrains.kotlin.plugin.compose at version $kotlin and remove\n" +
" kotlinCompilerExtensionVersion.\n" +
" Do NOT pass suppressKotlinVersionCompatibilityCheck: it hides the warning,\n" +
" not the incompatibility, and the build then fails in IR lowering instead."
)
}
}
private companion object {
val COMPOSE_TO_KOTLIN = mapOf(
"1.5.14" to listOf("1.9"),
"1.5.15" to listOf("1.9"),
)
}
}
And the output it produced, run against the module as it stood before the migration:
$ ./gradlew :shared:checkComposeLockstep
FAILURE: Build failed with an exception.
* What went wrong:
Compose compiler 1.5.14 targets Kotlin 1.9, but this build uses 2.0.20.
The plugin rewrites Kotlin IR, which is not stable across versions, so the
failure surfaces as type errors in build/generated rather than here.
Fix: apply org.jetbrains.kotlin.plugin.compose at version 2.0.20 and remove
kotlinCompilerExtensionVersion.
Do NOT pass suppressKotlinVersionCompatibilityCheck: it hides the warning,
not the incompatibility, and the build then fails in IR lowering instead.
One failure, before compilation, naming the fix — against seventeen type errors in generated code.
How we proved it
That the errors were a consequence and the warning was the cause — established by reverting only the Kotlin version and counting:
warning errors in build/generated
kotlin 1.9.24 + compose 1.5.14 no 0
kotlin 2.0.20 + compose 1.5.14 YES 17
kotlin 2.0.20 + compose 1.5.15 YES 17
kotlin 2.0.20 + bundled plugin no 0
Bumping the plugin patch version changed nothing, which is what rules out "an old plugin" and rules in "a plugin for a different Kotlin line".
That suppressing the check makes the failure worse. Measured, because it is the flag the warning's own text invites you toward:
failure stage line number in the error?
default compilation yes (generated file)
+ suppressKotlinVersionCompatibilityCheck IR lowering no — internal error
A clear failure traded for an unclear one.
That the module with no Compose code was not special. The partial-green build sent the first hour of the investigation after the wrong question:
$ ./gradlew build 2>&1 | grep -E 'Task.*(compileKotlin|FAILED)' | head
> Task :core:model:compileKotlinAndroid SUCCESS # no @Composable anywhere
> Task :core:designsystem:compileKotlinAndroid FAILED
> Task :feature:profile:compileKotlinAndroid FAILED
Every failing module contained @Composable; every succeeding one did not. The plugin only
transforms what it recognises, so a module with nothing to transform is unaffected by a
transformation bug — which reads as "those modules are fine" and means "those modules were never
asked".
That the check fires before anything expensive. A deliberately-mismatched fixture, run to confirm the failure lands at configuration rather than after a two-minute compile:
$ ./gradlew :fixtures:compose-mismatch:build
FAILURE in 1s — Compose compiler 1.5.14 targets Kotlin 1.9, but this build uses 2.0.20.
One second, against the 94 seconds the real failure took to reach seventeen errors.
What I cannot keep current. COMPOSE_TO_KOTLIN is a hand-maintained table and it will go
stale — which is why the unknown-version branch tells you to migrate rather than guessing. The
table exists for modules that have not yet moved to the bundled plugin, and the correct lifetime
of this whole check is "until every module has". We deleted it from four of eleven modules in the
first week and the remaining seven are tracked.
And the limit of the diagnosis. This explains a version mismatch. It does not cover a plugin that is correctly paired and still miscompiles — which happens, rarely, and looks identical from the build output. The distinguishing test is whether the bundled plugin at the exact Kotlin version reproduces it; if it does, the table is not your problem.

What this generalizes to
A plugin that operates on a compiler's internal representation is coupled to that compiler's
version in a way its own version number does not express, and the failure surfaces in generated
output rather than at the coupling. The reliable habit when a build fails inside build/generated
is to read upward for a warning rather than outward from the error — the error is downstream of
whatever ran and the warning is the thing that ran. And where a toolchain offers to bundle a
coupled pair, taking the bundle is worth a migration: it removes a class of mistake rather than
documenting it.
Related
- A klib is not a jar — the other build failure that reports a symptom one layer away from its cause.
- Why the build cache helps the JVM target and almost never the native ones — the toolchain version appearing in a cache key, for the same underlying reason.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".