Launch by Lunch

Databases, DevOps, and Development

Introducing pg-java, a new PostgreSQL driver for the JVM

Background

I've been working on pgjdbc, the PostgreSQL JDBC driver, for many years. It's a great driver, gets millions of monthly downloads, and it's not going anywhere.

It's also a driver whose shape was decided a very long time ago. JDBC came first and PostgreSQL came second. The JVM it was designed for had no virtual threads, no records, and no sealed types. Most of what you'd want to change about that today can't be changed in place. Doing so would break the applications that depend on the current behavior, and that's most of the Java world talking to PostgreSQL.

So I started a new one: pg-java.

It's a modern, PostgreSQL-specific driver for the JVM, and it is pre-release. But the core driver works, there's a JDBC layer on top of it, and it's been tested far more thoroughly than "pre-release" usually implies.

Why a new driver

Four things drove the design.

PostgreSQL-first. The native API is designed around PostgreSQL's wire protocol and feature set. It is not designed around the lowest common denominator that JDBC has to support across every database on earth. If PostgreSQL can do it, the API should be able to say it directly.

JDBC as a layer, not a foundation. Full JDBC compliance is a long-term goal. There's already a java.sql.* layer that registers a Driver and gives you Connection, PreparedStatement, ResultSet, DatabaseMetaData, DataSource, and XA. But it's built on top of the native API rather than dictating its shape. That ordering matters. Once JDBC's assumptions get into the execution core they never come back out.

Virtual threads for I/O. For a decade, "fast driver" implied an async or reactive API, because the alternative was a thread per connection. On Java 21 that trade is gone. pg-java is ordinary blocking-style code that is careful never to pin a carrier thread (which mostly means ReentrantLock instead of synchronized around I/O). You can run thousands of connections on virtual threads without an event loop or a callback API in sight. It's also significantly easier to reason about both how it works and how you would use it.

Streaming by default. The core query primitive is a pull cursor. It reads exactly enough off the wire to produce the next row and never buffers a whole result set. forEach, map, collect, and friends are adapters built on top of it, not a second read path.

Here's the native API:

PgConnectionConfig config = PgConnectionConfig.builder()
        .host("localhost")
        .database("appdb")
        .user("app")
        .password(secret)
        .build();

try (PgConnection connection = PgConnections.connect(config);
        PgResultStream rows = connection.execute(
                "select id, name from widget where kind = $1", List.of("gear"))) {
    while (rows.next()) {
        Row row = rows.currentRow();
        System.out.println(row.getLong(1) + " " + row.getString(2));
    }
}

Note the $1 and the 1-based column indexes. This is PostgreSQL's protocol, spelled the way PostgreSQL spells it, with no java.sql anywhere.

And here's the same thing through JDBC, which is what most people will actually use:

String url = "jdbc:pg://localhost:5432/appdb?user=app&sslmode=verify-full";
try (Connection connection = DriverManager.getConnection(url, "app", secret);
        PreparedStatement statement =
                connection.prepareStatement("select id from t where name = ?")) {
    statement.setString(1, "widget");
    try (ResultSet rows = statement.executeQuery()) {
        while (rows.next()) {
            System.out.println(rows.getLong(1));
        }
    }
}

The driver owns the jdbc:pg: scheme and also answers to jdbc:postgresql: for ported applications.

There's a third module too: a pgjdbc source-compatibility layer that exposes org.postgresql.Driver and the usual org.postgresql.* vendor types. It's there for applications that want to try the new driver without touching their code. Its target is a migration aid rather than a certified drop-in (and like everything else, still pre-release).

Things you won't find in other Java drivers

Some of what follows is just modern-rewrite table stakes. A few pieces I haven't seen anywhere else.

Batch INSERT rewritten to array parameters

Every driver hits the same wall on bulk DML: a batch of N inserts is N round trips. pgjdbc's answer is reWriteBatchedInserts, which collapses them into one multi-row VALUES statement. That works, but it has costs. The SQL text grows with the batch size. It burns N*M scalar parameters, so it has to chunk to stay under the protocol's 65535-parameter cap. And every distinct batch size is a new statement for the server to parse and plan.

pg-java can rewrite the same batch into array parameters instead:

-- your SQL, batched N times:
INSERT INTO t (a, b, c) VALUES (?, ?, ?)

-- rewritten, one execution, three array parameters of length N:
INSERT INTO t (a, b, c) SELECT * FROM unnest(?::int4[], ?::text[], ?::timestamptz[])

