Skip to content

Calling C Functions

Calling a C function from Zig has three parts.

Calling a C function from Zig has three parts.

First, Zig must know the C function exists.

Second, the final executable must link to the compiled C code.

Third, your Zig code must call the function using the right types.

A header gives Zig the declaration:

// mathlib.h

int add(int a, int b);

A C file gives the linker the implementation:

// mathlib.c

#include "mathlib.h"

int add(int a, int b) {
    return a + b;
}

Your Zig file imports the header and calls the function:

const std = @import("std");

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

pub fn main() void {
    const result = c.add(10, 20);
    std.debug.print("result = {}\n", .{result});
}

The call itself looks simple:

const result = c.add(10, 20);

But behind that line, Zig is following the C ABI. It passes the arguments the way C expects, receives the return value the way C returns it, and uses the imported declaration to check the call.

A C Function Declaration

A C function declaration says the function name, parameters, and return type.

int add(int a, int b);

This means:

The function is named add.

It takes two int values.

It returns an int.

After @cImport, Zig sees this as a C function under the imported namespace:

c.add

So Zig code can call:

const x = c.add(2, 3);

If the C function returns 5, then x receives that value.

C Types at the Boundary

C types and Zig types are not always the same.

At the C boundary, use C-compatible types.

C typeZig type at C boundary
charc_char
signed charc_schar
unsigned charc_uchar
shortc_short
unsigned shortc_ushort
intc_int
unsigned intc_uint
longc_long
unsigned longc_ulong
long longc_longlong
unsigned long longc_ulonglong
floatf32
doublef64
void *?*anyopaque or pointer types from import

For example, if C declares:

int add(int a, int b);

then the Zig result is C-compatible:

const result: c_int = c.add(10, 20);

You can convert it into a normal Zig type if needed:

const value: i32 = @intCast(c.add(10, 20));

The cast is explicit. Zig does not want silent narrowing or surprising integer conversion.

Calling a Function That Returns Nothing

In C, a function that returns nothing uses void.

void say_hello(void);

In Zig, this maps naturally to a function that returns void.

You call it like this:

c.say_hello();

There is no result to store.

If you write:

const x = c.say_hello();

then x would have type void, which is rarely useful.

Calling a Function That Returns a Status Code

Many C APIs report errors with integer return codes.

For example:

int save_file(const char *path);

The library might document:

0 means success
nonzero means failure

In Zig, you should usually wrap this in an error union:

const SaveError = error{
    Failed,
};

