Kandra

v0.4.6 · Apache 2.0 · Kotlin + ScyllaDB/Cassandra

A Kotlin ORM that doesn't pretend Cassandra is SQL with extra steps.

Kandra is a Ktor-native ORM for ScyllaDB and Cassandra. It makes partitioning, denormalization, and distributed tradeoffs visible in your entity definitions — so the query that looks correct actually is.

For teams evaluating an ORM

The Cassandra mistakes that page your team at 3am — caught before they ship.

Partition-wide deletes, batches that look atomic and aren't, tombstone buildup that degrades reads for days — every one of these is a real, root-caused bug from Kandra's own development against a live cluster, not a theoretical pitch.

Every failure mode on this page is documented with a root cause and a fix — not hypothetical.

One call site, two eras

This isn't a hypothetical — it's ISS-025, a real data-loss bug caught in Kandra's own development, and the fix that replaced a silent partition wipe with a loud, named, call-time exception. Full writeup →

Todo.kt — today, v0.4.6
// Todo(ownerId: PartitionKey, todoId: ClusteringKey, ...)
 
todoRepo.deleteById(ownerId, todoId) // deletes exactly one row
 
todoRepo.deleteById(ownerId)
// Fails at call time: KandraSchemaException, naming the missing
// clustering-key column — not a silent partition wipe.
Maven Central Apache 2.0 Kotlin 2.1+ ScyllaDB Apache Cassandra Ktor Native

What it actually feels like to build on Cassandra without this

Every one of these is a real failure mode, not a hypothetical — several are documented, root-caused bugs from Kandra's own development, caught against a real cluster. This isn't Cassandra taught by analogy to SQL: a distributed, partition-oriented, leaderless database needs distributed thinking, and that starts with naming the pain plainly. Read the full argument →

The business case for Kandra

Every card below is backed by a real, linked incident or a real annotation — insurance, not a features list.

Prevent costly incidents

A relational-instinct delete or a batch that only looks atomic are exactly the kind of mistake that ships clean, passes review, and pages someone in production. Both are real, root-caused bugs from Kandra's own development, not hypotheticals.

Full-key requirements on deleteById/findById (fails loudly with KandraSchemaException, not silently) and saveInBatch's distinct method name (so it can't be shadowed into a non-atomic call) are both direct fixes for issues found against a real cluster.

Onboard engineers faster

Cassandra's distributed model is a steep learning curve for anyone coming from a relational background, and that knowledge usually lives in one senior engineer's head. Kandra puts it in the entity definition instead.

@PartitionKey, @ClusteringKey, and @LookupIndex make partitioning and denormalization explicit, typed decisions on the data class itself — not tribal knowledge a new hire has to be told about before their first write path.

Reduce operational overhead

Tombstone buildup and unbudgeted LWT cost are the kind of gradual, hard-to-diagnose problems that eat on-call time without ever being one clean incident to point at.

@SoftDelete uses a TTL instead of a DELETE and warns before a bulk delete would generate real tombstone volume. @Version/saveIfNotExists (LWT) stay opt-in and visible in the entity, never a hidden default you're paying for on every write.

The API that respects the database

Three files, one running app: an entity that states its partitioning and lifecycle up front, a repository call site where atomicity is a named method, and a plugin install where the schema and connection settings live in one place.

entities.kt
import io.kandra.core.annotations.*
import java.time.Instant
import java.util.UUID
 
@ScyllaTable("users")
data class User(
    @PartitionKey val userId: UUID,
    @LookupIndex(tableSuffix = "by_email", consistency = LookupConsistency.BATCH)
    val email: String,
    val name: String,
)
 
@ScyllaTable("todos")
@SoftDelete(ttlSeconds = 604_800, markerProperty = "isDeleted") // 7 days
data class Todo(
    @PartitionKey val ownerId: UUID,
    @ClusteringKey(order = ClusteringOrder.DESC) val createdAt: Instant,
    val todoId: UUID,
    val title: String,
    val isDeleted: Boolean = false,
)

If you want SQL magic, use Spring Data. If you want raw control, use the driver directly. If you want Cassandra-native Kotlin, use Kandra.

 Raw DataStax Driver Spring Data CassandraKandra
PartitioningManual PreparedStatement + partition-key discipline, on youHidden behind @IdExplicit @PartitionKey + @ClusteringKey
Dangerous queriesALLOW FILTERING is a plain query flag — no guardrailALLOW FILTERING is one flag awayStructurally absent from the predicate DSL — one logged exception (findActive())
Cross-table writesBatchStatement is available; atomicity discipline is on youManual, non-atomic by default@LookupIndex + logged batches, atomic by construction
How it wires upManual CqlSession lifecycle, no DIClasspath scanning, dynamic proxiesKtor plugin install, DI-native, traceable

Start small. Scale with confidence.

01

Evaluate

Read /foundations and /battle-scars before writing code — the distributed-systems tradeoffs and the real bugs they came from are the actual pitch, not a features list. Drop kandra-runtime + kandra-ktor into one service; nothing else has to change.

02

Pilot

Model one bounded context's tables with real @PartitionKey/@ClusteringKey/@LookupIndex decisions. Schema and query-shape validation at install time means a bad model fails at startup in a pilot, not under load in production.

03

Scale

The annotations that caught mistakes in the pilot are the same ones in production — nothing changes shape between the two. Standardizing means one distributed-data vocabulary across every Kotlin service, not one team's tribal knowledge.

terminal
$ ./gradlew dependencies | grep kandra
+--- ke.co.coinx.kandra:kandra-runtime:0.4.6