A high-level PostgreSQL client library for the ZenC programming language, wrapping libpq with an ergonomic, memory-safe API.
Write modern, safe database code like a high-level language, link and run on bare C.
- Simple, ergonomic API —
PgConnection::new(),conn.query(),conn.exec() - Memory-safe by default —
Dropimplementations automatically callPQfinishandPQclear - ZenC-native error handling — Returns
Result<T>instead of raw error codes - No build system required — Uses ZenC's built-in
//> link:directive - Lightweight wrapper — Thin abstraction over
libpqwith zero runtime overhead
-
ZenC compiler (
zc) — Installation guide -
PostgreSQL client libraries (
libpq):# Arch Linux sudo pacman -S postgresql-libs # Ubuntu / Debian sudo apt-get install libpq-dev # Fedora / RHEL sudo dnf install postgresql-devel # macOS brew install libpq
pg/
├── sys.zc # Low-level C interop layer (raw libpq bindings)
└── pg.zc # High-level ZenC wrapper (PgConnection, PgResult)
You only need to import pg/pg.zc in your application code.
Add the link directive and import the module:
//> link: -lpq
import "std/io.zc"
import "./pg/pg.zc"
fn main() {
let conninfo = "host=localhost dbname=postgres user=postgres";
let conn_res = PgConnection::new(conninfo);
if (conn_res.is_err()) {
println "Failed to connect: {conn_res.err}";
return;
}
let conn = conn_res.unwrap();
// Create a table
let create = conn.exec(
"CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)"
);
if (create.is_err()) {
println "CREATE failed: {create.err}";
return;
}
// Insert data
let insert = conn.exec(
"INSERT INTO users (name) VALUES ('Alice'), ('Bob')"
);
if (insert.is_err()) {
println "INSERT failed: {insert.err}";
return;
}
// Query data
let query_res = conn.query("SELECT id, name FROM users");
if (query_res.is_err()) {
println "SELECT failed: {query_res.err}";
return;
}
let result = query_res.unwrap();
println "Rows: {result.row_count()}, Columns: {result.column_count()}";
for (let i = 0; i < result.row_count(); i = i + 1) {
let id = result.get(i, 0).unwrap();
let name = result.get(i, 1).unwrap();
println "{id} | {name}";
id.destroy();
name.destroy();
}
// Or use the iterator interface:
// let iter = PgResultIterator::new(&result);
// while (iter.has_next()) {
// let row = iter.next().unwrap();
// let id = row.get(0).unwrap();
// let name = row.get(1).unwrap();
// println "{id} | {name}";
// id.destroy();
// name.destroy();
// }
}
Compile and run:
zc run main.zcOr build an executable:
zc build main.zc -o myapp
./myappRepresents an open connection to a PostgreSQL server.
| Method | Signature | Description |
|---|---|---|
new |
fn new(conninfo: char*) -> Result<PgConnection> |
Opens a connection using a libpq connection string |
query |
fn query(self, sql: char*) -> Result<PgResult> |
Executes a SQL query that returns rows (e.g. SELECT) |
exec |
fn exec(self, sql: char*) -> Result<bool> |
Executes a SQL command with no result set (e.g. CREATE, INSERT) |
query_params |
fn query_params(self, sql: char*, params: char**, nParams: c_int) -> Result<PgResult> |
Executes a parameterized query (e.g. SELECT ... WHERE x = $1) |
exec_params |
fn exec_params(self, sql: char*, params: char**, nParams: c_int) -> Result<bool> |
Executes a parameterized command (e.g. INSERT ... VALUES ($1, $2)) |
last_error |
fn last_error(self) -> String |
Returns the last libpq error message |
Connection string examples:
"host=localhost dbname=mydb user=myuser password=secret"
"host=/var/run/postgresql dbname=mydb user=myuser"
"postgresql://myuser:secret@localhost:5432/mydb"
Represents the result of a query() call.
| Method | Signature | Description |
|---|---|---|
row_count |
fn row_count(self) -> c_int |
Number of rows in the result |
column_count |
fn column_count(self) -> c_int |
Number of columns in the result |
get |
fn get(self, row: c_int, col: c_int) -> Option<String> |
Returns the value at (row, col) as an Option<String> — None for SQL NULL |
column_name |
fn column_name(self, col: c_int) -> String |
Returns the name of the given column |
Represents an active database transaction. Created via PgConnection::begin().
| Method | Signature | Description |
|---|---|---|
commit |
fn commit(self) -> Result<bool> |
Commits the transaction |
rollback |
fn rollback(self) -> Result<bool> |
Rolls back the transaction |
exec |
fn exec(self, sql: char*) -> Result<bool> |
Executes a SQL command inside the transaction |
query |
fn query(self, sql: char*) -> Result<PgResult> |
Executes a query inside the transaction |
exec_params |
fn exec_params(self, sql: char*, params: char**, nParams: c_int) -> Result<bool> |
Parameterized command inside the transaction |
query_params |
fn query_params(self, sql: char*, params: char**, nParams: c_int) -> Result<PgResult> |
Parameterized query inside the transaction |
last_error |
fn last_error(self) -> String |
Returns the last libpq error message |
Note: The
PgConnectionused to start the transaction must outlive theTransactionobject. Dropping aTransactionwithout callingcommit()orrollback()automatically issuesROLLBACK.
ZenC async/await is supported via wrapper functions. Under the hood ZenC v0.4 uses OS threads, so these wrappers run the blocking libpq call on a background thread.
| Function | Signature | Description |
|---|---|---|
pg_connect_async |
async fn pg_connect_async(conninfo: char*) -> Result<PgConnection> |
Opens a connection asynchronously |
pg_query_async |
async fn pg_query_async(conn: PgConnection*, sql: char*) -> Result<PgResult> |
Executes a query asynchronously |
pg_exec_async |
async fn pg_exec_async(conn: PgConnection*, sql: char*) -> Result<bool> |
Executes a command asynchronously |
pg_query_params_async |
async fn pg_query_params_async(conn: PgConnection*, sql: char*, params: char**, nParams: int) -> Result<PgResult> |
Parameterized query asynchronously |
pg_exec_params_async |
async fn pg_exec_params_async(conn: PgConnection*, sql: char*, params: char**, nParams: int) -> Result<bool> |
Parameterized command asynchronously |
pg_begin_async |
async fn pg_begin_async(conn: PgConnection*) -> Result<Transaction> |
Begins a transaction asynchronously |
pg_tx_commit_async |
async fn pg_tx_commit_async(tx: Transaction*) -> Result<bool> |
Commits a transaction asynchronously |
pg_tx_rollback_async |
async fn pg_tx_rollback_async(tx: Transaction*) -> Result<bool> |
Rolls back a transaction asynchronously |
pg_tx_exec_async |
async fn pg_tx_exec_async(tx: Transaction*, sql: char*) -> Result<bool> |
Async command inside a transaction |
pg_tx_query_async |
async fn pg_tx_query_async(tx: Transaction*, sql: char*) -> Result<PgResult> |
Async query inside a transaction |
pg_tx_query_params_async |
async fn pg_tx_query_params_async(tx: Transaction*, sql: char*, params: char**, nParams: int) -> Result<PgResult> |
Async parameterized query in a transaction |
pg_tx_exec_params_async |
async fn pg_tx_exec_params_async(tx: Transaction*, sql: char*, params: char**, nParams: int) -> Result<bool> |
Async parameterized command in a transaction |
Lifetime safety: The
connortxpointer passed to an async function must remain valid until the future is awaited. Do not drop the connection or transaction before awaiting the result.
A fixed-size, thread-safe connection pool. Pre-warms connections on creation and reuses them across concurrent workloads.
| Method | Signature | Description |
|---|---|---|
new |
fn new(conninfo: char*, max_size: int) -> Result<PgPool> |
Creates a pool of max_size connections |
get |
fn get(self) -> Result<PooledConnection> |
Blocking checkout of a connection from the pool |
A borrowed connection from the pool. Automatically returns to the pool on drop.
| Method | Signature | Description |
|---|---|---|
exec |
fn exec(self, sql: char*) -> Result<bool> |
Executes a command |
query |
fn query(self, sql: char*) -> Result<PgResult> |
Executes a query |
exec_params |
fn exec_params(self, sql: char*, params: char**, nParams: int) -> Result<bool> |
Parameterized command |
query_params |
fn query_params(self, sql: char*, params: char**, nParams: int) -> Result<PgResult> |
Parameterized query |
begin |
fn begin(self) -> Result<Transaction> |
Begins a transaction |
last_error |
fn last_error(self) -> String |
Returns the last libpq error message |
release |
fn release(self) |
Manually returns the connection to the pool |
| Async Function | Signature | Description |
|---|---|---|
pg_pool_get_async |
async fn pg_pool_get_async(pool: PgPool*) -> Result<PooledConnection> |
Async checkout from the pool |
Note:
PooledConnectionholds a pointer into the pool's internal array. ThePgPoolmust outlive all checked-outPooledConnectionobjects.
Provides row-by-row iteration over a PgResult. The PgResult must outlive the iterator.
| Method | Signature | Description |
|---|---|---|
new |
fn new(result: PgResult*) -> PgResultIterator |
Creates an iterator over the given result |
next |
fn next(self) -> Option<Row> |
Returns the next row, or None if exhausted |
has_next |
fn has_next(self) -> bool |
Returns true if more rows are available |
Represents a single row within a result set.
| Method | Signature | Description |
|---|---|---|
get |
fn get(self, col: int) -> Option<String> |
Returns the value at the given column index — None for SQL NULL |
column_count |
fn column_count(self) -> int |
Number of columns in the row |
let result = conn.query("SELECT id, name FROM users").unwrap();
let iter = PgResultIterator::new(&result);
while (iter.has_next()) {
let row = iter.next().unwrap();
let id = row.get(0).unwrap();
let name = row.get(1).unwrap();
println "{id} | {name}";
id.destroy();
name.destroy();
}
All database operations return Result<T>:
let res = conn.exec("DELETE FROM users WHERE id = 99");
if (res.is_err()) {
println "Delete failed: {res.err}";
} else {
println "Delete succeeded";
}
To inspect the underlying libpq error after a failure, use last_error():
let conn_res = PgConnection::new("host=badhost dbname=test");
if (conn_res.is_err()) {
println "Connection error: {conn_res.err}";
// libpq-specific detail is not available here because
// the connection handle is already cleaned up.
}
Both PgConnection and PgResult implement Drop, so resources are freed automatically when they go out of scope:
{
let conn = PgConnection::new("...").unwrap();
let res = conn.query("SELECT * FROM users").unwrap();
// PQclear(res) and PQfinish(conn) are called automatically here
}
String lifetimes: PgResult::get() returns an Option<String> and PgResult::column_name() returns an owned String. You must call .destroy() on the unwrapped String values when done, or let them fall out of scope.
let maybe_name = result.get(0, 1);
if (maybe_name.is_some()) {
let name = maybe_name.unwrap();
println "Name: {name}";
name.destroy();
} else {
println "Name is NULL";
}
The //> link: -lpq directive at the top of your entry file tells the ZenC compiler to link against libpq automatically. No Makefile required.
If libpq-fe.h is in a non-standard location (e.g. macOS Homebrew), add:
//> include: /opt/homebrew/opt/libpq/include
//> lib: /opt/homebrew/opt/libpq/lib
//> link: -lpq
let maybe_value = result.get(0, 2);
if (maybe_value.is_none()) {
println "Value is NULL";
} else {
let value = maybe_value.unwrap();
println "Value: {value}";
value.destroy();
}
for (let c = 0; c < result.column_count(); c = c + 1) {
let col_name = result.column_name(c);
println "Column {c}: {col_name}";
col_name.destroy();
}
let conn = PgConnection::new("host=localhost dbname=shop").unwrap();
let r1 = conn.exec("BEGIN");
let r2 = conn.exec("UPDATE inventory SET qty = qty - 1 WHERE id = 42");
let r3 = conn.exec("COMMIT");
if (r1.is_ok() && r2.is_ok() && r3.is_ok()) {
println "Transaction committed";
} else {
conn.exec("ROLLBACK");
println "Transaction rolled back";
}
Use the Transaction struct for safer transaction handling with automatic rollback on drop:
let conn = PgConnection::new("host=localhost dbname=shop").unwrap();
let tx_res = conn.begin();
if (tx_res.is_err()) {
println "BEGIN failed: {tx_res.err}";
return;
}
let tx = tx_res.unwrap();
let r1 = tx.exec("UPDATE inventory SET qty = qty - 1 WHERE id = 42");
let r2 = tx.exec("INSERT INTO orders (item_id) VALUES (42)");
if (r1.is_ok() && r2.is_ok()) {
tx.commit();
println "Transaction committed";
} else {
tx.rollback();
println "Transaction rolled back";
}
Run blocking operations on background threads using ZenC's async/await:
// Establish two connections in parallel
let f1 = pg_connect_async("host=db1 dbname=shop user=postgres");
let f2 = pg_connect_async("host=db2 dbname=shop user=postgres");
let conn1 = (await f1).unwrap();
let conn2 = (await f2).unwrap();
// Run queries in parallel on different connections
let q1 = pg_query_async(&conn1, "SELECT * FROM inventory");
let q2 = pg_query_async(&conn2, "SELECT * FROM orders");
let res1 = (await q1).unwrap();
let res2 = (await q2).unwrap();
println "Inventory rows: {res1.row_count()}, Order rows: {res2.row_count()}";
Note: ZenC's current
async/awaitimplementation uses OS threads. The pointer passed to an async wrapper (&conn,&tx) must remain valid until the future is awaited.
Reuse connections across concurrent workloads with a fixed-size pool:
let pool = PgPool::new("host=localhost dbname=shop user=postgres", 4).unwrap();
// Two workers share the pool (only 4 connections ever created)
{
let pc = pool.get().unwrap();
let res = pc.query("SELECT * FROM inventory");
// PooledConnection auto-releases on drop
}
{
let pc = pool.get().unwrap();
let tx = pc.begin().unwrap();
tx.exec("UPDATE inventory SET qty = qty - 1 WHERE id = 42");
tx.commit();
}
Use exec_params and query_params to pass values safely without manual escaping:
let conn = PgConnection::new("host=localhost dbname=shop").unwrap();
let params = ["Alice", "30"];
let insert = conn.exec_params(
"INSERT INTO users (name, age) VALUES ($1, $2)",
params, 2
);
let query_params = ["Alice"];
let res = conn.query_params(
"SELECT id, name, age FROM users WHERE name = $1",
query_params, 1
).unwrap();
println "Rows: {res.row_count()}";
This project includes an integration test suite in the tests/ directory.
-
Ensure you have a PostgreSQL server running on
localhost. -
(Optional) Set the
ZENC_PG_TEST_CONNINFOenvironment variable if your server uses non-default credentials:export ZENC_PG_TEST_CONNINFO="host=localhost dbname=postgres user=postgres password=secret"
-
Run all tests:
./run_tests.sh
Or run individual test files directly:
zc run tests/test_connection.zc
zc run tests/test_query.zc
zc run tests/test_null.zc
zc run tests/test_transaction.zc
zc run tests/test_async.zc
zc run tests/test_pool.zc
zc run tests/test_iterator.zczc run main.zcIf no server is available, the tests and demo will report connection errors.
- Parameterized queries —
PQexecParamswrapper for safe value binding - Transactions — Dedicated
Transactionstruct withcommit()/rollback() - Async support — Integration with ZenC's
async/await - Connection pooling — Simple pool for concurrent workloads
- Iterator interface — Row-by-row iteration over
PgResult - Better NULL handling — Return
Option<String>instead of empty strings
This wrapper is provided as-is for use in ZenC projects. It links against PostgreSQL's libpq, which is licensed under the PostgreSQL License.