An Introduction to Kotlin Symbol Processing

9.6.2022 | 6 minutes of reading time

This article will give an introduction to Kotlin annotation processing using Kotlin Symbol Processing (KSP). We’ll take a look at KSP, compare it to the alternative KAPT, and implement a simple example.

I was inspired to write this post after multiple people asked me about KSP when reading my last blog post , which talked about generating Kotlin DSLs.

What is annotation processing?

Annotation processing in the JVM world usually refers to generating new source code at build time based on annotations present in the existing source code, which is also the scenario we’ll look at here.

A lot of variations are possible though: You could modify existing code instead of generating new code, as Project Lombok does; generate non-code resources, like AutoService ; or process annotations at runtime, akin to Spring Boot .

History of annotation processing

JVM Annotations were introduced in Java 5 and with them the Annotation Processing Tool (apt) (not to be confused with the Advanced Packaging Tool (apt) commonly used on Linux systems).
Apt was at the time a standalone program you had to run before invoking the Java Compiler (javac). That was of course not very convenient and apt was integrated into javac with Java 6.

After that, little changed for annotation processing in Java, but I’m not here to talk about Java anyway – this is about Kotlin. Annotation processing in Kotlin started with the release of Kotlin Annotation Processing Tool (kapt).

What is kapt?

Kapt is essentially a wrapper around apt: It generates equivalent Java stubs for all Kotlin classes and then calls javac to let it process the annotations within. It only needs to generate stubs (classes and methods without any instructions), because apt doesn’t look at instructions anyway.

This brought a major advantage: almost all annotation processors written for Java were suddenly compatible with Kotlin.

But it also has some major drawbacks:

  • All Kotlin language features without direct equivalent in Java are effectively hidden from annotation processors. E.g. explicit nullability, primary constructors, properties, etc. (There are ways around this, parsing Kotlin metadata, but it’s clunky and too big of a topic to get into here.)
  • Stub generation is painfully slow, often taking longer than annotation processing itself

For these reasons kapt is officially in maintenance mode and it is recommended to use KSP instead.

What is KSP?

KSP is a Kotlin compiler plugin aiming to provide a stable annotation processing API. This means while KSP needs to be updated for almost every Kotlin release, your annotation processor does not.

Aside from not supporting Java annotation processors, KSP has several advantages:

  • Speed. According to the documentation , it can be up to 2 times faster than kapt.
  • Support for all Kotlin language features. For me the most important one here is explicit nullability.
  • Compatibility with non-JVM Kotlin targets, like Javascript or native. (Although improving the multiplatform experience is still on the roadmap .)

Okay, so now you know KSP is awesome, but how do you use it?

Getting started with KSP

For this example I’m going to use a very simple gradle project (KSP supports maven as well if that’s more your jam).