The SQL text is now constant regardless of the batch size. That means one prepared statement, planned once, with perfect cache reuse. It uses M parameters (one array per column) instead of N*M, so it never approaches the parameter cap and never needs chunking. And it's a single round trip and a single server-side execution. In the benchmarks it runs about 2x the non-rewritten path and lands slightly ahead of pgjdbc's best batch mode.

It's opt-in via rewriteBatchUsingArrays and, for now, INSERT-only. That restriction is deliberate. For UPDATE and DELETE you can't reconstruct per-element update counts from a single execution, which silently breaks optimistic-locking checks and anything built on them. UPDATE has a sharper problem. If two batch entries target the same row then UPDATE ... FROM unnest(...) applies one arbitrarily chosen source row instead of applying them in order. The final state of your data can differ, not just the counts. Silently changing what a batch does is not an acceptable price for making it fast.

The pgjdbc-style multi-row VALUES rewrite is implemented too, separately, so the compat layer can reproduce pgjdbc's exact behavior when an application asks for it.

A real pipelining API

The extended query protocol never required waiting for one statement's results before sending the next. libpq has had pipeline mode for years. Java drivers generally don't expose it. pg-java does:

try (PgPipeline p = connection.pipeline()) {
    PgPipelineStatement a = p.queue("insert into t (v) values ($1)", List.of(1));
    PgPipelineStatement b = p.queue("select v from t");
    p.sync();                                  // explicit error boundary
    a.results().summary();                     // results come back in queue order
    try (PgResultStream rs = b.results()) { /* ... */ }
}

sync() is the public error-boundary primitive. Each one closes a section. If the server errors inside a section, the failing handle's read throws, later handles in that same section throw a "skipped" exception carrying the first error as the cause, and later sections are unaffected. Handles are strict FIFO.

The window is byte-budgeted with explicit flow control. That part is not tuning. A pipeline that writes blindly while the server streams rows back will deadlock the moment both TCP buffers fill.

It won't hand your password to an unencrypted socket

sslmode defaults to prefer, matching libpq.

Defaulting it to verify-full sounds better in theory but works out worse in practice. It breaks every server with a self-signed or private-CA certificate, which is most PostgreSQL installations. So it would be a default that everybody has to override, and they'd override it with sslmode=disable. That's strictly worse.

So the credential is gated on encryption instead, and the transport default is left alone. When the server asks for password or md5 authentication over a TCP connection with no TLS, the driver refuses before the credential is disclosed. SCRAM is unaffected, since no password crosses the wire there. Unix sockets are exempt. sslmode itself is untouched.

The check can't live in sslmode. The server chooses the authentication method, or an active interceptor answering in its place does. The disclosure has to be judged where it happens rather than from the policy the client set at connect time.

There's an opt-out, allowUnencryptedPasswordAuth. You'll need it for pre-14 servers using password authentication, since password_encryption defaulted to md5 until then, and for PgBouncer. The pgjdbc compat layer sets it by default. A drop-in that changes your application's security behavior on a jar swap is the one thing a drop-in must never do.

There's also require_auth for accepting or refusing specific authentication methods, and channelBinding=require. Together those give a deployment real downgrade protection.

These defaults are strictly for the core PostgreSQL-specific native driver. The compatibility layer atop it applies the same defaults as pgjdbc.

The public API surface is a file that the build enforces

docs/api-surface.md enumerates every public type in the core module along with its stability tier. A test reflects over the exported packages and fails the build if a public type isn't listed, if a listed type has vanished, or if an @Experimental annotation and the table disagree.

Adding a public type is therefore a deliberate act with a paper trail. It doesn't happen just because some class needed to be visible to a sibling package. The table is executable, not decorative.

Small other stuff

  • Zero runtime dependencies in the protocol module. It compiles against an empty classpath.
  • The core module adds only the ongres SCRAM jars and the SLF4J API.
  • .pgpass support, including libpq's refusal to read a password file that's group or world readable. Reading it is opt-in via passfile or usepassfile, as a driver has no business reading files in your home directory unless you asked it to.
  • The PG* environment variables are honored natively and ignored by the compat layer, since pgjdbc never read them.
  • GraalVM native-image metadata, with a smoke test that actually builds and runs a native binary so the metadata can't silently rot.
  • Server errors surfaced with full fidelity: SQLSTATE, severity, detail, hint, position, schema, table, column, and constraint. Nothing gets flattened into a message string. Even the errors are PostgreSQL at its purest.

Performance

