Skip to content

Using C Structs

A C struct groups several fields into one value.

A C struct groups several fields into one value.

For example:

struct Point {
    int x;
    int y;
};

This defines a value with two fields:

x
y

In Zig, you can use C structs in two main ways.

You can import a struct from a C header.

You can define a C-compatible struct yourself with extern struct.

Both are useful.

Importing a C Struct

Suppose you have this C header:

// point.h

#ifndef POINT_H
#define POINT_H

struct Point {
    int x;
    int y;
};

#endif

You can import it into Zig:

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

Then you can create a value:

pub fn main() void {
    var p = c.struct_Point{
        .x = 10,
        .y = 20,
    };

    _ = p;
}

C structs imported by @cImport may appear with generated names such as c.struct_Point, depending on how the C header declares them.

If the C header uses typedef, the imported name is usually cleaner.

// point.h

#ifndef POINT_H
#define POINT_H

typedef struct Point {
    int x;
    int y;
} Point;

#endif

Now Zig can usually use:

var p = c.Point{
    .x = 10,
    .y = 20,
};

The typedef gives the struct a convenient C type name. Zig imports that name.

Reading and Writing Fields

A C struct field is accessed with dot syntax, just like a Zig struct field.

var p = c.Point{
    .x = 10,
    .y = 20,
};

p.x = 30;

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

This prints:

(30, 20)

The syntax is simple. The important part is not the field access. The important part is memory layout.

C Struct Layout

A struct is not only a group of names. It is also a memory layout.

For this C struct:

typedef struct Point {
    int x;
    int y;
} Point;

C decides where x and y live in memory. Usually, it looks like this:

Point
+---------+---------+
| x       | y       |
+---------+---------+

If int is 4 bytes, the whole struct is usually 8 bytes.

But real structs can include padding.

typedef struct Example {
    char a;
    int b;
} Example;

This may look like 5 bytes: 1 byte for char, 4 bytes for int.

But the compiler will usually add padding so b is properly aligned:

Example
+---+---+---+---+---------+
| a | padding   | b       |
+---+---+---+---+---------+

The struct may be 8 bytes, not 5.

This matters when Zig and C share structs. Both sides must agree on the same layout.

extern struct

When you define a struct in Zig and want C-compatible layout, use extern struct.

const Point = extern struct {
    x: c_int,
    y: c_int,
};

The word extern tells Zig:

Use a layout compatible with C.

This is the correct choice when you pass the struct to C, receive it from C, or expose it to C.

Do not use a normal Zig struct for C ABI data unless you have a specific reason and know the layout rules.

This is a normal Zig struct:

const Point = struct {
    x: i32,
    y: i32,
};

This is a C-compatible Zig struct:

const Point = extern struct {
    x: c_int,
    y: c_int,
};

The second one is the right form for C interop.

Passing Structs to C

Suppose C declares this:

typedef struct Point {
    int x;
    int y;
} Point;

void print_point(Point p);

In Zig:

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

pub fn main() void {
    const p = c.Point{
        .x = 10,
        .y = 20,
    };

    c.print_point(p);
}

Here, the struct is passed by value. Zig copies the whole struct into the C function call according to the C ABI.

For small structs, this is common.

For larger structs, C APIs often use pointers.

Passing Pointers to Structs

C often uses this style:

void move_point(Point *p, int dx, int dy);

The function receives a pointer, then modifies the struct through that pointer.

In Zig:

var p = c.Point{
    .x = 10,
    .y = 20,
};

c.move_point(&p, 5, 7);

After the call, C may have changed the fields:

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

If the C function promises not to modify the struct, it may use const:

void print_point(const Point *p);

Then Zig can pass a pointer to a const value:

const p = c.Point{
    .x = 10,
    .y = 20,
};

c.print_point(&p);

The difference is important:

Point *p

means C may modify the struct.

const Point *p

means C should only read it.

Structs with Pointers

Many C structs contain pointers.

typedef struct Buffer {
    unsigned char *data;
    size_t len;
} Buffer;

In Zig, this may be imported as a struct with a pointer field and a length field.

You can create one from a Zig slice:

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

var buffer = c.Buffer{
    .data = slice.ptr,
    .len = slice.len,
};

This is a common bridge between Zig slices and C structs.

But remember: the C struct does not own the memory automatically. It only stores a pointer and a length.

The actual bytes still live in bytes.

That means lifetime matters.

This is safe:

var bytes: [1024]u8 = undefined;

var buffer = c.Buffer{
    .data = bytes[0..].ptr,
    .len = bytes.len,
};

c.use_buffer(&buffer);

This is dangerous if the C code stores the pointer for later use after bytes is gone.

A C struct can contain a pointer. That does not tell you who owns the memory. You must know the API’s ownership rules.

Structs Returned from C

C functions can return structs.

Point make_point(int x, int y);

Zig can call this:

const p = c.make_point(10, 20);

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

The returned value is a normal Zig value with C-compatible layout.

C functions can also return pointers to structs:

Point *create_point(int x, int y);
void destroy_point(Point *p);

In Zig:

const p = c.create_point(10, 20) orelse return error.CreatePointFailed;
defer c.destroy_point(p);

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

The orelse handles a null pointer. The defer ensures cleanup.

The pattern is common when using C libraries:

const handle = c.create_something(...) orelse return error.CreateFailed;
defer c.destroy_something(handle);

Opaque Structs

Some C libraries hide struct fields from you.

The header may say:

typedef struct Database Database;

Database *database_open(const char *path);
void database_close(Database *db);

This declares a type named Database, but it does not show its fields.

This is an opaque struct. You can hold a pointer to it, but you cannot access its fields.

In Zig, you treat it as a handle:

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

