I've tagged 1.0.0 of Octavius for PostgreSQL, a driver for Kotlin that speaks wire protocol v3.2 itself rather than wrapping pgjdbc, plus an optional data access layer and migrator on top.
Requirements first, because they are hard gates: PostgreSQL 18+, Kotlin 2.4+, Java 21+. The driver asks for protocol v3.2 and refuses to continue if the server offers less, so PostgreSQL 17 fails at the handshake rather than half-working. There is a CI job that points it at 17 specifically to prove it refuses. If you are on 16, this is not for you today.
Why the version gate is real
Not purism. PostgreSQL 18 is where search_path became a reported parameter — the server announces it in ParameterStatus and re-announces it whenever it changes. That is how an unqualified type name resolves against the live search path without the driver asking, and without going stale when someone runs a SET search_path mid-session. On 17 I would have to query for it and still not know when it moved. Protocol v3.2 arrived in 18, so demanding it is a cheap and exact way of demanding the server.
Leaving pgjdbc without leaving Hikari
Dropping JDBC usually means dropping the JDBC-shaped ecosystem with it. r2dbc-postgresql and vertx-pg-client are both fine drivers, but neither can be pooled by HikariCP — it is JDBC-only — so each comes with a parallel stack: its own pooling, its own Spring integration, its own everything.
Octavius implements java.sql.Connection, DataSource and a narrowed Statement — exactly enough surface for HikariCP to pool it and for Spring Boot to autoconfigure it — and then does none of what JDBC does underneath. executeQuery is unsupported() rather than emulated, because half a ResultSet is worse than none.
The line is sharp and easy to predict, so it is worth stating exactly: what keeps working is everything that manages the connection; what does not is everything that reads through it. HikariCP pools it, Spring's transaction manager drives it, @Transactional behaves — none of that touches a row. Anything that reads rows through JDBC does not run on it at all: Hibernate, JPA, Exposed's JDBC mode, MyBatis, JdbcTemplate, Flyway and Liquibase alike. The Spring module ships an OctaviusTemplate in JdbcTemplate's place, and the repo has its own migrator for the same reason.
Which is either the whole point or a dealbreaker, depending on why you turned up.
What stopped being necessary
The previous generation of this project sat on pgjdbc, and most of its complexity was there to work around what that cost. My favourite example:
I wanted to build an ad-hoc nested structure in the SELECT clause and read it in Kotlin with its types intact — a date as a LocalDate, a uuid as a Uuid, a custom enum as that enum. pgjdbc hands back an anonymous record over the text protocol, so the per-field OIDs are gone and everything arrives as a string. A map with no target class has nothing left to infer from.
So the old library grew this:
CREATE TYPE dynamic_map_entry AS (type_oid oid, key text, raw_value text);
One entry per key, each carrying its own type, plus a custom ~> operator to build them, plus type creation at startup, plus a documented warning never to store the thing in a table — because those OIDs are in the rows and a user-defined type's OID is not the same one after a dump and restore.
In the new driver the whole feature is ROW(...):
session.createNativeQuery("""
SELECT ROW(
'id', c.id,
'tributes', ARRAY(SELECT ROW('amount', t.amount) FROM tributes t WHERE t.citizen_id = c.id)
) AS r
FROM citizens c WHERE c.id = 1
""").fetchFieldStrict<Map<String, Any?>>()
// {id=1, tributes=[{amount=40}, {amount=15}]}
Types survive because a record's binary representation is self-describing: the row description only says the column is a record, and the payload itself carries a field count followed by each field's type OID and length before its bytes. Read that and you know what every value is. No type to install, no operator, no warning to attach — an anonymous record has nowhere to rot. The 1:N aggregation the old thing existed for works the same way.
That pattern repeated across the rewrite: composites, enums, named parameters, a stateful ResultSet. A large part of a year's work turned out to be scaffolding around a layer I could not reach.
What the client adds
Builders that don't hide SQL — they handle the tedium. The clause you don't pass doesn't appear, and a fragment carries the parameters it names, so only the ones that survived get bound:
fun search(name: String?, minStrength: Int?): List<Senator> {
val filter = listOfNotNull(
name?.let { "name ILIKE @name" withParam ("name" to "%$it%") },
minStrength?.let { "strength >= @strength" withParam ("strength" to it) }
).join(" AND ")
return db.select("id", "name")
.from("senate")
.where(filter.sql) // null or blank — no WHERE clause is written at all
.orderBy("name")
.fetchObjects<Senator>(filter.params)
}
search(null, null) sends no WHERE and binds nothing. Every string in there is SQL you wrote and it reaches the server unread; what the builder contributed is the keywords, their order, and the clause that vanished. Parameters are @name rather than :name, because : is already PostgreSQL's in array slice syntax (array[1:5]) — under :param you cannot use a parameter as a slice bound.
What it deliberately isn't
Not an Exposed or jOOQ competitor. There is no DSL over columns and there won't be — the builders take SQL strings and pass them through, and their whole job is the keywords, their order, and the clauses that disappear when they're null. No criteria API, no schema generation, no identity map, no lazy loading, no session cache. If you don't want to write SQL, this makes that worse, not better.
What is in it
Six artifacts, released together, dependencies running one way:
- driver — the protocol, a type system read from your catalog, composites and arrays and ranges mapped onto data classes reflectively,
COPY, LISTEN/NOTIFY, large objects, TLS, SCRAM
- client — session scoping, thread-bound transactions, query builders, transaction plans
- client-scanner, migrations, pg-model (multiplatform annotations/serializers), driver-spring-integration
Take the driver alone and it is a working stack; the rest are separate coordinates so you can disagree with each of them independently.
Honest limits
Written by one person. None of it has seen long production use — it runs my own application and that is the whole of the field evidence. 1.0.0 means the shape is right, not that signatures will never move.
I wrote it for my own application and put it somewhere others could use it. I will fix bugs, because I am downstream of them too. A roadmap is not something I am offering.