fn saveFile(path: [*:0]const u8) SaveError!void {
    const rc = c.save_file(path);

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

Then the rest of your Zig code can use:

try saveFile("data.txt");

This is better than spreading raw C status checks across your program.

Passing Numbers

Passing numbers is direct when the types match.

C:

int max_int(int a, int b);

Zig:

const result = c.max_int(10, 20);

For constants like 10 and 20, Zig can usually coerce them into the expected C integer type.

For variables, be more explicit:

const a: c_int = 10;
const b: c_int = 20;

const result = c.max_int(a, b);

If you have a Zig usize and the C function expects int, do not pass it blindly:

const n: usize = 100;
const result = c.take_int(@intCast(n));

This says clearly: convert usize to the C integer type expected by the function.

Passing Pointers

C uses pointers heavily.

A C function might expect a pointer to an integer:

void increment(int *value);

In Zig, you can pass the address of a variable:

var x: c_int = 41;
c.increment(&x);

After the call, C may have modified x.

std.debug.print("x = {}\n", .{x});

If the C function expects const int *, it promises not to modify the value:

void print_int(const int *value);

Zig can pass a pointer to a const value:

const x: c_int = 42;
c.print_int(&x);

The distinction between mutable and const pointers still matters.

Passing Buffers

C often represents a buffer as a pointer plus a length:

int fill_buffer(unsigned char *buffer, size_t len);

In Zig, a slice already contains a pointer and a length:

var buffer: [1024]u8 = undefined;
const slice = buffer[0..];

To call the C function, pass the slice pointer and length:

const rc = c.fill_buffer(slice.ptr, slice.len);

Then check the return code:

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

A wrapper is cleaner:

const FillError = error{
    FillFailed,
};

fn fillBuffer(buffer: []u8) FillError!void {
    const rc = c.fill_buffer(buffer.ptr, buffer.len);

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

Now callers do not need to remember the C pointer-plus-length pattern:

try fillBuffer(buffer[0..]);

Passing C Strings

C strings usually end with a zero byte.

A C function might look like this:

void print_name(const char *name);

You can pass a Zig string literal:

c.print_name("Zig");

This works because Zig string literals are compatible with null-terminated C string parameters.

But a runtime slice is different:

const name: []const u8 = "Zig";

Do not assume this is safe:

c.print_name(name.ptr);

The pointer does not carry the length. C will keep reading until it finds a zero byte.

Use a null-terminated string type when the C function requires it:

const name: [*:0]const u8 = "Zig";
c.print_name(name);

The :0 means there is a zero sentinel at the end.

Receiving C Strings

A C function may return a string:

const char *get_name(void);

In Zig, that may appear as a pointer to a null-terminated sequence.

const ptr = c.get_name();

Before using it, ask two questions.

Can it be null?

Who owns the memory?

If the C documentation says the return value can be null, check it:

const ptr = c.get_name();

if (ptr == null) {
    return error.NoName;
}

If the documentation says the returned pointer must not be freed, do not free it.

If the documentation says the caller must free it, call the matching C cleanup function.

C APIs depend heavily on documentation. Zig can check types, but it cannot infer every ownership rule from a header.

Calling Functions That Allocate

Suppose C has this API:

char *make_message(void);
void free_message(char *message);

The Zig code should pair allocation and cleanup:

const ptr = c.make_message();

if (ptr == null) {
    return error.OutOfMemory;
}

defer c.free_message(ptr);

// use ptr here

The defer makes cleanup reliable. When the current scope exits, Zig calls free_message.

Do not use a Zig allocator to free memory allocated by C unless the C library explicitly says that is valid.

The allocator that allocates memory should normally free it.

Calling Functions with Output Parameters

C often returns data through pointer parameters.

Example:

int get_size(int *width, int *height);

The function returns a status code. The actual values are written into width and height.

Zig code:

var width: c_int = undefined;
var height: c_int = undefined;

const rc = c.get_size(&width, &height);

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

std.debug.print("{} x {}\n", .{ width, height });

A Zig wrapper can return a struct instead:

const Size = struct {
    width: c_int,
    height: c_int,
};

const SizeError = error{
    GetSizeFailed,
};

fn getSize() SizeError!Size {
    var width: c_int = undefined;
    var height: c_int = undefined;

    const rc = c.get_size(&width, &height);

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

    return Size{
        .width = width,
        .height = height,
    };
}

Now the caller gets a normal Zig value:

const size = try getSize();

This is easier to use correctly.

Calling Variadic C Functions

Some C functions accept a variable number of arguments.

The classic example is printf:

int printf(const char *format, ...);

Zig can call C variadic functions, but you should be careful.

Example:

_ = c.printf("value = %d\n", @as(c_int, 42));

Notice the explicit type:

@as(c_int, 42)

C variadic functions do not carry strong type information for the extra arguments. If you pass the wrong type, the compiler may not protect you fully.

In Zig code, prefer std.debug.print or std.io formatting when you can:

std.debug.print("value = {}\n", .{42});

Use C variadic functions mainly when calling existing C APIs that require them.

Function Pointers from C

C APIs sometimes accept callback functions.

Example:

typedef void (*callback_t)(int value);

void run_callback(callback_t cb);

In Zig, you can pass a compatible function:

fn callback(value: c_int) callconv(.c) void {
    std.debug.print("value = {}\n", .{value});
}

pub fn main() void {
    c.run_callback(callback);
}

The important part is:

callconv(.c)

This tells Zig the function uses the C calling convention.

When C calls back into Zig, the ABI must match.

Null Pointers

C uses null pointers often.

A C function might return NULL when it fails:

void *create_handle(void);

In Zig, imported nullable C pointers are usually represented as optional pointers.

That means you should check them:

const handle = c.create_handle();

if (handle == null) {
    return error.CreateFailed;
}

After checking, unwrap the optional:

const non_null_handle = handle.?;

Then use it:

defer c.destroy_handle(non_null_handle);

The pattern is common:

const handle = c.create_handle() orelse return error.CreateFailed;
defer c.destroy_handle(handle);

This is concise and clear.

Do Not Guess Ownership

When calling C functions, the type signature does not tell the whole story.

These two functions may look similar:

const char *get_static_name(void);
char *make_owned_name(void);

But they may have very different ownership rules.

The first might return a pointer to static memory. You must not free it.

The second might allocate memory. You must free it.

The header alone may not tell you enough. Read the C library documentation.

For every pointer returned from C, ask:

Can it be null?

How long is it valid?

Who frees it?

Which function frees it?

Can it be used from multiple threads?

These questions matter more than the syntax.

A Practical Wrapper Example

Suppose a C library exposes this:

int read_config(const char *path, char *buffer, size_t buffer_len);

It returns 0 on success and nonzero on failure.

A direct Zig call might look like this:

var buffer: [4096]u8 = undefined;

const rc = c.read_config("config.txt", &buffer, buffer.len);

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

But a wrapper is better:

const ConfigError = error{
    ReadConfigFailed,
};

fn readConfig(path: [*:0]const u8, buffer: []u8) ConfigError!void {
    const rc = c.read_config(path, buffer.ptr, buffer.len);

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

Now callers write:

var buffer: [4096]u8 = undefined;
try readConfig("config.txt", buffer[0..]);

This wrapper turns a C-style function into a Zig-style function.

The C function uses raw pointers and status codes.

The Zig function uses slices and error unions.

What to Remember

Calling C functions from Zig is direct, but the boundary still matters.

Use @cImport to import declarations.

Make sure the C implementation is linked.

Use C-compatible types at the boundary.

Pass slices as pointer plus length when C expects that pattern.

Use null-terminated strings for C string parameters.

Check nullable pointers.

Pair C allocation with C cleanup.

Wrap C APIs in Zig functions so the rest of your program can use Zig errors, slices, structs, and clear ownership rules.