skip to content

Explain multi-round processing in KSP: why does process() run multiple rounds, what does returning deferred symbols mean, and how does this compare to kapt's rounds?

level: seniorimportance: should knowfreq 30%

answer

  1. Generated code can need more processing => rounds
  2. process() returns List<KSAnnotated> = deferred
  3. validate() gates ready vs deferred
  4. rounds until no new files + nothing deferred
  5. kapt = javac RoundEnvironment, no explicit return

basics

~20 s

Some generated code creates new things other processors must then look at. So processing runs in repeated rounds until nothing new appears. In KSP, a processor returns the symbols it couldn't fully handle yet, and they're retried in the next round.

solid answer

~40 s

Annotation processing is **multi-round** because generated code can itself contain symbols that need processing, or a symbol may reference a type that doesn't exist yet because another processor will generate it. In KSP, `SymbolProcessor.process(resolver)` returns a `List<KSAnnotated>` of **deferred symbols** — those it could not fully resolve this round (e.g. they depend on yet-to-be-generated types). KSP re-invokes `process()` in subsequent rounds, re-feeding the deferred symbols, until a round produces no new files and no deferrals. `finish()` is then called for cleanup. This mirrors kapt/javac's `RoundEnvironment` model where processors run repeatedly over rounds until no new sources are generated. The key KSP-specific mechanic is **explicitly returning unresolved symbols** rather than relying solely on the framework re-scanning; correct deferral handling prevents 'symbol not found' errors when generation order matters.

go deeper

for a junior

Aware that processing can run more than once when new code is generated, without the deferral mechanics.

for a middle

Explains rounds exist because generated code needs reprocessing and that KSP retries until stable.

for a senior

Describes returning deferred KSAnnotated from process(), validate() gating, finish(), and maps it to kapt's RoundEnvironment loop.

for a principal

Reasons about generation-order dependencies across processors, idempotency/duplicate-output risks, and designing processors that defer correctly at scale.

## Why processing needs multiple rounds A single pass isn't enough because **generated code can trigger more processing**: - A processor emits a new class annotated with something another processor handles. - A symbol you're processing **references a type that hasn't been generated yet** (generation order). You can't resolve it this round, so you must wait. So the framework runs in **rounds**: process → generate → re-scan/re-process → … until a round generates nothing new. ## KSP's round model and deferred symbols Your processor implements: ```kotlin class MyProcessor( private val codeGenerator: CodeGenerator, private val logger: KSPLogger ) : SymbolProcessor { override fun process(resolver: Resolver): List<KSAnnotated> { val symbols = resolver .getSymbolsWithAnnotation("com.example.MyAnno") .toList() val (ready, deferred) = symbols.partition { it.validate() } ready.filterIsInstance<KSClassDeclaration>().forEach { decl -> // generate code for fully-resolvable symbols } // hand back what we couldn't process yet return deferred } override fun finish() { /* optional cleanup */ } } ``` Key points: - **`process()` returns `List<KSAnnotated>`** — the **deferred** symbols you could not handle this round (commonly the ones where `validate()` is false because a referenced type isn't generated yet). - KSP **re-runs `process()`** in the next round, and the previously deferred symbols become resolvable once the missing types exist. - `validate()` is the idiomatic check: it returns true only when a symbol and the types it depends on are fully resolved. - Rounds continue until **no new files are generated and nothing is deferred**; then **`finish()`** runs once. - Each round, `getSymbolsWithAnnotation(...)` returns symbols **newly visible** that round (including those in code generated by the previous round), plus your returned deferrals are retried. ## Comparison to kapt / javac rounds kapt delegates to **javac's** `javax.annotation.processing` model: - Processors implement `process(annotations, roundEnv)` and inspect `RoundEnvironment`. - javac loops: each round, newly generated sources are compiled to elements and processors run again, until a round generates no new sources; then a final round runs with `roundEnv.processingOver() == true`. - There is **no explicit 'return deferred symbols' return value**; the framework re-scans the new sources automatically, and processors track their own pending state. KSP makes deferral **explicit and first-class** (the return value), which gives precise control over generation-order dependencies and is the idiomatic way to avoid premature 'unresolved type' failures. ## Common pitfalls - **Not deferring** symbols that reference not-yet-generated types → spurious resolution failures. - **Re-processing already-handled symbols** every round → duplicate output; only defer what's genuinely unresolved. - **Doing work in the wrong place** → use `finish()` for end-of-run aggregation, not per-round side effects that assume completeness. ## Summary Multi-round exists because generated code feeds further generation. KSP models this by having `process()` **return deferred `KSAnnotated` symbols** that are retried each round until quiescence, then `finish()`. kapt achieves the same outcome through javac's `RoundEnvironment` loop, but without an explicit deferred-return value.

  • What does returning a non-empty list from process() cause KSP to do?
    It marks those symbols as deferred; KSP re-invokes process() in a later round and re-supplies them, so they get another chance once dependencies are generated.
  • When is finish() called and what's it for?
    Once, after all rounds complete and nothing more is deferred or generated. It's for final aggregation/cleanup that needs the whole run to be done.

It's like grading essays where some cite a classmate's not-yet-written essay: you set those aside and re-grade them in a later pass once the references exist.

saying these in an interview costs you the question

  • Claiming KSP processing is always a single pass
  • Saying deferred symbols are silently dropped
  • Confusing finish() with a per-round callback
  • Deferring everything or nothing regardless of validate()
  • Not knowing kapt rounds map to javac RoundEnvironment

context