The benchmark harness is driver-agnostic and measures the driver, not the database. The driver under test is supplied at runtime and never bundled.

Against pgjdbc 42.7.13 on PostgreSQL 16, pg-java's native path runs at roughly 101-108% of pgjdbc's throughput across the whole workload sweep. That's select-1, select-by-id, select-rows, prepared, insert, update, returning, types, copy, and mixed. The array batch rewrite roughly doubles batch-insert throughput and edges past pgjdbc's own rewrite.

Allocation is the axis I'd still call unfinished. Some paths are at parity or better. On wide rows both the native and compat layers allocate 26% less than pgjdbc. Others, the write side in particular, allocate substantially more.

The numbers all live in docs/benchmarks/ as dated, append-only reports. Fifteen macro runs so far, including the regressions and the negative results. Run 011 measured a change that turned out to do nothing at all and it's written up anyway. The point of keeping the record is that a failed idea doesn't get re-tried three weeks later.

About that "written by AI" part

Essentially the whole driver was written by Claude, in a loop, over about six weeks. That's roughly 1,400 commits between June 19th and today: about 52,000 lines of main Java, 43,000 lines of tests, 22 architecture decision records, and 70 design documents. The designs were manually reviewed and revised, but the code is Claude's on all but a handful of commits.

I want to be precise about what that means. "AI wrote it" is doing a lot of work in that sentence and it's not the interesting part. The interesting part is the harness around it.

None of which is to say nobody was driving. What I spent six weeks doing was writing the contract and rejecting the wrong answers. AGENTS.md is twelve kilobytes of conventions, module rules, and definitions of done, and it is the single reason that a session with no memory of the previous one still lands code that fits the code already there. The ADRs exist for the same reason.

The calls that mattered most were about what not to keep. Routing the multi-row VALUES batch chunks through the general pipelining API looked like obvious reuse, but it measured 61% of pgjdbc's throughput, so it lost to a batch-only primitive built on the same underlying engine (ADR-0021). A security audit flagged the sslmode=prefer default and proposed flipping the transport to verify-full, which I overruled, because the finding was really about the credential and the credential is what should be gated (ADR-0009). Both of those are in the repo with the reasoning attached, which is the part I'd defend. The decisions are auditable rather than vibes.

There is a plan, and the plan is the unit of work. docs/plans/overall.md is a phased, numbered implementation plan where each item (C8.4, N5.7, P4) is intended to be exactly one commit. The loop picks up the next item, implements it, and ticks the checkbox in a separate commit. Anything deferred goes into docs/follow-up.md with the files, the line hints, and the reason, so that a fresh session with no memory of the conversation can pick it up later.

Decisions get written down before they're implemented. Twenty-two ADRs cover the I/O model, module boundaries, the result model, exceptions, TLS posture, secret handling, both batch rewrites, pipelining, and API stability. The ADR index carries two separate columns, Status and Built, because an accepted decision is not a claim that the code does it yet. An agent that would otherwise re-litigate async versus blocking every third session instead reads ADR-0001 and gets on with it.

The build is the fitness function. mvn verify fails on Spotless formatting, a 7-bit-ASCII policy over all source and docs, a module-layering test, a JaCoCo floor, dependency convergence, and the API surface manifest described above. None of those are things a model reliably remembers. All of them are things a build can enforce every single time.

Regression testing is most of the work. There is a full suite of unit tests that run entirely in memory, and an equally wide suite of integration tests on top of that. The unit tests need no Docker at all. There's a MockServer that lets a test drive real protocol byte sequences, so most protocol behavior is testable without a server anywhere in the picture. The integration tests run under Testcontainers across a matrix of server versions (9.1 through 18 are supported and 14 through 18 gate every change), plus dedicated harnesses for TLS, PgBouncer, authentication methods, and Unix sockets.

Baking the container startup into the test suite greatly simplifies the agentic dev loop as targeting a different PostgreSQL server version is a java system property (-Dpg.it.image=...). This allows automated testing of multiple versions in a simple loop.

And then the part I'd recommend to anybody doing this: run somebody else's test suite.

compat-suites/ runs pgjdbc's own upstream test suite, and Hibernate's, against pg-java's compat layer in Docker, and gates on committed baselines. As of the current baseline, 6,851 of pgjdbc's 7,331 test cases pass against a driver that shares no code with it.

Of the remaining tests, they're not all true failures either. The vast majority are tests that cover purely internal aspects of pgjdbc. The compatibility suite does cover some of them, but the primary focus is the public surface area of the driver and matching the behavior of pgjdbc.

