pg.zig: a native PostgreSQL driver for Zig with a connection pool
Native PostgreSQL driver / client for Zig
At a glance
- What is it?
- pg.zig is a PostgreSQL client written in Zig, written against Zig 0.17.0, that covers the extended query protocol, a sized connection pool with a URI constructor, LISTEN and NOTIFY, and explicit lifetime rules on row data. It is MIT licensed and ships with an example and a test suite that runs against a real server.
- Who is it for?
- Adopt pg.zig if you are writing a Zig service that talks to PostgreSQL and want a native driver with a real connection pool, prepared statements with parameters, and LISTEN support without leaving the language. Skip it if your project is not on Zig 0.17.0, since the README says to use the branches for other versions, and skip it if you need a mature ecosystem of extensions around the driver.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly Zig, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Written for one Zig version, with branches for the others
pg.zig is a native PostgreSQL driver and client for Zig, and it supports LISTEN and NOTIFY. The version constraint is stated plainly at the top of the README: this is for Zig 0.17.0, and you should see the branches for other versions. That is the single most important operational fact about the project, because Zig's standard library and build system change shape between releases, and a driver written against one version does not simply compile against the next. The branch strategy is the standard answer to that problem, but it means that picking a driver version is also picking a Zig version, and that a project pinned to an older Zig needs to find the matching branch rather than the default one. The repository layout reflects a small, focused library. There is a src directory, a build.zig and a build.zig.zon for the Zig build, an example directory with its own build files and a main.zig, a tests directory, a test_runner.zig, a Makefile and a readme. The example is explicitly pointed at by the README as the place to see a number of examples, which is the fastest way to understand the API shape.
Adding it as a dependency takes two edits
Installation is the standard Zig package flow, and it happens in two places. First you add pg.zig as a dependency in your build.zig.zon.
zig fetch --save git+https://github.com/karlseguin/pg.zig#masterThen, in your build.zig, you add the pg module as a dependency of your program.
const pg_module = b.dependency("pg", .{}).module("pg");
// the executable from your executable/library
const exe = b.addExecutable(.{
.name = "example",
...
.imports = &.{
.{ .name = "pg", .module = pg_module },
},
});The structure of that snippet is worth noting because it is the part that trips people up with Zig builds. The dependency is fetched as a package, then a module is requested from it by name, then that module is registered in the executable's imports under a name, and only then can your source import it. The name in the imports list is the name your code uses, so the module name and the import name do not have to match, though here they are both pg. Note also that the fetch command points at the master branch rather than a tag, which for a driver that tracks a specific Zig version is a choice you may want to change in your own build.zig.zon.
A pool with a size, a timeout and an eager connection count
The connection pool is the main entry point, and it is where the design decisions are visible. The pool holds up to size database connections. The acquire method retrieves a connection, and when no idle connection is available and the pool holds fewer than size connections, acquire opens a new one. A connection released in an invalid state, for example after an error mid-query, is closed rather than reused, and a new one is opened the next time one is needed, which is the behaviour you want since a half-finished transaction is not a connection you want back in rotation. The options are size, the maximum number of connections, defaulting to 10, a timeout in milliseconds for how long acquire waits for a connection to become available, and connect_on_init_count, the number of connections to open eagerly during init, defaulting to null, which means all of them. Setting connect_on_init_count to 0 prevents init from failing except in extreme cases such as an out of memory error, but that hides any configuration or connection problem until the first query runs. There is also an initUri variant that takes a std.Uri and sets the auth and connect fields automatically, so you must not set them yourself when using it.
Rows are only valid until the next call, and the API says so
The example query shows the shape of the read API, and the shape has one rule that matters more than anything else in the library.
var pool = try pg.Pool.init(io, allocator, .{
.size = 5,
.connect = .{
.port = 5432,
.host = "127.0.0.1",
},
.auth = .{
.username = "postgres",
.database = "postgres",
.password = "postgres",
.timeout = 10_000,
}
});
defer pool.deinit();
var result = try pool.query("select id, name from users where power > $1", .{9000});
defer result.deinit();
while (try result.next()) |row| {
const id = try row.get(i32, 0);
// this is only valid until the next call to next(), deinit() or drain()
const name = try row.get([]u8, 1);
}Rows are obtained with a while loop over result.next(), and values are read with row.get taking a Zig type and a column index, so the type is checked against what you expect. The comment in the example is the part to internalise, because a value obtained with row.get is only valid until the next call to next(), deinit() or drain(), which means the row buffer is reused. If you need a value to outlive the iteration, you copy it, and the library does not hide that constraint. The query itself takes a SQL string with a dollar-quoted parameter placeholder and a tuple of parameter values, so parameters are bound rather than interpolated, and the type of each parameter is inferred from the Zig value you pass.
pool.query and pool.row release the connection for you, conn.query does not
There is an asymmetry in the API that has to be understood or you will leak connections. The pool offers convenience wrappers around the connection's exec, query, queryOpts, row and rowOpts methods, and for those wrappers the connection lifecycle is handled. pool.exec acquires, executes and releases. pool.query and poolOpts acquire and execute the query, and the connection is automatically returned to the pool when result.deinit() is called, and the README flags this as a special behaviour of pool.query. The same applies to pool.row and pool.rowOpts, where the connection is returned when row.deinit() is called. When the result comes explicitly from conn.query or conn.row, deinit does not release the connection back to the pool, because the connection was never taken from the pool in the first place. The rule that follows is simple. Use the pool wrappers for one-off statements and let deinit do the work, and use acquire with an explicit release when you want to run several statements on the same connection.
const conn = try pool.acquire();
defer pool.release(conn);
_ = try conn.exec("...", .{...});acquire returns an error.Timeout if the connection cannot be acquired, which happens when all size connections are in use, using the timeout passed to init.
Buffer sizes, keepalive and the LISTEN connection
The connection options include several knobs that only matter once you are under load, and the README documents each of them with a default. The write buffer defaults to 2048 and cannot be less than 128, and it is the buffer used when sending messages to the server, which will temporarily allocate more space as needed, so raising it can improve performance slightly if you write large SQL or have large parameters such as long text values. The read buffer defaults to 4096 and the README says that given most apps read rows of data, this can have a large impact on performance. The result state size defaults to 32, and it decides whether a Result's metadata about the data, such as the type of each column, is held in a static container or requires a dynamic allocation, so queries with more columns than the limit pay an allocation. The keepalive family is the most operationally interesting, since keepalive enables SO_KEEPALIVE on the socket, is on by default, and is ignored for unix socket connections, and the README recommends it precisely so that idle pooled connections silently dropped by a NAT, conntrack or load balancer are detected by the kernel rather than only surfacing on the next query. The three timer options, keepalive_idle at 30 seconds, keepalive_interval at 10 seconds and keepalive_count at 3 unanswered probes, are best effort and silently ignored on platforms that do not support them. For notifications, newListener returns a Listener and creates a new connection rather than taking one from the pool, which is a convenience for code that already has a pool configured and wants the same connection and authentication settings.
The test suite runs against a real server
The Makefile shows how the project tests itself, and the answer is a real PostgreSQL server in Docker rather than a mock. The t target runs the test suite through the Zig build with a reference trace, and accepts a TEST_FILTER variable so a single test can be run. The d target starts the test server, but it depends on a tests/server.key target that exists to refuse to start the container before the certificates are generated, because otherwise Docker creates directories at the mount points and the container has to be recreated after running make ssl. The ssl target generates the certificates with openssl, a self signed server key and certificate for localhost plus a client certificate signed by that root, and it does the server and the client separately with two openssl invocations. The certificate list in the repository indicates that the test suite exercises TLS connections and client authentication rather than only plaintext, which is the difference between a driver that works in a development setup and one that works against a managed database. The repository has no GitHub releases, the last push was on 2026-10-14, and the project is MIT licensed, so the driver can be vendored into a commercial product without licensing friction.
Editorial conclusion
Adopt pg.zig if you are writing a Zig service that talks to PostgreSQL and want a native driver with a real connection pool, prepared statements with parameters, and LISTEN support without leaving the language. Skip it if your project is not on Zig 0.17.0, since the README says to use the branches for other versions, and skip it if you need a mature ecosystem of extensions around the driver. Verify first that your Zig version matches, that you read the row lifetime rule before holding values across iterations, and that your test environment can run the server the test suite expects.
Frequently asked questions
Which version of Zig does pg.zig target?
The README states the driver is for Zig 0.17.0 and points to branches for other versions. The driver tracks a specific Zig release because the standard library and build system change between Zig versions.
How do I add pg.zig to a Zig project?
Run zig fetch --save git+https://github.com/karlseguin/pg.zig#master to add it to build.zig.zon, then in build.zig take the pg module from the dependency with b.dependency("pg", .{}).module("pg") and register it in the executable's imports under the name pg.
How long is a row value valid in pg.zig?
A value read with row.get is only valid until the next call to next(), deinit() or drain(), because the row buffer is reused. Copy anything you need to keep beyond the current iteration.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/karlseguin-pg-zig)Was this useful?