ABI means Application Binary Interface.
An API describes how source code calls something. An ABI describes how compiled code calls something.
When Zig calls C, source code is only half of the story. The final machine code must agree on details such as:
| ABI detail | What it controls |
|---|---|
| Calling convention | How function arguments are passed |
| Return values | How results come back |
| Struct layout | How fields are placed in memory |
| Alignment | Where values may legally live in memory |
| Symbol names | What function names look like to the linker |
| Integer sizes | How large C types are on the target |
| Stack rules | Who cleans up and how stack memory is used |
If Zig and C disagree on these details, the program may compile but still behave incorrectly.
API vs ABI
A C header gives you an API.
int add(int a, int b);This says:
There is a function named add.
It takes two int values.
It returns an int.The ABI answers lower-level questions:
Which registers hold the arguments?
How large is int on this target?
Where does the return value go?
What symbol name does the linker search for?You usually do not write ABI rules by hand. The compiler and target platform handle them. But when Zig and C interact, you must use types and declarations that let both compilers agree.
Calling Convention
A calling convention is a rule for calling functions at the machine-code level.
For C interop, use the C calling convention.
When you import a C function with @cImport, Zig knows it is a C function.
When you export a Zig function to C, use C-compatible exports:
export fn add(a: c_int, b: c_int) c_int {
return a + b;
}For callbacks passed from Zig to C, write the calling convention explicitly:
fn callback(value: c_int) callconv(.c) void {
_ = value;
}Then pass it to C:
c.run_callback(callback);The callconv(.c) part matters. It tells Zig to generate a function that C can call correctly.
C Type Sizes Depend on the Target
C type sizes are not identical on every platform.
For example, int is commonly 32 bits, but long differs across common systems.
| C type | Common Linux x86_64 | Common Windows x64 |
|---|---|---|
int | 32 bits | 32 bits |
long | 64 bits | 32 bits |
long long | 64 bits | 64 bits |
| pointer | 64 bits | 64 bits |
This is why Zig provides C-compatible type names:
c_int
c_long
c_longlong
c_uint
c_ulongDo not assume that C long is always the same as Zig i64.
At the C boundary, prefer C-compatible types:
export fn take_long(x: c_long) void {
_ = x;
}Inside Zig code, you can convert to a fixed-width type when that is what your logic needs:
const value: i64 = @intCast(x);Struct Layout
A normal Zig struct uses Zig layout rules.
const Point = struct {
x: i32,
y: i32,
};A C-compatible struct must use C layout rules:
const Point = extern struct {
x: c_int,
y: c_int,
};Use extern struct when the struct crosses the C boundary.
For example, if C expects this:
typedef struct Point {
int x;
int y;
} Point;then Zig should match it with:
const Point = extern struct {
x: c_int,
y: c_int,
};The field order and field types must match.
Padding and Alignment
C structs often contain padding.
Consider this C struct:
typedef struct Example {
char a;
int b;
} Example;You might think it uses 5 bytes: 1 byte for char, 4 bytes for int.
On many targets, it uses 8 bytes because int needs stronger alignment.
A possible layout:
byte 0 a
byte 1-3 padding
byte 4-7 bZig must match this layout if the struct is passed to C.
That is what extern struct is for.
You can inspect size and alignment in Zig:
std.debug.print("size = {}\n", .{@sizeOf(Example)});
std.debug.print("align = {}\n", .{@alignOf(Example)});For imported C structs, Zig uses the C layout from the target.
Packed Structs Are Special
C code sometimes uses packed structs to remove padding.
#pragma pack(push, 1)
typedef struct Header {
unsigned char tag;
unsigned int len;
} Header;
#pragma pack(pop)Packed structs are common in binary formats, wire protocols, and hardware-facing code.
Do not treat them as ordinary structs.
In Zig, packed struct has bit-level layout rules, while extern struct follows C ABI layout rules. They solve different problems.
Use extern struct for normal C ABI structs.
Use packed struct only when you need exact packed layout and know the target representation.
Enums
C enums are usually represented as integer types, but the exact underlying type can depend on the C compiler, target, and flags.
If you import a C enum through @cImport, Zig handles the imported representation.
If you manually define a Zig type for a C enum boundary, be careful. For stable public C APIs, integer constants are often simpler.
C:
#define MODE_READ 1
#define MODE_WRITE 2Zig boundary:
export fn open_mode(mode: c_int) c_int {
_ = mode;
return 0;
}Inside Zig, you can translate that integer into a safer Zig enum if you want.
Symbol Names
The linker does not call functions by source-code syntax. It uses symbols.
This Zig function:
export fn mylib_add(a: c_int, b: c_int) c_int {
return a + b;
}exports a symbol named:
mylib_addA C header can declare:
int mylib_add(int a, int b);Then the C object file and Zig object file can link.
C has a mostly flat symbol namespace, so use prefixes for library APIs:
mylib_create
mylib_destroy
mylib_read
mylib_writeThis avoids collisions with other libraries.
Name Mangling and C++
C++ changes symbol names through name mangling.
C++ function:
int add(int a, int b);may not export a symbol literally named add.
To make a C-compatible function from C++, use extern "C":
extern "C" int add(int a, int b);Then Zig can treat it like a C function.
This chapter is about C ABI compatibility. C++ ABI compatibility is much more complicated because of constructors, destructors, templates, exceptions, overloading, RTTI, and standard library differences.
When mixing Zig with C++, prefer a small extern "C" wrapper.
Nullability Is Partly a Convention
C pointers can be null unless the documentation says otherwise.
Zig can represent nullable pointers with optionals:
?*TWhen exporting to C, use optional pointers if C may pass null:
export fn destroy_handle(handle: ?*Handle) void {
const h = handle orelse return;
allocator.destroy(h);
}When a pointer must not be null, a non-optional pointer documents that expectation on the Zig side:
fn useHandle(handle: *Handle) void {
_ = handle;
}But C can still pass invalid data. A C caller can violate your contract.
For public C APIs, defensive null checks are often worth the small cost.
Ownership Is Not in the ABI
The ABI tells compiled code how to pass values. It does not tell you who owns memory.
These two C functions may have the same ABI shape:
char *get_name(void);
char *make_name(void);But their ownership rules may differ.
get_name might return a borrowed pointer.
make_name might allocate memory that the caller must free.
The ABI cannot tell you that. The header may not tell you either. You need documentation and naming conventions.
Good C APIs make ownership visible:
char *mylib_message_create(void);
void mylib_message_destroy(char *message);The matching create and destroy functions give callers a clear contract.
ABI Stability
ABI stability means compiled code can keep working across versions.
If you publish a C library, changing these can break ABI compatibility:
| Change | ABI risk |
|---|---|
| Reordering struct fields | High |
| Changing field types | High |
| Removing exported functions | High |
| Changing function parameters | High |
| Changing return type | High |
| Changing enum size or representation | Medium to high |
| Adding fields to public structs | Often high |
| Adding new functions | Usually safe |
This is why many C libraries use opaque structs.
Instead of exposing fields:
typedef struct Database {
int fd;
int flags;
} Database;they expose only the name:
typedef struct Database Database;Then callers use pointers:
Database *database_open(const char *path);
void database_close(Database *db);The library can change the internal struct later without breaking callers.
Stable Public ABI Pattern
For a stable C ABI from Zig, prefer this pattern:
typedef struct MyLibHandle MyLibHandle;
MyLibHandle *mylib_create(void);
void mylib_destroy(MyLibHandle *handle);
int mylib_do_work(MyLibHandle *handle);The Zig implementation can keep the actual struct private:
const MyLibHandle = struct {
value: u32,
};
export fn mylib_create() ?*MyLibHandle {
const handle = allocator.create(MyLibHandle) catch return null;
handle.* = .{ .value = 0 };
return handle;
}
export fn mylib_destroy(handle: ?*MyLibHandle) void {
const h = handle orelse return;
allocator.destroy(h);
}
export fn mylib_do_work(handle: ?*MyLibHandle) c_int {
const h = handle orelse return -1;
h.value += 1;
return 0;
}C sees only a pointer. Zig keeps the layout private.
This is the safest long-term design.
Cross-Compilation and ABI
The ABI depends on the target.
This command builds for one target:
zig build -Dtarget=x86_64-linux-gnuThis builds for another:
zig build -Dtarget=x86_64-windows-gnuThe same C-looking type may have different layout or size across those targets. That is normal.
Use Zig’s target-aware C types and imported headers. Avoid hardcoding assumptions such as:
long is always 64 bits
pointers are always 8 bytes
struct padding is always the sameThose assumptions break portability.
What to Remember
ABI compatibility is about compiled-code agreement.
Use C-compatible types at the boundary.
Use extern struct for structs shared with C.
Use callconv(.c) for callbacks called by C.
Use export fn for functions C should call.
Do not export Zig-only types directly.
Do not assume C type sizes are the same on every platform.
Keep public C ABIs small and stable.
Use opaque handles when you want long-term compatibility.
The compiler can help with ABI details, but it cannot infer ownership, lifetime, thread safety, or API promises. Those must be designed and documented.