Create PostgreSQL extensions using Zig.
See the code
pgzx is a library for developing PostgreSQL extensions written in Zig. It provides utilities (error handling, memory allocators, wrappers) and a development environment that simplify integrating with the Postgres code base.
Zig is a small, simple language that aims to be a "modern C" with safe memory management, compile-time execution (comptime), and a rich standard library. It speaks the C ABI, works with C pointers and types directly, and can import and translate C headers — so a Zig extension can do anything a C extension can, with a modern language on top.
In practice you still need to understand a lot of Postgres internals, and Postgres leans on macros that cannot always be translated automatically. pgzx provides the Zig modules for those cases.
The following sample extensions (ordered from simple to complex) show how to use pgzx:
| Extension | Description |
|---|---|
| char_count_zig | Adds a function that counts how many times a particular character shows up in a string. Shows how to register a function and how to interpret the parameters. |
| pghostname_zig | Adds a function that returns the database server's host name. |
| sqlfns | The ways to export a Zig function to SQL: a raw C export, PG_FUNCTION_V1, and PG_EXPORT for a whole struct or file. |
| guc | Registers custom GUCs (bool, int, string, enum) in _PG_init, including a check hook that clamps a value, and reads them back into SQL. |
| bgworker | Registers a static background worker (pgzx.bgworker.register) that the postmaster launches on the next server start. |
| pg_audit_zig | Inspired by the pgaudit C extension, this one registers callbacks to multiple hooks and uses more advanced error handling and memory allocation patterns. |
| srf | Port of pgrx-examples/srf. Set-returning functions with pgzx.srf.SetOf (RETURNS SETOF) and pgzx.srf.Table (RETURNS TABLE), driven by a plain Zig iterator. |
| arrays | Port of pgrx-examples/arrays. Postgres arrays as Zig slices ([]const i32, []const ?i32, text[]), plus parameter defaults, VARIADIC and pgzx.IntList. |
| spi_sql | Uses SPI to run SQL statements; the exported functions double as the regression-test fixture. |
| spi | Port of pgrx-examples/spi. Queries with arguments, a prepared plan kept for the session, cursors fetched in batches, subtransactions that skip failing rows, and SECURITY DEFINER. |
| rational | A rational base type with btree and hash operator classes. Shows the type system end to end: pg_type, operators, pg_opclass, casts and how they make indexes and GROUP BY work. |
| file_io | Backend-safe file I/O through the virtual file descriptor layer (pgzx.fd), plus asynchronous VFD reads on PostgreSQL 18 (pgzx.aio). |
| compress_zig | A Timescale-style columnar compression layer: SPI reads a heap table, encodes it into type-aware batches (plain, rle, delta, delta2, gorilla, dictionary, bit-packed bool) in a sidecar relation, and decodes them back. |
The reference documentation is available at here. The examples above are the best place to start; the sections below walk through the most important utilities.
This project uses Nix flakes to manage build dependencies and provide a development shell. A template bootstraps a new extension:
$ mkdir my_extension
$ cd my_extension
$ nix flake init -t github:xataio/pgzx
This creates a working extension named my_extension exporting a hello() function. Its README explains how to enter the shell, build, and test it. Rename the project by updating the files in extension/ and replacing my_extension in README.md, build.zig, build.zig.zon, and the extension SQL file.
The development shell sets the environment used by the project (see devshell.nix): PRJ_ROOT is the project folder, and PG_HOME is the Postgres install prefix (the shell relocates Postgres into ./out and points ./out/default at the active version). For a complete local setup guide see HACKING.md.
Postgres error reporting functions provide log levels, formatting, and errors that can be thrown and caught like exceptions. pgzx wraps them for Zig.
Simple logging uses Log, Info, Notice or Warning:
elog.Info(@src(), "input_text: {s}\n", .{input_text});
The @src() built-in records the file location in the error report.
To report errors, use Error (returns a Zig error) or ErrorThrow (throws a Postgres error report):
if (target_char.len > 1) {
return elog.Error(@src(), "Target char is more than one byte", .{});
}
The module also exposes the C-style API (ereport, errcode, errmsg, ...).
Postgres handles errors with longjmp, which can skip Zig defer/errdefer cleanup. pgzx provides a Zig alternative to PG_TRY:
var errctx = pgzx.err.Context.init();
defer errctx.deinit();
if (errctx.pg_try()) {
// Zig code that calls Postgres C functions.
} else {
return errctx.errorValue();
}
This catches errors raised by Postgres functions and returns them as Zig errors, so all defer/errdefer in the callers run. The wrap helper packages this pattern:
try pgzx.err.wrap(myFunction, .{arg1, arg2});
See pgzx.err.Context for details.
Postgres uses a memory context system: allocations belong to a context, and freeing a context frees everything in it at once. Contexts are hierarchical, so a child context is freed with its parent.
pgzx wraps contexts as Zig allocators. createAllocSetContext returns a MemoryContextAllocator:
var memctx = try pgzx.mem.createAllocSetContext("zig_context", .{ .parent = pg.CurrentMemoryContext });
const allocator = memctx.allocator();
pg.CurrentMemoryContext is the context of the running query, so memory allocated with allocator is freed when the query finishes. You can also register a callback for when a context is reset or deleted, to release resources tied to it:
try memctx.registerAllocResetCallback(
queryDesc.*.estate.*.es_query_cxt,
pgaudit_zig_MemoryContextCallback,
);
Register Zig functions so they can be called from SQL with PG_FUNCTION_V1:
comptime {
pgzx.PG_FUNCTION_V1("my_function", myFunction);
}
Parameters are received from Postgres serialized, and pgzx deserializes them into Zig types automatically.
SETOF and TABLE functions return a pgzx.srf.SetOf(I) or pgzx.srf.Table(I) wrapping an iterator I: any struct with a next(self: *I) method that yields the next row, or null when the set is exhausted. next may also return !?Item to raise a Zig error as a Postgres error.
const Series = struct {
cur: i64,
finish: i64,
step: i64,
pub fn next(self: *Series) ?i64 {
if (self.cur > self.finish) return null;
defer self.cur += self.step;
return self.cur;
}
};
pub fn srf_series(start: i64, finish: i64, step: i64) pgzx.srf.SetOf(Series) {
return .{ .iter = .{ .cur = start, .finish = finish, .step = step } };
}
For Table(I) the item is a struct with one field per column; the field names and the pgzx.datum converters (see SQL schema generation) produce the RETURNS TABLE (...) clause in generated DDL. Use SetOf for a single unnamed column.
Postgres uses the value-per-call protocol: it calls the function once per row. The function body runs only on the first call, in a memory context that lives for the whole set, and the iterator is moved there; later calls run only next. Allocate everything the iterator needs while building it with mem.PGCurrentContextAllocator — next runs in a short-lived per-call context, so memory it allocates is only good for the row it returns. An iterator type may also define fn deinit(self: *I) void, which runs when the set ends or the query stops early (a LIMIT, an error, a cancelled query); it must not allocate or raise errors.
See examples/srf for a complete set of fixtures, including nullable columns and iterators that fail.
Instead of hand-writing the versioned extension script (<name>--<version>.sql), you can describe your SQL objects in a comptime declaration and let zig build render it. By convention the declaration lives in src/schema.zig and is named pgzx_sql:
const functions = @import("functions.zig");
pub const pgzx_sql = .{
.functions = .{
.{ .name = "char_count_zig", .func = functions.char_count_zig },
},
};
Then enable the schema step in build.zig:
_ = proj.addSteps(.{
.schema = .{},
// ...
});
zig build (or zig build sql) compiles a small generator, renders the SQL, and installs it next to the .control file so CREATE EXTENSION picks it up. Argument types are derived from the Zig signatures through the pgzx.datum converters: i32 becomes integer, []const u8 becomes text, and optional arguments do not change the SQL type. A pg.FunctionCallInfo parameter is treated as the fmgr context and is not emitted as an SQL argument.
Per-function options:
| Option | Meaning |
|---|---|
name | SQL function name (required). |
func | The Zig function (required). |
volatility | pgzx.ddl.Volatility: .@"volatile" (default), .stable, .immutable. |
strict | Emit STRICT (default false). |
parallel | pgzx.ddl.Parallel: .unsafe, .restricted, .safe. |
args | Override argument SQL types, e.g. &.{"text", "integer"}. |
returns | Override the return SQL type. |
comment | Emit a COMMENT ON FUNCTION. |
Use args/returns for signatures with no direct SQL mapping, such as raw pg.Datum arguments. Keep PG_FUNCTION_V1/PG_EXPORT in main.zig, not in the schema module: the generator is linked as a standalone executable and cannot resolve Postgres server symbols. Objects that are not CREATE FUNCTION (types, operators, operator classes, casts) go in a source-level catalog.sql, which the generator appends after the functions. See examples/rational and examples/sqlfns for the complete pattern.
pgzx provides pg_regress tests and in-server unit tests, set up through the PGBuild.Project build helper:
const std = @import("std");
const PGBuild = @import("pgzx").Build;
pub fn build(b: *std.Build) void {
const proj = PGBuild.Project.init(b, .{
.name = "my_extension",
.version = .{ .major = 0, .minor = 1 },
.root_dir = "src/",
.root_source_file = "src/main.zig",
});
_ = proj.addSteps(.{
.pg_regress = .{
.db_user = "postgres",
.db_port = 5432,
.scripts = &[_][]const u8{"my_extension_test"},
},
.unit = .{
.db_user = "postgres",
.db_port = 5432,
},
});
}
addSteps creates the check, install, pg_regress (when .pg_regress is set) and unit (when .unit is set) build steps, and defines the build_options module that gates testfn.
pg_regress tests work like they do for C extensions: inputs go in sql/, expected outputs in expected/, and run with:
zig build pg_regress
Unit tests run inside Postgres, so they compile in the same environment as the tested code and can call Postgres APIs. Each function whose name starts with test in a registered test suite is a unit test:
comptime {
pgzx.testing.registerTests(@import("build_options").testfn, .{Tests});
}
registerTests may only be called once per extension; pass multiple suites in the array. Run the tests with:
zig build unit -p $PG_HOME
This builds a dedicated {name}_unit library with testfn = true, deploys it, and calls SELECT run_tests();. The separate library name means the test build never collides with the production extension.
pgzx is under heavy development by the Xata team. Expect breaking changes and potential instability. If you need help, join us on the Xata discord.
PostgreSQL 15, 16, 17 and 18 are supported: the examples and unit tests are compiled and run against all four in CI. PostgreSQL 14 is not supported yet.
ROADMAP.md tracks the feature status relative to pgrx:
zig build package, and PostgreSQL 14 support, among other items.For a complete local development guide — creating a local PostgreSQL install, building, testing, debugging, editor setup, and switching Postgres versions — see HACKING.md.
$ nix develop # enter the development shell
$ ./dev/docker/run.sh # or use the docker development shell
The examples build and test through each example's ci/run.sh; ./ci/run.sh at the repo root runs all of them.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
If you have any questions, encounter issues, or need assistance, open an issue in this repository or join our Discord, and our community will be happy to help.
Made with ❤️ by Xata 🦋
Create PostgreSQL extensions using Zig.
See the code
pgzx is a library for developing PostgreSQL extensions written in Zig. It provides utilities (error handling, memory allocators, wrappers) and a development environment that simplify integrating with the Postgres code base.
Zig is a small, simple language that aims to be a "modern C" with safe memory management, compile-time execution (comptime), and a rich standard library. It speaks the C ABI, works with C pointers and types directly, and can import and translate C headers — so a Zig extension can do anything a C extension can, with a modern language on top.
In practice you still need to understand a lot of Postgres internals, and Postgres leans on macros that cannot always be translated automatically. pgzx provides the Zig modules for those cases.
The following sample extensions (ordered from simple to complex) show how to use pgzx:
| Extension | Description |
|---|---|
| char_count_zig | Adds a function that counts how many times a particular character shows up in a string. Shows how to register a function and how to interpret the parameters. |
| pghostname_zig | Adds a function that returns the database server's host name. |
| sqlfns | The ways to export a Zig function to SQL: a raw C export, PG_FUNCTION_V1, and PG_EXPORT for a whole struct or file. |
| guc | Registers custom GUCs (bool, int, string, enum) in _PG_init, including a check hook that clamps a value, and reads them back into SQL. |
| bgworker | Registers a static background worker (pgzx.bgworker.register) that the postmaster launches on the next server start. |
| pg_audit_zig | Inspired by the pgaudit C extension, this one registers callbacks to multiple hooks and uses more advanced error handling and memory allocation patterns. |
| srf | Port of pgrx-examples/srf. Set-returning functions with pgzx.srf.SetOf (RETURNS SETOF) and pgzx.srf.Table (RETURNS TABLE), driven by a plain Zig iterator. |
| arrays | Port of pgrx-examples/arrays. Postgres arrays as Zig slices ([]const i32, []const ?i32, text[]), plus parameter defaults, VARIADIC and pgzx.IntList. |
| spi_sql | Uses SPI to run SQL statements; the exported functions double as the regression-test fixture. |
| spi | Port of pgrx-examples/spi. Queries with arguments, a prepared plan kept for the session, cursors fetched in batches, subtransactions that skip failing rows, and SECURITY DEFINER. |
| rational | A rational base type with btree and hash operator classes. Shows the type system end to end: pg_type, operators, pg_opclass, casts and how they make indexes and GROUP BY work. |
| file_io | Backend-safe file I/O through the virtual file descriptor layer (pgzx.fd), plus asynchronous VFD reads on PostgreSQL 18 (pgzx.aio). |
| compress_zig | A Timescale-style columnar compression layer: SPI reads a heap table, encodes it into type-aware batches (plain, rle, delta, delta2, gorilla, dictionary, bit-packed bool) in a sidecar relation, and decodes them back. |
The reference documentation is available at here. The examples above are the best place to start; the sections below walk through the most important utilities.
This project uses Nix flakes to manage build dependencies and provide a development shell. A template bootstraps a new extension:
$ mkdir my_extension
$ cd my_extension
$ nix flake init -t github:xataio/pgzx
This creates a working extension named my_extension exporting a hello() function. Its README explains how to enter the shell, build, and test it. Rename the project by updating the files in extension/ and replacing my_extension in README.md, build.zig, build.zig.zon, and the extension SQL file.
The development shell sets the environment used by the project (see devshell.nix): PRJ_ROOT is the project folder, and PG_HOME is the Postgres install prefix (the shell relocates Postgres into ./out and points ./out/default at the active version). For a complete local setup guide see HACKING.md.
Postgres error reporting functions provide log levels, formatting, and errors that can be thrown and caught like exceptions. pgzx wraps them for Zig.
Simple logging uses Log, Info, Notice or Warning:
elog.Info(@src(), "input_text: {s}\n", .{input_text});
The @src() built-in records the file location in the error report.
To report errors, use Error (returns a Zig error) or ErrorThrow (throws a Postgres error report):
if (target_char.len > 1) {
return elog.Error(@src(), "Target char is more than one byte", .{});
}
The module also exposes the C-style API (ereport, errcode, errmsg, ...).
Postgres handles errors with longjmp, which can skip Zig defer/errdefer cleanup. pgzx provides a Zig alternative to PG_TRY:
var errctx = pgzx.err.Context.init();
defer errctx.deinit();
if (errctx.pg_try()) {
// Zig code that calls Postgres C functions.
} else {
return errctx.errorValue();
}
This catches errors raised by Postgres functions and returns them as Zig errors, so all defer/errdefer in the callers run. The wrap helper packages this pattern:
try pgzx.err.wrap(myFunction, .{arg1, arg2});
See pgzx.err.Context for details.
Postgres uses a memory context system: allocations belong to a context, and freeing a context frees everything in it at once. Contexts are hierarchical, so a child context is freed with its parent.
pgzx wraps contexts as Zig allocators. createAllocSetContext returns a MemoryContextAllocator:
var memctx = try pgzx.mem.createAllocSetContext("zig_context", .{ .parent = pg.CurrentMemoryContext });
const allocator = memctx.allocator();
pg.CurrentMemoryContext is the context of the running query, so memory allocated with allocator is freed when the query finishes. You can also register a callback for when a context is reset or deleted, to release resources tied to it:
try memctx.registerAllocResetCallback(
queryDesc.*.estate.*.es_query_cxt,
pgaudit_zig_MemoryContextCallback,
);
Register Zig functions so they can be called from SQL with PG_FUNCTION_V1:
comptime {
pgzx.PG_FUNCTION_V1("my_function", myFunction);
}
Parameters are received from Postgres serialized, and pgzx deserializes them into Zig types automatically.
SETOF and TABLE functions return a pgzx.srf.SetOf(I) or pgzx.srf.Table(I) wrapping an iterator I: any struct with a next(self: *I) method that yields the next row, or null when the set is exhausted. next may also return !?Item to raise a Zig error as a Postgres error.
const Series = struct {
cur: i64,
finish: i64,
step: i64,
pub fn next(self: *Series) ?i64 {
if (self.cur > self.finish) return null;
defer self.cur += self.step;
return self.cur;
}
};
pub fn srf_series(start: i64, finish: i64, step: i64) pgzx.srf.SetOf(Series) {
return .{ .iter = .{ .cur = start, .finish = finish, .step = step } };
}
For Table(I) the item is a struct with one field per column; the field names and the pgzx.datum converters (see SQL schema generation) produce the RETURNS TABLE (...) clause in generated DDL. Use SetOf for a single unnamed column.
Postgres uses the value-per-call protocol: it calls the function once per row. The function body runs only on the first call, in a memory context that lives for the whole set, and the iterator is moved there; later calls run only next. Allocate everything the iterator needs while building it with mem.PGCurrentContextAllocator — next runs in a short-lived per-call context, so memory it allocates is only good for the row it returns. An iterator type may also define fn deinit(self: *I) void, which runs when the set ends or the query stops early (a LIMIT, an error, a cancelled query); it must not allocate or raise errors.
See examples/srf for a complete set of fixtures, including nullable columns and iterators that fail.
Instead of hand-writing the versioned extension script (<name>--<version>.sql), you can describe your SQL objects in a comptime declaration and let zig build render it. By convention the declaration lives in src/schema.zig and is named pgzx_sql:
const functions = @import("functions.zig");
pub const pgzx_sql = .{
.functions = .{
.{ .name = "char_count_zig", .func = functions.char_count_zig },
},
};
Then enable the schema step in build.zig:
_ = proj.addSteps(.{
.schema = .{},
// ...
});
zig build (or zig build sql) compiles a small generator, renders the SQL, and installs it next to the .control file so CREATE EXTENSION picks it up. Argument types are derived from the Zig signatures through the pgzx.datum converters: i32 becomes integer, []const u8 becomes text, and optional arguments do not change the SQL type. A pg.FunctionCallInfo parameter is treated as the fmgr context and is not emitted as an SQL argument.
Per-function options:
| Option | Meaning |
|---|---|
name | SQL function name (required). |
func | The Zig function (required). |
volatility | pgzx.ddl.Volatility: .@"volatile" (default), .stable, .immutable. |
strict | Emit STRICT (default false). |
parallel | pgzx.ddl.Parallel: .unsafe, .restricted, .safe. |
args | Override argument SQL types, e.g. &.{"text", "integer"}. |
returns | Override the return SQL type. |
comment | Emit a COMMENT ON FUNCTION. |
Use args/returns for signatures with no direct SQL mapping, such as raw pg.Datum arguments. Keep PG_FUNCTION_V1/PG_EXPORT in main.zig, not in the schema module: the generator is linked as a standalone executable and cannot resolve Postgres server symbols. Objects that are not CREATE FUNCTION (types, operators, operator classes, casts) go in a source-level catalog.sql, which the generator appends after the functions. See examples/rational and examples/sqlfns for the complete pattern.
pgzx provides pg_regress tests and in-server unit tests, set up through the PGBuild.Project build helper:
const std = @import("std");
const PGBuild = @import("pgzx").Build;
pub fn build(b: *std.Build) void {
const proj = PGBuild.Project.init(b, .{
.name = "my_extension",
.version = .{ .major = 0, .minor = 1 },
.root_dir = "src/",
.root_source_file = "src/main.zig",
});
_ = proj.addSteps(.{
.pg_regress = .{
.db_user = "postgres",
.db_port = 5432,
.scripts = &[_][]const u8{"my_extension_test"},
},
.unit = .{
.db_user = "postgres",
.db_port = 5432,
},
});
}
addSteps creates the check, install, pg_regress (when .pg_regress is set) and unit (when .unit is set) build steps, and defines the build_options module that gates testfn.
pg_regress tests work like they do for C extensions: inputs go in sql/, expected outputs in expected/, and run with:
zig build pg_regress
Unit tests run inside Postgres, so they compile in the same environment as the tested code and can call Postgres APIs. Each function whose name starts with test in a registered test suite is a unit test:
comptime {
pgzx.testing.registerTests(@import("build_options").testfn, .{Tests});
}
registerTests may only be called once per extension; pass multiple suites in the array. Run the tests with:
zig build unit -p $PG_HOME
This builds a dedicated {name}_unit library with testfn = true, deploys it, and calls SELECT run_tests();. The separate library name means the test build never collides with the production extension.
pgzx is under heavy development by the Xata team. Expect breaking changes and potential instability. If you need help, join us on the Xata discord.
PostgreSQL 15, 16, 17 and 18 are supported: the examples and unit tests are compiled and run against all four in CI. PostgreSQL 14 is not supported yet.
ROADMAP.md tracks the feature status relative to pgrx:
zig build package, and PostgreSQL 14 support, among other items.For a complete local development guide — creating a local PostgreSQL install, building, testing, debugging, editor setup, and switching Postgres versions — see HACKING.md.
$ nix develop # enter the development shell
$ ./dev/docker/run.sh # or use the docker development shell
The examples build and test through each example's ci/run.sh; ./ci/run.sh at the repo root runs all of them.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
If you have any questions, encounter issues, or need assistance, open an issue in this repository or join our Discord, and our community will be happy to help.
Made with ❤️ by Xata 🦋