You should not try to inspect the internal memory. The C library owns the implementation details.

Opaque structs are common in libraries. They help keep the API stable.

Examples of this style include database handles, window handles, parser objects, compression streams, and graphics contexts.

Nested Structs

C structs can contain other structs.

typedef struct Size {
    int width;
    int height;
} Size;

typedef struct Rect {
    int x;
    int y;
    Size size;
} Rect;

In Zig:

var r = c.Rect{
    .x = 0,
    .y = 0,
    .size = c.Size{
        .width = 640,
        .height = 480,
    },
};

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

The nested struct is just another field.

Again, the key rule is layout compatibility. Imported C structs already use the C layout. Manually defined Zig structs should use extern struct when shared with C.

Packed Structs Are Different

C code sometimes uses packed structs:

#pragma pack(push, 1)
typedef struct Header {
    unsigned char tag;
    unsigned int len;
} Header;
#pragma pack(pop)

Packed structs remove normal padding.

This is common in binary file formats, network protocols, and hardware registers.

Be careful. Packed layout has stricter rules and may cause unaligned access. You should not assume a C packed struct maps to an ordinary Zig struct.

Zig has packed struct, but packed struct and extern struct serve different purposes.

Use extern struct for normal C ABI layout.

Use packed struct for bit-level or byte-exact layouts when appropriate.

Do not guess. Check the C header and the ABI expectations.

Bitfields

C structs can contain bitfields:

typedef struct Flags {
    unsigned int readable : 1;
    unsigned int writable : 1;
    unsigned int executable : 1;
} Flags;

Bitfields are difficult because their layout can depend on compiler and target rules.

Zig may import some C bitfields, but they are more delicate than ordinary fields.

For stable interop, many libraries avoid exposing bitfields directly. If you control the C API, prefer ordinary integer flags:

#define FLAG_READABLE   1
#define FLAG_WRITABLE   2
#define FLAG_EXECUTABLE 4

typedef struct FileMode {
    unsigned int flags;
} FileMode;

Then Zig can use bit operations clearly.

const readable = (mode.flags & c.FLAG_READABLE) != 0;

This is easier to port and easier to reason about.

Initializing C Structs Safely

C APIs sometimes expect you to zero-initialize a struct.

Example:

typedef struct Options {
    int enable_cache;
    int max_connections;
    void *user_data;
} Options;

A C example may do this:

Options options = {0};

In Zig, you can initialize fields explicitly:

var options = c.Options{
    .enable_cache = 0,
    .max_connections = 100,
    .user_data = null,
};

This is often best because every field is visible.

Some imported C structs may have many fields. If zero initialization is correct according to the C library, Zig may allow:

var options: c.Options = std.mem.zeroes(c.Options);

But do this only when the C library documents that zero is a valid initial state.

Zero bytes are not always a valid value for every type or every library.

Struct Version Fields

Some C APIs require a struct size or version field.

Example:

typedef struct Options {
    size_t struct_size;
    int enable_cache;
} Options;

The C library may expect:

options.struct_size = sizeof(Options);

In Zig:

var options = c.Options{
    .struct_size = @sizeOf(c.Options),
    .enable_cache = 1,
};

This pattern lets the C library know which version of the struct you are using.

If a C API requires this field, do not forget it. The call may fail or behave incorrectly.

Wrapping C Structs in Zig Types

Imported C structs are useful, but you often do not want the whole program using them directly.

You can wrap them in a Zig type.

Suppose C exposes:

typedef struct Image Image;

Image *image_load(const char *path);
void image_free(Image *image);
int image_width(const Image *image);
int image_height(const Image *image);

A Zig wrapper might look like this:

const ImageError = error{
    LoadFailed,
};

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

    pub fn load(path: [*:0]const u8) ImageError!Image {
        const ptr = c.image_load(path) orelse return error.LoadFailed;

        return Image{
            .ptr = ptr,
        };
    }

    pub fn deinit(self: Image) void {
        c.image_free(self.ptr);
    }

    pub fn width(self: Image) c_int {
        return c.image_width(self.ptr);
    }

    pub fn height(self: Image) c_int {
        return c.image_height(self.ptr);
    }
};

Then callers use:

const image = try Image.load("photo.png");
defer image.deinit();

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

This is much better than exposing raw C calls everywhere.

The wrapper gives the C object a Zig shape:

Load returns an error union.

Cleanup uses deinit.

Methods hide the raw pointer.

The rest of the program uses a safer interface.

Common Mistakes

The first common mistake is defining a normal Zig struct and passing it to C.

const Point = struct {
    x: i32,
    y: i32,
};

This is not the right default for C interop. Use:

const Point = extern struct {
    x: c_int,
    y: c_int,
};

The second common mistake is forgetting that structs with pointer fields do not automatically own memory.

var buffer = c.Buffer{
    .data = slice.ptr,
    .len = slice.len,
};

This does not copy the bytes. It only points to them.

The third common mistake is freeing memory with the wrong allocator.

If C creates a struct, usually C must destroy it.

If Zig creates memory, usually Zig must free it.

The fourth common mistake is assuming all C structs can be safely zeroed.

Only zero a C struct when the C library says zero initialization is valid.

What to Remember

C structs are about memory layout.

Imported C structs already use C-compatible layout.

When defining a Zig struct for C interop, use extern struct.

Use C-compatible field types such as c_int, c_uint, and c_long.

Pass small structs by value when the C API expects that.

Pass pointers when the C API expects pointers.

Treat opaque structs as handles.

Be careful with pointer fields, lifetime, ownership, packed structs, and bitfields.

The best pattern is to keep raw C structs at the boundary and wrap them in clean Zig types when possible.