C Interop
@cImport, link C libraries from build.zig, and share structs that follow the C ABI. This lesson shows how to stand on the shoulders of the entire C ecosystem.
Importing C
The @cImport builtin compiles the named headers into a Zig container. Every C function, type, and constant becomes accessible through the resulting namespace — translated automatically, with types mapped to their Zig equivalents.
@cImport and @cInclude
const c = @cImport({
@cInclude("math.h"); // pull in the C header
@cInclude("stdlib.h");
});
const root = c.sqrt(16.0); // call the C function
std.debug.print("{d}\n", .{root}); // 4.0
Namespaced Access
Everything lands under c., so a C symbol can never collide with a Zig name. Constants, enum values, and function pointers all travel through the same namespace — c.EXIT_SUCCESS works like c.errno.
Linking Libraries
Declaring headers is only half the story; the binary must also link the library. Zig keeps this in the build script where everyone can see it.
linkSystemLibrary
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
exe.root_module.addCMacro("USE_SSE", "1"); // define a macro
if (target.result.os.tag == .linux) {
exe.root_module.linkSystemLibrary("m", .{}); // libm on Linux
}
b.installArtifact(exe);
Static Libraries
Static archives link the same way: linkSystemLibrary for standard names or addObjectFile/addIncludePath for vendored builds. Third-party C code ships with its own compile flags, and Zig honors the include and library paths you declare.
Sharing Types and the ABI
Passing data across the boundary requires agreeing on layout. Zig gives you precise tools for that agreement — the same ones from the structures lesson.
extern Structs and Opaque Types
// matches the C declaration: struct timespec { tv_sec; tv_nsec; }
const Time = extern struct {
sec: i64,
nsec: i64,
};
// opaque hides the layout of a foreign handle (e.g. FILE*)
const OpaqueFile = opaque {};
const file: *OpaqueFile = undefined;
C Strings
A C char* arrives as a sentinel pointer; convert to a Zig slice with std.mem.span (which uses the NUL terminator) and back with @ptrCast + @intFromPtr where the ABI demands it.
Automatic Translation
When a whole C file must become Zig, zig translate-c produces idiomatic (or at least faithful) Zig. It is the ideal starting point for migration and for vendoring a library without a hand-written wrapper.
zig translate-c
zig translate-c legacy.c > legacy.zig
# review the output, then @import it or fold it into your build
Translate or Hand-Write
Hand-writing small, stable interfaces keeps types precise and Zig-idiomatic. Translating whole libraries is faster and less error-prone for large surface areas. Prefer the smaller hand-crafted seam: less generated code to audit.
Common Pitfalls
C NULL Is an Optional
C functions that may return NULL map to optionals (?*T). Forgetting to unwrap is a compile error — which is precisely the protection you never had in C.
Memory Across the Boundary
Memory allocated by a C library must be freed by the library (or std.heap.c_allocator), never by a Zig arena. Keep ownership on the side that allocated — mixed free is a fast way to corrupt both heaps.
Next: Testing & Debugging — making the compiler prove your code.