Skip to content

Wrapping Existing Libraries

Wrapping a C library means building a Zig layer around it.

Wrapping a C library means building a Zig layer around it.

The C library remains underneath. Zig calls it. But most of your Zig program does not call the C API directly. Instead, your program calls a cleaner Zig API.

This is the usual shape:

Application Zig code
        |
        v
Zig wrapper
        |
        v
C library

The wrapper is the boundary. It translates C habits into Zig habits.

C often uses raw pointers, null pointers, integer return codes, manual cleanup, and null-terminated strings. Zig usually prefers slices, optionals, error unions, deinit, and explicit ownership.

Why Wrap a C Library

You can call C functions directly:

const rc = c.database_exec(db, "select 1");

if (rc != 0) {
    return error.ExecFailed;
}

But if you write this everywhere, your Zig code becomes C-shaped.

A wrapper lets the rest of your code use Zig style:

try db.exec("select 1");

The wrapper has one job: keep C details contained.

A Raw C API

Suppose a C library exposes this header:

typedef struct Database Database;

Database *database_open(const char *path);
void database_close(Database *db);
int database_exec(Database *db, const char *sql);
const char *database_last_error(Database *db);

This is a normal C API.

It uses an opaque pointer:

Database *

It uses a null pointer for open failure.

It uses an integer status code for execution failure.

It uses C strings:

const char *

Now we import it:

const c = @cImport({
    @cInclude("database.h");
});

Direct use would look like this:

const db = c.database_open("app.db") orelse return error.OpenFailed;
defer c.database_close(db);

const rc = c.database_exec(db, "create table users(id integer)");

if (rc != 0) {
    return error.ExecFailed;
}

This works. But it exposes C details everywhere.

A Zig Wrapper Type

A better interface starts with a Zig struct:

const Database = struct {
    ptr: *c.Database,
};

The struct stores the raw C pointer. The field is private by convention because users of the wrapper should not need to touch it.

Now add an open function:

const DatabaseError = error{
    OpenFailed,
    ExecFailed,
};

const Database = struct {
    ptr: *c.Database,

    pub fn open(path: [*:0]const u8) DatabaseError!Database {
        const ptr = c.database_open(path) orelse return error.OpenFailed;

        return Database{
            .ptr = ptr,
        };
    }
};

Now callers can write:

const db = try Database.open("app.db");

That already feels more like Zig.

Add Cleanup with deinit

C APIs often have matching pairs:

open  / close
create / destroy
init / deinit
alloc / free

In Zig wrappers, the usual cleanup method is named deinit.

pub fn deinit(self: Database) void {
    c.database_close(self.ptr);
}

Now usage becomes:

const db = try Database.open("app.db");
defer db.deinit();

This is a strong pattern.

Acquire the resource.

Immediately schedule cleanup with defer.

Use the resource.

Add a Method for Work

Now wrap database_exec:

pub fn exec(self: Database, sql: [*:0]const u8) DatabaseError!void {
    const rc = c.database_exec(self.ptr, sql);

    if (rc != 0) {
        return error.ExecFailed;
    }
}

Callers now write:

try db.exec("create table users(id integer)");

The raw C status code stays inside the wrapper.

Complete First Wrapper

const c = @cImport({
    @cInclude("database.h");
});

const DatabaseError = error{
    OpenFailed,
    ExecFailed,
};

const Database = struct {
    ptr: *c.Database,

    pub fn open(path: [*:0]const u8) DatabaseError!Database {
        const ptr = c.database_open(path) orelse return error.OpenFailed;

        return Database{
            .ptr = ptr,
        };
    }

    pub fn deinit(self: Database) void {
        c.database_close(self.ptr);
    }

    pub fn exec(self: Database, sql: [*:0]const u8) DatabaseError!void {
        const rc = c.database_exec(self.ptr, sql);

        if (rc != 0) {
            return error.ExecFailed;
        }
    }
};

Usage:

const db = try Database.open("app.db");
defer db.deinit();

try db.exec("create table users(id integer)");
try db.exec("insert into users values (1)");

The application code does not see database_open, database_close, or database_exec. It sees Database.open, db.deinit, and db.exec.

Handling Error Messages

Many C libraries provide a way to read the last error message.

const char *database_last_error(Database *db);

A simple Zig wrapper can expose it:

pub fn lastError(self: Database) [*:0]const u8 {
    return c.database_last_error(self.ptr);
}

Usage:

db.exec("bad sql") catch |err| {
    std.debug.print("error: {s}\n", .{db.lastError()});
    return err;
};

This returns a C string pointer. That may be enough for simple cases.

A more Zig-shaped wrapper may convert it to a slice:

pub fn lastError(self: Database) []const u8 {
    const ptr = c.database_last_error(self.ptr);
    return std.mem.span(ptr);
}

std.mem.span reads a sentinel-terminated string and returns a slice.

Use this only when the C function never returns null. If it can return null, handle that:

pub fn lastError(self: Database) []const u8 {
    const ptr = c.database_last_error(self.ptr) orelse return "";
    return std.mem.span(ptr);
}

Accepting Zig Slices

The wrapper above still requires null-terminated strings:

pub fn exec(self: Database, sql: [*:0]const u8) DatabaseError!void

That works for string literals:

try db.exec("select 1");

