
Offers a high-performance, non-blocking database driver supporting PostgreSQL, MySQL, and SQLite. Enables asynchronous I/O, customizable connection pooling, and auto-generated CRUD operations.
A coroutine-first SQL toolkit with compile-time query validations for Kotlin Multiplatform. PostgreSQL, MySQL/MariaDB, and SQLite are supported.
sqlx4k is not an ORM. Instead, it provides a comprehensive toolkit of primitives and utilities to communicate directly with your database. The focus is on giving you control while catching errors early through compile-time query validation—preventing runtime surprises before they happen (see Validation for more details).
The library is designed to be extensible, with a growing ecosystem of tools and extensions like PGMQ (PostgreSQL Message Queue), SQLDelight integration, and more.
📖 Documentation · 📚 API reference · 🤖 Agent skill
Short deep‑dive posts covering Kotlin/Native, FFI, and Rust ↔ Kotlin interop used in sqlx4k:
The Gradle plugin adds the driver and sets up the code generation for you:
plugins {
kotlin("multiplatform") // or kotlin("jvm")
id("io.github.smyrgeorge.sqlx4k") version "1.17.0"
}
sqlx4k {
driver = PostgreSQL // also: MySQL, MariaDB, SQLite, SQLiteCipher
generatedCodePackage = "com.example.generated"
}Or add the driver yourself:
[!NOTE] The driver alone does not set up the code generation (
@Table,@Repository, compile-time validation): use the plugin, or configure KSP yourself as described in Manual setup.
implementation("io.github.smyrgeorge:sqlx4k-postgres:1.17.0")
// or for MySQL
implementation("io.github.smyrgeorge:sqlx4k-mysql:1.17.0")
// or for SQLite
implementation("io.github.smyrgeorge:sqlx4k-sqlite:1.17.0")
// or for SQLite with encryption (SQLCipher)
implementation("io.github.smyrgeorge:sqlx4k-sqlite-cipher:1.17.0")[!TIP] Using an AI coding agent (Claude Code, Codex, Cursor, Copilot, …)? Install the sqlx4k skill, so it sets up the project and writes the code the recommended way:
npx skills add smyrgeorge/sqlx4kSee AI agents.
Map a data class to a table with @Table:
@Table("users")
data class User(
@Id
val id: Long = 0, // Generated by the database, and read back on insert.
val name: String,
@Column(name = "mail_address")
val email: String,
)Declare a @Repository: the code generator writes its implementation, UserRepositoryImpl, with your @Query
methods and the CRUD operations:
insert: inserts the entity and returns it as stored, with the generated columns (e.g. the id) read backupdate: updates the row matched by the @Id and returns the entity as storeddelete: deletes the row matched by the @Id
save: inserts a new entity (id 0) or updates an existing onebatchInsert and batchUpdate: insert or update many entities with a single statement@Repository
interface UserRepository : ContextCrudRepository<User> {
@Query("SELECT * FROM users WHERE mail_address = :email")
context(context: QueryExecutor)
suspend fun findOneByEmail(email: String): Result<User?>
@Query("SELECT * FROM users WHERE name = :name")
context(context: QueryExecutor)
suspend fun findAllByName(name: String): Result<List<User>>
@Query("SELECT count(*) FROM users")
context(context: QueryExecutor)
suspend fun countAll(): Result<Long>
}[!TIP] Using Arrow? Extend
ArrowContextCrudRepository(orArrowCrudRepository) fromsqlx4k-arrowinstead, and the generated methods returnEither<SQLError, T>rather thanResult<T>. See Repository interfaces.
The methods take the QueryExecutor (the database, a connection or a transaction) from the scope, as a
context parameter:
val db = PostgreSQL(url = "postgresql://localhost:15432/test", username = "postgres", password = "postgres")
// The transaction returns the value of its block: here, the updated user.
val updated: User = db.transaction {
// Every call in the block runs in the transaction.
// The entities are immutable: every call returns a new instance, with the values the database stored.
val user = User(name = "Alice", email = "alice@example.com") // id = 0
val alice = UserRepositoryImpl.insert(user).getOrThrow() // id generated by the database
val renamed = alice.copy(name = "Alice Smith")
UserRepositoryImpl.update(renamed).getOrThrow()
}
with(db) {
val alice: User? = UserRepositoryImpl.findOneByEmail("alice@example.com").getOrThrow()
}See Repositories, Entities and Context parameters for the details.
Every @Query is checked while the code is generated, so a broken query fails the build instead of failing at
runtime:
@Query("SELECT * FROM users WHERE emial = :email") // Fails the build: `emial` is not a column of `User`.
context(context: QueryExecutor)
suspend fun findOneByEmail(email: String): Result<User?>See Query validations for the full list.
The generated SQL is optimized while the code is generated, without changing its result. For instance,
findOneByEmail from the repository above runs as:
-- SELECT * is expanded into the columns of the entity, and LIMIT 2 is enough to tell one row from many.
SELECT id, name, mail_address FROM users WHERE mail_address = :email LIMIT 2See Optimizations for the full list.
Extend CrudRepository instead, and pass the QueryExecutor as the first argument of every call:
@Repository
interface UserRepository : CrudRepository<User> {
@Query("SELECT * FROM users WHERE mail_address = :email")
suspend fun findOneByEmail(context: QueryExecutor, email: String): Result<User?>
}
val updated: User = db.transaction {
// save inserts a new entity (id = 0) and updates an existing one.
val user = User(name = "Alice", email = "alice@example.com")
val alice = UserRepositoryImpl.save(this, user).getOrThrow() // insert
UserRepositoryImpl.save(this, alice.copy(name = "Alice Smith")).getOrThrow() // update
}
val alice: User? = UserRepositoryImpl.findOneByEmail(db, "alice@example.com").getOrThrow()The code generation is optional: the driver runs your SQL as it is, with the values bound as the parameters of a prepared statement.
[!NOTE] The compile-time validations and optimizations above apply to the
@Querymethods of@Repositoryinterfaces only: SQL passed straight to the driver runs as written.
// execute returns the number of affected rows.
val affected: Long = db.execute("delete from users where name = 'Bob'").getOrThrow()
// Bind the values instead of concatenating them into the SQL (named :name or positional ? parameters).
val statement = Statement
.create("select * from users where name = :name")
.bind("name", "Alice")
// fetchAll returns all the rows: read their columns yourself...
val emails: List<String> = db.fetchAll(statement).getOrThrow().map { it.get("mail_address").asString() }
// ...or with a RowMapper.
object UserRowMapper : RowMapper<User> {
override fun map(row: ResultSet.Row, converters: ValueEncoderRegistry): User = User(
id = row.get("id").asLong(),
name = row.get("name").asString(),
email = row.get("mail_address").asString(),
)
}
val users: List<User> = db.fetchAll(statement, UserRowMapper).getOrThrow()
// fetch streams the rows as a Flow, without loading them all in memory.
db.fetch("select * from users", UserRowMapper).collect { println(it) }
// The transaction is committed at the end of the block, and rolled back if the block fails.
db.transaction {
execute("insert into users(name, mail_address) values ('Bob', 'bob@example.com')").getOrThrow()
execute("update users set name = 'Robert' where name = 'Bob'").getOrThrow()
}See Running queries, Mapping rows and Transactions for the details.
Full runnable applications, built with the Gradle plugin:
See Building for how to build the project (it needs the Rust toolchain), and the Roadmap for what is coming next; contributions are welcome.
The documentation lives in docs and is published with Zensical on every release.
sqlx4k stands on the shoulders of excellent open-source projects, such as:
See the full list.
MIT — see LICENSE.
A coroutine-first SQL toolkit with compile-time query validations for Kotlin Multiplatform. PostgreSQL, MySQL/MariaDB, and SQLite are supported.
sqlx4k is not an ORM. Instead, it provides a comprehensive toolkit of primitives and utilities to communicate directly with your database. The focus is on giving you control while catching errors early through compile-time query validation—preventing runtime surprises before they happen (see Validation for more details).
The library is designed to be extensible, with a growing ecosystem of tools and extensions like PGMQ (PostgreSQL Message Queue), SQLDelight integration, and more.
📖 Documentation · 📚 API reference · 🤖 Agent skill
Short deep‑dive posts covering Kotlin/Native, FFI, and Rust ↔ Kotlin interop used in sqlx4k:
The Gradle plugin adds the driver and sets up the code generation for you:
plugins {
kotlin("multiplatform") // or kotlin("jvm")
id("io.github.smyrgeorge.sqlx4k") version "1.17.0"
}
sqlx4k {
driver = PostgreSQL // also: MySQL, MariaDB, SQLite, SQLiteCipher
generatedCodePackage = "com.example.generated"
}Or add the driver yourself:
[!NOTE] The driver alone does not set up the code generation (
@Table,@Repository, compile-time validation): use the plugin, or configure KSP yourself as described in Manual setup.
implementation("io.github.smyrgeorge:sqlx4k-postgres:1.17.0")
// or for MySQL
implementation("io.github.smyrgeorge:sqlx4k-mysql:1.17.0")
// or for SQLite
implementation("io.github.smyrgeorge:sqlx4k-sqlite:1.17.0")
// or for SQLite with encryption (SQLCipher)
implementation("io.github.smyrgeorge:sqlx4k-sqlite-cipher:1.17.0")[!TIP] Using an AI coding agent (Claude Code, Codex, Cursor, Copilot, …)? Install the sqlx4k skill, so it sets up the project and writes the code the recommended way:
npx skills add smyrgeorge/sqlx4kSee AI agents.
Map a data class to a table with @Table:
@Table("users")
data class User(
@Id
val id: Long = 0, // Generated by the database, and read back on insert.
val name: String,
@Column(name = "mail_address")
val email: String,
)Declare a @Repository: the code generator writes its implementation, UserRepositoryImpl, with your @Query
methods and the CRUD operations:
insert: inserts the entity and returns it as stored, with the generated columns (e.g. the id) read backupdate: updates the row matched by the @Id and returns the entity as storeddelete: deletes the row matched by the @Id
save: inserts a new entity (id 0) or updates an existing onebatchInsert and batchUpdate: insert or update many entities with a single statement@Repository
interface UserRepository : ContextCrudRepository<User> {
@Query("SELECT * FROM users WHERE mail_address = :email")
context(context: QueryExecutor)
suspend fun findOneByEmail(email: String): Result<User?>
@Query("SELECT * FROM users WHERE name = :name")
context(context: QueryExecutor)
suspend fun findAllByName(name: String): Result<List<User>>
@Query("SELECT count(*) FROM users")
context(context: QueryExecutor)
suspend fun countAll(): Result<Long>
}[!TIP] Using Arrow? Extend
ArrowContextCrudRepository(orArrowCrudRepository) fromsqlx4k-arrowinstead, and the generated methods returnEither<SQLError, T>rather thanResult<T>. See Repository interfaces.
The methods take the QueryExecutor (the database, a connection or a transaction) from the scope, as a
context parameter:
val db = PostgreSQL(url = "postgresql://localhost:15432/test", username = "postgres", password = "postgres")
// The transaction returns the value of its block: here, the updated user.
val updated: User = db.transaction {
// Every call in the block runs in the transaction.
// The entities are immutable: every call returns a new instance, with the values the database stored.
val user = User(name = "Alice", email = "alice@example.com") // id = 0
val alice = UserRepositoryImpl.insert(user).getOrThrow() // id generated by the database
val renamed = alice.copy(name = "Alice Smith")
UserRepositoryImpl.update(renamed).getOrThrow()
}
with(db) {
val alice: User? = UserRepositoryImpl.findOneByEmail("alice@example.com").getOrThrow()
}See Repositories, Entities and Context parameters for the details.
Every @Query is checked while the code is generated, so a broken query fails the build instead of failing at
runtime:
@Query("SELECT * FROM users WHERE emial = :email") // Fails the build: `emial` is not a column of `User`.
context(context: QueryExecutor)
suspend fun findOneByEmail(email: String): Result<User?>See Query validations for the full list.
The generated SQL is optimized while the code is generated, without changing its result. For instance,
findOneByEmail from the repository above runs as:
-- SELECT * is expanded into the columns of the entity, and LIMIT 2 is enough to tell one row from many.
SELECT id, name, mail_address FROM users WHERE mail_address = :email LIMIT 2See Optimizations for the full list.
Extend CrudRepository instead, and pass the QueryExecutor as the first argument of every call:
@Repository
interface UserRepository : CrudRepository<User> {
@Query("SELECT * FROM users WHERE mail_address = :email")
suspend fun findOneByEmail(context: QueryExecutor, email: String): Result<User?>
}
val updated: User = db.transaction {
// save inserts a new entity (id = 0) and updates an existing one.
val user = User(name = "Alice", email = "alice@example.com")
val alice = UserRepositoryImpl.save(this, user).getOrThrow() // insert
UserRepositoryImpl.save(this, alice.copy(name = "Alice Smith")).getOrThrow() // update
}
val alice: User? = UserRepositoryImpl.findOneByEmail(db, "alice@example.com").getOrThrow()The code generation is optional: the driver runs your SQL as it is, with the values bound as the parameters of a prepared statement.
[!NOTE] The compile-time validations and optimizations above apply to the
@Querymethods of@Repositoryinterfaces only: SQL passed straight to the driver runs as written.
// execute returns the number of affected rows.
val affected: Long = db.execute("delete from users where name = 'Bob'").getOrThrow()
// Bind the values instead of concatenating them into the SQL (named :name or positional ? parameters).
val statement = Statement
.create("select * from users where name = :name")
.bind("name", "Alice")
// fetchAll returns all the rows: read their columns yourself...
val emails: List<String> = db.fetchAll(statement).getOrThrow().map { it.get("mail_address").asString() }
// ...or with a RowMapper.
object UserRowMapper : RowMapper<User> {
override fun map(row: ResultSet.Row, converters: ValueEncoderRegistry): User = User(
id = row.get("id").asLong(),
name = row.get("name").asString(),
email = row.get("mail_address").asString(),
)
}
val users: List<User> = db.fetchAll(statement, UserRowMapper).getOrThrow()
// fetch streams the rows as a Flow, without loading them all in memory.
db.fetch("select * from users", UserRowMapper).collect { println(it) }
// The transaction is committed at the end of the block, and rolled back if the block fails.
db.transaction {
execute("insert into users(name, mail_address) values ('Bob', 'bob@example.com')").getOrThrow()
execute("update users set name = 'Robert' where name = 'Bob'").getOrThrow()
}See Running queries, Mapping rows and Transactions for the details.
Full runnable applications, built with the Gradle plugin:
See Building for how to build the project (it needs the Rust toolchain), and the Roadmap for what is coming next; contributions are welcome.
The documentation lives in docs and is published with Zensical on every release.
sqlx4k stands on the shoulders of excellent open-source projects, such as:
See the full list.
MIT — see LICENSE.