1// settings.gradle.kts
1// build.gradle.kts
2plugins {
3    kotlin("jvm") version "1.6.21" apply false
6subprojects {
7    repositories {
8        mavenCentral()
9    }
1// app/build.gradle.kts
2plugins {
3    kotlin("jvm")
4    application
7application {
8    mainClass.set("MainKt")
1// app/src/main/kotlin/main.kt
2fun main() {
1// app/src/main/kotlin/Printer.kt
2interface Printer {
3    fun `hello world!`()

Our goal in this example will be to generate an implementation of Printer, which logs anything we write in the method name to the standard output.

1. Setup modules

Our annotation processor needs its own module, and we’ll create one for our custom annotation as well:

1// settings.gradle.kts
1// annotation/build.gradle.kts
2plugins {
3    kotlin("jvm")

Our processor will need access to the annotation and the KSP API. I’ll use KotlinPoet to support code generation so add that as well:

1// processor/build.gradle.kts
2plugins {
3    kotlin("jvm")
6dependencies {
7    implementation("com.google.devtools.ksp:symbol-processing-api:1.6.21-1.0.5")
8    implementation("com.squareup:kotlinpoet-ksp:1.11.0")
9    implementation(project(":annotation"))

Our app needs the annotation plus the processor. We’ll also make IntelliJ aware of our generated sources.

1// app/build.gradle.kts
2plugins {
3    kotlin("jvm")
4    application
5    id("com.google.devtools.ksp") version "1.6.21-1.0.5"
8dependencies {
9    implementation(project(":annotation"))
10    ksp(project(":processor"))
13application {
14    mainClass.set("MainKt")
17kotlin {
18    sourceSets.main {
19        kotlin.srcDir("build/generated/ksp/main/kotlin")
20    }
21    sourceSets.test {
22        kotlin.srcDir("build/generated/ksp/test/kotlin")
23    }

If you’re only looking to use a processor instead of writing one, this last snippet is all you need, obviously replacing the dependencies with that processor.

2. Add annotation

To be able to process annotations, we need an annotation. Let’s call it @GenerateConsolePrinter:

1// annotation/src/main/kotlin/GenerateConsolePrinter.kt
2@Retention(AnnotationRetention.SOURCE) // we only need this in source files
3@Target(AnnotationTarget.CLASS) // should only go on classes or interfaces
4annotation class GenerateConsolePrinter

I’ll also annotate our Printer at this time, but you could do that later as well.

1// app/src/main/kotlin/Printer.kt
3interface Printer {
4    fun `hello world!`()

3. Processor Setup

We need to implement a SymbolProcessor (KSP support in KotlinPoet is in beta, so we need to opt in).

1// processor/src/main/kotlin/ConsolePrinterProcessor.kt
3class ConsolePrinterProcessor(private val codeGenerator: CodeGenerator) : SymbolProcessor {
4    override fun process(resolver: Resolver): List<KSAnnotated> {
5        return emptyList()
6    }

And a SymbolProcessorProvider:

1// processor/src/main/kotlin/ConsolePrinterProcessorProvider.kt
2class ConsolePrinterProcessorProvider : SymbolProcessorProvider {
3    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
4        return ConsolePrinterProcessor(environment.codeGenerator)
5    }

Which we then need to register using the fully qualified name for KSP to discover it.
For this we add a file: processor/src/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider


Now that we finished the setup, it’s time for implementation:

4. Processor Implementation

In this section I’m using KotlinPoet to describe the source code we want to generate. It provides convenient abstractions (TypeSpec, FunSpec and FileSpec below), which in my opinion make it the best tool for generating Kotlin sources from code, but you could use other methods (e.g. string templating) as well.

All the code in this section is part of the process function of ConsolePrinterProcessor.

First, we need to get all classes with our annotation:

1val annotatedClasses = resolver.getSymbolsWithAnnotation(GenerateConsolePrinter::class.java.name)
2    .filterIsInstance<KSClassDeclaration>()

We want to generate an implementation for each of these:

1for (annotatedClass in annotatedClasses) {
2    val className = annotatedClass.simpleName.asString() + "Impl"
3    val typeBuilder = TypeSpec.classBuilder(className)
4        .addSuperinterface(annotatedClass.toClassName())

Next, we want to override each method:

1    typeBuilder.addFunctions(annotatedClass.getDeclaredFunctions().map { function ->
2        val name = function.simpleName.asString()
3        FunSpec.builder(name)
4            .addModifiers(KModifier.OVERRIDE)
5            .addStatement("println(%S)", name)
6            .build()
7    }.toList())

Our implementation here just prints out the method name with convenient KotlinPoet templating .

And then we just need to write that all out into a file:

1    val fileSpec = FileSpec.builder(annotatedClass.packageName.asString(), className)
2        .addType(typeBuilder.build())
3        .build()
4    codeGenerator.createNewFile(
5            Dependencies(false, annotatedClass.containingFile!!),
6            fileSpec.packageName,
7            fileSpec.name
8        )
9        .writer()
10        .use { fileSpec.writeTo(it) }

The Dependencies here tell KSP which sources we used to generate that file, allowing it to only regenerate if relevant sources changed. This is great for performance on later builds.

And that’s it! Run a gradle build and have a look at our newly generated class:

1public class PrinterImpl : Printer {
2  public override fun `hello world!`(): Unit {
3    println("hello world!")
4  }

We can now use our generated class:

1// app/src/main/kotlin/main.kt
2fun main() {
3    PrinterImpl().`hello world!`()

And test it with gradle run:

2> Task :app:run
3hello world!

Success! Of course in this example we needed to write more code than we saved, but I think it’s easy to imagine how you could use this to avoid boilerplate code. Annotation processing is best suited for situations where you need to write a lot of code with very similar structure, but not so similar that you could just extract it into a method.

You can find the complete sample code here .


In this blog post we’ve learned what kapt and KSP are, and the major advantages of KSP, namely speed, Kotlin language feature support and independence from the JVM. We’ve also seen how to use a KSP processor and how to create our own with KotlinPoet.

I hope the next time you repeat the same boilerplate code over and over again you’ll think of this post and maybe even write your own processor to save you all that effort.

If you’re currently using KAPT, you can also check if your processors already support KSP for a nice build speed boost.

share post




More articles in this subject area\n

Discover exciting further topics and let the codecentric world inspire you.


Gemeinsam bessere Projekte umsetzen

Wir helfen Deinem Unternehmen

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.