But many Zig strings are slices:

const sql: []const u8 = getSqlFromSomewhere();

A C function that expects const char * needs a null-terminated string. The wrapper can allocate a temporary copy:

pub fn exec(
    self: Database,
    allocator: std.mem.Allocator,
    sql: []const u8,
) DatabaseError!void {
    const sql_z = allocator.dupeZ(u8, sql) catch return error.OutOfMemory;
    defer allocator.free(sql_z);

    const rc = c.database_exec(self.ptr, sql_z.ptr);

    if (rc != 0) {
        return error.ExecFailed;
    }
}

Now update the error set:

const DatabaseError = error{
    OpenFailed,
    ExecFailed,
    OutOfMemory,
};

Callers can pass ordinary slices:

try db.exec(allocator, sql);

This is more flexible, but it requires an allocator. That is a reasonable tradeoff when conversion needs memory.

Keep Ownership Explicit

A wrapper must make ownership clear.

If the C library returns borrowed memory, do not free it.

If the C library returns owned memory, provide a Zig cleanup path.

Example C API:

char *database_get_allocated_message(Database *db);
void database_free_message(char *message);

A Zig wrapper might expose a small owned type:

const Message = struct {
    ptr: [*:0]u8,

    pub fn deinit(self: Message) void {
        c.database_free_message(self.ptr);
    }

    pub fn bytes(self: Message) []const u8 {
        return std.mem.span(self.ptr);
    }
};

Then:

pub fn getMessage(self: Database) DatabaseError!Message {
    const ptr = c.database_get_allocated_message(self.ptr) orelse {
        return error.OutOfMemory;
    };

    return Message{
        .ptr = ptr,
    };
}

Usage:

const msg = try db.getMessage();
defer msg.deinit();

std.debug.print("{s}\n", .{msg.bytes()});

This wrapper makes the cleanup rule visible.

Avoid Leaking Raw Pointers

This is weak:

pub fn raw(self: Database) *c.Database {
    return self.ptr;
}

It may be necessary sometimes, but it weakens the wrapper. Once callers can freely access the raw pointer, they can bypass your safety rules.

Prefer not to expose raw handles unless there is a strong reason.

If you do expose one, name it clearly:

pub fn rawHandle(self: Database) *c.Database {
    return self.ptr;
}

That tells readers they are leaving the safer wrapper layer.

Convert C Status Codes Once

Do not repeat this everywhere:

if (rc != 0) return error.ExecFailed;

Create a helper:

fn checkExec(rc: c_int) DatabaseError!void {
    if (rc != 0) {
        return error.ExecFailed;
    }
}

Then:

pub fn exec(self: Database, sql: [*:0]const u8) DatabaseError!void {
    try checkExec(c.database_exec(self.ptr, sql));
}

This keeps the mapping from C status code to Zig error in one place.

For richer libraries, use a switch:

fn check(rc: c_int) DatabaseError!void {
    return switch (rc) {
        0 => {},
        1 => error.ExecFailed,
        2 => error.OutOfMemory,
        else => error.Unknown,
    };
}

Wrap Constants and Flags

C APIs often use integer flags:

#define DATABASE_READONLY 1
#define DATABASE_CREATE   2

Instead of exposing raw constants everywhere, wrap them:

const OpenFlags = packed struct {
    readonly: bool = false,
    create: bool = false,

    fn toC(self: OpenFlags) c_int {
        var flags: c_int = 0;

        if (self.readonly) flags |= c.DATABASE_READONLY;
        if (self.create) flags |= c.DATABASE_CREATE;

        return flags;
    }
};

Usage:

const flags = OpenFlags{
    .readonly = true,
    .create = false,
};

const c_flags = flags.toC();

This gives Zig callers named fields instead of raw bit operations.

Choose the Right Wrapper Thickness

A thin wrapper stays close to C.

pub fn exec(self: Database, sql: [*:0]const u8) DatabaseError!void {
    const rc = c.database_exec(self.ptr, sql);
    if (rc != 0) return error.ExecFailed;
}

A thick wrapper changes the interface more.

pub fn exec(self: Database, allocator: std.mem.Allocator, sql: []const u8) DatabaseError!void {
    const sql_z = allocator.dupeZ(u8, sql) catch return error.OutOfMemory;
    defer allocator.free(sql_z);

    try check(c.database_exec(self.ptr, sql_z.ptr));
}

Both are valid.

Use a thin wrapper when the C API is already clean or performance-critical.

Use a thicker wrapper when you want a safer, more natural Zig interface.

Keep the Wrapper Honest

A wrapper should not hide expensive work.

If a function allocates, make that visible by taking an allocator.

If a function may fail, return an error union.

If a function borrows memory, document the lifetime.

If a function transfers ownership, provide a deinit.

Good Zig APIs make cost and ownership visible.

What to Remember

Wrapping a C library means containing C details at the edge.

Use a Zig struct to hold the C handle.

Use open, create, or init to acquire resources.

Use deinit, close, or destroy to release resources.

Translate null pointers into errors or optionals.

Translate status codes into error unions.

Convert C strings into slices when useful.

Accept Zig slices when possible, but allocate null-terminated copies only when needed.

Expose raw C handles only when necessary.

A good wrapper lets most of your program feel like Zig, even when the implementation uses C underneath.