A new failure fails the gate. An improvement prints a nudge to refresh the baseline so the win gets locked in. Nothing an agent writes about its own code is worth as much as several thousand tests written by people who were trying to pin down a different implementation's behavior.

So does it work? Mostly, and the failure modes are also interesting.

The loop is very good at breadth. Every codec, every metadata method, every negative TLS case. It's also good at the kind of grinding consistency that humans are bad at, which is most of what a driver's test suite is.

It is prone to declaring victory. That's why the gates and the baselines exist, and why the contributor doc has a rule that says to report honestly and state plainly when a step was skipped.

A long time coming

What's surreal about this entire development process is that both the plan and the result are what we had discussed almost eight years ago:

Date: Thu, 25 Oct 2018 10:42:13 -0400
Subject: Re: Rewriting the driver
From: Sehrope Sarkuni <sehrope@jackdb.com>

Regarding the overall idea, I'm all for a true "next gen" driver though I'd
hope that it'll be something that allows for a more PostgreSQL-specific
backend.

The JDBC layer should be atop of that, not the other way around. For
example, processing rows one by one to limit memory usage shouldn't require
obscure combos of opening transactions and setting fetch sizes.
Notifications and arbitrary response handling should be baked in from the
beginning (ex: I should be able to run "SELECT 1" or "COPY (SELECT 1) TO
STDOUT" without knowing anything in advance about the response type of the
SQL command) .

[ ... truncated ... ]

Two points on actual development of this. It's my experience that things
like don't come together from group discussion. Someone (Dave? :D) needs to
create the "core" that others can then join onto. It's no small undertaking
but it's how all projects of this size come about.

Secondly, and IMHO more importantly, there needs to be a decision from the
beginning of whether backwards compatibility is going to be a design goal.
I'd vote "no" on that. This would be a great chance to clean up the driver
properties and drop any excess baggage. As an example, there could be a
unified track for configuring SSL.

If backwards compatibility is desired, at the very least the core should be
able to stand on its own with any compatibility layer being handle in the
JDBC component.

[ ... truncated ... ]

Regards,
-- Sehrope Sarkuni
Founder & CEO | JackDB, Inc. | https://www.jackdb.com/

Read against what shipped, it's very nearly a checklist.

Processing rows one by one without the obscure combo of an open transaction and a fetch size is the pull cursor. "The core should be able to stand on its own with any compatibility layer being handled in the JDBC component" is the module split, almost word for word. The unified track for configuring SSL is sslmode. Arbitrary response handling landed too: the native API hands you a result stream without needing to know in advance what the command returns.

The one line I keep coming back to is the other one. Someone needs to create the core that others can join onto, and it's no small undertaking. That was true in 2018, and it's why the idea sat for eight years.

It turns out the answer to "who is going to write it" was a model in a loop for six weeks.

Status and what's next

Where it stands today:

  • Pre-release. Nothing is published to Maven Central yet.
  • No compatibility guarantees. The API is still moving and will keep moving for a while.
  • The compat layer is a migration aid, not a certified drop-in. Some PGConnection methods are still stubs.
  • Two accepted ADRs are unbuilt. JSON value binding and composite-to-record mapping are decided designs that nobody has written yet.

What already works:

  • Connects over TCP, TLS, and Unix domain sockets.
  • Authenticates with SCRAM-SHA-256, including channel binding.
  • Simple and extended query protocols, with a server-side statement cache.
  • COPY in and out, LISTEN/NOTIFY, and large objects.
  • Pipelining, and batches with either rewrite strategy.
  • A JDBC layer with DataSource and XA sitting on top of all of it.
  • Passes the vast majority of the pgjdbc test suite

To build it you need Java 21, plus Docker if you want to run the integration tests:

git clone https://github.com/pgjdbc/pg-java.git
cd pg-java
./mvnw clean install
./mvnw verify -Pintegration-tests   # needs a Docker daemon

It's released under the PostgreSQL License.

Final Thoughts

If you try it, I want to hear what breaks. That goes double for anyone with an application weird enough to have found the corners of pgjdbc, because those corners are exactly what a new driver gets wrong.

Open an issue on GitHub or email me. If you think you've found a security issue, please use GitHub's private vulnerability reporting instead of a public issue.

Getting the driver this far has been the most interesting open source project I've worked on in years. The next part, where other people start using it and telling me where it's wrong, is the part that actually makes it a driver.